Skip to content

Schemas

Document types are defined with a schema — a single object that declares fields, permissions, membership, sharing, and UI metadata. The schema is the single source of truth for a document type.

Defining a schema

typescript
import { DocumentTypeSchema } from 'document-store';

const discussionSchema: DocumentTypeSchema = {
    type: 'discussion',

    meta: {
        label: { en: 'Discussion', fi: 'Keskustelu' },
        icon: 'chat',
    },

    fields: {
        name: { type: 'string', required: true, maxLength: 128 },
        description: { type: 'string', display: 'textarea' },
        members: {
            type: 'array',
            membership: {
                userField: 'userId',
                roleField: 'role',
                roleHierarchy: ['admin', 'member'],
            },
            temporal: {
                table: 'discussion_members',
                key: 'userId',
            },
            items: {
                userId: { type: 'uid', required: true },
                role: { type: 'enum', values: ['admin', 'member'] },
            },
        },
    },

    write: {
        '*': { allow: 'uid' },
        $delete: { allow: 'uid' },
        $child: {
            comment: {
                $create: { allow: 'any' },
                '*': { allow: 'uid' },
                $delete: { allow: ['uid', '^uid'] },
            },
        },
    },
};

Schema form vs document form

The { allow: 'uid' } shape above is the schema form — it can carry extra knobs like immutable and unless. Documents store a stripped form that uses bare permissions: { '*': 'uid', $delete: 'uid', ... }. Passing the schema form directly into store.add() raises Unknown permission type: object at write time.

Use extractWriteRules(schema) to get the document-shaped rules, or write them in the stripped form directly. See Write Rules for the full stored format.

Registering a type

Pass the schema to registerType(). This creates the rendered table, extracts membership config, and sets up temporal tables:

typescript
store.registerType(discussionSchema);

You can also register a minimal type with just a name:

typescript
store.registerType('bookmark');

(Search indexes and field naming are render-handler concerns — see Search and Rendering.)

Schema structure

type

Unique identifier for this document type. Used in add() and as the rendered table/collection name.

meta

Human-readable metadata for UI:

typescript
meta: {
    label: { en: 'Bookmark', fi: 'Kirjanmerkki' },
    description: { en: 'A saved link' },
    icon: 'bookmark',
}

Labels are localized strings — objects keyed by locale.

fields

Field definitions with types, validation, and UI hints:

typescript
fields: {
    url: {
        type: 'string',
        required: true,
        maxLength: 2048,
        label: { en: 'URL' },
        placeholder: { en: 'https://...' },
    },
    tags: {
        type: 'array',
        items: { type: 'string' },
        label: { en: 'Tags' },
    },
    priority: {
        type: 'enum',
        values: ['low', 'medium', 'high'],
    },
}

Field types

TypeDescription
stringText, with optional maxLength, pattern, display
numberNumeric value
booleanTrue/false
uidIdentity UID (Buffer)
hashDocument hash (Buffer)
bytesArbitrary binary data (Buffer)
dateTimestamp
enumOne of values
arrayList of items, with items schema
objectNested object, with items as field map

Display hints

HintEffect
display: 'text'Single-line input (default)
display: 'textarea'Multi-line text
display: 'markdown'Markdown editor
display: 'rich'Rich text editor
display: 'hidden'Not shown in forms

Membership on array fields

An array field can declare that it represents a membership group. See Membership below.

Temporal tracking on array fields

An array field can track its full add/remove history. See Temporal Arrays.

write

Permission rules controlling who can edit which fields. See Write Rules.

share

Default sharing policy for new documents of this type. See Share Policies.

Membership

When an array field has a membership declaration, document-store computes tokens from the member list. These tokens enable sync access — members can pull the document without being listed in share.users.

Declaring membership

typescript
fields: {
    members: {
        type: 'array',
        membership: {
            userField: 'userId',               // which sub-field holds the user identity
            roleField: 'role',                  // which sub-field holds the role (optional)
            roleHierarchy: ['admin', 'member'], // highest to lowest privilege
        },
        items: {
            userId: { type: 'uid', required: true },
            role: { type: 'enum', values: ['admin', 'member'] },
        },
    },
}

How tokens work

When a document with membership is created or edited, the store computes tokens from the members array and stores them alongside the rendered document.

Document tokens are computed from the member list:

  • A discussion with members [{userId: alice, role: 'admin'}, {userId: bob, role: 'member'}] gets tokens like discussion_<hash>:admin and discussion_<hash>:member

User tokens are computed for each peer during sync:

  • Alice (admin) gets tokens for admin and member (hierarchy — admin includes member-level access)
  • Bob (member) gets a token for member only

Access is granted when a peer's user tokens match the document's tokens.

Why tokens?

Without membership tokens, you'd need share: { users: { alice: ..., bob: ... } } on every document. When a new member joins, you'd have to edit the share policy on every existing document — write amplification.

With tokens, adding a member to the discussion automatically grants them access to the discussion and all its children (via share: { ref: 'parent' }). No edits to existing documents needed.

Membership + temporal

Membership and temporal work together. Declare both on the same field:

typescript
members: {
    type: 'array',
    membership: {
        userField: 'userId',
        roleField: 'role',
        roleHierarchy: ['admin', 'member'],
    },
    temporal: {
        table: 'discussion_members',
        key: 'userId',
    },
    items: { ... },
}
  • Membership drives access control (who can sync now)
  • Temporal preserves history (who was a member when)

Current tokens are computed from the rendered document (current members only). The temporal table tracks the full history independently.

Extraction

The schema contains both logic and UI metadata. Extract what each layer needs:

typescript
// NOTE: these helpers are not exported by the package today — apps vendor
// a small schema module (see apps/forge/backend for the pattern). The
// extraction logic is mechanical; the shapes below are what they produce.
import { extractWriteRules, extractCapabilities, extractMembership } from './schema';

// For document-store — permission logic only
const writeRules = extractWriteRules(discussionSchema);
// → { '*': 'uid', $delete: 'uid', $child: { comment: { ... } } }

// For frontend — UI metadata (labels, icons, actions)
const capabilities = extractCapabilities(discussionSchema);
// → { type, meta, fields: { name: { label, ... }, ... }, actions: [...] }

// For membership config
const membership = extractMembership(discussionSchema);
// → { field: 'members', userField: 'userId', roleField: 'role', roleHierarchy: [...] }

Use extractWriteRules when creating documents. registerType() does not auto-apply schema rules to documents — every document must carry its own write field for children to be acceptable. If you omit write on a parent, add() of a child fails with Parent has no rules for child type 'X'.

typescript
const [errors, hash] = await store.add('discussion', {
    uid,
    name: 'Project Chat',
    members: [{ userId: uid, role: 'admin' }],
    write: extractWriteRules(discussionSchema),
    share: { self: true },
});

Schema validation

Content validation runs inside the store (the Rust engine): register a full schema with registerTypeSchema() and every add() and edit() is validated against it — required fields, types, lengths, enums. Registering a schema is opting into enforcement; registerType('name') alone stays unvalidated.

typescript
const store = await createStore({ storage: './data/ds.sqlite', renderHandler });

await store.registerTypeSchema({
    type: 'bookmark',
    fields: {
        url:   { type: 'string', required: true, maxLength: 2048 },
        title: { type: 'string', maxLength: 256 },
    },
});

// This will fail — url is required
const [errors, hash] = await store.add('bookmark', {
    uid,
    title: 'Missing URL',
});
// errors contains field-level validation errors

Because validation lives in the engine, every language lane gets the same enforcement — and synced writes from peers are validated by the same rules as local ones.

Why a master schema?

In a typical app, permissions and UI are defined separately and inevitably drift. A button says "Edit" but the backend rejects the write. A field is marked required in the form but optional in the store.

The master schema keeps them in sync:

  • Change a permission → the UI action updates automatically
  • Add a field → validation, labels, and permissions are defined in one place
  • The extraction is mechanical — no manual synchronization needed

This is particularly valuable in P2P apps where write rules travel with the document. The rules stored in the document are the same ones the UI reads to decide what to show.