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
putnami deps add @putnami/documentIf you use Firestore, install its optional peer dependency too:
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 },
);Both calls return the stored document read back from the adapter, not the input
object. That keeps server-applied transforms visible and prevents a caller from
mistaking its mutable request value for stored state. saveMany() follows the
same read-back contract and always performs full-document validation.
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
Inline credentials and key-file paths are configuration inputs only. Parse errors identify the bad setting without echoing its secret value, and generated infrastructure requirements contain store and database names, never credentials.
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.
Support and compatibility
@putnami/document is a public, documented, maintained package classified
stable. Its document-repository specification and accepted adapter-capability
ADR live next to the package source. The supported contract is the structured
repository vocabulary described here: adapters either implement an operation
with the same observable meaning or reject an unsupported capability
explicitly; transactions never span named stores; pagination stays cursor-based
and deterministic; and credential values never enter generated manifests or
errors.
The package does not claim to be the default persistence framework and makes no
cross-language parity promise. Before v1.0.0, minor 0.x releases may contain
documented breaking changes.