Document Storage
Use @putnami/document when your data is document-shaped and you want typed repositories, schema validation, cursor pagination, and pluggable document backends.
This sits next to:
@putnami/databasefor relational PostgreSQL data@putnami/storagefor blobs and file uploads
Installation
bunx putnami deps add @putnami/documentIf you use Firestore, install its optional peer dependency too:
bunx putnami deps add @google-cloud/firestoreEnable the plugin
import { application } from '@putnami/application';
import { document } from '@putnami/document';
export const app = () => application().use(document());The plugin closes cached adapters on shutdown.
Define collections
import { Collection, DateIso, DocumentId, Email, Field, Optional } from '@putnami/document';
export const UsersCollection = Collection(
'users',
{
id: DocumentId(String),
email: Field(Email),
name: Field(String),
nickname: Field(Optional(String)),
createdAt: Field(DateIso),
},
{
indexes: [{ fields: ['email'] }],
},
);Collection() declares:
- the collection name
- the document schema
- optional named-store and index metadata
DocumentId() marks one or more fields as the document id. The public type system supports composite ids. The in-memory adapter supports them today; the Firestore adapter currently requires a single scalar id.
Create a repository
import { Repository } from '@putnami/document';
import { UsersCollection } from './users.collection';
export class UserRepository extends Repository<typeof UsersCollection> {
constructor() {
super(UsersCollection);
}
}Configuration
Default store:
document:
backend: memoryNamed stores:
document:
backend: memory
audit:
backend: firestore
projectId: my-project
emulatorHost: 127.0.0.1:8080Bind a collection to a named store:
Collection(
'audit',
{
id: DocumentId(String),
action: Field(String),
},
{ db: 'audit' },
);Repository API
const users = new UserRepository();
await users.save({
id: 'user-123',
email: 'alice@example.com',
name: 'Alice',
createdAt: new Date().toISOString(),
});
const user = await users.get('user-123');
const exists = await users.exists('user-123');
const result = await users.find(
{
email: { not: 'blocked@example.com' },
createdAt: { exists: true },
},
{
limit: 20,
orderBy: { field: 'createdAt', direction: 'desc' },
},
);Available methods:
get(id, { consistency? })exists(id, { consistency? })findOne(filters, options?)find(filters, { limit?, cursor?, orderBy?, consistency? })save(document, { merge?, strict? })saveMany(documents)delete(id)deleteMany(filters)
Query model
Portable operators:
- equality:
equals,not - comparison:
gt,gte,lt,lte - set membership:
in,notIn - array membership:
contains - presence:
exists
Ordering is structured, not SQL text:
await users.find({}, { orderBy: { field: 'email', direction: 'asc' } });Pagination is cursor-based:
const firstPage = await users.find({}, { limit: 10, orderBy: { field: 'email', direction: 'asc' } });
const secondPage = await users.find({}, {
limit: 10,
cursor: firstPage.nextCursor,
orderBy: { field: 'email', direction: 'asc' },
});There is no offset API.
Save semantics
save() is replace-by-default:
await users.save({
id: 'user-123',
email: 'alice@example.com',
name: 'Alice Updated',
});Use merge: true for patch-style writes:
await users.save(
{
id: 'user-123',
nickname: 'ally',
},
{ merge: true },
);Schema validation uses the same primitives exported by @putnami/runtime.
Transactions
import { runInTransaction } from '@putnami/document';
await runInTransaction(async () => {
await users.save({ id: 'user-123', email: 'alice@example.com', name: 'Alice' });
await users.save({ id: 'user-456', email: 'bob@example.com', name: 'Bob' });
});Transactions are scoped to a single named store. Cross-store repository access inside a transaction throws TransactionNotSupported.
Index declarations
Collections can declare portable index coverage:
Collection(
'users',
{
id: DocumentId(String),
email: Field(String),
createdAt: Field(DateIso),
},
{
indexes: [
{ fields: ['email'] },
{ partition: ['email'], sort: ['createdAt'] },
],
},
);Enable strict index enforcement in config:
document:
strictIndexes: trueQueries that are not covered by declared index metadata throw IndexMissing.
Backends
Memory
Good for tests and lightweight local use.
- no external dependency
- transactions supported
- composite ids supported
Firestore
Good for managed remote document storage.
- loaded through optional peer dependency
- transactions supported
- single scalar document id only
- supports
projectId,databaseId,emulatorHost, andcredentials
Lifecycle And Observability
Use useBackend(), closeBackend(), and closeAllBackends() when you need manual backend control. Repositories also emit per-collection metrics and structured logs for get, find, save, saveMany, delete, and deleteMany.