Appearance
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
| Type | Description |
|---|---|
string | Text, with optional maxLength, pattern, display |
number | Numeric value |
boolean | True/false |
uid | Identity UID (Buffer) |
hash | Document hash (Buffer) |
bytes | Arbitrary binary data (Buffer) |
date | Timestamp |
enum | One of values |
array | List of items, with items schema |
object | Nested object, with items as field map |
Display hints
| Hint | Effect |
|---|---|
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 likediscussion_<hash>:adminanddiscussion_<hash>:member
User tokens are computed for each peer during sync:
- Alice (admin) gets tokens for
adminandmember(hierarchy — admin includes member-level access) - Bob (member) gets a token for
memberonly
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 errorsThe 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.