workspace-api
Workspace API
Type-safe schema definitions for tables and KV stores.
When to Apply This Skill
- Defining a new table or KV store with
defineTable()ordefineKv() - Adding a new version to an existing table definition
- Writing table migration functions
Tables
Shorthand (Single Version)
Use when a table has only one version:
import { defineTable } from '@epicenter/workspace';
import { type } from 'arktype';
const usersTable = defineTable(type({ id: UserId, email: 'string', _v: '1' }));
export type User = InferTableRow<typeof usersTable>;
Every table schema must include _v with a number literal. The type system enforces this — passing a schema without _v to defineTable() is a compile error.
Builder (Multiple Versions)
Use when you need to evolve a schema over time:
const posts = defineTable()
.version(type({ id: 'string', title: 'string', _v: '1' }))
.version(type({ id: 'string', title: 'string', views: 'number', _v: '2' }))
.migrate((row) => {
switch (row._v) {
case 1:
return { ...row, views: 0, _v: 2 };
case 2:
return row;
}
});
KV Stores
KV stores use defineKv(schema, defaultValue). No versioning, no migration—invalid stored data falls back to the default.
import { defineKv } from '@epicenter/workspace';
import { type } from 'arktype';
const sidebar = defineKv(type({ collapsed: 'boolean', width: 'number' }), { collapsed: false, width: 300 });
const fontSize = defineKv(type('number'), 14);
const enabled = defineKv(type('boolean'), true);
KV Design Convention: One Scalar Per Key
Use dot-namespaced keys for logical groupings of scalar values:
// ✅ Correct — each preference is an independent scalar
'theme.mode': defineKv(type("'light' | 'dark' | 'system'"), 'light'),
'theme.fontSize': defineKv(type('number'), 14),
// ❌ Wrong — structured object invites migration needs
'theme': defineKv(type({ mode: "'light' | 'dark'", fontSize: 'number' }), { mode: 'light', fontSize: 14 }),
With scalar values, schema changes either don't break validation (widening 'light' | 'dark' to 'light' | 'dark' | 'system' still validates old data) or the default fallback is acceptable (resetting a toggle takes one click).
Exception: discriminated unions and Record<string, T> | null are acceptable when they represent a single atomic value.
Branded Table IDs (Required)
Every table's id field and every string foreign key field MUST use a branded type instead of plain 'string'. This prevents accidental mixing of IDs from different tables at compile time.
Pattern
Define a branded type + arktype validator + generator in the same file as the workspace definition:
import type { Brand } from 'wellcrafted/brand';
import { type } from 'arktype';
import { generateId, type Id } from '@epicenter/workspace';
// 1. Branded type + arktype validator (co-located with workspace definition)
export type ConversationId = Id & Brand<'ConversationId'>;
export const ConversationId = type('string').as<ConversationId>();
// 2. Generator function — the ONLY place with the cast
export const generateConversationId = (): ConversationId =>
generateId() as ConversationId;
// 3. Use in defineTable + co-locate type export
const conversationsTable = defineTable(
type({
id: ConversationId, // Primary key — branded
title: 'string',
'parentId?': ConversationId.or('undefined'), // Self-referencing FK
_v: '1',
}),
);
export type Conversation = InferTableRow<typeof conversationsTable>;
// 4. At call sites — use the generator, never cast directly
const newId = generateConversationId(); // Good
// const newId = generateId() as string as ConversationId; // Bad
Rules
- Every table gets its own ID type:
DeviceId,SavedTabId,ConversationId,ChatMessageId, etc. - Foreign keys use the referenced table's ID type:
chatMessages.conversationIdusesConversationId, not'string' - Optional FKs use
.or('undefined'):'parentId?': ConversationId.or('undefined') - Composite IDs are also branded:
TabCompositeId,WindowCompositeId,GroupCompositeId - Use generator functions: When IDs are generated at runtime, use a
generate*factory:generateConversationId(). Never scatter double-casts across call sites. - Functions accept branded types:
function switchConversation(id: ConversationId)not(id: string)
Why Not Plain 'string'
// BAD: Nothing prevents mixing conversation IDs with message IDs
function deleteConversation(id: string) { ... }
deleteConversation(message.id); // Compiles! Silent bug.
// GOOD: Compiler catches the mistake
function deleteConversation(id: ConversationId) { ... }
deleteConversation(message.id); // Error: ChatMessageId is not ConversationId
Reference Implementation
See apps/tab-manager/src/lib/workspace.ts for the canonical example with 7 branded ID types and 4 generator functions.
See packages/filesystem/src/ids.ts for the reference factory pattern (generateRowId, generateColumnId, generateFileId).
See specs/20260312T180000-branded-id-convention.md for the full inventory and migration plan.
Workspace File Structure
A workspace file has two layers:
- Table definitions with co-located types —
defineTable(schema)as standalone consts, each immediately followed byexport type = InferTableRow<typeof table> createWorkspace(defineWorkspace({...}))call — composes pre-built tables into the client
Pattern
import {
createWorkspace,
defineTable,
defineWorkspace,
type InferTableRow,
} from '@epicenter/workspace';
// ─── Tables (each followed by its type export) ──────────────────────────
const usersTable = defineTable(
type({
id: UserId,
email: 'string',
_v: '1',
}),
);
export type User = InferTableRow<typeof usersTable>;
const postsTable = defineTable(
type({
id: PostId,
authorId: UserId,
title: 'string',
_v: '1',
}),
);
export type Post = InferTableRow<typeof postsTable>;
// ─── Workspace client ───────────────────────────────────────────────────
export const workspaceClient = createWorkspace(
defineWorkspace({
id: 'my-workspace',
tables: {
users: usersTable,
posts: postsTable,
},
}),
);
Why This Structure
- Co-located types: Each
export typesits right below itsdefineTable— easy to verify 1:1 correspondence, easy to remove both together. - Error co-location: If you forget
_vorid, the error shows on thedefineTable()call right next to the schema — not buried insidedefineWorkspace. - Schema-agnostic inference:
InferTableRowworks with any Standard Schema (arktype, zod, etc.) and handles migrations correctly (always infers the latest version's type). - Fast type inference:
InferTableRow<typeof usersTable>resolves against a standalone const. Avoids the expensiveInferTableRow<NonNullable<(typeof definition)['tables']>['key']>chain that forces TS to resolve the entiredefineWorkspacereturn type. - No intermediate
definitionconst:defineWorkspace({...})is inlined directly intocreateWorkspace()since it's only used once.
Anti-Pattern: Inline Tables + Deep Indirection
// BAD: Tables inline in defineWorkspace, types derived through deep indirection
const definition = defineWorkspace({
tables: {
users: defineTable(type({ id: 'string', email: 'string', _v: '1' })),
},
});
type Tables = NonNullable<(typeof definition)['tables']>;
export type User = InferTableRow<Tables['users']>;
// GOOD: Extract table, co-locate type, inline defineWorkspace
const usersTable = defineTable(type({ id: UserId, email: 'string', _v: '1' }));
export type User = InferTableRow<typeof usersTable>;
export const workspaceClient = createWorkspace(
defineWorkspace({ tables: { users: usersTable } }),
);
The _v Convention
_vis a number discriminant field ('1'in arktype = the literal number1)- Required for tables — enforced at the type level via
CombinedStandardSchema<{ id: string; _v: number }> - Not used by KV stores — KV has no versioning;
defineKv(schema, defaultValue)is the only pattern - In arktype schemas:
_v: '1',_v: '2',_v: '3'(number literals) - In migration returns:
_v: 2(TypeScript narrows automatically,as constis unnecessary) - Convention:
_vgoes last in the object ({ id, ...fields, _v: '1' })
Table Migration Function Rules
- Input type is a union of all version outputs
- Return type is the latest version output
- Use
switch (row._v)for discrimination (tables always have_v) - Final case returns
rowas-is (already latest) - Always migrate directly to latest (not incrementally through each version)
Table Anti-Patterns
Incremental migration (v1 -> v2 -> v3)
// BAD: Chains through each version
.migrate((row) => {
let current = row;
if (current._v === 1) current = { ...current, views: 0, _v: 2 };
if (current._v === 2) current = { ...current, tags: [], _v: 3 };
return current;
})
// GOOD: Migrate directly to latest
.migrate((row) => {
switch (row._v) {
case 1: return { ...row, views: 0, tags: [], _v: 3 };
case 2: return { ...row, tags: [], _v: 3 };
case 3: return row;
}
})
Note: as const is unnecessary
TypeScript contextually narrows _v: 2 to the literal type based on the return type constraint. Both of these work:
return { ...row, views: 0, _v: 2 }; // Works — contextual narrowing
return { ...row, views: 0, _v: 2 as const }; // Also works — redundant
Document Content (Per-Row Y.Docs)
Tables with .withDocument() create a content Y.Doc per row. Content is stored using a timeline model: a Y.Array('timeline') inside the Y.Doc, where each entry is a typed Y.Map supporting text, richtext, and sheet modes.
Reading and Writing Content
Use handle.read()/handle.write() on the document handle:
const handle = await documents.open(fileId);
// Read content (timeline-backed)
const text = handle.read();
// Write content (timeline-backed)
handle.write('hello');
// Editor binding — Y.Text (converts from other modes if needed)
const ytext = handle.asText();
// Richtext editor binding — Y.XmlFragment (converts if needed)
const fragment = handle.asRichText();
// Spreadsheet binding — SheetBinding (converts if needed)
const { columns, rows } = handle.asSheet();
// Current content mode
handle.mode; // 'text' | 'richtext' | 'sheet' | undefined
// Advanced timeline operations
const tl = handle.timeline;
For filesystem operations, fs.content.read(fileId) and fs.content.write(fileId, data) open the handle and delegate to these methods internally.
Batching Mutations
Use handle.batch() to group multiple mutations into a single Yjs transaction:
handle.batch(() => {
handle.write('hello');
// ...other mutations
});
Do NOT call handle.ydoc.transact() directly. Use handle.batch() instead.
Anti-Patterns
Do not access handle.ydoc for content operations:
// ❌ BAD: bypasses timeline abstraction
const ytext = handle.ydoc.getText('content');
handle.ydoc.transact(() => { ... });
// ✅ GOOD: use handle methods
const ytext = handle.asText();
const fragment = handle.asRichText();
handle.batch(() => { ... });
handle.ydoc is an escape hatch for document extensions (persistence, sync providers) and tests. App code should never need it.
References
packages/workspace/src/workspace/define-table.tspackages/workspace/src/workspace/define-kv.tspackages/workspace/src/workspace/index.tspackages/workspace/src/workspace/create-tables.tspackages/workspace/src/workspace/create-kv.ts