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');

Or with options for search and custom timestamp rendering:

typescript
store.registerType('bookmark', {
    render: { createdAt: 'created', updatedAt: 'modified' },
    search: async (doc) => ({
        content: [doc.title, doc.url],
    }),
});

See Render options for details on timestamp customisation.

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
import { extractWriteRules, extractCapabilities, extractMembership } from 'document-store';

// 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

Document-store has an optional schemaValidator for validating document content on writes. When set, every add() and edit() validates against the registered schema.

typescript
import { DocumentStore, SurrealdbPersistence, SchemaValidator } from 'document-store';

const validator = new SchemaValidator();

validator.register({
    type: 'bookmark',
    properties: {
        url: { type: 'string', maxLength: 2048 },
        title: { type: 'string', maxLength: 256 },
    },
    required: ['url'],
});

const store = new DocumentStore({
    storage: new SurrealdbPersistence('surrealkv://./data'),
    schemaValidator: validator,
});

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

The validator supports custom keywords for binary types:

  • binaryLength — validates Buffer fields with exact length (e.g., 32-byte hashes)
  • binaryType — validates that a field is a Buffer

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.