Skip to content

refactor(workspace): remove KV migration machinery#1519

Merged
braden-w merged 18 commits intomainfrom
opencode/silent-squid
Mar 13, 2026
Merged

refactor(workspace): remove KV migration machinery#1519
braden-w merged 18 commits intomainfrom
opencode/silent-squid

Conversation

@braden-w
Copy link
Member

@braden-w braden-w commented Mar 13, 2026

Simplifies the workspace KV system end-to-end: polishes the schema layer, adds required defaults so get() always returns a typed value, then removes the migration machinery that defaults made unnecessary.

This is Wave 1 of the Whispering workspace polish spec plus two follow-on specs that it unlocked. The three phases built on each other—schema polish exposed that KV defaults were missing, defaults made migration structurally unnecessary, and removing migration was the payoff.

Wave 1 — Schema polish
  │  Break config blobs → individual KVs, flat camelCase schemas,
  │  discriminated unions for run tables, JSDoc on all entries
  │
  ▼
defineKv defaults
  │  Add required defaultValue param, simplify get() → always returns T,
  │  add observeAll(), update all call sites in Whispering
  │
  ▼
Remove KV migration
     Drop variadic overload + migrate field entirely.
     define-kv.ts: 163 → 47 lines.

KV defaults

defineKv(schema) had no concept of a default value. get() returned a discriminated union ({ status: 'valid', value } | { status: 'not_found' } | { status: 'invalid' }) that forced every consumer to handle three cases. For the upcoming settings separation (Wave 2), we need get() to always return a valid T.

// BEFORE: no default, consumers handle three cases
const result = kv.get('sound.manualStart');
if (result.status === 'valid') { /* use result.value */ }

// AFTER: required default, get() always returns T
const value = kv.get('sound.manualStart');
// value: boolean — stored value if valid, default otherwise

The default is never written to Yjs—it would pollute CRDT history and cause initialization races on multi-device sync. Added observeAll() for reactive observation of all KV entries. Updated all 44 defineKv call sites in Whispering to include defaults.

This phase also exposed a real bug: the isSecondArgSchema heuristic used typeof args[1] === 'object' to distinguish schemas from default values, but arktype schemas are functions. The check silently misidentified the second schema as a default value in multi-version definitions.

Remove KV migration machinery

Removes the variadic defineKv(v1, v2, ...).migrate(fn, default) pattern and all migration infrastructure from KV stores. defineKv now has exactly one signature: defineKv(schema, defaultValue).

The variadic pattern had zero production usage across 44 defineKv call sites in Whispering—every single one already used the shorthand. The multi-version overload existed only for test coverage, and the isSecondArgSchema detection heuristic it relied on caused a real bug: arktype schemas are functions, not objects, so typeof args[1] === 'object' silently misidentified the second schema as a default value. More fundamentally, KV stores hold scalar preferences, not accumulated rows. Schema changes either don't break validation (widening an enum) or the default fallback is acceptable (resetting a toggle). Migration is structurally unnecessary for this data lifecycle.

// BEFORE — two overloads, migration identity function on every read
defineKv(schema, defaultValue)          // → { schema, migrate: (v) => v, defaultValue }
defineKv(v1, v2).migrate(fn, default)   // → { schema: union, migrate: fn, defaultValue }

// get() called definition.migrate(result.value) on every read

// AFTER — one function, validate-or-default
defineKv(schema, defaultValue)          // → { schema, defaultValue }

// get() returns result.value directly
BEFORE                                    AFTER
──────                                    ─────

defineKv(schema, default)                 defineKv(schema, default)
  → { schema, migrate: identity, default }  → { schema, default }

defineKv(v1, v2).migrate(fn, default)     (removed)
  → { schema: union, migrate: fn, default }

get(key):                                 get(key):
  raw = ykv.get(key)                        raw = ykv.get(key)
  if missing → default                     if missing → default
  validate(raw)                             validate(raw)
  if invalid → default                     if invalid → default
  migrate(validated) → return               return validated

The type system reflects the simplification. KvDefinition dropped its TVersions tuple generic for a single TSchema, and the migrate field is gone:

// BEFORE
type KvDefinition<TVersions extends readonly CombinedStandardSchema[]> = {
  schema: CombinedStandardSchema<unknown, StandardSchemaV1.InferOutput<TVersions[number]>>;
  migrate: (value: ...) => StandardSchemaV1.InferOutput<LastSchema<TVersions>>;
  defaultValue: StandardSchemaV1.InferOutput<LastSchema<TVersions>>;
};

// AFTER
type KvDefinition<TSchema extends CombinedStandardSchema> = {
  schema: TSchema;
  defaultValue: StandardSchemaV1.InferOutput<TSchema>;
};

InferKvValue simplified from LastSchema indirection to direct extraction. InferKvVersionUnion was removed entirely. LastSchema and createUnionSchema remain untouched—tables still use them.

In create-kv.ts, the parseValue helper (which existed solely to call definition.migrate) was removed. get(), observe(), and observeAll() now return result.value directly after validation. The file went from importing CombinedStandardSchema and KvGetResult (for the helper's type signature) to importing only what it needs.

define-kv.ts went from 163 lines (two overloads, implementation with isSecondArgSchema branching, createUnionSchema import) to 47 lines (one function that returns { schema, defaultValue }).

Six documentation files were updated to replace variadic KV examples with the shorthand pattern. The workspace-api skill file now documents a scalar-per-key convention: use dot-namespaced keys ('theme.mode', 'theme.fontSize') instead of structured objects, which makes migration structurally unnecessary. A seventh doc (inline-single-use-definitions-in-tests.md) was caught in a straggler sweep—its defineKv examples were missing the now-required default value.

All 347 tests pass. Zero production code changes—every Whispering defineKv call was already using the shorthand.

Schema polish

The Whispering workspace definition had structural issues from rapid prototyping: a transcription.config blob KV that should have been individual keys, transformationSteps using a nested discriminated union that could be a flat camelCase schema, and no JSDoc on any table or KV group.

// BEFORE: one blob with mixed concerns
'transcription.config': defineKv(TranscriptionConfig)
// get() returns the whole object; changing one field requires read-modify-write

// AFTER: individual keys with scalar-per-key convention
'transcription.outputLanguage': defineKv(type("string"), 'en')
'transcription.prompt': defineKv(type("string"), '')
'transcription.temperature': defineKv(type("number"), 0)

Also added completion.openrouter.model KV entry and used discriminated unions for transformation run schemas (spec Decision 4).

Why remove instead of deprecate?

Zero production callers. The variadic pattern had test-only usage and had already caused one bug. Deprecation warnings serve callers who need time to migrate; there were none.

Why not add an optional migrate callback?

No production KV entry needs one today. If a genuine case appears, the escape hatch is straightforward: add a new KV key with the new schema and write app-level migration code. Or add { migrate? } as an optional third parameter at that time.

Tables are deliberately different

Tables accumulate rows that must survive schema changes—migration is mandatory. KV stores hold single preferences where resetting to default is acceptable. This PR makes the asymmetry explicit rather than having KV borrow table machinery it doesn't need.

braden-w added 18 commits March 12, 2026 14:19
…with flat camelCase schema

table.set() replaces entire rows atomically—dot-notation keys provided
zero per-field conflict resolution benefit and forced bracket access.
camelCase is consistent with every other table in workspace.ts;
dot-notation is reserved for KV keys where per-key LWW matters.
Single blob meant LWW would overwrite service + model together on
concurrent edits. Individual KVs give per-key resolution—changing the
service on one device and the model on another both survive. Per-service
model memory is preserved when switching between transcription services.
Only missing synced setting found during audit of settings.ts.
OpenRouter model preference roams across devices.
Wave D audit found all types already correct—the two mismatches with
settings.ts (retention.maxCount as number vs string.digits, temperature
as number vs string) are intentional: the workspace uses semantically
correct types while settings.ts has localStorage string artifacts.

Wave E adds JSDoc explaining design rationale for flat row schema,
per-service model memory, LWW key granularity, and intentional type
differences with the old settings schema.
ASCII diagrams show where data lives today vs after migration, and
the wave progression from schema polish through settings split,
migration, and eventual sync wiring.
…schemas

Replace flat nullable fields with arktype discriminated unions on `status`
for `transformationRuns` and `transformationStepRuns` tables. Uses the
`Base.merge(type.or(...))` pattern where `.merge()` distributes over each
union branch.

Unlike `transformationSteps` (which stays flat to preserve per-provider model
memory across type switches), runs have one-way state transitions with no data
to preserve across states.
Document why transformationRuns and transformationStepRuns use discriminated
unions on status while transformationSteps stays flat. Contrasts the one-way
state transition argument against Decision 1's bidirectional type switching.
…return type

- Add required defaultValue field to KvDefinition type
- Change KvHelper.get() return type from KvGetResult<T> to T directly
- Update defineKv() overloads to require defaultValue parameter
- Single-version: defineKv(schema, defaultValue)
- Multi-version: defineKv(v1, v2).migrate(fn, defaultValue)
- Distinguish schemas from values via '~standard' property check

Completed spec items 1, 2
- get() now returns defaultValue on miss/invalid instead of KvGetResult union
- Added observeAll() to KvHelper type and create-kv.ts implementation
- observeAll() batches all key changes per transaction, skips invalid/unknown
- Completed spec Tasks 3 and 4
…ersion schema detection

Update all 43 KV entries in workspace.ts with correct defaults per old
settings schema. Update all test files to pass defaultValue to defineKv().

Fix arktype schema detection in define-kv.ts: arktype schemas are
functions (typeof === 'function'), not objects, so the isSecondArgSchema
check now handles both types. This prevented multi-version defineKv(v1, v2)
from working correctly.
KV settings are simple flags—not versioned documents. The 'invalid →
return default' behavior already handles schema changes. The multi-schema
variadic overload adds complexity nobody uses.
…Kv, and createKv

- Simplify KvDefinition to single schema (no TVersions tuple, no migrate field)
- Rewrite defineKv to single signature: defineKv(schema, defaultValue)
- Remove variadic overload, isSecondArgSchema detection, createUnionSchema import
- Simplify createKv get/observe/observeAll to return validated values directly
- Remove parseValue helper and all definition.migrate calls from KV path
- Update tests: remove variadic/migration tests, keep shorthand + validate-or-default
- Completed spec items 1.1-5.3
- Rewrite workspace-api skill: KV section now shows defineKv(schema, default) only
- Add scalar-per-key convention to skill file
- Update versioned-schemas article: KV uses validate-or-default, not migration
- Update API design decisions: explain deliberate Tables/KV asymmetry
- Update static workspace guide: replace variadic KV examples with shorthand
- Update index.ts module JSDoc: simplified KV example
- Completed spec items 6.1-6.6
…section

All success criteria met. 347 tests pass, 0 production code changes needed.
Update inline-single-use-definitions-in-tests.md to use the current
defineKv(schema, defaultValue) signature. The old examples showed
defineKv(type(...)) without a default, which no longer compiles.
…type

KvDefinition now documents the full KV-vs-tables design rationale: why KV
uses validate-or-default instead of migration, and how defaultValue serves
dual duty (initial state + validation fallback). KvHelper methods (get, set,
delete, observe) expanded with behavioral details and usage examples.

Removed KvGetResult—the old three-status discriminated union from before
get() was simplified to always return T. Zero internal consumers remained.

Fixed stale defineKv examples in typescript SKILL.md that had _v discriminants
and were missing the now-required defaultValue argument.
@braden-w braden-w merged commit 8e1e825 into main Mar 13, 2026
1 of 9 checks passed
@braden-w braden-w deleted the opencode/silent-squid branch March 13, 2026 20:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant