home / skills / epicenterhq / epicenter / static-workspace-api

static-workspace-api skill

/.agents/skills/static-workspace-api

This skill helps you manage versioned table and KV schemas with migration workflows for safe schema evolution.

npx playbooks add skill epicenterhq/epicenter --skill static-workspace-api

Review the files below or copy the command above to add this skill to your agents.

Files (1)
SKILL.md
4.2 KB
---
name: static-workspace-api
description: Static 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.
metadata:
  author: epicenter
  version: '2.0'
---

# Static 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:

```typescript
import { defineTable } from 'epicenter/static';
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:

```typescript
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)

```typescript
import { defineKv } from 'epicenter/static';

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)

```typescript
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;
		}
	});
```

## 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)

```typescript
// 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:

```typescript
return { ...row, views: 0, _v: 2 }; // Works — contextual narrowing
return { ...row, views: 0, _v: 2 as const }; // Also works — redundant
```

## References

- `packages/epicenter/src/static/define-table.ts`
- `packages/epicenter/src/static/define-kv.ts`
- `packages/epicenter/src/static/index.ts`
- `packages/epicenter/src/static/create-tables.ts`
- `packages/epicenter/src/static/create-kv.ts`

Overview

This skill provides a type-safe, versioned API for defining workspace tables and key-value (KV) stores using defineTable and defineKv. It enforces schema discriminants, supports multi-version builder patterns, and includes structured migration functions. Use it to manage schema evolution with clear migration rules and compile-time guarantees.

How this skill works

You declare schemas either as a single-version shorthand or with a builder that registers multiple .version(...) entries. Tables must include a numeric _v discriminant; KV stores may omit _v or use it explicitly. A .migrate(fn) handler receives any older version and must return the latest-version shape, with the type system narrowing _v automatically.

When to use it

  • Defining a new table or KV store schema for workspace data.
  • Adding a new schema version to an existing table or KV store.
  • Writing migration functions to transform older rows to the latest version.
  • Converting shorthand single-version schemas to the builder pattern for future versions.
  • Enforcing compile-time guarantees about schema versions and required fields.

Best practices

  • Always include _v (number literal) for tables; KV stores can omit it if you prefer field-presence detection.
  • Place _v as the last field in schema objects to follow the convention.
  • Write migration functions that switch on row._v and return the latest shape in each case.
  • Migrate directly to the latest version in one step — avoid incremental chaining through intermediate versions.
  • Rely on TypeScript’s contextual narrowing; you don’t need as const for _v in migration returns.

Example use cases

  • Create a users table with a required _v and extend it later with new fields via .version(...) and .migrate(...).
  • Define a theme KV store without _v, then add a fontSize field in a later version and migrate existing entries.
  • Convert a single-version posts table shorthand into a builder to add views and tags in subsequent versions.
  • Implement a migration that transforms v1 rows directly to v3 by handling each discriminant case.
  • Use defineKv() for flexible per-user settings where schema changes are optional and field-presence is acceptable.

FAQ

Do I always need _v for KV stores?

No. KV stores can omit _v and rely on field presence, but you may include _v if you want an explicit numeric discriminant.

Should migrations step through intermediate versions?

No. Migrations should convert directly to the latest version in a single function for clarity and correctness.