EpicenterHQ

workspace-api

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.

EpicenterHQ 4,603 348 Updated 3mo ago
GitHub

Install

npx skillscat add epicenterhq/epicenter/workspace-api

Install via the SkillsCat registry.

SKILL.md

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() or defineKv()
  • 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

  1. Every table gets its own ID type: DeviceId, SavedTabId, ConversationId, ChatMessageId, etc.
  2. Foreign keys use the referenced table's ID type: chatMessages.conversationId uses ConversationId, not 'string'
  3. Optional FKs use .or('undefined'): 'parentId?': ConversationId.or('undefined')
  4. Composite IDs are also branded: TabCompositeId, WindowCompositeId, GroupCompositeId
  5. Brand at generation site: When creating IDs with generateId(), cast through string: generateId() as string as ConversationId
  6. 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.

The _v Convention

  • _v is a number discriminant field ('1' in arktype = the literal number 1)
  • 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 const is unnecessary)
  • Convention: _v goes last in the object ({ id, ...fields, _v: '1' })

Migration Function Rules

  1. Input type is a union of all version outputs
  2. Return type is the latest version output
  3. Use switch (row._v) for discrimination (tables always have _v)
  4. Final case returns row as-is (already latest)
  5. 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.ts
  • packages/epicenter/src/workspace/define-kv.ts
  • packages/epicenter/src/workspace/index.ts
  • packages/epicenter/src/workspace/create-tables.ts
  • packages/epicenter/src/workspace/create-kv.ts