latticesql 4.3.8 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1122,10 +1122,41 @@ interface LatticeEntityDef {
1122
1122
  interface LatticeConfig {
1123
1123
  db: string;
1124
1124
  entities: Record<string, LatticeEntityDef>;
1125
+ computed?: Record<string, ComputedTableDef>;
1126
+ }
1127
+
1128
+ // Computed tables — config-defined, read-only SQL projections (the `computed:`
1129
+ // section). See computed-tables.md for the full guide, the GUI builder, and
1130
+ // the /api/computed-tables HTTP surface.
1131
+ interface ComputedTableDef {
1132
+ base: string; // a declared entity or another computed table
1133
+ description?: string;
1134
+ fields: Record<string, ComputedFieldDef>; // projected in declaration order
1125
1135
  }
1126
- ```
1127
1136
 
1128
- See [Configuration Guide](./configuration.md) for the complete YAML reference.
1137
+ type ComputedFieldDef =
1138
+ | { kind: 'alias'; source: string }
1139
+ | { kind: 'calc'; expr: string; type?: LatticeFieldType }
1140
+ | {
1141
+ kind: 'ai_classify';
1142
+ input: string;
1143
+ prompt: string;
1144
+ labels: string[];
1145
+ model?: 'default' | 'cheapest';
1146
+ }
1147
+ | { kind: 'ai_transform'; inputs: string[]; prompt: string; model?: 'default' | 'cheapest' }
1148
+ | {
1149
+ kind: 'aggregate';
1150
+ via: string; // '<junctionTable>.<remoteRelation>'
1151
+ fn: 'count' | 'sum' | 'avg' | 'min' | 'max' | 'concat';
1152
+ column?: string; // required for every fn except count
1153
+ };
1154
+ ```
1155
+
1156
+ See [Configuration Guide](./configuration.md) for the complete YAML reference,
1157
+ and [Computed tables](./computed-tables.md) for the computed-table guide (field
1158
+ kinds, the GUI builder, AI-value refresh, and the `/api/computed-tables` HTTP
1159
+ routes).
1129
1160
 
1130
1161
  ---
1131
1162
 
package/docs/assistant.md CHANGED
@@ -22,11 +22,36 @@ Every `ANTHROPIC_OAUTH_*` value (authorize/token URL, client id, scopes,
22
22
  redirect) can be overridden via the environment for a non-default deployment —
23
23
  see [`.env.example`](../.env.example).
24
24
 
25
+ ### Use an OpenAI-compatible model instead
26
+
27
+ The assistant is not tied to Anthropic. On the first-run connect screen, choose
28
+ **or connect an OpenAI-compatible model** to point it at any OpenAI-compatible
29
+ `chat/completions` endpoint — OpenAI, Azure, OpenRouter, a local vLLM / Ollama /
30
+ LM Studio server, your own gateway, or GitHub Copilot if you point it there. You
31
+ supply three things: a **base URL** (up to `/v1`), an **API key** (sent as a
32
+ Bearer token — leave blank for a keyless local server), and a **model** id
33
+ (e.g. `gpt-4o`). Lattice ships **no** provider-specific auth or header
34
+ impersonation; if your endpoint needs extra headers (an Azure `api-key`, a
35
+ gateway token) they can be supplied alongside the config.
36
+
37
+ Once connected it becomes the active backend and **every** assistant feature —
38
+ chat, auto-linking / ingestion, computed-table fills, as-of detection, HTML
39
+ authoring — runs on it. Switching backends and disconnecting are available via
40
+ the API (`PUT /api/assistant/provider`, `DELETE
41
+ /api/assistant/provider/openai-compat`); with nothing configured the assistant
42
+ resolves a connected Claude subscription exactly as before. Image / PDF vision
43
+ still uses a connected Claude subscription when one is available. Credentials are
44
+ stored encrypted in the machine-local assistant credential store, never returned
45
+ by any endpoint.
46
+
25
47
  ## Chat
26
48
 
27
- The rail runs a Claude tool-calling loop streamed over SSE. The model can list,
28
- read, **full-text search**, create, update, link, delete tables, and revert in
29
- the active database. **Every edit goes through the same audited, undoable
49
+ The rail runs a Claude tool-calling loop. Sending a message returns immediately
50
+ (the request is acknowledged, not held open); the turn runs in the background and
51
+ its text streams to the browser over the multiplexed event WebSocket, so closing
52
+ the panel, navigating away, or reloading never cancels an in-flight answer. The
53
+ model can list, read, **full-text search**, create, update, link, delete tables,
54
+ and revert in the active database. **Every edit goes through the same audited, undoable
30
55
  mutation path as a manual edit** — it appears in the activity feed and the
31
56
  version history and can be reverted.
32
57
 
@@ -0,0 +1,71 @@
1
+ # Connecting a database imports every row, then silently wipes it
2
+
3
+ **Date:** 2026-07-05
4
+ **Area:** db-source connector connect route (`src/gui/db-sources-routes.ts`) + teardown (`src/connectors/teardown.ts`)
5
+
6
+ ## Symptom
7
+
8
+ A user connected an external Postgres (≈20 tables, ~2,900 rows). Every table was
9
+ created and every row was imported — and then, ~50 seconds later, **all of it
10
+ disappeared**: the graph and object views showed "No objects with data yet", and
11
+ the connection was gone from the Databases list. From the user's side it looked
12
+ like nothing had ingested at all.
13
+
14
+ ## Root cause
15
+
16
+ The connect handler ran the entire import inside one `try`:
17
+
18
+ ```
19
+ defineLate(each table) → enableConnectorRls → syncConnector → publishImportSummary
20
+ ```
21
+
22
+ and its `catch` responded to **any** throw — including one that happens _after_
23
+ all the rows are already committed — by calling
24
+ `disconnectConnector(mode: 'hard')`. That teardown soft-deletes every imported
25
+ row (under one shared `deleted_at` timestamp) and hard-deletes the registry row.
26
+
27
+ So the handler conflated two very different failures:
28
+
29
+ 1. **Pre-persistence** (bad creds, no reachable tables, a `defineLate`/RLS
30
+ failure) — nothing landed, so rolling back is correct.
31
+ 2. **Post-persistence** (a late/derived/transient step throws after thousands of
32
+ rows are committed) — the rollback **destroys a fully successful import**.
33
+
34
+ The specific late throw is not even deterministic on a local SQLite workspace,
35
+ which is the tell: the defect isn't any single operation, it's the _all-or-nothing
36
+ rollback policy_. It also destroyed its own evidence — the only persisted error
37
+ trace (the registry's `last_error`) was hard-deleted by the same teardown, so the
38
+ failure surfaced as "nothing ingested" with no cause.
39
+
40
+ ## Fix
41
+
42
+ Split the connect into two phases:
43
+
44
+ - **Setup** (`defineLate` + RLS) keeps the hard rollback — a failure here means
45
+ nothing landed, so no phantom entry is left behind.
46
+ - **Import** (`syncConnector`) no longer rolls back on failure. `syncConnector`'s
47
+ own catch has already stamped the registry row `status='error'` + `last_error`,
48
+ and `GET /api/db-sources` returns every status, so the connection stays visible
49
+ with its error, the imported rows stay live, the raw error is logged
50
+ server-side, and the user can **Refresh** to retry or **Disconnect** to remove.
51
+
52
+ This is the intended loud-failure behavior: surface the error, keep the data.
53
+
54
+ ## Lessons
55
+
56
+ - An "atomic connect" must not treat a post-commit failure the same as a
57
+ pre-commit one — discarding already-persisted user data to satisfy atomicity is
58
+ worse than a visible partial import.
59
+ - A rollback that also deletes the error record makes the failure undiagnosable.
60
+ Log the raw error before any cleanup.
61
+
62
+ ## Regression tests
63
+
64
+ `tests/integration/gui-db-sources-rollback.test.ts`:
65
+
66
+ - An import failure keeps an errored connection (registry row survives with
67
+ `status='error'`, shows in `/api/db-sources`, creds/descriptor retained) instead
68
+ of wiping everything.
69
+ - A **post-persistence** failure (rows imported for the first model, the second
70
+ model throws) keeps the already-imported rows **live** and the connection
71
+ present — the exact production data-loss path.
@@ -0,0 +1,61 @@
1
+ # Switching workspaces left the Settings / Version-history takeover showing the old workspace
2
+
3
+ **Date:** 2026-07-06
4
+ **Area:** GUI client — workspace switch routing (`src/gui/app/modules/search.ts`)
5
+ **Severity:** stale/misleading data (a workspace-scoped panel kept showing the previous workspace)
6
+
7
+ ## Symptom
8
+
9
+ With the **Settings** drawer open (Workspace tab — the workspace name, database
10
+ connection, and data model), switching to a different workspace from the topbar
11
+ dropdown updated the topbar (it now named the new workspace) but the Settings panel
12
+ kept showing the **previous** workspace's data — the topbar named workspace B while the
13
+ Workspace Settings display name still showed workspace A. The same happened for any open
14
+ takeover: **Version history** kept showing the old workspace's history.
15
+
16
+ ## Root cause
17
+
18
+ `reloadEverything()` is the single canonical workspace-switch path. To keep the user
19
+ in the same section across a switch it computes a `switchTarget` hash from the current
20
+ route and navigates there (or soft-re-renders if already on it). The mapping handled
21
+ Analytics / Graph / Tables / Objects but had **no case for `#/settings/*`**, so a
22
+ settings or version-history route fell through to the `#/folders` default.
23
+
24
+ The Settings drawer and Version history are **takeover overlays** that the route
25
+ dispatcher (re)renders from the hash: `#/settings/database → openSettingsDrawer('database')`,
26
+ etc. By routing the switch to `#/folders`, `reloadEverything()` both (a) failed to
27
+ re-invoke `openSettingsDrawer` for the new workspace and (b) never closed the drawer —
28
+ so the overlay stayed on screen still populated with the previous workspace's
29
+ `renderDatabaseSettings` / `renderHistory` output. The panel content is workspace-
30
+ specific (name, DB connection, data model, history), so it read as another workspace's
31
+ data leaking into the current view.
32
+
33
+ ## Fix
34
+
35
+ Add a `#/settings/*` case to the `switchTarget` computation that **preserves the exact
36
+ route** (`cur`). When the target equals the current hash, `reloadEverything()` takes its
37
+ existing "already on target → `renderRoute({ soft: true })`" branch, which re-dispatches
38
+ the settings route → `openSettingsDrawer(section)` → `selectDrawerTab` → re-renders the
39
+ drawer body (`renderDatabaseSettings` / `renderLatticeSettings` / `renderUserConfig` /
40
+ `renderHistory`) against the now-active new workspace. The takeover stays open and shows
41
+ the new workspace's data. Version history (`#/settings/history`) is covered by the same
42
+ prefix.
43
+
44
+ ## Lessons learned
45
+
46
+ - A "keep the user in the same section on switch" mapping must account for **every**
47
+ routed surface, including overlay takeovers — not just the main content sections. A
48
+ route that isn't in the mapping silently falls to the default and strands whatever
49
+ overlay was open.
50
+ - Overlays that render workspace-scoped data are a cross-workspace-leak risk on switch,
51
+ the same class as the Outputs-markdown leak: the switch path must refresh (or close)
52
+ every workspace-scoped surface, not only the ones rendered into `#content`.
53
+
54
+ ## Regression tests
55
+
56
+ - `tests/unit/outputs-workspace-switch.test.ts` — "a switch while a Settings /
57
+ Version-history takeover is open re-renders it for the new workspace": drives the real
58
+ `reloadEverything()` in jsdom with `location.hash = '#/settings/database'` and asserts
59
+ the hash is **preserved** (not reset to `#/folders`) and `renderRoute({ soft: true })`
60
+ is called (which re-renders the drawer for the new workspace). Fails before the fix
61
+ (`#/folders`), passes after.
@@ -0,0 +1,63 @@
1
+ # Chat lost the currently-open dashboard/record as context on the 5.0 Workspace routes
2
+
3
+ - **Date:** 2026-07-11
4
+ - **Area:** GUI chat context (`activeContext`) / client route detection
5
+ - **Severity:** High (core "assistant knows what you're looking at" feature silently no-ops)
6
+
7
+ ## Symptom
8
+
9
+ With a dashboard open, asking the assistant "why is this dashboard blank?" produced a reply
10
+ asking the user to _open_ the dashboard they were already viewing (e.g. "I need to see the
11
+ dashboard to diagnose the issue. Could you open it so I can investigate?"). The same class of
12
+ failure applied to any open table record or file — the assistant never received the
13
+ currently-open surface as context, so "this dashboard / this row / this file" resolved to
14
+ nothing and the self-diagnosis (`investigate`) tool had no default target.
15
+
16
+ ## Root cause
17
+
18
+ The client sends a `activeContext: { table, id }` hint with every chat message, computed by
19
+ `activeElement()` in `src/gui/app/modules/boot-interstitial.ts`. Its dashboard branch matched
20
+ **only** the retired route `#/analytics/<id>`. In the 5.0 single layout:
21
+
22
+ - the canonical open-dashboard route is `#/w/dash/<id>` (see `renderRoute` in
23
+ `analytics-view.ts`), and
24
+ - `normalizeLegacyHash` (`workspace-switch-progress.ts`) rewrites `#/analytics/<id>` →
25
+ `#/w/dash/<id>` on entry,
26
+
27
+ so a user viewing a dashboard is **always** on `#/w/dash/<id>` — a hash `activeElement()`
28
+ never recognized. It therefore returned `null`, the client POSTed `activeContext: null`, and
29
+ the entire (correct) server chain went idle: `parseActiveContext` → `undefined`,
30
+ `describeActiveView` emitted no note, `dispatch.activeDashboardId` was never set, and the
31
+ `investigate` tool returned "No dashboard is open… have them open it." The same gap dropped
32
+ open table records (`#/w/table/<name>/<rowId>`) and files (`#/w/file/<id>`) from chat context.
33
+
34
+ The server side of the feature was fully built and correct — it was starved of the one input
35
+ it needed because the client route detector was never migrated to the `#/w/…` scheme.
36
+
37
+ ## Fix
38
+
39
+ Add a `#/w/(dash|table|file|md)/<first>[/<drill-in>…]` branch to `activeElement()` that mirrors
40
+ `renderRoute`'s parser:
41
+
42
+ - `dash` → `{ table: 'dashboards', id }`
43
+ - `file` → `{ table: 'files', id }`
44
+ - `table` / `md` → the deepest complete `table,id` pair (a bare collection yields `null`)
45
+
46
+ The legacy `#/analytics/<id>` branch is retained for back-compat.
47
+
48
+ ## Lessons learned
49
+
50
+ - When a route scheme is migrated (`#/analytics` / `#/fs` → `#/w/…`), audit **every** consumer
51
+ of the hash, not just the renderers. A route producer (`renderRoute`) and a route _reader_
52
+ (`activeElement`) drifted apart, and only the reader was missed — a silent no-op with no error.
53
+ - Client-computed context that feeds the model needs a behavioral test at the client seam.
54
+ There was thorough coverage of the _server_ consuming a well-formed hint and of the
55
+ downstream tools once `activeDashboardId` was set, but nothing asserted the client actually
56
+ _produces_ the hint from the current route — so the regression slipped through green tests.
57
+
58
+ ## Regression tests
59
+
60
+ - `tests/unit/gui-active-element-dashboard.test.ts` — jsdom test that evals the client segment
61
+ and asserts `activeElement()` returns the right `{ table, id }` for `#/w/dash/<id>` (incl.
62
+ percent-encoded ids), `#/w/file/<id>`, `#/w/table/<name>/<rowId>`, a drilled relation
63
+ (deepest pair), `null` for a bare collection, and the legacy `#/analytics/<id>` route.
@@ -0,0 +1,55 @@
1
+ # Clicking a post-open registered table 404s "Unknown table"
2
+
3
+ - **Date:** 2026-07-11
4
+ - **Area:** GUI row/read routes — table-name validation vs. the live registry
5
+ - **Severity:** High (a table shown in the sidebar is unclickable — hard error)
6
+
7
+ ## Symptom
8
+
9
+ After connecting a connector (e.g. a Gmail connector registering `gmail_labels`), the new
10
+ table appears in the sidebar TABLES list, but clicking it returns `Unknown table: gmail_labels`
11
+ and the collection never loads. A page reload does not help. The same applies to tables
12
+ registered by a connected external database (db-source).
13
+
14
+ ## Root cause
15
+
16
+ The sidebar list is built from the **live** table registry (`db.getRegisteredTableNames()`,
17
+ via `entitiesSummary` / `registeredExtraTables`), but the row + read routes validated table
18
+ names against `active.validTables` — a **snapshot** built once when the workspace opened
19
+ (config tables ∪ registered non-internal tables at open time). A connector or db-source
20
+ registers its tables via `db.defineLate` **after** the workspace opened, so those tables are
21
+ in the live registry (hence listed) but absent from the snapshot (hence rejected). The list
22
+ source and the validation source diverge.
23
+
24
+ The gate appeared in several places — `tables-routes.ts` (row CRUD, the reported error),
25
+ `read-routes.ts` (provenance graph/row + the rows-markdown listing) and `server.ts`
26
+ (`/api/gui-meta`) — all using the bare `active.validTables.has(table)` snapshot check. Notably
27
+ the schema-op paths (`computed-ops.ts`, `schema-ops.ts`, `schema-routes.ts`) **already** used
28
+ the correct `validTables.has(x) || db.getRegisteredTableNames().includes(x)` idiom; the row/read
29
+ gates were simply never migrated to it.
30
+
31
+ ## Fix
32
+
33
+ Add one shared predicate `isRegisteredTable(active, table)` in `active-db.ts`: true if the
34
+ table is in the `validTables` snapshot **or** in the live registry, with internal
35
+ (`__lattice*` / `_lattice*`) tables always excluded so the security boundary is unchanged.
36
+ Replace the bare `validTables.has` snapshot checks on the row/read/navigate gates
37
+ (`tables-routes.ts`, `read-routes.ts` ×3, `server.ts` gui-meta) with it. Genuinely-unknown
38
+ tables still 400/404 (they are in neither set).
39
+
40
+ ## Lessons learned
41
+
42
+ - A "snapshot at open" set that a later runtime step (`defineLate`) can extend is a divergence
43
+ waiting to happen. When the same question ("is this a real table?") is answered against two
44
+ sources — a live registry for _listing_ and a snapshot for _validation_ — they will drift.
45
+ Answer it one way. Here the schema paths already had the right idiom; the fix is consistency.
46
+ - When adding a gate, prefer a single named predicate over inlining `set.has(x)` at each call
47
+ site, so a policy change lands in one place instead of ~40.
48
+
49
+ ## Regression tests
50
+
51
+ - `tests/unit/gui-registered-table.test.ts` — pins `isRegisteredTable`: accepts a snapshot
52
+ table, accepts a table registered after open (absent from the snapshot — the regressed case),
53
+ rejects a table in neither, and never exposes internal `__lattice*` / `_lattice*` tables even
54
+ when live-registered. The existing integration test that a genuinely-unknown table still
55
+ returns "Unknown table" continues to guard the reject path.
@@ -0,0 +1,55 @@
1
+ # Image descriptions never generate with a bring-your-own Claude API key
2
+
3
+ - **Date:** 2026-07-12
4
+ - **Area:** Ingest — image/PDF vision credential resolution
5
+ - **Severity:** High for BYO-key users (image files ingest with no description; the file view shows "No source text.")
6
+
7
+ ## Symptom
8
+
9
+ Opening an image file record shows a body of exactly "No source text." instead of the
10
+ expected AI-generated description of the image. Chat and text enrichment work fine for the
11
+ same user — only image (and scanned-PDF) descriptions are missing.
12
+
13
+ ## Root cause
14
+
15
+ It is an ingest-side credential gap, not a render bug. Image vision (`extractImage`) and the
16
+ scanned-PDF fallback resolve their Anthropic auth via `resolveClaudeAuth`, which is **narrower**
17
+ than the resolver chat + text enrichment use (`resolveLlmProvider`): `resolveClaudeAuth` returns
18
+ auth only for a managed env key or a connected Claude **subscription (OAuth)**, and never
19
+ consults the **bring-your-own Claude API key** configured as an API provider pointed at an
20
+ Anthropic host (`readOpenAiCompatConfig` + `isAnthropicEndpoint`).
21
+
22
+ So for a BYO-key user: `resolveClaudeAuth` returns null → `extractImage` returns null → the file
23
+ is written with `extracted_text=''` and `extraction_status='skipped'` (and, because the row is
24
+ `skip`ped, LLM enrichment — the only writer of a `description` — never runs either). The record
25
+ view faithfully reads `extracted_text` and, finding it empty, renders "No source text.".
26
+
27
+ The render side was correct throughout; there was never an AI description sitting in a field the
28
+ view ignored — the description was simply never generated.
29
+
30
+ ## Fix
31
+
32
+ Add `resolveVisionAuth(db)` in `src/gui/ai/provider.ts` and use it at the two vision call sites
33
+ (`ingest-routes.ts` `extractImage` + the PDF fallback) instead of `resolveClaudeAuth`. It unifies
34
+ vision's credentials with the rest of the assistant — managed env key, connected subscription,
35
+ **and** a BYO Claude API key on an Anthropic host — following the same managed → active-provider
36
+ ordering as `resolveLlmProvider`. Living in `provider.ts` (which already imports
37
+ `resolveClaudeAuth` + the config helpers) avoids an import cycle with `assistant-routes.ts`.
38
+
39
+ Known limitation: `ClaudeAuth` carries no `baseURL`, so a BYO key against a **non-default**
40
+ Anthropic host (a gateway) is not honored for vision — the common `api.anthropic.com` key works;
41
+ a custom-host key falls through to a connected subscription, else no vision.
42
+
43
+ ## Lessons learned
44
+
45
+ - Two credential resolvers for "the same" thing (chat vs. vision) drift. When a feature needs
46
+ Anthropic auth, it should resolve it through one shared path, not a hand-rolled narrower check.
47
+ - "A key is present" is a trap: the key was present for chat and invisible to vision. Test the
48
+ credential resolver directly at the exact configuration state that reproduces the gap.
49
+
50
+ ## Regression tests
51
+
52
+ - `tests/unit/llm-provider.test.ts` — with only a BYO Claude API key on an Anthropic endpoint
53
+ (no OAuth, not managed): `resolveClaudeAuth` returns null (the root cause) while
54
+ `resolveVisionAuth` returns the usable `{ apiKey }`. Plus: a non-Anthropic OpenAI-compatible
55
+ endpoint yields no vision auth, and nothing-configured stays null.
@@ -0,0 +1,104 @@
1
+ # Desktop app crashes with a JS-heap OOM during bulk document ingest
2
+
3
+ - **Date:** 2026-07-13
4
+ - **Area:** Ingest — desktop runtime memory ceiling + extraction concurrency
5
+ - **Severity:** Critical (the whole desktop app process aborts mid-batch; repeated, reproducible)
6
+
7
+ ## Symptom
8
+
9
+ Ingesting a folder of large Office documents (~60 files, a few hundred MB total, individual
10
+ spreadsheets/decks up to ~35 MB) crashes the desktop app partway through the batch. The
11
+ macOS crash report shows `EXC_BREAKPOINT (SIGTRAP)` on the embedded runtime thread. Time to
12
+ crash varies wildly with what is being ingested — from ~70 seconds after launch to many
13
+ hours — and the same folder ingests fine through the CLI-launched GUI on the same machine.
14
+
15
+ Symbolicating the crash frames against the runtime binary identifies the abort precisely:
16
+ V8 `FatalProcessOutOfMemory` with the reason string **"Ineffective mark-compacts near heap
17
+ limit"** — the JS old-space heap hit its configured maximum and consecutive full GCs could
18
+ not reclaim anything. Notably, resident memory at death was only ~270 MB, which had
19
+ previously been read as evidence this was _not_ an OOM; it is — the heap limit itself was
20
+ tiny.
21
+
22
+ ## Root cause
23
+
24
+ Two independent causes compound:
25
+
26
+ 1. **The compiled desktop runtime ships a conservative default V8 heap limit.** Nothing in
27
+ the desktop build passed `--v8-flags`, and the packaged runtime's default old-space
28
+ ceiling is a few hundred MB — vs ~4 GB for the CLI (`deno run`) default on the same
29
+ machine. This is why the crash class is desktop-only and why every prior "bulk-ingest
30
+ crash" investigation on the desktop kept finding memory-shaped failures. There is no
31
+ runtime escape hatch in the packaged app (no env-var flag pass-through), so the limit
32
+ must be baked in at build time.
33
+
34
+ 2. **Extraction transients are input-side, large, and were multiplied by concurrency.** The
35
+ folder-ingest pool runs 4 files at once and the browser upload path allows 3 more, and
36
+ each in-flight file can materialize:
37
+ - a full archive inflation of an Office/OpenDocument/EPUB file into memory
38
+ (`unzipSync` map, up to the 256 MB aggregate cap) plus full JS-string decodes of the
39
+ large XML parts;
40
+ - a pdf.js parse graph bounded only by a wall-clock timeout, not by memory;
41
+ - for scanned PDFs, a base64 copy of the whole file (~1.37×) inside the model request
42
+ body, which the SDK re-serializes and retains across 429/5xx retries — this leg had no
43
+ lock at all (only native image normalization was serialized);
44
+ - for browser uploads, the full request buffer (≤50 MB) retained by the handler for the
45
+ rest of its lifetime, alongside all of the above.
46
+
47
+ The 200 KB cap on _extracted text_ never bounded any of these input-side intermediates.
48
+ Count-based concurrency limits (4 + 3) cannot bound the heap when a single file's
49
+ transients run to hundreds of MB: concurrency × peak-transient is the number that has to
50
+ fit the heap, and it didn't.
51
+
52
+ ## Fix
53
+
54
+ - **Bake a real heap ceiling into every desktop build.** All four `deno desktop` build
55
+ scripts now pass `--v8-flags=--max-old-space-size=4096` (matching the CLI default on a
56
+ 16 GB machine and the ceiling CI already builds with). It is a ceiling, not a
57
+ reservation — the process still only commits what it allocates.
58
+ - **Serialize heavy extraction.** New `src/gui/ai/extract-gate.ts`: files ≥ 8 MB on disk
59
+ acquire a process-wide single-slot lane around the whole extraction (`extractSource`),
60
+ covering archive inflation, PDF parsing, and the scanned-PDF vision call for every ingest
61
+ door (folder pool, browser upload, local-path ingest, chat attachments). Smaller files —
62
+ the long tail — keep the pool's full concurrency. Lock order with the existing native
63
+ image lock is strictly lane → native, so the two cannot deadlock; the lane releases in
64
+ `finally`, so a throwing extraction never poisons it.
65
+ - **Release the upload buffer before extraction.** The browser-upload handler now hashes
66
+ and sizes the request bytes up front, keeps them only if an S3 push will need them, and
67
+ drops the reference before extraction begins — a large upload's in-memory copy no longer
68
+ coexists with the extraction transients.
69
+ - **Log the effective heap limit at startup** (`[desktop] … (V8 heap limit N MB)`), so a
70
+ memory-starved build is visible at a glance instead of only as a mid-ingest crash.
71
+
72
+ ## Lessons learned
73
+
74
+ - **Output caps don't bound input transients.** The extracted-text cap suggested extraction
75
+ memory was bounded; the actual heap cost lives in the intermediates (inflation maps,
76
+ parse graphs, base64 request bodies) that exist before any cap applies.
77
+ - **A compiled runtime's V8 defaults are not the CLI's defaults — assert them, don't assume
78
+ them.** The startup heap-limit log and the build-script regression test exist so this
79
+ divergence can never be invisible again.
80
+ - **Low resident memory does not rule out an OOM.** A process can die of heap exhaustion at
81
+ ~270 MB RSS when the configured limit is small. Symbolicate the abort reason before
82
+ classifying a crash.
83
+ - **Per-stage locks don't compose into a memory budget.** Serializing one native step
84
+ (image normalization) still left every JS-heavy leg concurrent. The budget that matters
85
+ is concurrency × peak-per-file-transient across the whole pipeline.
86
+
87
+ ## Regression tests
88
+
89
+ - `tests/unit/extract-gate.test.ts` — heavy extractions serialize (`maxActive === 1`),
90
+ small files stay concurrent and are never blocked behind the lane, and a rejected heavy
91
+ extraction releases the lane for the next waiter.
92
+ - `tests/unit/desktop-heap-flags.test.ts` — every desktop build script carries
93
+ `--v8-flags=--max-old-space-size=4096`; fails loudly if the flag is ever dropped.
94
+ - The upload-buffer release is a reference-lifetime change with no cheap deterministic
95
+ test (observing collectability mid-handler requires GC instrumentation); it is covered by
96
+ review and the end-to-end bulk-ingest verification.
97
+
98
+ ## Follow-ups (noted, not in this fix)
99
+
100
+ - `autoImportStructured` (spreadsheet re-import on upload) runs outside the lane,
101
+ post-extraction, upload-only — worth folding under the lane if it ever shows up in a
102
+ heap profile.
103
+ - pdf.js parsing remains timeout-bounded rather than memory-bounded; the lane serializes
104
+ big PDFs, which contains the risk, but a streaming/paged parse would bound it properly.