Workspace App Layout
A workspace app is composed in layers: a pure isomorphic doc factory, one or
more environment factories that bind it to a runtime (browser, Chrome
extension, Tauri), a single side-effectful workspace singleton, and (for
multi-platform apps) a build-time platform DI seam. Daemon and script bindings
do not live in the app package at all; they live per-project under
workspaces/<app>/ and are registered through epicenter.config.ts.
There is ONE composition shape (ADR-0088: sign-in is an enhancement, never a
door). Every app boots into a working local workspace with one call;
toConnection reads the persisted auth.state once and projects it to the
connection (signed in) or null (signed out, bare local wiring, ADR-0094).
Storage is an environment concern: browser apps use the default IndexedDB
local persistence, while Bun hosts inject bunLocalPersistence({ dir, nodeId })
through connect(null, { persistence }) (ADR-0095).
model.connect(toConnection(auth, nodeId), compose);
The workspace is never null, no route gates on identity, and an owner change
reloads the page (reloadOnOwnerChange) so the next boot re-projects.
When a schema needs a per-platform argument, the model is a factory
define<App>(args): the id and tables stay fixed, and only defaults or other
read-side schema inputs vary.
File Layout
Two layouts ship today. Older single-platform apps keep the composition files
flat at the package root; apps preparing for multi-platform builds nest the
same files under src/lib/workspace/ and add a src/lib/platform/ seam.
Flat root (opensidian, vocab):
apps/<app>/
|- <app>.ts iso schema + workspace model (package "." export)
|- <app>.browser.ts browser env factory open<App>Browser() (the preset branch)
|- <app>.test.ts tests
|- mount.ts optional mount factory <app>() (package "./mount" export)
`- src/lib/
|- <app>.ts the workspace singleton
|- migration/sign-in-migration.ts first-sign-in migration wiring
`- platform/auth/ auth client construction
Nested under src/lib/workspace/ (honeycrisp, whispering):
apps/<app>/
|- package.json "imports" map declares the #platform/* seams
`- src/lib/
|- workspace/
| |- index.ts iso schema + workspace model (package "." export)
| |- browser.ts browser env factory open<App>Browser() (the preset branch)
| |- index.test.ts tests
| `- mount.ts optional mount factory <app>() (package "./mount" export)
|- <app>.ts the workspace singleton
|- migration/sign-in-migration.ts first-sign-in migration wiring
`- platform/ #platform/* impls (X.browser.ts / X.tauri.ts) + types.ts contract
The extension app (tab-manager) keeps its deferred boot module at
src/lib/session.svelte.ts: chrome.storage is async, so the auth client and
workspace bundle are built after a readiness promise resolves, and the module
exports a tabManagerBoot handle whose getters throw only before storage
readiness (never a signed-out branch).
Package exports follow the file's actual owner. Flat-root apps export the iso
factory as .; only apps with a live daemon consumer export a mount factory as
./mount. Apps without a daemon mount export narrower surfaces instead:
// honeycrisp (nested, no mount)
"exports": {
".": "./src/lib/workspace/index.ts"
}
// whispering (nested, no mount): no `.` or `./mount`, since whispering isn't
// daemon-mounted.
"exports": {
"./commands": "./src/lib/commands.ts",
"./workspace": "./src/lib/workspace/index.ts"
}
Opensidian additionally exports "./browser": "./opensidian.browser.ts"; the
others do not export their browser factory. That asymmetry is honest, opensidian
has a consumer that needs the bare browser factory and the others do not. Do not
add a ./browser export to the rest for symmetry's sake.
Layers
| Layer | File | Job | Returns |
| --- | --- | --- | --- |
| Iso factory | <app>.ts / workspace/index.ts | defineWorkspace({...}): pure doc model | workspace model (create, connect, mount) |
| Browser factory | <app>.browser.ts / workspace/browser.ts | open<App>Browser({ auth, nodeId }): the one boot call | LocalWorkspace \| ConnectedWorkspace bundle (storage, collaboration, wipe, child-doc openers) |
| Extension / tauri factory | <app>.extension.ts etc. | same branch after async storage resolves | iso bundle plus runtime resources |
| Mount factory | mount.ts / workspace/mount.ts | Optional. <app>(opts?) calls <app>Workspace.mount({ runtime: nodeMountRuntime(), ... }) and returns the Mount a project's epicenter.config.ts default-exports | Mount (node persistence, materializers) |
| Workspace singleton | src/lib/<app>.ts | compose the bundle with app state, alias whenReady | <app> handle, never null |
| Migration | src/lib/migration/sign-in-migration.ts | wire createSignInMigration (local source + words) | signInMigration state for the shared dialog |
| Auth | src/lib/platform/auth/ (or #platform/auth) | auth client construction | auth |
The iso factory and browser/extension factory are pure construction surfaces. Side effects (HMR disposal, persisted state, network) live only in the workspace singleton module.
Iso Factory
The iso model builds the document schema and returns the workspace model. It is
the package . export and the wire contract for sync: browser, daemon,
local-host, and test consumers import it when they need the shared schema.
Forking a table column shape breaks sync compatibility with peers running the
canonical schema.
Rules:
- Keep the iso factory free of
node:*,bun:*,chrome.*, Tauri APIs,y-indexeddb,BroadcastChannel, and runtime singletons. It must type-check and run isomorphically. - Put pure actions inline as
actions: defineActions({ ... })in the model when they depend only on tables. - Keep env-bound actions in the env factory when they need filesystem, SQLite, shell, or browser persistence. Extract only when the runtime action set is shared or owns a boundary that would be harder to read inline.
Browser Factory
open<App>Browser({ auth, nodeId }) is the one boot call. Both connection
arms return the same bundle shape (per-row child-doc openers and wipe()
included), so nothing downstream branches on auth again:
import { toConnection } from '@epicenter/svelte/auth';
export function openHoneycrispBrowser({
auth,
nodeId,
}: {
auth: SyncAuthClient;
nodeId: NodeId;
}) {
return honeycrispWorkspace.connect(toConnection(auth, nodeId));
}
When the app layers a runtime composition, pass compose as the second
argument. An inline arrow infers its parameter; a named compose function
annotates it with ComposeContext<typeof myAppWorkspace> from
@epicenter/workspace, never a hand-written Pick or Parameters<...>
extraction.
Workspace Singleton
The singleton lives in src/lib/<app>.ts (a plain .ts module). It builds
the bundle once at module load and composes app state on top; it is never
null and has no require*() accessor:
import { createNodeId } from '@epicenter/workspace';
import { auth } from '#platform/auth'; // nested apps; flat-root apps import from $lib/platform/auth
import { openHoneycrispBrowser } from './workspace/browser';
const browser = openHoneycrispBrowser({
auth,
nodeId: createNodeId({ storage: localStorage }),
});
export const honeycrisp = {
...browser,
state: createHoneycrispState(browser),
/** Resolves when local persistence has hydrated the root doc. */
whenReady: browser.storage.whenLoaded,
};
The root layout mounts reloadOnOwnerChange(auth) once (onMount), the
WorkspaceGate, the migration check(), and the shared dialogs. AccountPopover
is the only auth surface; there is no signed-out screen and no (signed-in)
route group.
createSession (@epicenter/svelte/auth) never owns a workspace lifecycle
(ADR-0088). It survives only for auxiliary signed-in-only resources whose whole
existence is tied to an identity (e.g. the vault keyring session).
Sign-In Migration
The first signed-in boot that finds bare local rows offers the flag-free
Add / Delete / Keep dialog. The mechanics (probe, crash-safe child-doc phases,
copy-then-clear) live in @epicenter/app-shell/sign-in-migration; the app
supplies only openLocalSource (the iso model's .create() plus a bare
attachIndexedDb) and the words (describe, note, errorNoun):
export const signInMigration = createSignInMigration({
auth,
openLocalSource,
target: honeycrisp,
describe: describeLocalContents,
errorNoun: 'notes',
});
Child-doc guids are derived from the tables returned by openLocalSource
(ADR-0092). Deliberately excluding a table from openLocalSource (e.g.
tab-manager's always-populated devices) excludes it from the probe, row copy,
and child-doc migration together.
Gating Readiness on Hydration
A workspace-backed route reads empty tables until the workspace's readiness
promise resolves (storage.whenLoaded, aliased as whenReady; matter's is the
once()-memoized store read ensureHydrated()), so it flashes an empty state
("No recordings yet", "All clear"). No useful partial UI exists here, so gate
the first paint rather than skeleton it.
One rule: gate where the readiness promise is first reachable, decided by where the workspace is built.
| Workspace built | Reachable in | Gate |
| --- | --- | --- |
| Eager module singleton with route loads: todos, whispering, skills, matter | a route load | load: await x.whenReady (matter: ensureHydrated()) |
| Eager module singleton, gate in the root layout: honeycrisp, vocab, opensidian | the root layout | <WorkspaceGate pending={<app>.whenReady} onForgetDevice onSignOut> |
| Extension entrypoint behind async storage: tab-manager | the component | outer {#await boot.whenReady}, then WorkspaceGate |
- Correctness gates (404 / redirect / param) always go in
load; onlyloadcanerror()/redirect()(mattervault/[id]). - The promise must be resolve-only or the gate blocks paint forever
(
storage.whenLoadedis kept resolve-only by the y-indexeddb corrupt-load patch). Fix the promise, never add a timeout.
The blank-shell (load) vs <Loading> (WorkspaceGate) difference follows from
the boundary, not a separate choice. For the load-blocks-render rule ground
against sveltejs/kit; for the {#await} form see the svelte skill.
Platform DI: the #platform/* seam
Multi-platform apps (the app with src-tauri/: currently whispering) select
browser-vs-Tauri implementations at BUILD time via Node-standard #platform/*
subpath imports. This is the canonical mechanism. It replaced the old
resolve.extensions / moduleSuffixes suffix trick (see "Why not suffixes"
below).
1. Declare the seam in package.json "imports". Each seam maps a bare
specifier to a Tauri impl and a default (browser) impl:
"imports": {
"#platform/tauri": {
"tauri": "./src/lib/platform/tauri.tauri.ts",
"default": "./src/lib/platform/tauri.browser.ts"
}
}
2. Consume the bare specifier, with NO platform branch at the call site:
import { tauri } from '#platform/tauri';
3. The build picks the impl by condition. The web build uses default
(browser). The Tauri build activates the tauri condition in vite.config.ts:
const isTauri = process.env.TAURI_ENV_PLATFORM !== undefined;
// ...
resolve: {
// Custom conditions REPLACE Vite's defaults, so the
// ...defaultClientConditions spread is LOAD-BEARING (drop it and all
// dependency resolution breaks).
...(isTauri && { conditions: ['tauri', ...defaultClientConditions] }),
},
4. tsconfig needs nothing. No moduleSuffixes, no per-target tsconfig.
Bundler moduleResolution reads the imports field and lands on default
(browser) for the editor and typecheck.
5. Each seam has a shared contract. A types.ts declares the contract;
both impls annotate against it with a type annotation, not satisfies:
// platform/types.ts
export type Tauri = { /* ... */ };
// platform/tauri.browser.ts
export const tauri: Tauri | null = null; // no native capability on web
// platform/tauri.tauri.ts
export const tauri: Tauri | null = tauriOnly;
Use export const x: Contract = ..., NOT satisfies. satisfies would leak the
concrete type and break the lockstep that keeps both variants conforming to the
same shape.
.tauri.ts-only exports bypass the seam. A symbol that only exists on Tauri
(e.g. whispering's tauriOnly) is imported DIRECTLY by .tauri.ts files (e.g.
import { tauriOnly } from '$lib/tauri.tauri'), not through #platform/*
(which resolves to null on web).
The guarantee. Because the wrong-platform file is never resolved,
@tauri-apps/* code is PHYSICALLY ABSENT from the web bundle (a build-time
guarantee, not Rollup tree-shaking). A Tauri-only file imported by shared code
fails the web build instead of shipping a broken runtime.
Why not suffixes
The old mechanism put .browser.ts / .tauri.ts ahead of .ts in Vite
resolve.extensions, mirrored by tsconfig moduleSuffixes. That was GLOBAL:
every bare import was magic, which is why a bare ./fuji once collided with a
fuji.browser.ts. The #platform/* mechanism is scoped to the #platform/*
specifiers only, so the rest of the import graph stays ordinary. Do not
reintroduce resolve.extensions suffixes or tsconfig moduleSuffixes.
Daemon and Script Placement
Daemon and script bindings are NOT in the app package. They live per-project
under workspaces/<app>/ (e.g. playground/opensidian-e2e/workspaces/opensidian/daemon.ts)
and are registered through epicenter.config.ts at the Epicenter root:
import { defineConfig } from '@epicenter/workspace';
import opensidian from './workspaces/opensidian/daemon.ts';
export default defineConfig({
routes: [opensidian],
});
The daemon imports the app's mount factory (the ./mount export) to construct
its Mount. epicenter.config.ts marks the Epicenter root and is the route
registry; .epicenter/ is machine state under that root, not a discovery marker. The public
lifecycle command is epicenter daemon up, not epicenter serve.
Anti-Patterns
- Gating any route or the app shell on identity: no
(signed-in)route groups, no signed-out screen, no redirect-to-sign-in. Sign-in is an enhancement (ADR-0088); signed-in-only features get small inline affordances. - Owning a workspace lifecycle with
createSession, or adding arequire*()accessor / nullable workspace handle. The singleton is nevernull. - Hand-rolling child-doc wiring, wipe, or migration phases in an app. The presets and the sign-in migration kit own them; the app supplies words and guid readers.
- Branching on
auth.stateanywhere except the one preset branch in the environment factory (and small inline feature affordances). - Putting auth,
createPersistedState,auth.onStateChange, or HMR disposal in the browser/extension/tauri factory. Those belong in the singleton module. - Adding a second singleton home (
client.ts). The singleton already lives insrc/lib/<app>.ts. - Putting auth subscriptions or workspace construction in a Svelte component. They belong in the singleton module.
- Forgetting
disabledReasononAccountPopoverwhen the app has an unsafe-to-interrupt moment (an in-flightMediaRecordercannot survive the owner-change reload). - Branching on platform at a
#platform/*call site. Import the bare specifier and let the build select the impl. - Using
satisfieson a#platform/*impl instead of a: Contractannotation. - Importing a
.tauri.ts-only symbol through#platform/*(it isnullon web); import it directly from the.taurimodule inside another.tauri.tsfile. - Reintroducing
resolve.extensionssuffixes or tsconfigmoduleSuffixesfor platform selection. - Dropping
...defaultClientConditionsfrom the Tauriconditionsarray. - Adding a
./browserpackage export to honeycrisp/vocab for symmetry with opensidian. Keep the asymmetry; only opensidian has a consumer for it. - Adding
./mountback to honeycrisp. Honeycrisp's integration contract is the package.isomorphic workspace export. - Placing
daemon.tsorscript.tsinside the app package. They live under a project'sworkspaces/<app>/and are registered viaepicenter.config.ts. - Restoring
serveas the public lifecycle command (it isepicenter daemon up). - Load-gating where the readiness promise is not reachable, or showing a
<Loading>skeleton for a fast eager-workspace gate (the spinner just flashes). Gate where the readiness promise is first reachable; see Gating Readiness on Hydration.