refactor: workspace primitive collapse, package consolidation, CLI redesign#1705
Merged
refactor: workspace primitive collapse, package consolidation, CLI redesign#1705
Conversation
…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.
…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.
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`.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This branch deletes
defineWorkspaceand thewithExtensionchain that drove every workspace in the codebase for a year. The terminal API isattach*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.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.handleenvelope 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 anyDisposable. 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-docgot renamed to@epicenter/document;@epicenter/documentgot merged wholesale into@epicenter/workspace. One published surface, one barrel, one place to find anything.@epicenter/authsplit into framework-agnostic core + Svelte wrapper becausecreateAuthwas 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-indexeddborBroadcastChannel. 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
Documentwithout rewriting the CLI loader, you can't split@epicenter/authwithout 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:
830a7ef8c— WebSocket subprotocol auth. The wire-protocol pivot Section 1 builds on. Tokens leave URLs.b62cc5ae3— deletecreateWorkspace+ extension chain. The moment the original builder dies, after every consumer migrated to closure-composedattach*.3dec00926—attachSynctakesdispatch:andgetToken:callbacks. ReplacessetToken/requiresToken/serveRpc/IIFE token bootstrap. The clean-shape pivot for the transport boundary.814965d10—createDocumentFactory→createDisposableCache. The framework primitive stripped to its honest contract: a refcount cache for anythingDisposable.Document,DocumentHandle,DOCUMENT_HANDLE,iterateActions,ActionIndexall go in the same wave.8f46308e9— iso/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.mdwalks 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
WebSocketconstructor accepts a list of subprotocols, theSec-WebSocket-Protocolheader is just ASCII tokens, and headers aren't in default log fields.The server reads the bearer entry and synthesizes an
Authorizationheader for Better Auth. The DO echoes onlyepicenterto complete the handshake; the bearer entry never round-trips. Constants and parse helpers live in@epicenter/sync/auth-subprotocolso 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.mdcovers 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
onSuccesshandler writes rotated tokens.useSession.subscribewrites 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.
useSessionalways ownsuserandencryptionKeys. Fortoken, 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:A same-token guard on
onSuccessskips the write when the server echoes an unchanged token, so non-rotating requests don't fan out subscribers.signOutnow returnsResult<undefined, SignOutFailed>like every other auth method — previously it swallowed errors viaconsole.error.Keystones:
e3b2a38b8,d11ae8e00,ff852c3c2,e2f7ed3c9.3. Package consolidation
Two separate consolidations, both load-bearing for everything else.
@epicenter/authsplit into core + sveltecreateAuthwas Svelte-coupled — it took a Svelte store assessionand calleduseSession.subscribefrom the framework's runtime. That made auth unusable in Node tools, the CLI, and any non-Svelte consumer. The split:@epicenter/auth— core. Framework-agnosticcreateAuth(...)over aSessionStorecontract.@epicenter/auth-svelte— Svelte wrapper. Spreads core methods, exposes the reactive session, adaptsSessionStoreto a Svelte rune store.Migration ran as seven sequential commits (
9a066780d→808e9bfb2), one step per concern: scaffold the package shell, move auth-types, defineSessionStorecontract, portcreateAuthcore, ship the Svelte wrapper, migrate six apps, delete the oldsvelte-utils/auth. Every app'sclient.tsends up importing from@epicenter/auth-svelte.@epicenter/yjs-doc→@epicenter/document→ merged into@epicenter/workspaceThe CRDT primitives lived in three packages chasing the same boundary.
yjs-docgot renamed todocument(2a012c087);documentgot merged wholesale intoworkspace(a7547cd5emigrates consumers;11efb21eddeletes the package). The boundary survives aspackages/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 plainattach*calls against a Y.Doc the caller owns. The history of how it got there is in the article; the terminal contract is:open<App>(...)factory returns.Y.Doc, expose[Symbol.dispose], and (if anything is async) awhenReady: Promise<unknown>.There is no
Documentstructural type, noDocumentHandlebrand, noDocumentBundle, nocreateDocumentFactory, nodefineWorkspace, no extension chain.What survived from the old framework:
createDisposableCacheis 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 anyDisposableresource: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),
createDisposableCacheis 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 iny-indexeddb,BroadcastChannel, orchrome.*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:
index.ts@epicenter/workspacecore, schemas<binding>.ts./index+ env-specificattach*client.ts./<binding>+createAuthauth+ singleton + lifecycle subscriptionsBinding 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 throughindex.ts.Fuji's terminal shape (
apps/fuji/src/lib/fuji/):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),2cc080bd0→fcf6de7d2(per-app rollout, six commits).6. Encryption coordinator + encrypted CRDT primitives
attachEncryptionbecame a coordinator that exposes.attachTables/.attachKvmethods directly. The form makes it visually clear that encryption is applied first as a stateful container, and tables/kv are wired through it:Underneath: encrypted variants of
YKeyValueLwwand friends live inpackages/workspace/src/document/encrypted-*, with aregister()coordinator pattern that letsattachEncryptionintrospect what's been wired and apply keys uniformly. Key rotation upgrades old-version ciphertext onapplyKeys(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:
Both are one-way (workspace → store); both register
ydoc.once('destroy', ...)so destroying the ydoc tears down the mirror; both exposewhenFlushedfor tests. Markdown materializer supports arebuildmode for orphan cleanup. SQLite materializer'srebuildmatches the sqlite materializer's parity convention.The
@epicenter/skillspackage uses these for disk-round-trip of agent skill definitions (importFromDisk/exportToDiskactions inpackages/skills/src/node.ts).Keystones:
9383ed707(spec), the materializer subdirectory atpackages/workspace/src/document/materializer/.8. Structured logger + JSONL sink
Replaced ad-hoc
console.*calls across the workspace package withwellcrafted/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 viaBun.file(path).writer(). Lives atpackages/workspace/src/shared/logger/jsonl-sink.tsbecause it can't run in browsers; the logger core itself is platform-agnostic and imported fromwellcrafted/logger.Every previously-
console.*site in the workspace package now has a typed error variant (AttachSyncError.PingTimeout,BroadcastChannelError.SerializeFailed, etc.) defined viadefineErrors. 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 aRemoteReturn<T>conditional type doing real work.We collapsed it once, then walked half of it back. Terminal shape:
defineMutation({ handler: ... })returns the handler verbatim with metadata attached. Sync stays sync, raw stays raw,Resultif explicit. Local callers see exactly what the author wrote.invokeNormalized(action, input, label)to get uniformPromise<Result<T, RpcError>>. Thrown handlers becomeErr(ActionFailed). Raw values getOk-wrapped.WrapAction<F>flattens the four possible handler return shapes into onePromise<Result<T, E | RpcError>>;RemoteActions<A>mirrors an action tree's structure with each leaf wrapped.ACTION_BRANDis gone (isAction(v)is now structural).RemoteReturn<T>is gone.iterateActionswas 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 oldActionIndex.get(path)lookup.ActionFailedis now a type alias over@epicenter/sync'sRpcError.ActionFailed. One nominal type; no nesting;isRpcErrorworks across boundaries.ADR:
specs/20260425T200000-actions-passthrough-adr.mddocuments 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,.extensionsavailable. 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:
Anything else: write a
.tsscript that importsepicenter.config.tsand calls the typed handle directly.bun run scripts/foo.tsis the runtime.Invocation:
JSON-only input. Three sources, all routed through
parseJsonInput: positional ('{...}'or@file.json), stdin pipe, or--peerpayload. The previoustypeboxToYargsOptionsflag-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.peersis a one-shot snapshot of remote awareness. You don't appear in your own list.Exit codes carry meaning for scripts.
1usage or setup error,2action returnedError remote RPC failed,3peer didn't resolve within--wait. The split between2and3lets a script retry on3(transient) without retrying on2(real failure).peersdefaults to--wait 0.run --peerdefaults to--wait 5000(resolve target + complete RPC).attachSessionUnlockis a new primitive inpackages/cli/src/auth/. Thin wrapper overattachEncryptionthat sources keys from the CLI session store — the one piece a CLI-mode workspace can't synthesize from the workspace package alone. It exposeswhenChecked: Promise<unknown>soattachSync({ 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 offentry.workspace(noentry.handle.Xenvelope, no duck-typedgetSync/extractAwarenesshelpers).Article:
docs/articles/you-already-built-cqrs.mdcovers why writes flow through Yjs as state and reads/queries dispatch through addressabledefineQuery/defineMutationnodes — CQRS without anyone planning it.Keystones:
db4a8c4e5(schema-to-yargs flag bridge removed),a56369aac(peers + remote dispatch),c1ee2e853(exit codes +--waitrename),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
createDisposableCacheat the workspace's env layer:Components consume via
fromDisposableCache(entryContentDocs, () => entry.id)— a Svelte adapter in@epicenter/sveltethat bridges the cache to$derived+$effectlifecycle. Replaces the oldfromDocument(handle)which carried framework-specificDocumenttypes.Honeycrisp's note bodies follow the same shape (
createNoteBodyDoc+ cache).Keystones:
b4ef57db9(singular pure builders),2f4c93fec(fromDocument→fromDisposableCache).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:documenttovisibilitychangeand (per the article —pagehideis a window event, not a document event)window pagehide, flushing pending state through the sameupdateEntryaction 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— howydoc.destroy()cascades cleanup through every attachment.Pattern / lesson articles (referenced from skills):
singular-wrappers-delegate-to-plural.mdreactive-touch-is-a-missing-subscription.mdsvelte-effect-root-hmr-pattern.mdok-null-is-fine-err-null-is-a-lie.mdi-built-the-svelte-wrapper-first.mddont-export-everything.mdcallable-actions-pattern.mdyour-data-is-probably-a-table-not-a-file.mdtypescript-circular-inference.mdyjs-abstraction-leaks-cost-more-than-the-abstraction.mdwhy-tanstack-ships-separate-framework-packages.md20260420T160000-state-handle-null-is-the-component-lifecycle-in-disguise.md20260423T090839-query-params-leak-subprotocols-dont.mdPlus 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:
specs/20260425T000000-device-actions-via-awareness.mdPhase 1) —serializeActionManifest,invoke, awareness state convention, app wiring to publish offers. Builds on the post-teardown action registries + thedispatch:callback shape. No new attach primitive.epicenter devicescommand, 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 typecheckclean across the monorepobun testpasses inpackages/workspace(553 tests),packages/cli(19 e2e),packages/auth,packages/auth-svelte,packages/sync,packages/filesystem,packages/skillsfromDisposableCache, survives rapid component remountfs.read/fs.writeactions work; SQLite index search returns resultstabs.searchandtabs.closeround-tripepicenter listenumerates actionsepicenter run fuji.entries.create '{...}'round-tripsepicenter peersshows remote devicesepicenter run --peer deviceName=<x> fuji.entries.listdispatches to a peer?token=onSuccessdoesn't get clobbered byuseSession.subscriberefreshCoordination
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.