Skip to content

refactor: workspace primitive collapse, package consolidation, CLI redesign#1705

Merged
braden-w merged 524 commits intomainfrom
braden-w/document-primitive
Apr 26, 2026
Merged

refactor: workspace primitive collapse, package consolidation, CLI redesign#1705
braden-w merged 524 commits intomainfrom
braden-w/document-primitive

Conversation

@braden-w
Copy link
Copy Markdown
Member

@braden-w braden-w commented Apr 25, 2026

This branch deletes defineWorkspace and the withExtension chain that drove every workspace in the codebase for a year. The terminal API is attach* primitives composed inline against a Y.Doc the caller owns — no builder, no extension slots, no framework-imposed bundle shape. Domain shapes emerge in the caller; the framework just provides composable verbs.

// Before
const workspace = createFujiWorkspace()
  .withExtension('persistence', indexeddbPersistence)
  .withExtension('sync', createSyncExtension({ url, getToken }));

// After
const ydoc = new Y.Doc({ guid: 'epicenter.fuji', gc: false });
const encryption = attachEncryption(ydoc);
const tables = encryption.attachTables(ydoc, fujiTables);
const kv = encryption.attachKv(ydoc, {});
const awareness = attachAwareness(ydoc, {});
const actions = createFujiActions(tables);
const idb = attachIndexedDb(ydoc);
const sync = attachSync(ydoc, {
  url, waitFor: idb.whenLoaded, awareness: awareness.raw,
  getToken: () => auth.getToken(),
  dispatch: (method, input) => dispatchAction(actions, method, input),
});

The chain wasn't doing real work. Each .withExtension(name, factory) call was a typed closure with extra ceremony to make the extension's exports reachable through the framework's generic shape. Once you have a Y.Doc as a local variable, attachIndexedDb(ydoc) is shorter and exposes its own typed handle (idb.whenLoaded, idb.clearLocal) without traveling through a slot. Sync wiring that referenced both persistence (waitFor: idb.whenLoaded) and awareness was contorted under the chain. It reads naturally under closures.

The framework collapsed with the chain. Document (a structural contract for "what a workspace returns"), DocumentBundle, DocumentHandle (a refcounted brand around bundles), DocumentFactory, createDocumentFactory, defineDocument, defineWorkspace, ActionIndex (a flat map of branded actions walked from arbitrary bundles), iterateActions, ACTION_BRAND (the symbol that made the walk possible), entry.handle envelope in the CLI loader — all gone. What's left is the smallest set of primitives that can build everything those layers were built to do, plus one piece of factory-shaped infrastructure (createDisposableCache) that survived because it does work the caller can't trivially do inline: refcount + grace-period teardown for any Disposable. Y.Docs are the most common case in this codebase; audio decoders, worker connections, and Tauri webview handles fit the same shape.

The package surface follows the API. @epicenter/yjs-doc got renamed to @epicenter/document; @epicenter/document got merged wholesale into @epicenter/workspace. One published surface, one barrel, one place to find anything. @epicenter/auth split into framework-agnostic core + Svelte wrapper because createAuth was Svelte-coupled and unusable from Node tooling.

Apps composed on the new primitives end up in three files per app — iso doc factory, env factory, singleton + lifecycle — because once you have a portable iso layer, build configs and Node tooling can construct the doc without dragging in y-indexeddb or BroadcastChannel. That's the iso/env/client convention codified at .claude/skills/workspace-app-layout/SKILL.md.

520 commits, 534 files, +37,672 / -23,256, 19 packages/apps touched. Single PR because most of the work is structurally coupled — you can't delete Document without rewriting the CLI loader, you can't split @epicenter/auth without migrating six apps' session subscriptions, you can't move actions to passthrough without touching every Result-consuming call site.

There is no follow-up cleanup PR for shapes introduced here. Two additive layers (awareness publishing, CLI cross-device dispatch) ship as separate PRs after this one merges. Architecture for those lives at specs/20260425T000000-device-actions-via-awareness.md.


How to read this PR

If you only have an hour, read these five commits in order — they're the spine:

  • 830a7ef8cWebSocket subprotocol auth. The wire-protocol pivot Section 1 builds on. Tokens leave URLs.
  • b62cc5ae3delete createWorkspace + extension chain. The moment the original builder dies, after every consumer migrated to closure-composed attach*.
  • 3dec00926attachSync takes dispatch: and getToken: callbacks. Replaces setToken/requiresToken/serveRpc/IIFE token bootstrap. The clean-shape pivot for the transport boundary.
  • 814965d10createDocumentFactorycreateDisposableCache. The framework primitive stripped to its honest contract: a refcount cache for anything Disposable. Document, DocumentHandle, DOCUMENT_HANDLE, iterateActions, ActionIndex all go in the same wave.
  • 8f46308e9iso/env/client three-file layout codified. The seam between iso construction and env binding that made workspace exports importable from Node tooling without dragging IndexedDB.

Everything else is variation on those five.

The 13 sections below cover what shipped. If you want narrative instead of reference, the article docs/articles/workspaces-were-documents-all-along.md walks the v1→v5 arc end-to-end.


1. WebSocket subprotocol auth

Better Auth sessions default to seven days. We were appending those tokens as ?token=... to every WebSocket URL, which means every token we'd ever issued was sitting in Cloudflare Logpush, readable until session expiry. Browser history had them. Every observability pipeline had them.

The fix uses RFC 6455's subprotocol channel. The browser WebSocket constructor accepts a list of subprotocols, the Sec-WebSocket-Protocol header is just ASCII tokens, and headers aren't in default log fields.

// Before
const ws = new WebSocket(`${url}?token=${token}`);

// After — bearer rides as a subprotocol; only `epicenter` is echoed on 101
const ws = new WebSocket(url, [MAIN_SUBPROTOCOL, `${BEARER_SUBPROTOCOL_PREFIX}${token}`]);

The server reads the bearer entry and synthesizes an Authorization header for Better Auth. The DO echoes only epicenter to complete the handshake; the bearer entry never round-trips. Constants and parse helpers live in @epicenter/sync/auth-subprotocol so the client (packages/workspace/src/document/attach-sync.ts), the Hono middleware (apps/api/src/app.ts), and the upgrade handler (apps/api/src/base-sync-room.ts) all read the same definition.

Article: docs/articles/tokens-dont-belong-in-urls.md covers what we missed for months and how to think about token-in-URL patterns generally.

Keystone: 830a7ef8c.


2. Session writer partition (token rotation race)

Two writers were updating the session record. The auth interceptor's onSuccess handler writes rotated tokens. useSession.subscribe writes the full enriched session from /auth/get-session. Either could fire first. Token T2 lands via rotation, then a stale T1 emits from the async refetch and clobbers it.

The fix partitions ownership by field. useSession always owns user and encryptionKeys. For token, it preserves the current value if one exists (rotation may have written a fresher one) and only falls back to BA's value when establishing initial state:

session.set({
  ...current,
  user: next.user,
  encryptionKeys: next.encryptionKeys,
  token: current?.token ?? next.token,
});

A same-token guard on onSuccess skips the write when the server echoes an unchanged token, so non-rotating requests don't fan out subscribers. signOut now returns Result<undefined, SignOutFailed> like every other auth method — previously it swallowed errors via console.error.

Keystones: e3b2a38b8, d11ae8e00, ff852c3c2, e2f7ed3c9.


3. Package consolidation

Two separate consolidations, both load-bearing for everything else.

@epicenter/auth split into core + svelte

createAuth was Svelte-coupled — it took a Svelte store as session and called useSession.subscribe from the framework's runtime. That made auth unusable in Node tools, the CLI, and any non-Svelte consumer. The split:

  • @epicenter/auth — core. Framework-agnostic createAuth(...) over a SessionStore contract.
  • @epicenter/auth-svelte — Svelte wrapper. Spreads core methods, exposes the reactive session, adapts SessionStore to a Svelte rune store.

Migration ran as seven sequential commits (9a066780d808e9bfb2), one step per concern: scaffold the package shell, move auth-types, define SessionStore contract, port createAuth core, ship the Svelte wrapper, migrate six apps, delete the old svelte-utils/auth. Every app's client.ts ends up importing from @epicenter/auth-svelte.

@epicenter/yjs-doc@epicenter/document → merged into @epicenter/workspace

The CRDT primitives lived in three packages chasing the same boundary. yjs-doc got renamed to document (2a012c087); document got merged wholesale into workspace (a7547cd5e migrates consumers; 11efb21ed deletes the package). The boundary survives as packages/workspace/src/document/ — directory, not package. One published surface, one barrel, one place to find anything.

Keystones: 9a066780d (auth split), 11efb21ed (document package deleted), a7547cd5e (consumers migrated).


4. Workspace primitive: terminal shape

The framework collapsed from defineWorkspace().withExtension(...) chains down to plain attach* calls against a Y.Doc the caller owns. The history of how it got there is in the article; the terminal contract is:

  • A workspace is whatever a open<App>(...) factory returns.
  • It must own a Y.Doc, expose [Symbol.dispose], and (if anything is async) a whenReady: Promise<unknown>.
  • Beyond that, the shape is the caller's choice.

There is no Document structural type, no DocumentHandle brand, no DocumentBundle, no createDocumentFactory, no defineWorkspace, no extension chain.

What survived from the old framework:

attachTables, attachKv, attachAwareness         (data primitives)
attachIndexedDb, attachSqlite                   (persistence)
attachBroadcastChannel, attachSync              (transport)
attachEncryption, attachSessionUnlock           (crypto)
attachMarkdownMaterializer, attachSqliteMaterializer  (derived stores)
attachRichText, attachPlainText, attachTimeline (editor bindings)
defineTable, defineKv                           (schemas)
defineQuery, defineMutation, dispatchAction     (actions)
createDisposableCache                           (refcount cache; opt-in)

createDisposableCache is the one piece of "factory-shaped infrastructure" that survived — and it survived because it does real work the caller can't easily do inline. Multiple components mounting the same per-row content doc need to share one Y.Doc; rapid entry-A→entry-B→entry-A clicks shouldn't thrash IndexedDB. The cache solves that for any Disposable resource:

export interface DisposableCache<Id, T> extends Disposable {
  open(id: Id): T & Disposable;
  has(id: Id): boolean;
}

export function createDisposableCache<
  Id extends string | number,
  T extends Disposable,
>(
  build: (id: Id) => T,
  opts?: { gcTime?: number },  // default 5_000ms
): DisposableCache<Id, T>;

Y.Docs satisfy Disposable. Audio decoders satisfy it. Tauri webview handles satisfy it. The cache doesn't know which.

For workspace singletons (one per app, lives for the app's lifetime), createDisposableCache is overkill — those just live at module scope. For per-row docs (Fuji entries, Honeycrisp notes), the cache is wired inline in the env factory.

Keystones: b62cc5ae3 (createWorkspace dies), 814965d10 (cache renamed and stripped), d2c375158 (iterateActions dropped from public API).


5. App layout: iso/env/client three-file convention

Once the framework collapsed to plain composition, every app put its workspace in one file (client.svelte.ts) at module scope. That worked until a Node consumer (build config, codegen, test fixture) needed to construct the workspace's Y.Doc without dragging in y-indexeddb, BroadcastChannel, or chrome.* globals. The single-file shape couldn't be split because identity, bindings, and singleton-with-side-effects were the same module statements.

The fix is structural — three files per app:

apps/<app>/src/lib/<app>/
├── index.ts       ← iso doc factory      open<App>()
├── <binding>.ts   ← env factory          open<App>({ deps })
└── client.ts      ← singleton + auth + lifecycle
File Imports Returns Side effects
index.ts @epicenter/workspace core, schemas doc bundle (ydoc, tables, kv, encryption, actions, batch, dispose) none
<binding>.ts ./index + env-specific attach* doc + env resources (idb, sync, materializers, caches) none
client.ts ./<binding> + createAuth auth + singleton + lifecycle subscriptions createAuth, singleton, onSessionChange, HMR

Binding name follows the actual platform: browser.ts (zhongwen, fuji, honeycrisp, opensidian), extension.ts (tab-manager), tauri.ts (whispering). Cross-environment imports are rejected by convention — siblings never import each other; they compose only through index.ts.

Fuji's terminal shape (apps/fuji/src/lib/fuji/):

// index.ts
export function openFuji() {
  const ydoc = new Y.Doc({ guid: 'epicenter.fuji', gc: false });
  const encryption = attachEncryption(ydoc);
  const tables = encryption.attachTables(ydoc, fujiTables);
  const kv = encryption.attachKv(ydoc, {});
  const awareness = attachAwareness(ydoc, {});
  const actions = createFujiActions(tables);
  return {
    ydoc, tables, kv, encryption, awareness, actions,
    batch: (fn: () => void) => ydoc.transact(fn),
    [Symbol.dispose]() { ydoc.destroy(); },
  };
}

// browser.ts
export function openFuji({ auth }: { auth: AuthClient }) {
  const doc = openFujiDoc();
  const idb = attachIndexedDb(doc.ydoc);
  attachBroadcastChannel(doc.ydoc);
  const entryContentDocs = createDisposableCache(
    (entryId: EntryId) => createEntryContentDoc({ entryId, /* ... */ auth }),
    { gcTime: 5_000 },
  );
  const sync = attachSync(doc.ydoc, {
    url: toWsUrl(`${APP_URLS.API}/workspaces/${doc.ydoc.guid}`),
    waitFor: idb.whenLoaded,
    awareness: doc.awareness.raw,
    getToken: () => auth.getToken(),
    dispatch: (action, input) => dispatchAction(doc.actions, action, input),
  });
  return { ...doc, idb, entryContentDocs, sync, whenReady: idb.whenLoaded };
}

// client.ts
const session = createPersistedState({ key: 'fuji:authSession', /* ... */ });
export const auth = createAuth({ baseURL: APP_URLS.API, session });
export const fuji = openFuji({ auth });

auth.onSessionChange((next, previous) => {
  if (next === null) {
    fuji.sync.goOffline();
    if (previous !== null) void fuji.idb.clearLocal();
    return;
  }
  fuji.encryption.applyKeys(next.encryptionKeys);
  if (previous?.token !== next.token) fuji.sync.reconnect();
});

Six apps migrated: fuji, honeycrisp, opensidian, zhongwen, tab-manager, whispering. The convention is codified at .claude/skills/workspace-app-layout/SKILL.md.

Keystones: 8f46308e9 (skill), 2cc080bd0fcf6de7d2 (per-app rollout, six commits).


6. Encryption coordinator + encrypted CRDT primitives

attachEncryption became a coordinator that exposes .attachTables / .attachKv methods directly. The form makes it visually clear that encryption is applied first as a stateful container, and tables/kv are wired through it:

const encryption = attachEncryption(ydoc);
const tables = encryption.attachTables(ydoc, fujiTables);
const kv = encryption.attachKv(ydoc, {});
encryption.applyKeys(session.encryptionKeys);

Underneath: encrypted variants of YKeyValueLww and friends live in packages/workspace/src/document/encrypted-*, with a register() coordinator pattern that lets attachEncryption introspect what's been wired and apply keys uniformly. Key rotation upgrades old-version ciphertext on applyKeys (06014afa5) — no separate re-encrypt pass.

Keystones: 49ff94d60 (coordinator pattern), f98d2c214 (terminal shape), 06014afa5 (rotation upgrades).


7. Materializer subsystem

Two new attaches that mirror Yjs table state to external stores:

// SQLite mirror — for fast indexed reads, FTS5 search
attachSqliteMaterializer(ydoc, { db: new Database('workspace.db'), waitFor })
  .table(tables.entries, { fts: ['title', 'body'] })
  .table(tables.tags);

// Markdown mirror — for human-readable export, git workflows
attachMarkdownMaterializer(ydoc, { dir: './data', waitFor })
  .table(tables.entries, {
    filename: slugFilename('title'),
    toMarkdown: ({ row }) => stringifyEntryMd(row),
    fromMarkdown: ({ md }) => parseEntryMd(md),
  })
  .kv(kv);

Both are one-way (workspace → store); both register ydoc.once('destroy', ...) so destroying the ydoc tears down the mirror; both expose whenFlushed for tests. Markdown materializer supports a rebuild mode for orphan cleanup. SQLite materializer's rebuild matches the sqlite materializer's parity convention.

The @epicenter/skills package uses these for disk-round-trip of agent skill definitions (importFromDisk / exportToDisk actions in packages/skills/src/node.ts).

Keystones: 9383ed707 (spec), the materializer subdirectory at packages/workspace/src/document/materializer/.


8. Structured logger + JSONL sink

Replaced ad-hoc console.* calls across the workspace package with wellcrafted/logger — a typed-error logger with five levels (trace/debug/info/warn/error) and dependency-injected sinks. Warn/error levels carry structured error variants, not free-form strings.

A new Bun-only sink, jsonlFileSink, writes structured records to a JSONL file via Bun.file(path).writer(). Lives at packages/workspace/src/shared/logger/jsonl-sink.ts because it can't run in browsers; the logger core itself is platform-agnostic and imported from wellcrafted/logger.

Every previously-console.* site in the workspace package now has a typed error variant (AttachSyncError.PingTimeout, BroadcastChannelError.SerializeFailed, etc.) defined via defineErrors. Skill at .claude/skills/logging/SKILL.md.

Keystones: 76f0ee1b0 (logger core), 8caced5e0 (JSONL sink), 19342b5d7 (console.* migration).


9. Action surface: passthrough handlers, Result envelope at boundaries

Local handlers used to be free to return whatever they wanted: a raw value, a Promise, a Result, a thrown exception. The wire couldn't propagate that — thrown errors don't cross processes, and the type machinery to merge "raw return" with "Result return" with "ActionFailed on the wire" was a RemoteReturn<T> conditional type doing real work.

We collapsed it once, then walked half of it back. Terminal shape:

  • Local actions are passthrough. defineMutation({ handler: ... }) returns the handler verbatim with metadata attached. Sync stays sync, raw stays raw, Result if explicit. Local callers see exactly what the author wrote.
  • The wire boundary normalizes. Generic in-process consumers (AI tool bridge, CLI dispatch, RPC server-side) call invokeNormalized(action, input, label) to get uniform Promise<Result<T, RpcError>>. Thrown handlers become Err(ActionFailed). Raw values get Ok-wrapped.
  • Remote callables get the wrapped shape via the type system. WrapAction<F> flattens the four possible handler return shapes into one Promise<Result<T, E | RpcError>>; RemoteActions<A> mirrors an action tree's structure with each leaf wrapped.
// Local — passthrough; whatever the handler returns
fuji.actions.entries.create({ title: 'hi' })
  // → whatever createMutation's handler returns (likely { id: EntryId } raw)

// AI bridge — normalized
const result = await invokeNormalized(action, input, 'entries.create');
if (result.error) throw result.error;
return result.data;

// Remote — typed as wrapped
const result = await remote.entries.create({ title: 'hi' });
//      Promise<Result<{ id: EntryId }, ActionFailed | RpcError>>

ACTION_BRAND is gone (isAction(v) is now structural). RemoteReturn<T> is gone. iterateActions was inlined into its sole live caller and dropped from the public API. dispatchAction(actions, path, input) resolves a dot-path against an action tree and invokes — replaces the old ActionIndex.get(path) lookup.

ActionFailed is now a type alias over @epicenter/sync's RpcError.ActionFailed. One nominal type; no nesting; isRpcError works across boundaries.

ADR: specs/20260425T200000-actions-passthrough-adr.md documents why we walked back the always-Result decision after one day of integration.

Keystones: fd3a1ce8d (drop ACTION_BRAND), 81cd627ee (defineMutation/defineQuery passthrough), 2be551876 (invokeNormalized), 81bdadb04 (unify ActionFailed).


10. CLI: scripting-first, three commands

The CLI was written against the old createWorkspace() shape, where every workspace had .tables, .kv, .actions, .extensions available. After the primitive collapse, a workspace export guarantees only { ydoc, [Symbol.dispose] } plus whatever the author chose to expose. Eight of the eleven commands (get/list <table>/count/delete/tables/kv/size/rpc/start/init/describe) speculated on structure the contract no longer carries.

Rather than reinvent CRUD-by-flag for each consumer's bundle shape, the CLI shrinks to what scripts can't do:

  • Manage interactive auth sessions
  • Introspect what's runnable
  • Dispatch a single branded action, locally or to a peer
  • Snapshot remote presence

Anything else: write a .ts script that imports epicenter.config.ts and calls the typed handle directly. bun run scripts/foo.ts is the runtime.

                Local            Remote
              ┌─────────┬─────────────────┐
  Enumerate   │  list   │  peers          │
  Invoke      │  run    │  run --peer     │
              └─────────┴─────────────────┘

  Cross-cutting: auth (server session, pre-workspace)
BEFORE (11 commands)                   AFTER (3 + auth)
auth { login/logout/status }    keep   auth { login/logout/status }
start                           drop   list  [dot.path]    new
get/list/count/delete <table>   drop   run   <dot.path>    rewritten
tables / kv / size / rpc        drop   peers               new
export / init / describe        drop
run <action>                 rewrite

Invocation:

$ epicenter run fuji.entries.create '{"title":"Hi","body":"..."}'
{ "id": "01HW..." }

$ cat payload.json | epicenter run fuji.entries.create
$ epicenter run fuji.entries.create @payload.json
$ epicenter run fuji.entries.list --peer deviceName=alice-laptop

JSON-only input. Three sources, all routed through parseJsonInput: positional ('{...}' or @file.json), stdin pipe, or --peer payload. The previous typeboxToYargsOptions flag-mapper is gone — flat-flag input was a leaky escape hatch that fell over on nested objects, arrays, and any flag colliding with yargs built-ins like --help. One input shape across local and remote.

peers is a one-shot snapshot of remote awareness. You don't appear in your own list.

$ epicenter peers
clientID  client     deviceName     since
8392114   chrome-ext alice-laptop   3s ago
1029384   epicenter  bob-mbp        18s ago

Exit codes carry meaning for scripts. 1 usage or setup error, 2 action returned Err or remote RPC failed, 3 peer didn't resolve within --wait. The split between 2 and 3 lets a script retry on 3 (transient) without retrying on 2 (real failure).

peers defaults to --wait 0. run --peer defaults to --wait 5000 (resolve target + complete RPC).

attachSessionUnlock is a new primitive in packages/cli/src/auth/. Thin wrapper over attachEncryption that sources keys from the CLI session store — the one piece a CLI-mode workspace can't synthesize from the workspace package alone. It exposes whenChecked: Promise<unknown> so attachSync({ waitFor: ... }) can compose with it the same way it composes with persistence.

CLI loader returns { entries: Array<{ name, workspace }>, dispose }. Commands read first-class fields off entry.workspace (no entry.handle.X envelope, no duck-typed getSync/extractAwareness helpers).

Article: docs/articles/you-already-built-cqrs.md covers why writes flow through Yjs as state and reads/queries dispatch through addressable defineQuery/defineMutation nodes — CQRS without anyone planning it.

Keystones: db4a8c4e5 (schema-to-yargs flag bridge removed), a56369aac (peers + remote dispatch), c1ee2e853 (exit codes + --wait rename), 3366fe3a9 (handle/ActionIndex → workspace/walkActions).


11. Per-row content docs

Fuji entries and Honeycrisp notes have rich-text bodies stored in their own per-row Y.Docs (split-pane editors, preview tiles, rapid entry-switching all need the same doc shared). The terminal shape is a pure singular builder wrapped in a createDisposableCache at the workspace's env layer:

// apps/fuji/src/lib/entry-content-docs.ts — pure builder
export function createEntryContentDoc({
  entryId, workspaceId, entriesTable, auth, apiUrl,
}: {
  entryId: EntryId;
  workspaceId: string;
  entriesTable: Table<Entry>;
  auth: Pick<AuthCore, 'getToken'>;
  apiUrl: string;
}): EntryContentDoc {
  const ydoc = new Y.Doc({ guid: docGuid({ workspaceId, collection: 'entries', rowId: entryId, field: 'content' }), gc: false });
  const body = attachRichText(ydoc);
  const idb = attachIndexedDb(ydoc);
  attachSync(ydoc, { url: toWsUrl(`${apiUrl}/docs/${ydoc.guid}`), waitFor: idb.whenLoaded, getToken: () => auth.getToken() });
  onLocalUpdate(ydoc, () => entriesTable.update(entryId, { updatedAt: DateTimeString.now() }));
  return { ydoc, body, whenReady: idb.whenLoaded, [Symbol.dispose]() { ydoc.destroy(); } };
}

// apps/fuji/src/lib/fuji/browser.ts — cache wired inline
const entryContentDocs = createDisposableCache(
  (entryId) => createEntryContentDoc({ entryId, workspaceId: doc.ydoc.guid, entriesTable: doc.tables.entries, auth, apiUrl: APP_URLS.API }),
  { gcTime: 5_000 },
);

Components consume via fromDisposableCache(entryContentDocs, () => entry.id) — a Svelte adapter in @epicenter/svelte that bridges the cache to $derived + $effect lifecycle. Replaces the old fromDocument(handle) which carried framework-specific Document types.

Honeycrisp's note bodies follow the same shape (createNoteBodyDoc + cache).

Keystones: b4ef57db9 (singular pure builders), 2f4c93fec (fromDocumentfromDisposableCache).


12. Tab-close safety net (commit-on-blur)

Fuji's title and subtitle fields commit on blur (not on every keystroke) for editing comfort. That's correct for in-tab editing but loses the in-flight edit if the user closes the tab mid-edit. The fix wires svelte:document to visibilitychange and (per the article — pagehide is a window event, not a document event) window pagehide, flushing pending state through the same updateEntry action that handles blur. Both events fire reliably across browser variants and don't suffer the unload-event deprecation.

Article: docs/articles/commit-on-blur-survives-tab-close.md. Skill: .claude/skills/commit-on-blur/SKILL.md.

Keystones: 9261b2d1a, e43699600, d25f6a521, 1016de9be.


13. Articles in this PR

Twenty articles, written or substantially revised. The narrative-driven ones (load these first if you only have time for a few):

  • workspaces-were-documents-all-along.md — full v1 → v5 arc of the workspace primitive. The longest and the most comprehensive narrative.
  • tokens-dont-belong-in-urls.md — Section 1's cover story.
  • you-already-built-cqrs.md — Section 9/10's framing.
  • commit-on-blur-survives-tab-close.md — the visibilitychange + .blur pattern.
  • 20260422T160000-sync-dispose-cascade.md — how ydoc.destroy() cascades cleanup through every attachment.

Pattern / lesson articles (referenced from skills):

  • singular-wrappers-delegate-to-plural.md
  • reactive-touch-is-a-missing-subscription.md
  • svelte-effect-root-hmr-pattern.md
  • ok-null-is-fine-err-null-is-a-lie.md
  • i-built-the-svelte-wrapper-first.md
  • dont-export-everything.md
  • callable-actions-pattern.md
  • your-data-is-probably-a-table-not-a-file.md
  • typescript-circular-inference.md
  • yjs-abstraction-leaks-cost-more-than-the-abstraction.md
  • why-tanstack-ships-separate-framework-packages.md
  • 20260420T160000-state-handle-null-is-the-component-lifecycle-in-disguise.md
  • 20260423T090839-query-params-leak-subprotocols-dont.md

Plus refreshed: 20251001T180000-plugins-to-workspaces.md, 20260127T120000-static-workspace-api-guide.md.


What's NOT in this PR

Two architectural layers are specced but deferred to follow-up PRs:

  • Awareness publishing (specs/20260425T000000-device-actions-via-awareness.md Phase 1) — serializeActionManifest, invoke, awareness state convention, app wiring to publish offers. Builds on the post-teardown action registries + the dispatch: callback shape. No new attach primitive.
  • CLI cross-device dispatch (same spec, Phase 3) — epicenter devices command, dot-prefix run resolution (epicenter run desktop-1.action.path). Builds on the awareness convention.

Both are additive to PR-A's terminal shapes — they don't break anything established here. They land as separate PRs after this one merges so their implementation prompts can be drafted against real merged code.


Test plan

  • bun run typecheck clean across the monorepo
  • bun test passes in packages/workspace (553 tests), packages/cli (19 e2e), packages/auth, packages/auth-svelte, packages/sync, packages/filesystem, packages/skills
  • All six apps cold-boot, hydrate, write end-to-end:
    • fuji: anonymous boot, sign in, create entry, edit body, sign out clears local data
    • honeycrisp: editor mounts via fromDisposableCache, survives rapid component remount
    • opensidian: fs.read / fs.write actions work; SQLite index search returns results
    • tab-manager: chrome.storage hydrates; tabs.search and tabs.close round-trip
    • whispering: Tauri loads; recordings materializer flushes to disk
    • zhongwen: cards CRUD + KV-backed app state
  • Two-tab editing on Fuji + Honeycrisp shows CRDT propagation (no regression from refcount-cache extraction)
  • Rapid entry-A → entry-B → entry-A clicks reuse the cached doc (no IndexedDB rehydrate flash)
  • CLI commands work end-to-end:
    • epicenter list enumerates actions
    • epicenter run fuji.entries.create '{...}' round-trips
    • epicenter peers shows remote devices
    • epicenter run --peer deviceName=<x> fuji.entries.list dispatches to a peer
  • WebSocket connects with subprotocol auth; access logs no longer contain ?token=
  • Token rotation via auth interceptor onSuccess doesn't get clobbered by useSession.subscribe refresh

Coordination

This PR is one of three in the document-primitive rollout. Tracker: specs/20260425T180002-orchestration-tracker.md. PR-D and PR-E architecture: specs/20260425T000000-device-actions-via-awareness.md.

braden-w added 30 commits April 21, 2026 22:34
…teredKv cell

Follow-up cleanup after the materializer redesign:

- Remove `TableMaterializerConfig` — dead export from sqlite/types.ts
  after the inline `TableConfig<TRow>` replaced it for per-call narrowing.
- Flatten the `registeredKv = { entry: … }` ref-wrapper in markdown to
  a plain `let registeredKv: RegisteredKv | undefined`. The `.entry`
  indirection was cargo-culted; a mutable let is what the code actually
  wanted.
…ples

The CLI redesign and primitive collapses removed `defineWorkspace`,
`createWorkspace`, the `.withDocumentExtension(...)` chain, and
`createWorkspaceClient`. Articles that taught those APIs or used them as
illustrative examples are now inaccurate at the code level. Fixing them
in two categories:

Group 1 — banner at top of article (whole article describes removed API):
- 20260127T120000-static-workspace-api-guide.md (full guide to removed
  defineWorkspace + createWorkspace)
- 20251001T180000-plugins-to-workspaces.md (historical narrative about
  definePlugin → defineWorkspace; both states now dead)

Group 2 — inline disclaimer at the code example (teaching pattern still
valid, only the Epicenter-specific example is outdated):
- callable-actions-pattern.md — the createWorkspaceClient example
- typescript-circular-inference.md — the defineWorkspace providers/actions
  example (TS circular-inference lesson generalizes)
- singular-wrappers-delegate-to-plural.md — the extension-chain example
  (pattern survives in the later registerForProfile / registerForProfiles
  example within the same article)

Group 2b — mechanical prose swap where defineWorkspace was illustrative:
- dont-export-everything.md — swapped defineWorkspace → defineDocument in
  the export-grading example list.

Not touched this pass: dense teaching articles that would need full
rewrites (why-async-client-creation, types-should-be-computed-not-declared,
workspaces-were-documents-all-along, etc.), and the live marketing blog
(apps/landing/src/content/blog/second-brain-infrastructure.md). Those need
per-article judgment and author voice review.
…ions

Rewrites `attachMarkdownMaterializer` so `push` and `pull` are declarative
defineMutation actions (matching the sqlite materializer's search/count/
rebuild pattern) instead of plain async methods named pushFromMarkdown /
pullToMarkdown. This makes them discoverable via `epicenter list` and
invokable via `epicenter run` dot-paths.

Both take an empty input schema (`Type.Object({})`) for now; return shapes
are preserved. `whenFlushed` stays a raw promise property. Tests and
docstring comments updated to match the new surface.
Replaces the string-keyed `SetupOptions.tables` (which forced `as any`
casts to bridge `tables[name]` back to a typed `Table<TRow>`) with a
callback form that receives the attached tables directly:

  setup({ tables: (t) => [{ table: t.posts, config: { fts: ['title'] } }] })

Eliminates both `as any` casts in the materializer test helpers. Same
72 tests still pass.
Two specs for the remaining deferred items from the attach-materializer
redesign:

- `reindex` mutation for markdown (closes the orphan-file gap with
  sqlite's `rebuild`).
- Split `serialize`/`deserialize` into orthogonal `filename` / `format`
  / `parse` slots. Matches gray-matter / 11ty / gatsby conventions and
  makes round-trip identity expressible at the type level.
Two hygiene fixes the self-review surfaced:

1. Document barrel now exports every primitive that lives in src/document/
   The root src/index.ts previously re-exported defineTable, defineKv,
   attachEncryption, EncryptionAttachment, EncryptionKey, EncryptionKeys,
   and encryptionKeysFingerprint directly — bypassing the document barrel
   even though those all live in src/document/ post Phase-5 collapse.
   Push them into document/index.ts; the root just does
   `export * from './document/index.js'` plus the root-level utilities.
   Net: 45 lines out of src/index.ts, 13 lines into document/index.ts.

2. DOCUMENT_HANDLE Symbol is now module-private in define-document.ts.
   Zero external consumers (grep across packages/ + apps/ + playground/).
   isDocumentHandle() remains the consumer-facing guard; the symbol
   itself stops cluttering the package barrel. If a future caller needs
   to brand a custom object, re-export at that point.
- Rename `pushImpl` → `pushMarkdownFiles` (descriptive, drops `Impl` suffix).
- Inline `pullImpl` body into the `pull:` mutation handler (22 lines, reads
  naturally at the call site; no other caller needed the extracted function).
- Extract `defaultSerialize` and `defaultDeserialize` as module-level
  constants — previously duplicated inline in `materializeTable`, `pullImpl`,
  and `pushImpl`.
- Replace the `whenFlushed` async IIFE with a named `initialize()` function
  for symmetry with the sqlite materializer's initialize().

No behavior change. 72 materializer tests continue to pass.
…extract kv default

Two audit findings from re-reading the markdown + sqlite materializers:

1. **Invariant hole** — `hasInitialized` was only set at the *end* of
   `initialize()`. If init threw mid-flight or returned early due to
   `isDisposed`, late `.table()` / `.kv()` calls would silently pass
   the guard even though the materializer was in a terminal state.

   Renamed to `isRegistrationOpen`, flipped `false` immediately after
   `await waitFor` — the commitment point past which registrations
   can't be picked up for initial flush regardless of what happens
   downstream. Applied to both markdown and sqlite for parity.

2. **Asymmetric defaults** (markdown only) — `defaultSerialize` /
   `defaultDeserialize` were module-level constants but the kv default
   serializer was still inline inside `materializeKv`. Extracted
   `defaultKvSerialize` to match the pattern.

72 materializer tests still pass.
packages/workspace/src/document/index.ts wasn't a public subpath
(only ./document/materializer/* are per package.json). It was pure
internal forwarding between src/index.ts and the document/* files.

Collapse: root src/index.ts now enumerates every public document
symbol explicitly, importing from each document/* file directly.
One curated list, no `export *`, no forwarding nesting doll.
Internal document/* consumers and benchmarks repoint from the
deleted barrel to direct sibling or root imports.

No public API change.
…d helpers

Closes the concern-mixing hole in the spec: `bodyField` as a config slot
would have required precedence rules between it and `toMarkdown` /
`fromMarkdown` (what wins if both are set?).

Replaces with two independent helpers in serializers.ts:
  - fieldAsBody(field)   → (row) => MarkdownShape       (for toMarkdown slot)
  - bodyAsField(field)   → (parsed) => row              (for fromMarkdown slot)

Each returns exactly one callable for exactly one slot. No spread magic,
no bundled { toMarkdown, fromMarkdown } pair, no 4th config slot. The
config object always has the same three optional keys, and every value
is a callable the caller can grep for directly.

Also renames `format`/`parse` → `toMarkdown`/`fromMarkdown` for
autocomplete self-documentation, and renames the existing internal
helper `toMarkdown(fm, body)` → `assembleMarkdown` to free the name.
Closes the orphan-file gap between markdown and sqlite:
  - pull is additive — leaves .md files alone even when rows are deleted
  - reindex is destructive — clears the output dir then rewrites all rows,
    sweeping orphans from deleted rows or stale serialize configs

API mirrors sqlite's `rebuild`:
  materializer.reindex({})              → reindex all registered tables + kv
  materializer.reindex({ table: 'x' })  → reindex just that table
  // throws if "x" isn't registered

Return shape: { deleted, written }.

Surfaces as a defineMutation so it's discoverable via `epicenter list`
and invokable via `epicenter run <export>.materializer.reindex`.

4 new tests (orphan sweep, single-table scope, unknown-table throw,
idempotence). 75 materializer tests pass total (was 71).
Both materializers now expose the same destructive-rebuild verb:
  - sqlite: materializer.rebuild({ table? })
  - markdown: materializer.rebuild({ table? })

`reindex` was an inconsistent name (same op, different verb across
materializers). `rebuild` is the cross-materializer convention and
doesn't mislead toward "search index" the way `reindex` can.

76 materializer tests pass (unchanged count).
…me + toMarkdown + fromMarkdown slots

Three orthogonal config slots replace the bundled serialize/deserialize pair:
- filename(row) → string: where to write
- toMarkdown(row) → { frontmatter, body }: how to format
- fromMarkdown(parsed) → row: how to parse back

The toMarkdown/fromMarkdown pair is symmetric over a shared MarkdownShape
type so round-trip identity is expressible at the type level. Rename the
internal helper toMarkdown(fm, body) in markdown.ts to assembleMarkdown,
freeing the toMarkdown name for the config slot. Tests and call sites
still reference the old names — migrated in the next commit.
…helpers

Replace the old bundled bodyField(field) helper with two independent,
single-callable helpers — one for each direction — that plug directly
into the new toMarkdown / fromMarkdown config slots:

  fieldAsBody('content')  // row → { frontmatter, body }
  bodyAsField('content')  // { frontmatter, body } → row

Both are generic over TRow extends BaseRow so the field name is
type-checked against the row schema. slugFilename() picks up the same
generic — it now returns a filename-slot-shaped callable instead of
the old SerializeResult.
…kdown/fromMarkdown slots

Opensidian's file materializer splits the old 30-line bundled serialize
into a sync filename slot and an async toMarkdown slot. Tab-manager's
slugFilename helper now plugs into the filename slot directly. Tests
exercise the new fromMarkdown and filename/toMarkdown slots.

Also rename the remaining toMarkdown import (the markdown-assembly
helper) to assembleMarkdown at one test-file call site.
…type

Add two tests that exercise the symmetric toMarkdown/fromMarkdown pair
over MarkdownShape: one with explicit callables that type-annotate both
directions against the shared type, and one that uses the fieldAsBody /
bodyAsField helpers end-to-end through the pull path.
…ializer API

Replace serialize/bodyField references in attach-primitive SKILL.md and
the workspace README with the new three-slot config (filename plus the
fieldAsBody / bodyAsField helpers).
…cquisition

Adds `factory.load(id): Promise<DocumentHandle<T>>` alongside the existing
sync `factory.open(id)`. `load()` is equivalent to `open()` + `await
handle.whenReady`, with the handle disposed on whenReady rejection so
refcount doesn't leak.

Why: the two-step `open(); await whenReady` dance is a footgun — forgetting
the await silently returns empty content on unready handles. `load()` bakes
the await in so imperative callers can't skip it. Sync `open()` remains for
reactive UI callers that want the handle immediately and subscribe to
whenReady separately.

Pairs naturally with `await using` for scope-bound release:

    await using h = await factory.load(id);
    h.content.write('…');
Migrates readContent, writeContent, appendFile, and sqlite-index's
readFileContent from `open(); await whenReady` to `await load()`. Also
updates sheet-file tests that open handles imperatively for batch writes.

No behavior change — `load()` is sync-equivalent to the prior two-step,
just wraps the await so the unready-handle footgun is uncallable.
Migrates readInstructions, readReference, and the import/export paths in
node.ts from `open(); await whenReady` to `await load()`. Updates JSDoc
examples in tables.ts accordingly. Reactive editor-binding examples that
use sync open() intentionally are left alone.
Imperative `open(); await whenReady; write; dispose` collapses to
`await using h = await contentDocs.load(id); h.content.write(...)`.
Updates README, architecture doc, workspace-api skill reference, and
document/README to show:

- Imperative code (read/write from scripts, CLI, tests) uses
  `await using h = await factory.load(id)`.
- Reactive code (Svelte `{#await}`, `$effect` subscriptions) keeps
  sync `factory.open(id)` so the handle is available before whenReady
  resolves.

No examples that depend on the sync-open reactive pattern were changed.
After migrating to `load()`, the helpers wrapped a single line each.
Inlining removes indirection and deletes a header comment that described
the pre-`load()` ceremony (open + await whenReady + dispose).

- readFile: inline `load()` directly
- writeFile: scoped block around `load()` so the handle releases
  before `tree.touch(id, size)`
- cp: scoped block so handle releases before the recursive writeFile
- appendFile already inlined the pattern; untouched
…load hazards

Three JSDoc updates to align documentation with the load() vs open() split:

- DocumentHandle examples now show only reactive patterns ($effect,
  `using` block) with a pointer to DocumentFactory.load for imperative
  callers. The prior "manual" example demonstrated the exact
  open+whenReady footgun load() was added to remove.

- DocumentFactory.load() JSDoc names the concurrent-close hazard: if
  close(id)/closeAll() fires while whenReady is in flight, the returned
  handle wraps a destroyed Y.Doc. Same risk as open+whenReady, but worth
  calling out since load() is the "safe" default.

- skillsDocument module example switches from using ws = open(...) to
  await using ws = await load(...) for convention consistency. Both
  work (whenReady is trivially Promise.resolve()), but docs should model
  the default imperative pattern.
These helpers covered the "one row field is the markdown body" case —
but every real app in this repo stores body content in a separate
Y.Doc via `defineDocument`, not as a row field. Grepped for call
sites: zero. The helpers existed only in their definition, exports,
and one round-trip test.

The content-doc pattern is inherently multi-step (open, await, read,
dispose via `await using`) and sufficiently varied across content
attachments (attachTimeline / attachRichText / attachPlainText each
expose body differently) that no single helper abstracts it usefully.
Inline bespoke callbacks are the right shape.

Keeping `slugFilename`, `toIdFilename`, `toSlugFilename` — all used by
opensidian for filename computation, which IS a cross-app pattern.

Test rewrites the round-trip assertion as an inline callback pair over
the shared `MarkdownShape` type — still proves the three-slot API
round-trips, no longer depends on the deleted helpers.

Skill doc updated to show the realistic content-doc callback as the
canonical example.

All 77 materializer tests pass.
…ite in writeFile

Two cleanups from the post-inline audit:

- writeFile / cp: remove the `{ }` blocks I added around `await using`.
  The factory has gcTime=Infinity, so refcount→0 doesn't actually evict
  the bundle; releasing the handle a few microseconds earlier saves
  nothing. Letting `using` scope to the function body is cleaner.

- writeFile: skip `tree.touch(id, size)` for newly-created rows.
  `tree.create(...)` already sets `size` and `updatedAt`, so the follow-up
  touch was overwriting identical values. Only run touch when updating
  an existing row.
`whenReady` was typed optional, framed as "user convention." Since `load()`
now reads it, the field is framework-consumed, and every real bundle
already sets it explicitly (Promise.resolve() for sync-ready, composed
promises for attachment-driven readiness). Making it required formalizes
reality and deletes the `await undefined` edge case.

Also cleans up DocumentHandle JSDoc to show the idiomatic split:
`await using h = await docs.load(id)` for imperative, `$effect + open()` for
reactive. The prior `using h = docs.open(...); /* subscribe to whenReady */`
example was a confused middle ground that modeled neither pattern well.

Test fixups: add `whenReady: Promise.resolve()` to inline bundles in
define-document.test.ts (8 sites) and sqlite.test.ts (2 sites) that relied
on the optional typing.
…ation

The walk inside `activateEncryption` used to have two cases: re-encrypt
plaintext, leave all ciphertext alone. The second rule was wrong for key
rotation — old-version ciphertext stayed at the old version indefinitely,
lazy-migrating only on the next `set()` for that key. For read-heavy
workspaces (notes, transcriptions, archives), old-version data would
never upgrade; if rotation was triggered because an old key leaked,
that's a meaningful gap.

Extend the walk to four cases, in priority order:

  1. Ciphertext already at currentVersion → skip (cheap).
  2. Ciphertext at non-current version, decryptable via keyring →
     decrypt, re-encrypt with currentKey.
  3. Plaintext → encrypt with currentKey (unchanged).
  4. Ciphertext at unknown version → skip (unchanged; catches up when
     the version joins the keyring on a later applyKeys).

Same `applyKeys(keys)` signature. No new methods, no opts, no error
types. The only behavior change is strict improvement: after
`applyKeys`, every decryptable entry is at the current key version.
Rotation ciphertext propagates to peers via normal CRDT LWW sync —
same mechanism as the plaintext-upgrade case — and every device's
live view converges to current-version ciphertext.

An earlier draft of this spec proposed three additions to get there:
`strict: true` flag (with `EncryptionNotReadyError`), `reencryptExisting:
false` opt-out on applyKeys, and a public `reencryptAll()` method. All
three were speculative — none of the shipped apps had a use case, and
the proposed zero-knowledge "strict" mode was belt-and-suspenders for
app-level UI gating that apps already do. The asymmetric Error subclass
also didn't fit the library's `throw new Error(...)` pattern, and
`wellcrafted/defineErrors` is a Result-type primitive not suited to a
sync-throw API with 394 call sites. Rejected all three; kept the one
change that actually mattered.

At microseconds per XChaCha20-Poly1305 op on small JSON blobs, the
extended walk costs ~30ms for 3000 rows, ~1s for 100k. Not a perf
concern given current workspace sizes.

Spec: specs/20260422T181617-encryption-policy-split.md
The `opts?: { initialKeyring }` parameter let callers construct the store
with encryption already active in one call. It was never used in
production — `attach-encryption.ts` always constructs in passthrough mode
and calls `activateEncryption` later via `applyKeys` on login. The
boot flow is:

  construct passthrough → IndexedDB loads blobs → user authenticates
  → applyKeys walks and decrypts

The key simply doesn't exist at construction time. Even when it does,
`createPassthrough + activateEncryption(keys)` produces identical state:
the walk's "ciphertext already at currentVersion → skip" case makes the
double pass free. Two ways to get into the same state is strictly worse
than one.

Drop the option. Construction is now always passthrough; encryption is
only enabled via `activateEncryption`. Removes 12 lines of duplicated
construction logic that mirrored what `activateEncryption` already did.

Clean break — the packages in this repo are pre-release and unpublished,
so there's no external API contract to preserve. Tests that used
`{ initialKeyring }` are updated to call `activateEncryption` directly;
the test file grows a small `setupActivated` helper to keep multi-doc
tests readable.

Also updates the stale module-level docstring that still described the
pre-rotation-fix "only plaintext is re-encrypted" behavior.

Out of scope: `docs/articles/yjs-storage-efficiency/*.ts` illustrative
scripts — they use an even older signature and are separately stale.
braden-w added 24 commits April 25, 2026 16:21
…tion

Add a SKILL describing the per-app workspace folder layout (index.ts iso
factory, <binding>.ts pure env factory, client.ts singleton + auth +
lifecycle), naming rules for binding files (browser/tauri/extension/...),
the dependency-injection signature for env factories that need auth, and
the no-destructure call-site rule. Spec captures the design rationale,
the conflict it reconciles with the recent flat-module-scope direction,
and the per-app rollout outcomes.
…file layout

- lib/zhongwen/index.ts: isomorphic openZhongwen() — Y.Doc + schemas + encryption
- lib/zhongwen/browser.ts: pure env factory adding idb + BroadcastChannel
- lib/zhongwen/client.ts: createAuth, singleton, onSessionChange, HMR dispose

Call sites import the singleton from $lib/zhongwen/client and access via
zhongwen.kv, zhongwen.tables, etc. — no destructuring.
… layout

- lib/fuji/index.ts: isomorphic openFuji() — Y.Doc + schemas + encryption +
  awareness + actions
- lib/fuji/browser.ts: pure env factory taking { auth }; adds idb, BC, sync,
  entryContentDocs cache
- lib/fuji/client.ts: persisted session, createAuth, singleton, onSessionChange,
  HMR dispose

Call sites import the singleton from $lib/fuji/client and access via
fuji.tables, fuji.actions, fuji.sync, etc. — no destructuring.
…e-file layout

- lib/honeycrisp/index.ts: isomorphic openHoneycrisp() — Y.Doc + schemas +
  encryption + actions
- lib/honeycrisp/browser.ts: pure env factory taking { auth }; adds idb, BC,
  sync, noteBodyDocs cache
- lib/honeycrisp/client.ts: persisted session, createAuth, singleton,
  onSessionChange, HMR dispose

Call sites import the singleton from $lib/honeycrisp/client and access via
honeycrisp.tables, honeycrisp.actions, etc. — no destructuring.
…e-file layout

- lib/opensidian/index.ts: isomorphic openOpensidian() — Y.Doc + schemas +
  encryption only (filesystem and actions depend on browser persistence so they
  live in browser.ts)
- lib/opensidian/browser.ts: pure env factory taking { auth }; adds idb, BC,
  fileContentDocs, sqliteIndex, fs, bash, all actions, sync
- lib/opensidian/client.ts: persisted session, createAuth, singleton,
  onSessionChange, HMR dispose, workspaceAiTools derived from singleton

Call sites import the singleton from $lib/opensidian/client and access via
opensidian.fs, opensidian.bash, opensidian.tables, etc. — no destructuring.
…ee-file layout

- lib/tab-manager/index.ts: isomorphic openTabManager() — Y.Doc + schemas +
  encryption + awareness + actions
- lib/tab-manager/extension.ts: pure env factory taking { auth }; adds idb,
  BC, sync. Live browser state stays in browser-state.svelte.ts.
- lib/tab-manager/client.ts: top-level await session.whenReady (chrome.storage
  hydration), createAuth, singleton, registerDevice helper, onSessionChange,
  HMR dispose, workspaceAiTools, awareness identity publish

rpc-contract derives Actions from typeof tabManager.actions. Call sites import
the singleton from $lib/tab-manager/client and access via tabManager.tables,
tabManager.actions, etc. — no destructuring.
…e-file layout

- lib/whispering/index.ts: isomorphic openWhispering() — Y.Doc + schemas +
  encryption
- lib/whispering/tauri.ts: pure env factory; adds idb, BC, recordingsFs
  (Tauri filesystem materializer; no-op in non-Tauri environments)
- lib/whispering/client.ts: singleton only — Whispering has no auth or sync,
  so the file is minimal but kept for symmetry with sibling apps

Call sites import the singleton from $lib/whispering/client and access via
whispering.tables, whispering.kv, whispering.batch, etc. — no destructuring.
Replaces ReturnType<typeof tables.X.getAllValid>[number] with
InferTableRow<typeof X> at the table definitions, making the schema
the single source of truth. State files no longer re-export row types;
consumers import directly from $lib/workspace.
Adds an explicit "Row Type Inference" section to the workspace-api skill
banning ReturnType<typeof tables.X.getAllValid>[number] in favor of
InferTableRow<typeof X>, and forbidding pass-through type re-exports
from state files (consumers import types directly from the workspace
module).
Two one-shot user-initiated entry mutations (delete, restore) now
toastOnError on the Result return. Both handlers return
Result<Row, TableParseError> — bug-class, but if it ever fires the
user deserves a signal rather than a silent failure on a confirmation
click.

The continuous oninput entry.update site is intentionally left bare
for now — per-keystroke toasting on a bug-class error would be worse
UX than silent. That site is the topic of an ongoing discussion;
options will be enumerated in a follow-up.

create / bulkCreate handlers return raw values (no Result), so no
change needed there.
Documents the four-category framework for choosing how to handle Result
returns from action calls:

- A: One-shot user actions → toastOnError
- B: High-frequency mutations on bug-class errors → bare call + comment
- C: Real domain errors → destructure + branch
- D: Mixed-success operations → branch on inner Result

Includes a per-action table mapping each existing fuji/tab-manager
action to its category and call-site pattern. Captures the rationale
for rejecting alternatives (throw-on-error inside handler, custom lint
rule, auto-toast in state wrappers) so future contributors don't
re-derive them.

Open question called out at the end: the Category B "high-frequency
continuous mutation" pattern is brittle if it proliferates. Today only
one site (fuji.entries.update via oninput) qualifies. Structural fixes
(debouncing, commit-on-blur, etc.) are tracked separately.
Hand-rolled `{ title; subtitle; tags; type }` mirrored the Entry row
schema and would silently drift if any of those columns changed type.
Use `Pick<Entry, ...>` so the search predicate stays bound to the
workspace definition.
… AI bridge

The CLI grew its own `walkActions` (packages/cli/src/util/walk-actions.ts)
that yields `[path, action]` tuples; the only remaining consumer of
`iterateActions` was `actionsToAiTools`, which wants `[action, path[]]`
so it can join with its own `_` separator. Inline the ten-line generator
locally as `walkActionTree`, drop the framework export.

Closes Phase 2 Deletion 5 of the document-primitive teardown:
discoverable-action walks now live next to their consumer, not as a
public framework primitive.
Sweep up the JSDoc and lead-comment fossils left over from the factory
teardown. JSDoc examples now show plain inline composition; lead comments
on app definition files point to the iso/env/client three-file layout.

The opensidian /about page sample code is also rewritten to mirror what
opensidian actually exports today (`openOpensidian()` + module-scope
singleton) instead of the long-removed `defineDocument` shape.
The v3 article ended on "delete the wrapper" — smug little ending. The
honest arc has two more beats:

- **v4**: I deleted the framework's wrapper too. Document, DocumentHandle,
  createDocumentFactory, ActionIndex — all gone. The refcount cache
  underneath was the only piece doing real work; rename it
  createDisposableCache, strip the constraint to T extends Disposable,
  let it cache anything.
- **v5**: I un-deleted my wrapper. openFuji() came back, but for
  bleed prevention, not encapsulation. The iso/env/client three-file split
  draws a line between "things that can run anywhere" and "things bound
  to this binding," keeping Node configs out of y-indexeddb's import
  graph.

The closing reflection updates the test: "is this called more than once?"
is the wrong measure. "Would removing this make a forbidden import
possible?" is the right one. By that test, defineWorkspace failed and
stayed deleted; openFuji() failed at v3 and came back at v5.
…etion 3

Phase 2 of the document-primitive teardown executed with one reversal.
Deletions 1, 2, 4, 5, 6, 7 land verbatim — annotate each with the
commit that landed it. Deletion 3 (delete openFuji() wrappers) executed
first then got reversed by 83feb2d + the iso/env/client three-file
split codified in 20260425T225350-app-workspace-folder-env-split.md.

Mark Deletion 3 as superseded with a gravestone explaining why the
reversal won: the spec's "called once = unused encapsulation" axiom
held for one consumer and broke the moment a second appeared. The
honest test isn't caller count, it's "would removing this make a
forbidden import possible?"

Drop grep targets that the gravestone makes stale (openFuji, function
openHoneycrisp, export const workspace =) — those shapes are
deliberately present under the new convention.

Update the orchestration tracker to mark Step 2 done with the reversal
noted, and Step 3 (article v4 coda) done with v5 added on top.
Adds a global visibilitychange + pagehide listener in the root layout.
When the page is being hidden (Cmd+W, Cmd+Q, tab switch, window
minimize, mobile app-switch, bfcache transition), force-blur the focused
element. Any input wired to commit on `onblur` then fires its handler
synchronously, updating the Y.Doc before the page is destroyed.

The full chain stays synchronous from the visibilitychange event
through the Y.Doc transaction:
  visibilitychange → .blur() → onblur → tables.X.update() → Y.Doc

Async observers (y-indexeddb, attachSync, BroadcastChannel) typically
complete within the browser's grace period for graceful close. For
ungraceful close (force-quit, OS crash) within the ~50ms IDB-flush
window, data may still be lost — that's an OS-level concern not
solvable in JS.

Pattern is enabled app-wide by this five-line listener; individual
input call sites just need plain `onblur={...}` to participate.

See docs/articles/commit-on-blur-survives-tab-close.md for the full
explanation, reliability discussion, and when not to use this pattern
(use Y.Text + y-prosemirror for character-level CRDT editing).
Title and subtitle text inputs swap from per-keystroke `oninput` writes
to `onblur` commits. Writes to Y.Doc go from N transactions per typing
session to 1.

The tab-close case (Cmd+W mid-edit) is covered by the visibilitychange
safety net in the root layout: `.blur()` on the focused element fires
the onblur handler synchronously before the page tears down.

The remaining 6 sites in this component (tags, type, date, rating) are
discrete events — they were already one-event-per-action and stay that
way. The `updateEntry` helper they share now wraps with toastOnError so
TableParseError surfaces as a friendly toast instead of being silently
discarded. Every call site in this file is now Category A in the
result-handling ADR.

ADR § Category B (high-frequency continuous mutations on bug-class
errors) loses its only example. Future Category B occurrences would
re-introduce the per-keystroke discussion; for now, the section is
documentation of the pattern's existence rather than a live convention.
…+ .blur

Article explaining the five-line save-on-tab-close pattern shipped in
the previous two commits. Covers:

- Why per-keystroke writes are wasteful in YJS apps (transaction
  per keystroke, sync chatter, broadcast channel posts)
- Why naive commit-on-blur loses Cmd+W edits (blur event doesn't
  fire on tab close)
- The visibilitychange + .blur() insight (synchronous chain through
  to Y.Doc update before page tear-down)
- Reliability of each persistence layer: Y.Doc memory (sync),
  BroadcastChannel (effectively sync), y-indexeddb (~95%),
  WebSocket sync (~70-90%, but local-first means next-launch
  pushes any missed updates)
- When to reach for it (plain string Y.Map fields like title /
  subtitle / name) and when not to (use Y.Text + y-prosemirror /
  y-codemirror for character-level CRDT text editing)
- Why <svelte:document> beats raw addEventListener (idiomatic Svelte 5,
  auto cleanup, SSR-safe)

Cross-references the ADR at specs/20260425T230000-result-handling-conventions.md
for the broader Result-handling framework.
Per Svelte's `packages/svelte/elements.d.ts`, `onvisibilitychange` lives
on `SvelteDocumentAttributes` and `onpagehide` lives on
`SvelteWindowAttributes`. The previous commit attached both to
`<svelte:document>`; on the wrong element, `onpagehide` typechecks
loosely but won't fire reliably.

Splits to `<svelte:document onvisibilitychange>` +
`<svelte:window onpagehide>`. visibilitychange remains the primary
signal (more reliable on iOS Safari); pagehide is the bfcache /
older-Safari belt-and-suspenders.

Comment updated to call out the element split, and the article
filename reference fixed.
SkillMetadataForm: the four metadata inputs (name, license, description,
compatibility) swap from per-keystroke `oninput` writes to `onblur`
commits. Yjs writes go from N transactions per typing session to 1.

The earlier draft of this used a `commit(field, next)` helper typed
against a `'name' | 'description' | 'license' | 'compatibility'` literal
union — a code smell that mirrored the table column type. Replaced with
a thin `updateSkill(updates)` partial-application helper (same shape as
fuji's `updateEntry`) and inline compare-then-write at each call site.
Each handler now reads top-to-bottom: read input value, compare against
current, commit if changed.

Layout: adds the tab-close safety net to apps/skills root layout so
Cmd+W mid-edit synchronously flushes the focused input's onblur handler
before the page tears down. `<svelte:document onvisibilitychange>` is
the document-event half; `<svelte:window onpagehide>` is the
window-event half (per Svelte's elements.d.ts split).

Optional fields (license, compatibility): empty input → undefined keeps
the row clean (no empty strings stored). Required fields (name,
description): empty stored as empty string, validation surfaces it.
…pattern

Adds the pattern as a top-level section in the `svelte` skill, framed as
the default for any new app: per-input `onblur` + compare-then-write,
plus the app-wide tab-close safety net in `+layout.svelte`. Includes the
"when NOT to use" table (Y.Text + ProseMirror/CodeMirror, discrete
selectors, local-only state) and the defensive local-state variant for
the rare clobber edge case.

Cross-references from `workspace-api`: the motivation (reducing Yjs
transactions per typing session) lives on the workspace side; the
implementation (Svelte event wiring) lives on the Svelte side. The
related-skills line points consumers from one to the other.

Article fix: the original example placed both `onvisibilitychange` and
`onpagehide` on `<svelte:document>`. Per Svelte's
`packages/svelte/elements.d.ts`, pagehide is a window event — splits
the example to `<svelte:document>` + `<svelte:window>` and adds a note
about why the element matters. Six lines, not five.
Bridge once at each boundary instead of twice per handler. The optional
license and compatibility handlers were coercing both sides of the
compare to string (`next !== (skill.x ?? '')`) and the write side back
to optional (`next || undefined`) — two coercions per onblur.

Cleaner: read with `|| undefined` once, then compare in model space
(`next !== skill.x`, both `string | undefined`). The schema already
matches the agent-skills.io spec — these fields are optional and
absent-by-omission, not empty-string-by-default. Per the spec:
"Most skills do not need the compatibility field."

Edge cases hold:
- undefined → blur empty: next=undefined, undefined !== undefined → skip
- "x" → cleared: next=undefined, undefined !== "x" → write { x: undefined }
- undefined → typed "y": next="y", "y" !== undefined → write { x: "y" }

Required fields (name, description) already read this way — no
coercion needed on either side. Form is now uniform: bridge once at
each side of the DOM ↔ model boundary, compare natural values.
… architecture spec

Two doc edits before merging PR #1705:

1. PR body (`20260425T180000-pr-body-document-primitive.md`) — full
   rewrite to cover the actual scope of the branch (520 commits, 534
   files, 19 packages). Previous body covered ~30% of what shipped.
   New body has 13 sections:

   - Subprotocol auth (kept)
   - Session writer partition (kept)
   - Package consolidation (NEW — auth split + document→workspace merge)
   - Workspace primitive terminal shape (NEW — describes IS, not journey)
   - iso/env/client three-file convention (NEW — terminal app shape)
   - Encryption coordinator + encrypted CRDT primitives (NEW)
   - Materializer subsystem (NEW)
   - Structured logger + JSONL sink (NEW)
   - Action surface (kept + updated to passthrough terminal shape)
   - CLI scripting-first redesign (kept + expanded)
   - Per-row content docs via createDisposableCache (NEW)
   - Tab-close safety net (commit-on-blur) (NEW)
   - Articles (expanded from 3 to ~20)

   Five-commit keystone reading guide at the top so reviewers with an
   hour can extract the spine. Skips rename journeys; describes terminal
   state. Adds delete-after-merge note per the convention.

2. Architecture spec (`20260424T180000-drop-document-factory-attach-
   everything.md`) — amendment to Layer 4 SPA bootstrap example.
   Original showed v3-era flat module-scope shape (no openFuji wrapper);
   terminal shape is iso/env/client three files. New example shows the
   terminal shape; original preserved in a collapsed details block as
   historical context for the rest of the spec's reasoning chain.

Two specs that were both untracked are now staged for the first time.
@braden-w braden-w changed the title refactor(document-primitive): collapse defineWorkspace, scripting-first CLI, subprotocol auth refactor: workspace primitive collapse, package consolidation, CLI redesign Apr 26, 2026
Conflicts:
- apps/whispering/src/lib/query/transformer.ts: kept the `whispering.tables`
  rename from this branch and applied the `satisfies` annotations from main.
- packages/workspace/src/workspace/create-workspace.test.ts: deleted; the
  defineWorkspace API it covered no longer exists on this branch.
- bun.lock: regenerated via `bun install`.
@braden-w braden-w merged commit 252dced into main Apr 26, 2026
1 of 9 checks passed
@braden-w braden-w deleted the braden-w/document-primitive branch April 26, 2026 04:26
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