Documents
The Documents service is a Firestore-style document store: schemaless JSON documents organised into named collections, addressed by path, with chainable queries. It needs no entry in db/ and no schema change of any kind — you just start writing documents. Use it for flexible, nested or fast-evolving data; use the relational Database when you want typed tables, constraints and row-level security. Like every other service, Documents is one of the ten singletons you import from @palbase/backend rather than something threaded through a context object.
Quick example
// modules/notes/notes.controller.ts
import { Body, Controller, Documents, Get, NotFound, PalError, Param, Post, User, z } from "@palbase/backend";
import type { UserT } from "@palbase/backend";
const CreateNote = z.object({ text: z.string().min(1) });
type CreateNote = z.infer<typeof CreateNote>;
// A route's return type must be a NAMED zod schema that exists as a VALUE in
// scope. The deploy stager reads the annotation and looks the name up; a bare
// `interface` is not a value, so it is refused: "return type Note has no
// matching imported/exported zod schema in scope".
const Note = z.object({
text: z.string(),
authorId: z.string(),
tags: z.array(z.string()),
createdAt: z.string(),
});
type Note = z.infer<typeof Note>;
const CreatedNote = z.object({ id: z.string() });
type CreatedNote = z.infer<typeof CreatedNote>;
@Controller("/notes")
export class NotesController {
@Post("")
async create(@Body(CreateNote) body: CreateNote, @User() user: UserT): Promise<CreatedNote> {
const { data, error } = await Documents.collection<Note>("notes").add({
text: body.text,
authorId: user.id,
tags: [],
createdAt: new Date().toISOString(),
});
if (error || !data) throw new PalError(502, "docs_write_failed", error?.message ?? "write failed");
return { id: data.path.split("/").pop()! };
}
@Get("/{id}")
async get(@Param("id") id: string): Promise<Note> {
const { data, error, status } = await Documents.collection<Note>("notes").doc(id).get();
if (error) {
if (status === 404) throw new NotFound("note not found");
throw new PalError(502, "docs_read_failed", error.message ?? "read failed");
}
return data!.data()!;
}
}
Two rules the deploy enforces on every controller, including this one: the class is exported by name and listed in its module's controllers — that list is the registration and the named export is how the module takes it — and every route's return type must be a named zod schema in scope. Neither is checked by tsc, so a file that compiles cleanly can still be refused at push. For the controller model itself — decorators, auth, validation — see Controllers & Routing.
Collections and documents
A collection is a named group of documents; a document is one JSON object inside it, addressed by id.
import { Documents, z } from "@palbase/backend";
const Note = z.object({
text: z.string(),
authorId: z.string(),
tags: z.array(z.string()),
createdAt: z.string(),
});
type Note = z.infer<typeof Note>;
const notes = Documents.collection<Note>("notes"); // collection reference
const note = notes.doc("abc123"); // document reference
const same = Documents.doc<Note>("notes/abc123"); // the same document, by path
- Collection names and document ids must match
[A-Za-z0-9_-]+— no slashes, dots or spaces. An invalid segment throws immediately, before any network call, naming the segment and the rule. Documents.doc(path)takes collection/documentId pairs, so the number of segments must be even. An odd count addresses a collection, and accepting it would write a document whose id happens to be a collection's name. It throwsInvalid document path: "notes". Expected collection/documentId pairs, got 1 segment(s).- Documents nest:
notes.doc("abc123").collection<Comment>("comments")is a subcollection, and its documents live atnotes/abc123/comments/<id>. - The generic parameter (
collection<Note>) types every read and write on that reference. It is a compile-time convenience only — the store is schemaless, so nothing enforces the shape server-side.
The result envelope
Unlike Database (which throws on failure), every Documents read and write resolves to a PalbaseResult<T>:
interface PalbaseResult<T> {
data: T | null;
error: { message?: string; code?: string } | null;
status?: number;
}
Always check error before touching data:
import { Documents, PalError, z } from "@palbase/backend";
const Note = z.object({ text: z.string() });
type Note = z.infer<typeof Note>;
declare const id: string;
const { data, error } = await Documents.collection<Note>("notes").doc(id).get();
if (error) {
// map to an HTTP error your clients understand
throw new PalError(502, "docs_read_failed", error.message ?? "read failed");
}
error.code is the module's own code, status is the HTTP status, and a failure to reach the module at all is enveloped too, as code: "network_error" with status: 0. Nothing here throws, so a try/catch around a Documents call catches nothing. This is the same { data, error } pattern the Storage service uses.
Creating documents
Two ways to write a new document:
import { Documents, z } from "@palbase/backend";
const Note = z.object({
text: z.string(),
authorId: z.string(),
tags: z.array(z.string()),
createdAt: z.string(),
});
type Note = z.infer<typeof Note>;
declare const user: { id: string };
// 1. add() — Palbase generates the id; you get back a reference to the new doc
const { data: ref, error } = await Documents.collection<Note>("notes").add({
text: "hello",
authorId: user.id,
tags: [],
createdAt: new Date().toISOString(),
});
// ref.path is "notes/<generated-id>"
// 2. doc(id).set() — you choose the id; set() fully replaces the document
const { error: setErr } = await Documents.collection<Note>("notes").doc("welcome").set({
text: "Welcome!",
authorId: "system",
tags: ["pinned"],
createdAt: new Date().toISOString(),
});
Warning:
set()is a full replace — fields missing from the payload are gone afterwards. Useupdate()for partial changes.
Reading documents
doc(id).get() resolves to a snapshot:
import { Documents, z } from "@palbase/backend";
const Note = z.object({
text: z.string(),
authorId: z.string(),
tags: z.array(z.string()),
createdAt: z.string(),
});
type Note = z.infer<typeof Note>;
import { NotFound, PalError } from "@palbase/backend";
declare const id: string;
const { data: snap, error, status } = await Documents.collection<Note>("notes").doc(id).get();
if (error) {
if (status === 404) throw new NotFound("note not found"); // missing document
throw new PalError(502, "docs_read_failed", error.message ?? "read failed");
}
snap!.id; // document id
snap!.exists; // true on a successful read
snap!.data(); // Note | undefined — the document body
snap!.ref.path; // "notes/<id>"
Note: A missing document surfaces as an
errorwithstatus: 404— checkstatusto tell "not found" apart from a real failure.existsis part of the snapshot shape, but on a miss you get the error envelope, not a snapshot.
Querying
Build queries by chaining where, orderBy and limit on a collection reference, then call .get():
import { Documents, z } from "@palbase/backend";
const Note = z.object({
text: z.string(),
authorId: z.string(),
tags: z.array(z.string()),
createdAt: z.string(),
});
type Note = z.infer<typeof Note>;
import { PalError } from "@palbase/backend";
declare const user: { id: string };
const { data: result, error } = await Documents.collection<Note>("notes")
.where("authorId", "==", user.id)
.where("tags", "array-contains", "pinned")
.orderBy("createdAt", "desc")
.limit(20)
.get();
if (error) throw new PalError(502, "docs_query_failed", error.message ?? "query failed");
result!.size; // number of documents returned
result!.empty; // true when no documents matched
for (const snap of result!.docs) {
console.log(snap.id, snap.data());
}
Each chain step returns a new reference — the original is untouched, so you can branch several queries from one shared base without one caller's filter leaking into another's.
Note: The chain decides the HTTP verb. An unchained
collection.get()is a plainGET /v1/docs/<collection>; the moment anywhere,orderByorlimitis applied it becomesPOST /v1/docs/<collection>/querywith the clauses in the body. Worth knowing when you are reading access logs and wondering why a read shows up as a write.
Where operators
| Operator | Matches when… |
|---|---|
== | field equals the value |
!= | field does not equal the value |
< | field is less than the value |
<= | field is less than or equal |
> | field is greater than the value |
>= | field is greater than or equal |
in | field equals any element of the given array |
array-contains | the (array) field contains the value |
orderBy(field, direction?) accepts "asc" (the default) or "desc". limit(n) caps the result count. A bare collection.get() with no clauses lists the collection.
Every document in a one-shot read reports type: "added" from docChanges() — the snapshot shape mirrors a streaming API it does not implement, so there is no "modified" or "removed" to react to here.
Updating and deleting
import { Documents, z } from "@palbase/backend";
const Note = z.object({
text: z.string(),
authorId: z.string(),
tags: z.array(z.string()),
createdAt: z.string(),
});
type Note = z.infer<typeof Note>;
declare const id: string;
declare const user: { id: string };
const note = Documents.collection<Note>("notes").doc(id);
// Partial update — only the listed fields change
await note.update({ text: "edited" });
// Full replace
await note.set({ text: "rewritten", authorId: user.id, tags: [], createdAt: new Date().toISOString() });
// Delete
await note.delete();
All three resolve to PalbaseResult<void> — check error as usual.
Batch writes
Documents.batch(operations) sends several writes as one call:
import { Documents } from "@palbase/backend";
const now = new Date().toISOString();
const { error } = await Documents.batch([
{ op: "set", ref: { path: "notes/welcome" }, data: { text: "Welcome!", authorId: "system", tags: [], createdAt: now } },
{ op: "update", ref: { path: "notes/abc123" }, data: { text: "edited" } },
{ op: "delete", ref: { path: "notes/old" } },
]);
op is "set", "update" or "delete"; ref is anything carrying a path, so a real PalbaseDocumentRef can be passed straight in; data is omitted for a delete.
- The cap is 500 operations, and it is refused locally, without a round trip:
error.codeisbatch_too_largeatstatus: 400, with the size in the message. - An empty batch is a no-op — it returns
{ data: null, error: null, status: 200 }and sends nothing.
Rate limits
The documents module applies per-collection rate limits of its own, read from the database and cached for five seconds, over a sliding window. They are fail-open: if the module cannot read its own limit configuration, the request proceeds rather than being refused. Do not treat a documents rate limit as an authorization boundary — the boundary is your route's auth option and what your handler decides to write.
Documents or Database?
Documents | Database | |
|---|---|---|
| Data model | Schemaless JSON documents | Relational tables declared in db/public.ts |
| Schema changes | None needed — just write new fields | Schema changes — db/public.ts is diffed and applied |
| Typing | Generic <T> hint (not enforced) | Generated from your schema (Database.public.*) |
| Querying | where/orderBy/limit chains | Filters, raw SQL, transactions |
| Access control | Application logic in your handlers | Row-Level Security enforced in the database |
| Error style | { data, error } envelope | Throws |
| Best for | Nested or varied content, prototypes, per-user blobs, activity feeds | Core domain data, relations, invariants, anything user-scoped via RLS |
A common split in a todo app: todos live in the Database (typed, RLS-protected), while free-form material like per-todo comment threads or drafts lives in Documents.
Reacting to document events
Documents raises document.created, document.updated and document.deleted. Handle them with @On methods on a class a module lists in its providers — there is no hooks/ directory and nothing reads one:
// modules/audit/document-listeners.hook.ts
import { Injectable, Log, On } from "@palbase/backend";
import type { DocumentHookEvent, HookMeta } from "@palbase/backend";
@Injectable()
export class DocumentListeners {
@On("document.created")
async onDocumentCreated(event: DocumentHookEvent, meta: HookMeta): Promise<void> {
Log.info(`document ${event.documentId} created in ${event.collection}`);
}
}
DocumentHookEvent carries event, collection, documentId, path, version, timestamp and — except on a delete — data. These are listeners: the write has already answered its caller before your handler runs, so a throw is logged and the document stays written. See Event Hooks.
Warning: hook registration is not reaching the stack right now — measured 2026-09-11. The documents module learns which events a project handles by reading
.palbase/hooks/hooks.manifest.jsonout of the artifact, and no CLI has written that file since 2026-09-01.palbase pushstill printsbundled hook(s) → …— that line is read out of the container and honestly says what the code carries — but nothing tells the stack which events to call, so an@Ondeployed today does not fire. The contract above is what the runtime implements; the delivery half is the gap. Checkpalbase --versionagainst the release notes before relying on one, and do not make a document listener the only thing that performs a required step. Event Hooks carries the detail.
Related
- Database — the relational alternative
- Controllers & Routing — the decorators, and the return-type rule this page's example follows
- Responses & Errors — mapping failures to typed HTTP errors
- Event Hooks — document lifecycle events
- Storage — the other service that answers with an envelope