Capability clusters
C1 · Core data & storage
The substrate everything else in eli stands on: a single storage contract with two production backends — markdown files on disk with a rebuildable SQLite index, and multi-tenant Supabase Postgres where RLS is the security boundary — plus the helpers, schemas, watcher, and bootstrap that keep it fresh.
This cluster is a single StorageAdapter interface (lib/storage/adapter.ts) implemented four times — LocalAdapter (markdown files on disk as source of truth plus a rebuildable per-vault SQLite index), SupabaseAdapter (multi-tenant Postgres where RLS is the security boundary), MemoryAdapter (test double), and JsonFileAdapter (single-file portability reference). It also owns mode selection (ELI_MODE), the vault config store (~/.eli/config.json), the markdown/frontmatter/wikilink helper library, both database schemas and migration chains (SQLite FTS5; Postgres tsvector + pgvector + RLS), the walker → scanner → incremental-index pipeline, the chokidar watcher + in-process event bus that keep the local index fresh, and process bootstrap (env.ts, instrumentation.ts, middleware.ts). Every Server Action, RSC page, API route, and MCP tool reaches notes exclusively through getStorage() (lib/storage/index.ts) — components never import an adapter directly.
- Modes: local (files + SQLite) and saas (Supabase Postgres + RLS), selected by
ELI_MODE. - Key dirs:
lib/storage/,lib/db/,lib/domain/,lib/index/,lib/watch/,lib/config/, root bootstrap (env.ts,instrumentation.ts,middleware.ts). - Depends on: better-sqlite3, chokidar, supabase-js, gray-matter, diff-match-patch.
- Feeds: every other cluster — authoring UX (02), search (03), AI platform (04), agent surface (05), versioning (06), SaaS collaboration (07), capture/templates (08), platform ops (09).
Capabilities
| Capability | What it does | Entry points | Modes |
|---|---|---|---|
| Mode selection | getStorage() returns LocalAdapter pinned to the active vault (ELI_VAULT env overrides config.jsonactiveVaultId) or SupabaseAdapter pinned to the request's workspace (eli_ws cookie + session). Env schema hard-fails on missing mode requirements (env.ts:103-133); route groups app/(local) / app/(saas) call notFound() for the wrong mode. | lib/storage/index.ts getStorage(), env.ts | both |
| Note CRUD, file-first (local) | createNote/updateNote write frontmatter+body to disk atomically (tmp + rename, echo-marked), then upsert notes/note_tags/note_links and append a versions row in one SQLite transaction; path traversal blocked by resolveVaultPath (lib/storage/local.ts:135-145); every save enqueues a best-effort git auto-commit. | StorageAdapter.createNote/updateNote/..., app/_actions/notes.ts | local |
| Note CRUD via RPCs (saas) | notes_save(...) (SECURITY INVOKER) updates the note row and appends a versions row atomically with OCC raising SQLSTATE 40001 on mismatch (lib/db/migrations/pg/0006_notes_save.sql:46-49); notes_rename(...) rewrites [[wikilinks]] across the workspace in SQL. | lib/storage/supabase/writes.ts | saas |
| Conflict auto-snapshot | Stale SaveOpts.expectedVersionId snapshots the about-to-be-overwritten body as a versions row (authorKind:'system', message auto-conflict-snapshot), then lets the incoming write win; snapshot id delivered via SaveOpts.onConflictSnapshot. SaaS retries notes_save with p_expected_version_id=null after a 40001 race. | lib/storage/adapter.ts:39-68, lib/storage/local/writes.ts:255-277, lib/storage/supabase/writes.ts:100-183 | both |
| Soft-delete trash + restore | deleteNote defaults to trash: local moves the file to <vault>/.eli/trash/<path> and stamps trashed_at; SaaS stamps only. trash:false hard-deletes. restoreNoteFromTrash reverses it. NoteFilter.onlyTrashed powers /v/trash. | deleteNote(id,{trash}), restoreNoteFromTrash | both |
| Rename with wikilink rewrite | Local: move file, then rewrite [[oldPath]] / [[oldPath-sans-.md]] / [[oldTitle]] in every other note (fence-aware, alias-preserving) with system versions (lib/storage/local/writes.ts:409-445). SaaS: the same, inside notes_rename SQL. | renameNote | both |
| Archive / buckets | archiveNote sets frontmatter status:'archived'. Bucket classification (inbox/active/archived) honours both path prefix and frontmatter status, archived winning; SQL predicates use IFNULL to defeat SQLite NULL logic (lib/storage/helpers/status.ts:47-55). | archiveNote/unarchiveNote, bucketCounts, NoteFilter.bucket | both |
| Listing & filtering | listNotes supports type, status, tag, bucket, projectId, substring query, folder prefix, favorite, onlyTrashed, limit (1-500 local / 200 saas) and updated_at cursor pagination; getNote accepts ULID/UUID id or vault-relative path. | listNotes(filter,opts), getNote(idOrPath) | both |
| Full-text search | Local: contentless FTS5 notes_fts kept in sync by AFTER triggers (lib/db/migrations/sqlite/0001_fts.sql), sanitized per-token prefix matching, bm25 ranking, <mark> snippets. SaaS: notes_search RPC over a stored generated tsvector, websearch_to_tsquery + ts_rank_cd, SECURITY INVOKER so RLS applies (0010_notes_search.sql). | StorageAdapter.search() | both |
| Embeddings storage | Local: note_embeddings Float32Array BLOBs keyed by model+dim, with bulk upsert, missing-list, progress counts, brute-force cosine KNN (lib/storage/local/embeddings.ts). SaaS: notes.embedding vector(1536) + HNSW index, service-role writes, notes_knn RPC under RLS. Note: StorageAdapter.semanticSearch itself still throws — C3 calls these modules directly. | knnByEmbedding, notes_knn RPC, scripts/embed-backfill.mjs | both |
| Backlinks | Local: note_links matched against target id, path, path-sans-.md, and title, with source line + surrounding text (lib/storage/local/reads.ts:334-387). SaaS: by target_id with relational select. | backlinks(id) | both |
| Sidebar lenses | Projects (type:'project' notes + child counts), Types (notes-as-source-of-truth: type:'type' notes contribute _icon/_color/_order/...), hierarchical Tags via /, Folders derived from paths with recursive counts. | listProjects/listTypes/listTags/listFolders | both |
| Versions & section diff | Append-only versions rows on every write (authorKind user/agent/clipper/system, parent chain); listVersions ordered by createdAt then monotonic ULID; diffVersions segments bodies into heading sections with stable slug anchors + diff-match-patch hunks (lib/storage/helpers/section-diff.ts:166-267); replaceSection for per-section restore. Consumed by C6. | listVersions/getVersion/restoreVersion/diffVersions | both |
| Comments (anchors, orphans, reattach) | Comments anchor to heading-path slugs or opaque block:<ulid> anchors; flagOrphanedComments stamps orphaned_at when a heading anchor no longer resolves; reattachComment clears it (SaaS also writes a comment.reattached audit row). | listComments/addComment/.../reattachComment | both |
| Favorites | Local: vault-relative paths stored in config.jsonso starring never dirties a note's content hash (lib/config/schema.ts:9-16). SaaS: user_favoritesjoin table with strictly self-only RLS — admins cannot see others' stars (0009_user_favorites.sql:31-37). | listFavorites/isFavorite/toggleFavorite | both |
| Attachments | SaaS only: upload to private attachments bucket at <workspace_id>/<note_id>/<attachment_id>-<name>, row insert with orphan-object cleanup, 1-hour signed URLs; bucket RLS ties folder segment to membership (lib/storage/supabase/attachments.ts:23-125). Local throws NotImplementedError. | putAttachment/getAttachmentURL | saas |
| Public share links | SaaS only: sha256(token) at rest, plaintext returned exactly once; resolveShareLink uses the service-role client (token possession is the authorisation) and returns uniform null to prevent enumeration (lib/storage/supabase/shares.ts:113-146); notes_select_share RLS policy honours a server-minted share_note_id JWT claim. | createShareLink/resolveShareLink | saas |
| Templates & daily note | applyTemplate (local) reads <vault>/.eli/templates/<id>.md through the shared template engine; SaaS throws (Phase 6). todayNote gets-or-creates daily/YYYY-MM-DD.md. See C8. | applyTemplate/todayNote | both |
| Live updates: watcher → index → SSE | instrumentation.ts starts a chokidar watcher on the active vault at boot; .md add/change/unlink triggers lib/index/incremental.updatePath then emits a ChangeEvent on the global bus; /api/events streams it as SSE (25s heartbeats, no backlog replay). Adapter writes markEcho() a 750ms per-path suppression. Delivery view in C9. | instrumentation.ts, GET /api/events, lib/watch/events-bus.ts | local |
| Vault scanning & indexing | scanVault walks all .md files (skipping .git/.eli/node_modules/.next), parses frontmatter/tags/wikilinks/title, skips unchanged files by sha256 hash, batches 500-row transactions, abortable, prunes deleted rows; first-touch scan gated by an exclusive-transaction meta row so concurrent Next workers don't race (lib/storage/local.ts:87-128). | scanVault, app/_actions/vault.ts, /api/dev/rescan | local |
| Local config store | Zod-validated, mtime-cached, atomically-written (0600) ~/.eli/config.json: vault registry + activeVaultId, per-vault favorites, auto-generated localToken/mcpToken, aiKey (BYOK), git auto-commit config, browser-extension pairings, keybinding overrides, aiGateway routing. ELI_HOME overrides ~/.eli. | lib/config readConfig/updateConfig | local |
| SQLite index client & migrations | openVaultIndex opens ~/.eli/index/<sha256(vaultPath)[:16]>.db with WAL pragmas, runs lexicographic idempotent .sql migrations, caches clients on globalThis with inode-change staleness detection (lib/db/client.sqlite.ts:56-99). | openVaultIndex/closeVaultIndex/closeAll | local |
| Postgres schema, RLS & tenancy | Hand-written canonical DDL (16 migrations): workspaces, memberships (5-role ladder), profiles, notes (generated fts tsvector, embedding vector(1536)), versions, note_links, comments, attachments, share_links, pending_invites, audit_log, PATs, user_favorites, ai_keys, chat threads, ai_usage; DB-level triggers bootstrap tenancy and audit. Consumed heavily by C7. | supabase db push (CI), lib/db/migrations/pg/runner.ts (PGlite tests) | saas |
| AI BYOK key vault | ai_keys_upsert RPC encrypts with pgp_sym_encrypt (aes256) under ELI_KMS_KEY; list returns masked previews; decrypt is service-role-only for immediate forwarding to the AI gateway (C4). Local counterpart: config.json#aiKey. | upsertAiKey/.../getAiKeyPlaintext | saas |
| Audit log & profiles | listAuditLog (owner/admin-gated) with batch actor/target name resolution; getProfiles/listWorkspaceMembers power comment avatars and @-mentions. | lib/storage/supabase/audit.ts, profiles.ts | saas |
| Test/reference adapters | MemoryAdapter: Map-backed full implementation incl. conflict snapshots and per-channel subscribe. JsonFileAdapter: everything in one atomic-flushed JSON file (schemaVersion 1), documented ~1k-note ceiling. seedSharedWorkspace seeds an owner+viewer fixture for PGlite and real Supabase. | lib/storage/memory.ts, lib/storage/json-file.ts, lib/db/seed.pg.ts | both |
| Bootstrap & middleware | env.ts validates all env at import (feature flags incl. ELI_LEGACY_EDITOR, ELI_HIPAA, ELI_KMS_KEY, CRON_SECRET); middleware.ts is a no-op unless saas, else refreshes the Supabase session cookie and redirects unauthenticated /w/*, /settings, /onboarding to /login?next=; next.config.ts marks better-sqlite3 + chokidar serverExternalPackages; supabase/config.toml pins the local Supabase stack. | env.ts, middleware.ts, instrumentation.ts | both |
System view — simplified
This cluster IS the persistence layer: one interface, two production backends, one mode switch. Standalone, it is a complete local-first markdown note store — point LocalAdapter at any folder of .md files and you get CRUD, full-text search, versions, backlinks, tags, and live re-indexing of external edits, with zero network, zero auth, and an index that can be deleted and rebuilt from the files at any time. The SaaS half is a drop-in replacement of the same interface where Postgres rows are the source of truth and row-level security (not application code) enforces tenancy. Everything above this layer — editor, search UI, AI, agents — is written against the StorageAdapter contract and does not know which backend it is talking to.
System view — detailed
| Component | Role | Files |
|---|---|---|
| StorageAdapter contract | Interface every backend implements: lenses, notes, favorites, search, backlinks/graph, versions, comments, attachments, shares, subscribe, templates; carries SaveOpts (authorKind/agentName/message/expectedVersionId/onConflictSnapshot) and NotImplementedError | lib/storage/adapter.ts |
| getStorage mode selector | server-only factory: ELI_MODE=local → LocalAdapter.forVault(ELI_VAULT ?? activeVault.path); else SupabaseAdapter.forActiveWorkspace(). The only sanctioned entry point (ARCHITECTURE.md §2.2) | lib/storage/index.ts |
| LocalAdapter | FS + SQLite implementation; per-vault instance cache on globalThis; db() lazily opens the index and runs a serialized first-touch scan; resolveVaultPath blocks escapes; delegates to lazily-imported submodules | lib/storage/local.ts, lib/storage/local/{reads,writes,search,versions,comments,favorites,embeddings,row}.ts |
| SupabaseAdapter | Postgres implementation via user-scoped supabase-js so RLS gates everything; constructed per-request from session + eli_ws cookie slug; fromClient() test seam; resolveShareLink is the only service-role path inside the adapter | lib/storage/supabase.ts, lib/storage/supabase/{queries,writes,row,comments,favorites,shares,attachments,ai-keys,knn,embeddings,audit,profiles}.ts |
| Memory & JsonFile adapters | Map-backed test double (full contract incl. subscribe); single-JSON-file reference backend per ADR-0004, O(N) per op, ~1k-note ceiling | lib/storage/memory.ts, lib/storage/json-file.ts |
| Markdown/domain helpers | Pure functions shared by all adapters and the scanner: lossless frontmatter round-trip, sha256 hash, tag extraction + ancestor expansion, fence-aware wikilink extract/rewrite, title derivation, bucket classification, section segmenter + slug anchors, orphan scan, section-level diff, per-section replace | lib/storage/helpers/{frontmatter,hash,tags,wikilinks,title,status,anchors,orphans,section-diff,section-replace}.ts |
| SQLite schema & client | Drizzle schema (notes, note_tags, note_links, versions, comments, meta, chat, note_embeddings) + client factory with per-vault globalThis cache, inode staleness detection, WAL pragmas, idempotent lexicographic migration runner | lib/db/schema.sqlite.ts, lib/db/client.sqlite.ts, lib/db/migrations/sqlite/* |
| Postgres schema, migrations & seed | Drizzle type-surface mirroring hand-written canonical SQL (tsvector/vector/bytea customTypes); 16 migrations from base tables through RLS (427 lines), triggers, RPCs, buckets, PATs, ai_keys, knn, chat, ai_usage; PGlite runtime runner for tests; owner+viewer seed fixture | lib/db/schema.pg.ts, lib/db/client.pg.ts, lib/db/seed.pg.ts, lib/db/migrations/pg/* |
| Domain ids & types | Branded string ids minted by a monotonic ULID factory (sub-millisecond ordering for versions); as*() cast helpers for trust boundaries; all domain types (Note, Version, Comment, NoteFilter, ChangeEvent, ...) | lib/domain/ids.ts, lib/domain/types.ts |
| Index pipeline | Async-generator walker (SKIP_DIRS .git/.eli/node_modules/.next); full-vault scanner with hash-skip/batch/progress/abort/prune; per-file incremental reaction (row delete on unlink, else prune:false rescan) | lib/index/walk.ts, lib/index/scanner.ts, lib/index/incremental.ts |
| Watcher & events bus | Per-vault globalThis watcher registry, awaitWriteFinish, .md-only, 750ms echo suppression, calls updatePath then emits ChangeEvent; bus is a globalThis singleton pub/sub with a 256-event ring buffer | lib/watch/chokidar.ts, lib/watch/events-bus.ts |
| Config store | ~/.eli/config.json (ELI_HOME-overridable) read/repair/write with mtime cache and 0600 atomic writes; vault registry, tokens, favorites, git, pairings, keybindings, aiGateway, aiKey | lib/config/{index,schema,path}.ts |
| Bootstrap & mode plumbing | Zod-validated env; boot-time watcher start (Node runtime, local only); SaaS session refresh + auth redirects; serverExternalPackages for native deps; drizzle configs; pinned local Supabase stack | env.ts, instrumentation.ts, middleware.ts, next.config.ts, drizzle.config.ts, drizzle.config.pg.ts, supabase/config.toml |
Data flow — simplified
The single most load-bearing flow: a local save writes the markdown file first (disk is the source of truth), then updates the derived SQLite index and appends an immutable version row in one transaction. markEcho is stamped before and after the rename so the chokidar watcher discards the filesystem event the write itself produces — without it every save would trigger a redundant full rescan. The emitted ChangeEvent fans out over SSE so other open tabs refresh.
Data flow — detailed
Local save (file-first write, echo suppression) followed by an external edit picked up by the watcher:
SaaS save via notes_save RPC with optimistic concurrency and conflict snapshot:
Works separately / works together
Standalone
With every other cluster turned off, this cluster is still a usable product core: a local markdown vault with CRUD, atomic file-first saves, full-text search (FTS5), version history with section-level diff, backlinks, tag/type/folder lenses, trash/restore, and an index that self-heals — delete ~/.eli/index and the first db() call rebuilds it from the files (lib/storage/local.ts:87-128). External tools (Obsidian, scripts, agents editing files directly) are first-class: the watcher re-indexes their writes within milliseconds. The SaaS half standalone is a complete multi-tenant note database whose invariants (role ladder, append-only versions, tenancy bootstrap, audit) live in the database itself — triggers and RLS hold even if application code is bypassed. MemoryAdapter and JsonFileAdapter prove the contract is backend-agnostic.
Composed
- Every cluster above consumes this one exclusively through
getStorage()—app/_actions/*.tsand allapp/(local)/v/*+app/(saas)/(app)/w/*RSC pages (C2), MCP tools (C5), the clipperapp/api/clip/route.ts(C8), and cron routes (C9). - C3 Semantic search:
lib/search/hybrid.tsandapp/api/search/hybrid/route.tsbypassadapter.semanticSearch(which throws) and calllib/storage/local/embeddings.knnByEmbedding/lib/storage/supabase/knn.knnNotesdirectly, reusing the adapter's RLS-scopedsbclient;app/api/ai/embed/route.ts,lib/ai/auto-embed-queue.ts, andscripts/embed-backfill.mjspopulate the embedding stores. - C4 AI platform:
lib/ai/key-resolver.tsreads BYOK keys fromlib/storage/supabase/ai-keys.getAiKeyPlaintext(service-role decrypt) orconfig.json#aiKey;workspaces.ai_gateway_configandconfig.json#aiGatewayfeed gateway routing;chat_threads/chat_messages/ai_usageexist in both schemas so the chat UI is mode-agnostic. - C5 Agent surface: MCP servers (
bin/eli-mcp.mjsstdio,app/api/mcp/[transport]/route.tsHTTP) authenticate againstconfig.json#mcpToken(local) orpersonal_access_tokensrows (saas) and operate on notes purely through the adapter contract; comment section anchors power theadd_commenttool. - C6 Versioning: the versions tables,
diffVersionssection diff, andreplaceSectionwritten here are the entire data layer of the history UI; every local save additionally callslib/git/commit-loop.enqueueCommitusingconfig.json#git. - C7 SaaS collaboration: memberships/invites/audit/profiles/shares/attachments and all RLS policies defined in
lib/db/migrations/pg/are its foundation;middleware.tsis its request-level gate. - C9 Platform ops: owns the delivery side of the freshness machinery described here (SSE route,
VaultEventsBridge, kill switches), the dev/test endpoints that reach intocloseAll/_resetCacheForTests/scanVault, the pgTAP CI gate over the RLS migrations, and the perf gate over local search. - Frontend live updates:
app/api/events/route.tssubscribes tolib/watch/events-busand streamsChangeEvents tocomponents/vault-events-bridge.tsx, which debounces 500ms and callsrouter.refresh().
Load-bearing details
ELI_MODEis a zod enum['local','saas']defaulting to'local'(env.ts:15); saas additionally requiresNEXT_PUBLIC_SUPABASE_URL/ANON_KEYviasuperRefine(env.ts:103-120). Verified in source.- The local index DB lives at
~/.eli/index/<first-16-hex-of-sha256(vaultPath)>.db(lib/db/client.sqlite.ts:31-34, verified);ELI_HOMEoverrides~/.eli(lib/config/path.ts:10-12). - SQLite clients, LocalAdapter instances, watchers, and the event bus are all cached on
globalThis(__ELI_SQLITE_CACHE__,__ELI_LOCAL_ADAPTERS__,__ELI_WATCHERS__,__ELI_EVENT_BUS__) to survive Turbopack multi-bundle evaluation and dev HMR; the SQLite cache is additionally invalidated by inode change so a wiped/recreated DB file under a live dev server isn't read through a stale fd (lib/db/client.sqlite.ts:19-29,62-73). - The two hash schemes are not comparable across modes: local
content_hash= sha256 of the full serialized file (frontmatter block + body,lib/storage/helpers/hash.ts); SaaS/memory/json-file computesha256(body + JSON.stringify(frontmatter))(lib/storage/supabase/writes.ts:40,98;memory.ts:72). - Conflict handling is last-write-wins + snapshot, never a 409: a stale
expectedVersionIdsnapshots the loser to a versions row taggedauto-conflict-snapshotand proceeds;onConflictSnapshotfires at most once perupdateNote(lib/storage/adapter.ts:43-68;local/writes.ts:255-277;supabase/writes.ts:100-183, verified).notes_saveraises errcode40001on OCC mismatch and the adapter's retry force-accepts withp_expected_version_id=nullafter snapshotting (0006_notes_save.sql:48-49, verified;supabase/writes.ts:132-172, verified). - Version author identity diverges: local always stores
author_idNULL (local/writes.ts:168); SaaSnotes_savederives it fromrequest.jwt.claims.subinside the function (0006:38); local comments use the literal sentinel'local'as author_id (local/comments.ts:23). - IDs are monotonic ULIDs locally (sub-millisecond ordering for versions,
lib/domain/ids.ts:1-9, verified) but random UUIDs in SaaS;getNotedisambiguates id-vs-path by ULID regex locally (local/row.ts:36-38) and UUID regex in SaaS (supabase/queries.ts:124). - Echo suppression:
markEchostamps a 750ms-TTL per-relative-path entry, GC'd beyond 500 entries; an entry is consumed (deleted) on first match (lib/watch/chokidar.ts:36-59, verified).atomicWritemarks before and after the rename because chokidar'sawaitWriteFinishcan fire later than the rename returns (local/writes.ts:35-49, verified). incremental.updatePathdeliberately re-runs the fullscanVault(prune:false)for any single-file change, relying on sha256 hash-skip for cheapness — an explicitly documented v1 tradeoff (lib/index/incremental.ts:35-42, verified in source comments).- First-touch indexing is race-proof across concurrent Next workers: an exclusive transaction stamps
meta.initial_scan_at='pending'before scanning (lib/storage/local.ts:100-124). - The walker's SKIP_DIRS includes
.eli, so files soft-deleted into<vault>/.eli/trash/never re-index (lib/index/walk.ts:5, verified). - Postgres
notes.ftsis a STORED generated tsvector with a GIN index andnotes.embeddingisvector(1536)with an HNSWvector_cosine_opsindex — declared only in hand-written SQL because drizzle-kit cannot emit them (0000_initial.sql:75-105;schema.pg.ts:34-41).notes_knnreturns1 - cosine distanceto match the localknnByEmbeddingcontract (0013_notes_knn.sql:31-36); local embedding BLOBs use explicit little-endian DataView reads for cross-CPU portability (local/embeddings.ts:45-53). - RLS role ladder: notes/versions/note_links/attachments/share_links writes require owner|admin|editor; comment inserts require commenter+ AND
author_id=auth.uid(); versions are append-only (no UPDATE/DELETE policies);user_favoritesandchat_threadsare strictly self-only even for admins (0001_rls.sql;0009;0014). Service-role usage is a documented allow-list (profiles trigger, share-token validation, seeds, invite acceptance, PATlast_used_atbumps, embedding writes —0001_rls.sqlfooter). - SaaS FTS goes through the
notes_searchSECURITY INVOKER function specifically to fix a previously injection-pronewebsearch_to_tsquerystring interpolation (0010_notes_search.sqlheader). postgres-jsis intentionally not a dependency:lib/db/client.pg.tsonly exposes a lazy factory; the production SaaS runtime path is entirely supabase-js so RLS is enforced via the session JWT (client.pg.ts:4-19). The pg migration runtime runner is used only by PGlite tests; production applies migrations viasupabase db push(lib/db/migrations/pg/runner.ts:12-15).config.jsonis written 0600 (dir 0700);localToken(CSRF/DNS-rebinding cookie) andmcpTokenare auto-generated 32-byte base64url secrets on first read (lib/config/index.ts:19-47); forward-compatible repair merges user data over defaults on schema mismatch (index.ts:103-115).instrumentation.register()only runs whenNEXT_RUNTIME==='nodejs'andELI_MODEis unset-or-local, because chokidar and better-sqlite3 break Edge bundles (instrumentation.ts:16-17); both areserverExternalPackages(next.config.ts:7).ai_keysciphertext usespgp_sym_encryptwith cipher-algo=aes256, s2k-mode=3, s2k-digest-algo=sha256 keyed byELI_KMS_KEY(min 16 chars,env.ts:45); decrypt is a service-role-only RPC (0011_ai_keys.sql:157,170-186).- Bucket predicates wrap status in
IFNULL(...,'')to avoid SQLite 3-valued NULL propagation silently hiding status-less notes (lib/storage/helpers/status.ts:41-55); defaultlistNotesexcludes archived but includes inbox;bucket:'all'disables status filtering (local/reads.ts:179-190).
Gaps and partially wired
StorageAdapter.semanticSearchthrowsNotImplementedErrorin both production adapters (lib/storage/local.ts:244-246;supabase.ts:133-135, both verified) even though the underlying embedding stores are fully built — hybrid search (C3) consumeslib/storage/local/embeddings.tsandlib/storage/supabase/knn.tsdirectly, including reaching intoSupabaseAdapter.sbas a public field. The contract method is effectively dead.graph()throws everywhere — reserved for Phase 7 (graph view).subscribeis a no-op in both LocalAdapter and SupabaseAdapter (local.ts:315-321;supabase.ts:295-299): local live updates flow watcher → bus → SSE instead; SaaS has no real-time channel at all pending Supabase Realtime. Only Memory/JsonFile implement in-process channel subscription.- Local mode has no attachments and no share links (
local.ts:299-304and share methods throwNotImplementedError). - SaaS
applyTemplatethrows (Phase 6) — templates are local-only today (C8). JsonFileAdapteris deliberately partial: comments, attachments, shares, and diff throw; O(N) per operation with a documented ~1k-note ceiling (lib/storage/json-file.ts:43-78).- Incremental indexing is not incremental: any single-file watcher event triggers a full-vault
scanVault(prune:false)walk, cheap only because of hash-skip (lib/index/incremental.ts:35-42) — acknowledged in-source as a v1 tradeoff to optimize when chokidar fan-out becomes a bottleneck. notes.locked_byis a reserved column in the Postgres schema with no wiring.- Cross-mode hash incompatibility: local vs SaaS
content_hashschemes differ (see load-bearing details), so hashes cannot be used to compare or sync content across modes. recentEvents()ring buffer is dead to the SSE path:/api/eventsintentionally does not replay backlog on connect (replay causedrouter.refreshflurries that broke e2e), and the companionhooks/use-vault-events.tshook is currently unmounted — see C9.- No Supabase adapter-contract test run exists:
tests/_helpers/pglite.tsdocs reference atests/adapter/supabase.spec.tsthat is not in the tree; the contract suite runs only against Local/Memory/JsonFile (C9). CRON_SECRETis zod-optional (env.ts:98) — if a deployment forgets to set it, both cron endpoints permanently return 401 (fail-closed, but silently disables digests and trash pruning; C9).