Workspace API patterns for defineTable, defineKv, versioning, and migrations. Use when defining workspace schemas, adding versions to existing tables/KV stores, or writing migration functions.
Install
npx skillscat add epicenterhq/epicenter/workspace-api Install via the SkillsCat registry.
Workspace API
Type-safe schema definitions for tables and KV stores with versioned migrations.
When to Apply This Skill
- Defining a new table or KV store with
defineTable()ordefineKv() - Adding a new version to an existing definition
- Writing migration functions
- Converting from shorthand to builder pattern
Tables
Shorthand (Single Version)
Use when a table has only one version:
import { defineTable } from '@epicenter/hq';
import { type } from 'arktype';
const users = defineTable(type({ id: 'string', email: 'string', _v: '1' }));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 are flexible — _v is optional. Both patterns work:
Without _v (field presence)
import { defineKv } from '@epicenter/hq';
const sidebar = defineKv(type({ collapsed: 'boolean', width: 'number' }));
// Multi-version with field presence
const theme = defineKv()
.version(type({ mode: "'light' | 'dark'" }))
.version(type({ mode: "'light' | 'dark' | 'system'", fontSize: 'number' }))
.migrate((v) => {
if (!('fontSize' in v)) return { ...v, fontSize: 14 };
return v;
});With _v (explicit discriminant)
const theme = defineKv()
.version(type({ mode: "'light' | 'dark'", _v: '1' }))
.version(
type({ mode: "'light' | 'dark' | 'system'", fontSize: 'number', _v: '2' }),
)
.migrate((v) => {
switch (v._v) {
case 1:
return { ...v, fontSize: 14, _v: 2 };
case 2:
return v;
}
});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 pipe pair in the same file as the workspace definition:
import type { Brand } from 'wellcrafted/brand';
import { type } from 'arktype';
// 1. Branded type + arktype pipe (co-located with workspace definition)
export type ConversationId = string & Brand<'ConversationId'>;
export const ConversationId = type('string').pipe(
(s): ConversationId => s as ConversationId,
);
// 2. Use in defineTable schema
conversations: defineTable(
type({
id: ConversationId, // Primary key — branded
title: 'string',
'parentId?': ConversationId.or('undefined'), // Self-referencing FK
_v: '1',
}),
),
chatMessages: defineTable(
type({
id: ChatMessageId, // Different branded type
conversationId: ConversationId, // FK to conversations — branded
role: "'user' | 'assistant'",
_v: '1',
}),
),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 - Brand at generation site: When creating IDs with
generateId(), cast through string:generateId() as string as ConversationId - 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 ConversationIdReference Implementation
See apps/tab-manager/src/lib/workspace.ts for the canonical example with 7 branded ID types.
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 }> - Optional for KV stores — KV keeps full flexibility
- 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' })
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)
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 — redundantReferences
packages/epicenter/src/workspace/define-table.tspackages/epicenter/src/workspace/define-kv.tspackages/epicenter/src/workspace/index.tspackages/epicenter/src/workspace/create-tables.tspackages/epicenter/src/workspace/create-kv.ts