Runtime Boot Sequence
The current source-visible browser/PWA boot path starts in packages/web/src/main.ts and progresses through host registration, vault selection, app construction, and sequential service initialization for the shared renderer shell. packages/workspace/src/main.ts remains a thin dev-shell entry that calls the same shared bootstrap helper.
Phase 1 — Host Entry Point (packages/web/src/main.ts)
- Calls
mountWorkspaceApp()frompackages/workspace/src/lib/components/app/bootstrap.tsto mountVaultBootstrap.svelteto DOM element#app. - Loads the shared renderer imports through that bootstrap helper: web fonts, app CSS, bundled core plugins, and buffered bootstrap timing measurements. External official plugins are not imported by the default workspace bundle.
- Registers browser/PWA host behavior by calling
registerWebPwa(), which wires service-worker registration, install-prompt handling, and simple host-level installed/offline-ready state markers.
At build time, packages/web/vite.config.ts also adds the web app manifest, icons, cross-origin-isolation headers, and a generated service worker that precaches the built asset graph for offline startup. The host raises Workbox’s per-file precache limit to 50 MiB so large browser runtime bundles such as SQLite, DuckDB, PDFium, and notebook assets stay inside the offline shell instead of silently dropping out of precache.
Notebook is an external official plugin in this phase. The default workspace bundle does not import notebook code; .notebook.md files route through the official on-demand install flow or an already installed lapis-notebook handler.
The approved hybrid-search direction follows the same rule: the bundled search plugin may register views and commands during boot, but SQLite vector search, local embedding model initialization, and background indexing remain behind the app-database worker and must not make renderer startup pay the cold model-load cost.
Phase 2 — Vault Bootstrap (VaultBootstrap.svelte)
A state machine with six phases:
| Phase | Action |
|---|---|
loading | Attempts to restore a previous vault profile from CurrentVaultProfile in browser storage. |
choose | User selects vault type: OPFS (new browser-local), File System Access (real folder), or Legacy (Lightning FS). |
permission | For File System Access vaults, requests readwrite permission on the directory handle. |
blocked | Fallback state used only when a competing tab owns the per-vault SQLite database and delegation is unavailable. |
ready | Vault session resolved. Transitions to App.svelte. |
error | Displays error with recovery options. |
The vault adapter and app database resolution step is deliberately outside App construction. The File System Access API requires a transient user activation (gesture) to pick or re-authorize a folder, so the shell must present that choice before the runtime starts. Browser bootstrap wraps the result in a VaultSession containing { runtime, profile, vaultAdapter, appDatabase }.
The built-in Open vault command returns to this chooser by clearing the stored current vault profile and reloading the browser shell.
Vault bootstrap also records timing measurements for profile restore, profile opening, and createVaultSession() so delays that happen before App.svelte mounts show up alongside the later task-runner spans.
For browser/PWA sessions, createVaultSession() prefers a SQLite WASM app database persisted under OPFS only when the browser exposes OPFS and the host is cross-origin isolated (SharedArrayBuffer + Atomics available under COOP/COEP). The browser shell now coordinates SQLite ownership per vault before it mounts App.svelte: it takes a Web Lock named from the vault id and resolves whether the tab will be an owner, proxy, or blocked session, but it defers the expensive appDatabase.open() work until the mounted shell begins background metadata hydration. Competing tabs no longer silently switch to a second generated-state store. Instead, when BroadcastChannel is available, the secondary tab receives a coordinated AppDatabase proxy that forwards requests to the owner tab over a per-vault RPC channel while it waits to take ownership later. The blocked shell state remains as a narrow fallback only when delegation is unavailable. IndexedDB fallback remains the compatibility path when Web Locks are unavailable, when OPFS / cross-origin-isolation prerequisites are missing, or when the eventual owner still fails SQLite WASM / OPFS startup and falls back during open(). The browser SQLite runtime is hosted behind a dedicated worker, so App.svelte and the rest of the UI talk to the app database through an async message boundary instead of executing SQLite directly on the UI thread. A future desktop host may short-circuit parts of this UI, but it must preserve the same phase ordering once control passes into App.svelte.
Phase 3 — App Construction (App.svelte)
setApplicationState(app)constructs the rootAppinstance from@lapis-notes/apiand injects it into Svelte context. New boot paths pass aVaultSession; compatibility paths may still pass only aDataAdapter.- Registers shared configuration schemas (appearance, editor).
- Registers the default
"empty"view type. - Installs a browser telemetry controller on
app.telemetry, including OpenTelemetry span creation plusweb-vitalsandlongtaskobservers when enabled. The controller keeps a stableapp.telemetryreference while allowing the external officiallapis-telemetryplugin, when installed and enabled, to override and persist its configuration through the Core plugins settings flow after activation, and it can optionally restore the diagnostics buffer from vault-scoped IndexedDB before the diagnostics view opens. - Sets up watchers: color scheme, vault events, metadata tracking.
Phase 4 — Sequential Task Runner
A TaskRunner executes startup tasks in strict order:
| Step | Task | Service |
|---|---|---|
| 1 | Load file system | app.vault.load() — initializes file tree from adapter |
| 2 | Load configuration | app.configuration.load() — restores persisted settings and canonical plugin compatibility data from app.json |
| 3 | Register core plugins | Registers bundled plugin constructors with PluginManager |
| 4 | Load plugins | app.plugins.registerDependencies(deps) then app.plugins.loadPlugins() to read installed provenance, discover external plugins, and activate bundled, configured official, and configured community plugins. Session Safe Mode can suppress optional core/official activation separately from community activation |
| 5 | Load layout | app.workspace.loadLayout() — restores split/tab/leaf tree unless session Safe Mode explicitly skips layout restore |
During plugin registration and later runtime configuration updates, the plugin manager also mirrors canonical app.json pluginData entries back into the legacy Obsidian plugin settings files so the compatibility layout stays current even when app.json changes externally.
Each step displays a progress message via the TaskRunner.message reactive property. The loading screen also renders the ordered task list with the active step highlighted so startup no longer appears as a single generic spinner label.
If a boot task throws, App.svelte now switches from the loading spinner to an explicit startup-failure surface that shows the failing step, the error detail, and recovery actions such as reload, session-scoped Safe Mode entry, generated-state rebuilds, and targeted plugin disable. The layout step also carries an explicit timeout (currently 20 seconds) so a stalled restore no longer looks like a perpetual Loading layout message.
Safe Mode is intentionally session-scoped. The recovery surface writes a temporary policy record into browser session storage, reloads, and lets the next boot skip specific risky startup work such as configured community-plugin activation, layout restore, or notebook execution. Once the user restarts normally, those temporary flags are cleared instead of becoming persistent vault configuration.
Bundled plugin activation is still synchronous at this phase, but heavyweight plugin internals may defer their own execution transports until first use. External official plugins such as Notebook, Canvas, Graph, Markdown Lint, PDF, Slides, and Telemetry activate only after installation and explicit enablement.
The shell wraps the overall task runner in app.boot and each startup step in app.boot.task spans so regressions can be attributed to a specific initialization phase. Slow spans automatically enrich themselves with workspace and plugin snapshot attributes before export or debug logging.
When App.svelte attaches telemetry, it first flushes the earlier bootstrap measurements from the shared app bootstrap helper and VaultBootstrap.svelte, then starts the existing app.boot and app.boot.task spans for the sequential runner itself.
Phase 5 — Workspace Rendering
After the task runner completes:
- Keyboard event handler installed: forwards
keydownto active leaf’s scope, recordsapp.lastEvent. - Console logger registered only when
window.__LAPIS_CONSOLE_LOGGING__orlocalStorage["lapis.logging.console"]explicitly enables it. - Schedules background metadata hydration on the next animation frame after the shell mounts.
app.metadataCache.load()opensAppDatabase, restores the cached metadata snapshot from the app database when available, falls back to the legacy browser snapshot when needed, and degrades to an empty in-memory cache when delegated proxy reads time out. - Renders the visible workspace:
Sidebar.Root— Three-column layout (ribbon + left dock, center split, right dock).Commands— Global command palette (Cmd+P / Ctrl+P).StatusBar— Bottom status bar (plugins inject content here). When the session is using delegated SQLite ownership, the shell also renders a built-in icon-onlyDB Proxybutton that opens a short explanatory popover on click, and removes it once the coordinated database promotes that tab to local SQLite ownership.Tooltip— Global tooltip handler observingdata-tooltipattributes via MutationObserver (500 ms default delay).ModeWatcher— Dark/light theme synchronization.Toaster— Toast notification container.
- Shell teardown disposes the telemetry service so browser performance observers do not linger across app unmounts.
Ordering Implications
- Shell before metadata: Layout and plugins can exist before metadata is fully ready. Metadata-driven features must tolerate boot-time lag and respond to the later
metadataCache.loadedevent. - Plugins before layout: Bundled plugins and configured external plugins complete activation before layout restoration. This ensures restored leaves can find their view constructors when the owning plugin is installed and enabled.
- Lazy app-database open: The app database opens on the first post-mount generated-state consumer, normally background metadata hydration. It still stores generated per-vault metadata and search state separately from canonical files.
- Vault before canonical config/plugins/layout: The file tree must be loaded before configuration (stored in
.obsidian/), plugins (loaded from.obsidian/plugins/), and layout. - Watchers early: Color scheme, vault, and metadata watchers are set up during construction, before the task runner starts, so they catch events from the first task onward.
Plugin Dependency Injection
Before PluginManager activates community plugins, the workspace registers a dependency bag (deps.ts) so dynamically loaded plugin code can require():
obsidian→@lapis-notes/api@codemirror/*→ CodeMirror packagesluxon,zod,clsx,bits-ui
This bridges the local runtime with community-style plugin code that was written for the Obsidian API.
Approved Host Direction
The accepted target architecture adds host-level setup around this sequence, but it does not change the ordering guarantees above. See ADR-001: Plugin Runtime Rework and ADR-003: Electron-First Desktop Shell.
- Resolve the active runtime (
web-pwaorelectron-desktop) after vault bootstrap and carry it inVaultSession. - Construct any host-specific plugin execution registry before community plugin activation.
- Preserve the invariant that community plugin activation completes before layout restoration.
The current implementation has the storage/session seam needed for host-specific setup, but does not yet provide the plugin host abstraction. All plugin evaluation still happens inside the active renderer/runtime process.