Palbase
Sign inGet started

Backend SDK

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 throws Invalid 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 at notes/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. Use update() 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 error with status: 404 — check status to tell "not found" apart from a real failure. exists is 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 plain GET /v1/docs/<collection>; the moment any where, orderBy or limit is applied it becomes POST /v1/docs/<collection>/query with 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

OperatorMatches 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
infield equals any element of the given array
array-containsthe (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.code is batch_too_large at status: 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?

DocumentsDatabase
Data modelSchemaless JSON documentsRelational tables declared in db/public.ts
Schema changesNone needed — just write new fieldsSchema changesdb/public.ts is diffed and applied
TypingGeneric <T> hint (not enforced)Generated from your schema (Database.public.*)
Queryingwhere/orderBy/limit chainsFilters, raw SQL, transactions
Access controlApplication logic in your handlersRow-Level Security enforced in the database
Error style{ data, error } envelopeThrows
Best forNested or varied content, prototypes, per-user blobs, activity feedsCore 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.json out of the artifact, and no CLI has written that file since 2026-09-01. palbase push still prints bundled 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 @On deployed today does not fire. The contract above is what the runtime implements; the delivery half is the gap. Check palbase --version against 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.