Documentation ziprack/1SDK 0.3.0

Capabilities

Database

db@1capability contract

On this page

Introduction

db@1 gives your applet CRUD over records in collections it declared, at a scope it declared. This is working state: ideas on a board, a team roster, a saved layout, a shared catalog.

Records are mutable and arbitrarily shaped, which is exactly why a JSON Schema is required, not optional. The schema is what the Rack validates writes against, what it renders and exports records from, and what it derives indexes from.

Declaring Collections

You declare every collection your applet uses in the manifest. For example, a room-wide idea board might be declared like so:

JSON
"collections": [
    {
        "name": "ideas",
        "scope": "room",
        "schema": {
            "type": "object",
            "properties": {
                "title":  { "type": "string", "maxLength": 120 },
                "body":   { "type": "string", "maxLength": 2000 },
                "status": { "type": "string", "enum": ["draft", "posted", "picked"] },
                "votes":  { "type": "integer", "minimum": 0 }
            },
            "required": ["title", "status"]
        },
        "indexed": ["status", "votes"],
        "quota": { "max_records": 500, "max_bytes": 16384 },
        "permissions": { "update": "owner", "delete": "facilitator" }
    }
]

Each collection accepts the following fields:

Field Rules
name Required. ^[a-z][a-z0-9_]*$, max 40, unique.
scope Required. attempt, user, room or applet. See Scopes.
schema Required. JSON Schema, type: "object".
indexed Optional, max 8 paths. Each must be a scalar property declared in the schema. Dotted paths allowed.
quota Optional. max_records, max_bytes: ceilings you may lower, never raise. See Quotas.
permissions Optional. update and delete: owner (default), members, facilitator. See Permissions.
seed Optional, applet scope only. Path to a JSON file in the package. See Seeding.

Scopes

A collection's scope answers, at declaration time, the otherwise ambiguous question of who shares a record and what happens to it when an attempt ends:

Scope Shared by Lifetime Requires
attempt One attempt Frozen when the attempt completes, alongside state attempt@1
user One participant Outlives any attempt —
room Everyone in the room The room broadcast@1
applet Everyone using the applet, on every version The applet. Carries over every promotion. Seed data, shared catalogs —

A session that has no attempt or no room cannot touch a collection scoped to one. The call is refused with forbidden rather than silently falling back to something else.

user, room and applet records belong to the applet, not to a version, so they are still there after a new version is promoted. You may read more about this in Data Across Versions. attempt records stay with their attempt, and so with the version it ran on.

A session reads and writes a collection through the definition of the version it launched on. An attempt pinned to an earlier version keeps that version's schema, indexes and quota until it completes, even after a later version changed them.

Reading and Writing

To get started, you may retrieve a collection by name using the collection method. The collection offers create, get, update and delete methods:

TypeScript
const ideas = Rack.db.collection<Idea>('ideas');

const created = await ideas.create({ title, status: 'draft', votes: 0 });
const idea    = await ideas.get(created.data.record.id);

await ideas.update(id, { status: 'posted' });          // merges by default
await ideas.update(id, next, { merge: false });        // replaces the document
await ideas.delete(id);

By default, update merges the given properties into the existing document. If you would rather replace the document entirely, you may pass { merge: false } as the third argument.

Every call resolves to an AckResult. A record comes back in the following shape:

TypeScript
interface RecordRow<T> {
    id: string;            // uuid
    data: T;
    owner_id: number | null; // the participant who created it; null when seeded
    created_at: string;
    updated_at: string;
}

Writes are validated against the declared schema after the merge. A partial update that would produce an invalid document is refused with validation, and details names the offending path.

Queries

You may use the list method to filter, order and page through a collection's records:

TypeScript
const { data } = await ideas.list({
    where: [['status', '=', 'posted'], ['votes', '>=', 3]],
    orderBy: 'votes',
    direction: 'desc',
    limit: 50,
});

data.records;      // RecordRow<Idea>[]
data.nextCursor;   // pass back as { cursor } for the next page

If you only need to know how many records match, you may use the count method instead:

TypeScript
const { data } = await ideas.count({ where: [['status', '=', 'draft']] });

The supported operators are =, !=, <, <=, >, >= and in.

Filtering and ordering work only on indexed paths. The declared schema is the query plan, and that is not a limitation the SDK could hide. Decide your query paths when you declare the collection. If you find yourself wanting a ninth index, the shape is probably wrong.

The page size caps at 100. Lists are always scoped: you cannot read another room's records, another participant's user records, or another attempt's records, because the Rack resolves the scope from the session, never from your payload.

Permissions

Reads are scoped; writes are also owned. By default, only the participant who created a record may update or delete it. You may relax this per collection using the permissions field:

Value Who may
owner The participant who created it. The default.
members Anyone who can see it: everyone in the room, for a room collection.
facilitator The owner, plus whoever currently holds the facilitator role.

For example, moderation of a shared board is "delete": "facilitator", while a genuinely collaborative document is "update": "members". Anything stricter than owner does not exist, because scope already decides visibility.

Quotas

The Rack enforces declared quotas on the server, regardless of what the applet believes. You may lower either limit in your declaration, but never raise it past the hard ceiling:

Limit Default Hard ceiling
max_records per scope 500 5000
max_bytes per record 16 KB 64 KB

Over quota, create() returns quota_exceeded. Handle it: a shared board hitting its cap during a live session is a real event, not a theoretical one.

Seeding

An applet collection may ship its initial records in the package. This is how you bring along a catalog, a question bank or any other data an existing app kept in a JSON file. To seed a collection, point its seed field at a file in your package:

JSON
{
    "name": "products",
    "scope": "applet",
    "schema": { "type": "object", "properties": { "sku": { "type": "string" }, "title": { "type": "string" } } },
    "indexed": ["sku"],
    "seed": "data/products.json"
}

The seed file is a JSON array of records, each one a document exactly as create() would take it:

JSON
[
    { "sku": "A-100", "title": "Starter kit" },
    { "sku": "A-200", "title": "Refill pack" }
]

The seed is checked at install and loaded at promotion. At install, the Rack holds every record to the rules a write would face: it must be an object, it must match the schema, it must fit max_bytes, and the file must fit max_records. One bad record refuses the whole package, and the error names the record's index and the failing path. A package with a bad seed never installs.

When the version is promoted, the seed loads into the collection only if its live dataset is empty. A seed is the starting data, never a reset. Once the applet has records, a later version's seed file is not loaded, even if it changed, and the cutover plan says so. To change records that already exist, ship a data migration.

There are a few more things to know about seeds:

  • Only applet scope. No room, participant or attempt exists at install, so nothing else has a scope to seed into.
  • Loaded once, then the data is yours. Records carry over every promotion. If the collection is emptied (preview records do not count), the next promotion loads the seed again.
  • Seeded records have no owner. owner_id is null, so under the default owner permission participants can read them but cannot change them. Declare members or facilitator if they should be editable.
  • The quota still applies. A seed is capped at 5000 records, and so is everything later written to that scope.
  • Not in the mock Rack. The SDK's MockRack does not load seed files. In local development, create the records from your dev harness.

Data Across Versions

Versions are immutable; data is not. Promoting a version carries the applet's data over to it. user, room and applet records are kept and checked against the new version's collections, while attempt records are left alone, in the shape their attempt wrote them.

Nothing about your data changes at install. The Rack checks a new version's collections, but the live version keeps reading what it always read until promotion. If an upload asks to be promoted straight away and the cutover needs a decision, the version stays installed but not current.

Planning the Cutover

At promotion, the Rack plans the cutover. For each declared collection, it works out whether the collection is added, removed, restored, changed or unchanged, and describes the structural diff: a new required property, a narrowed enum, a changed quota. When no migration runs, it also validates every existing record against the new schema and quota.

A promotion that would leave records the new version rejects, strand a collection that holds records, or roll migrations back is never done silently. It is refused until a human confirms, and the refusal carries the plan. You may read how the refusal reasons look in Publishing.

No silent data loss. A version whose schema or quota rejects existing records, or that drops a collection holding records, is not promoted until someone has seen what changes and confirmed it.

The following table describes what happens at promotion for each kind of change:

The new version… What happens at promotion
Changes nothing about its collections Promotes. Records are untouched; no snapshot is taken.
Makes a compatible change: a new optional property, a wider enum, a new index, a new collection Promotes. Existing records validate, so they carry over as they are. A snapshot is taken first when a collection holding records changed. New collections start empty, or with their seed.
Ships a migration that has not run on this applet Refused with migration_required until the Rack has run the pending migrations and checked the result. Then it commits.
Makes an incompatible change with no migration Refused with data_loss, naming how many records the new schema rejects, with samples. Once confirmed, rejected records are deleted, or kept as they are if the person promoting chooses to. A kept record fails validation on its next update until it is fixed.
Drops a collection that holds records Refused with data_loss. Once confirmed, the collection is set aside with its records, not deleted. A later version that declares it again gets them back.
Changes a collection's scope Refused at install: Collection [x] cannot change scope from [room] to [applet]; use a new collection name. A migration cannot move records between scopes either.
Lowers max_records below what a scope holds, or max_bytes below a record's size Refused with data_loss. Once confirmed, oversize records count as rejected. A scope over its new record quota keeps its records, but refuses create() with quota_exceeded until it is back under.
Ships a changed seed file for a collection that has records Promotes. The seed is not loaded, and the plan notes it. Use a migration.
Is an earlier version that lacks migrations already run (a rollback) Those migrations run down, newest first. See Rolling Back. An earlier version with no migrations involved is treated like any other change.
Is the applet's first Nothing to carry over. Its migrations are recorded as applied without running, and the seed loads.

Preview Records and Pinned Attempts

Two more rules apply to every cutover.

First, preview records are dropped where shapes change. A cutover that runs migrations deletes every preview record of the applet. One that does not run migrations deletes the preview records of the collections whose definition changed. Migrations never see preview records.

Second, pinned attempts keep the old shape. An in-progress attempt on the outgoing version reads and writes user, room and applet records through that version's definition until it completes. After a cutover that reshapes those records, it can still write records in the old shape.

If your applet is pinned, write code that tolerates both shapes, or reshape only what in-progress attempts do not write.

Data Migrations

When a new version needs existing records in a new shape (a renamed property, a split collection, a value back-filled from another), you may ship a data migration. A migration is an ES module in a migrations/ directory at the zip root:

my-applet-2.0.0.zip
├── manifest.json
├── migrations/
│   ├── 2026_09_01_090000_add_difficulty.js
│   └── 2026_10_09_120000_split_archived_ideas.js
└── …

Migration files follow these rules:

Rule
Name YYYY_MM_DD_HHMMSS_name.js (or .mjs), lowercase, directly inside migrations/. Anything else refuses the package at install.
Order Name order. The timestamp prefix is the order.
Cumulative Keep every migration you have ever shipped. The Rack runs the ones this applet has not run yet, so any earlier version can be brought forward.
Self-contained Each file is imported on its own, with no network. Do not import other files or packages. The starter copies migrations/ into the zip as written, unbundled.
Exports up(db, ctx), and down(db, ctx) if the change can be undone. Either as named exports or on the default export.

Writing a Migration

The simplest migration mutates records in place. For example, the following migration gives every question a default difficulty, and removes it again on the way down:

JavaScript
// migrations/2026_09_01_090000_add_difficulty.js
export function up(db, ctx) {
    for (const question of db.questions) {
        question.data.difficulty ??= 'medium';
    }
    ctx.log(`set a difficulty on ${db.questions.length} questions`);
}

export function down(db) {
    for (const question of db.questions) {
        delete question.data.difficulty;
    }
}

The db argument holds the applet's carried records, keyed by collection name: every user, room and applet collection, including ones the outgoing version no longer declares. Each record has the following shape:

TypeScript
interface MigrationRecord {
    id: string;
    scope_id: number;          // the room or participant; 0 for applet scope
    owner_id: number | null;
    data: object;              // the document, as create() took it
    created_at: string;
    updated_at: string;
}

Of course, you may not always want to edit in place. Instead, you may return { collection: records[] } for the collections you replace. A collection you return replaces that collection; one you leave out is kept unchanged, so its records must already fit the target version:

JavaScript
// migrations/2026_10_09_120000_split_archived_ideas.js
export default {
    up(db) {
        return {
            ideas: db.ideas.filter((idea) => idea.data.status !== 'archived'),
            archived_ideas: db.ideas.filter((idea) => idea.data.status === 'archived'),
        };
    },

    down(db) {
        return { ideas: [...db.ideas, ...db.archived_ideas] };
    },
};

In this example, archived ideas move to their own collection. A record that appears under another collection's name moves there, keeping its id. To add a record, leave out id and it will be given a new one. A collection the target version does not declare cannot be written to: returning it is ignored.

attempt records are never passed in; they stay in the shape their attempt wrote them. Preview records are not passed in either.

The Migration Context

The second argument, ctx, is { direction, applet, from, to, scopes, log }:

Key Value
direction 'up' or 'down'.
applet The applet slug.
from The outgoing version string, or null when nothing was current.
to The target version string.
scopes Each collection's scope, by name.
log(...) Writes a line that is kept with the snapshot taken before the cutover.

console.log, info, warn and error are captured the same way as log.

What the Rack Checks

The Rack checks what comes back. Every record the cutover leaves in place, returned or kept, must match the target version's schema and fit its quota. In addition:

  • A record cannot change its scope_id or owner_id.
  • A record can move only to a collection with the same scope.
  • A new record in a user or room collection needs the scope_id of a participant or room that already has records, and its owner_id must be null or someone who already owns one.
  • Records missing from a returned collection are deleted.

If any check fails, nothing is written, and the errors name the collection, the record and the problem.

These are checks on structure, not on which participant's values end up in which record. A migration sees every scope's data, so it is up to your code to keep each participant's values in their own records.

How Migrations Run

Migrations run in the browser of whoever promotes the version, in a sandboxed frame with no network access (see Runtime Environment), one after another, each with a time limit. A migration that throws, times out, lacks the function it needs, or returns anything other than { collection: records[] } stops the run, and nothing is written.

If the applet's data changes while the migrations run, the promotion is refused with data_changed and they run again.

Rolling Back

The Rack records which migrations have run on your applet, along with each one's source. Going forward, pending up functions run in name order. Promoting a version that lacks some applied migrations runs their down functions in reverse, from the stored source, so a rollback works even after a later package dropped the file.

A migration with no down cannot be rolled back. The Rack says so, and the way back is the snapshot taken before that migration ran.

Testing Migrations

Migrations are plain functions over plain arrays, so you may test them like any other code: import the module, call up on a fixture, and assert on the result. The mock Rack does not run them.

Snapshots

Before every cutover that changes data, the Rack copies the applet's entire dataset: every record in every collection and scope, preview and set-aside ones included, the collection definitions, the migration ledger and the applet's role assignments. The copy is complete before the cutover commits, so a migration that wrecks the data can always be undone.

A cutover never removes a record its snapshot does not hold. If a write lands between planning the cutover and committing it, the promotion is refused with data_changed before anything is deleted. Likewise, a promotion is refused with version_changed if another one committed while it was being prepared. Either way, plan the cutover again and retry.

Restoring a snapshot replaces all of the applet's records, collection definitions, migration ledger and role assignments with the snapshot's, then makes the version that was current when it was taken current again, if that version is still ready. A restore is itself preceded by a snapshot, so it can be undone too.