workspace-api
npx skills add https://github.com/epicenterhq/epicenter --skill workspace-api
Agent 安装分布
Skill 文档
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/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 are flexible â _v is optional. Both patterns work:
Without _v (field presence)
import { defineKv } from '@epicenter/workspace';
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. Wrap 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>;
const chatMessagesTable = defineTable(
type({
id: ChatMessageId, // Different branded type
conversationId: ConversationId, // FK to conversations â branded
role: "'user' | 'assistant'",
_v: '1',
}),
);
export type ChatMessage = InferTableRow<typeof chatMessagesTable>;
// 3. Compose in createWorkspace
export const workspaceClient = createWorkspace(
defineWorkspace({
tables: {
conversations: conversationsTable,
chatMessages: chatMessagesTable,
},
}),
);
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 ConversationId
Reference Implementation
See apps/tab-manager/src/lib/workspace.ts for the canonical example with 7 branded ID types.
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 }> - 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 â redundant
References
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