Status: accepted for implementation, session wiring gated by evidence
Date: 2026-07-25
ADR 0003 keeps the node-sql-parser adapter internal and unwired. Its parser
is synchronous, so an AbortSignal cannot interrupt it while it occupies the
JavaScript thread. Even a late result that is correctly discarded can make an
editor unresponsive.
Moving the current adapter object into a worker is not valid. Parser requests,
authorities, ranges, artifacts, and analyses are authenticated by
package-owned, realm-local WeakSet and WeakMap state. Structured cloning
would produce unauthenticated copies. The backend AST is also retained in a
realm-local weak map and cannot become a cross-realm semantic API.
The distributed dialect builds introduce separate constraints:
- The Node loader uses
node:moduleand intentionally rejects any realm withselforwindow. - The browser builds are CommonJS/UMD files which a consumer bundler must transform.
- Loading a build may write
NodeSQLParserorglobalon its realm. - A browser worker can be terminated for a wall-clock deadline, but browsers do not expose an enforceable per-worker heap limit.
- One worker per editor would multiply parser memory across marimo's many mounted editors.
This decision concerns local browser placement. Node worker_threads, native
providers, remote providers, and public packaging are separate decisions.
Interactive browser parsing will use a dedicated module worker. The existing pure-Node inline adapter remains internal evidence and batch-test infrastructure. It is not a fallback when browser worker construction, loading, or execution fails.
Browser placement is accepted with an explicit residual risk: input, queue, response, cache, and lifetime can be bounded, but transient parser allocation cannot be capped before the browser itself terminates an over-consuming worker. The current 16 KiB input ceiling remains an upper safety bound, not an interactive performance claim. Production session wiring remains blocked until adversarial memory, latency, failure-recovery, and many-editor gates pass.
Each SqlLanguageService will lazily own at most one dedicated parser worker.
All sessions opened by that service share it. The worker is neither a
SharedWorker nor a module-global singleton.
The private browser executor is single-lane:
- At most one request is posted at a time.
- The host queue is bounded independently by request count and retained UTF-16 text units.
- Worker construction is lazy and the executor owns listeners, timers, termination, and disposal.
- No worker pool or idle shutdown is introduced without profile evidence.
- Executor disposal terminates the worker and settles every pending consumer.
The executor and its worker factory remain package-private. No package export,
/vnext export, language-service module, session ownership, or public
configuration surface is introduced by this slice.
Ordinary caller cancellation and supersession settle the consumer promptly without relying on a worker message that cannot run during synchronous parsing. The executor may drain and discard that active result. A hard wall-clock deadline, worker crash, malformed protocol, or service disposal terminates the generation. Never-posted queued requests may continue on a fresh generation, but a posted request is never replayed.
The execution deadline belongs to the posted worker job, not to any attached consumer. Consumer cancellation never clears it. A draining operation retains the active lane until it returns or its generation is terminated; queued work has a separate wait deadline. Service disposal always terminates immediately. These rules prevent a cancelled hostile parse from occupying the only worker indefinitely.
The safety deadline is separate from product latency targets. A deadline failure never upgrades parser authority and an active request is not automatically replayed after a crash or timeout.
The worker is created with a same-origin URL:
new Worker(
new URL("./node-sql-parser-browser-worker.js", import.meta.url),
{ name: "codemirror-sql-parser", type: "module" },
);Blob, data, and evaluated workers are not used. Hosts must allow the emitted
worker URL in their Content Security Policy, normally through
worker-src 'self'. The worker asset response also receives a restrictive
policy because a worker has its own execution context.
The constructor shape and its literal options stay static. Current
Vite worker handling recognizes
the URL only when new URL(..., import.meta.url) appears directly inside the
worker constructor. The
platform worker contract
also requires a same-origin entry URL and JavaScript response media type.
The browser worker has a browser-specific loader. It does not weaken or reuse the pure-Node realm gate. It dynamically imports literal, dialect-specific paths only:
node-sql-parser/build/postgresql.js
node-sql-parser/build/bigquery.js
The worker verifies that self === globalThis and that no DOM window exists.
It snapshots and restores the exact NodeSQLParser and global descriptors
around the complete backend operation: dialect loading, module decoding,
parser construction, parsing, and output normalization. Cleanup failure
poisons that worker generation.
The worker protocol is package-private, versioned, closed, and decoded from
unknown on both sides. It transports plain evidence, never authenticated
syntax objects.
The initial request contains only:
- Protocol version
- Host correlation ID
- Grammar ID: PostgreSQL or BigQuery
- Exact untrimmed statement text
DuckDB uses the PostgreSQL grammar. Target-dialect policy stays in the main realm.
Protocol v2 contains only one closed outcome:
- Parsed normalized statement kind and a bounded query-binding DTO, or
nullwhen the statement has no supported query-binding evidence - Syntax rejection
- Bounded unsupported reason
- Bounded failure code; retryability is derived from that code
Messages do not contain:
- Public document revisions or session identities
- Parser authorities or dialect handles
AbortSignal,Error, stack, or raw backend message values- Source text echoed in a response
- Absolute document ranges
- Raw ASTs or generic payload bags
The wire does not transport an independently supplied retryability boolean.
The host treats only module-load as retryable; backend and
malformed-output are terminal. Every worker-reported failure retires the
current generation. In particular, backend cannot distinguish an ordinary
parse failure from descriptor-cleanup poisoning that closes the endpoint, and
module-load must not reuse a rejected dynamic-import realm. Never-posted
queued requests may continue on a fresh generation; the failed posted request
is never replayed.
The host requires the current protocol version and correlation ID, validates
all keys and closed values, and copies accepted data into new frozen objects.
A future statement coordinator will construct an authentic
SqlParserAnalysis with the exact pending request text and the host-owned
authority. PostgreSQL and BigQuery rejection remain uncovered constructs;
DuckDB rejection remains compatibility rejection. Worker isolation does not
strengthen the compatibility-only evidence recorded by ADR 0003.
Old-generation events are ignored by generation-owned listeners. A malformed, duplicate, unsolicited, or mismatched response kills the generation and settles the active operation exactly once without exposing raw event data.
Raw backend ASTs will not cross the worker boundary and the first protocol will not introduce remote AST handles or worker-local AST leases.
The first relation-completion slice remains parser-independent under ADR 0005. Protocol v1 remains historical and protocol v2 adds the internal query-binding DTO. Incomplete relation completion must not wait for worker startup, parser acceptance, timeout, or recovery.
The scope decoder runs once in the worker realm immediately after parsing. It returns query blocks, relation/alias bindings, persistent scopes, typed visibility regions, independent coverage, and closed issues. It does not return flat relation names, raw ASTs, source text, or absolute document ranges. The host revalidates the complete DTO before accepting the response.
Worker-local AST caching is deferred until profiling demonstrates that reparsing is material enough to justify leases, byte accounting, generation invalidation, and release semantics.
Core and /vnext imports must remain SSR-safe and contain no parser grammar or
worker asset. A future optional integration entry may create the worker lazily,
but it will expose an opaque language-service module factory rather than the
protocol, worker URL, transport, pool, or backend AST.
The initial supported bundler claim is limited to packed-consumer fixtures that run in CI. Source-workspace success is not packaging evidence.
A production-shaped fixture built alongside the exact npm pack archive must
prove:
- Core-only import emits no parser or worker bytes.
- PostgreSQL and BigQuery emit separate worker chunks.
- The all-dialect build is absent.
- Worker creation is lazy.
- Both grammars execute in a real browser.
- Main-window parser globals remain unchanged.
- Core import remains SSR-safe.
- A same-origin module worker runs under a strict CSP.
- Worker startup, cold import, warm parse, and message round-trip samples are recorded.
- Raw and gzip worker sizes are recorded.
Parser session-integration and future semantic slices additionally require:
- Main-thread long-task and event-loop responsiveness evidence.
- Malformed message, crash, timeout, late-event, and restart tests.
- Rapid-edit drain-versus-restart measurements.
- One, ten, and fifty editor scenarios.
- Retained worker, listener, timer, and memory checks after disposal.
- Adversarial statements at the accepted input ceiling.
The current product envelopes remain:
- No routine main-thread task over 50 ms.
- Warm active-statement analysis p95 under 16 ms.
- Local completion p95 under 50 ms.
Safety timeouts are not evidence that these product targets are met.
The placement harness introduced with this decision builds the exact packed
archive, verifies its core import in an isolated fixture, and separately uses
fixture-owned worker code with the pinned node-sql-parser dependency to prove
consumer-side Vite 8 placement feasibility. It serves the production output
with a same-origin CSP and runs it in Chromium.
The worker portion does not yet prove a packed optional parser integration; that entry does not exist. The protocol PR must move the worker implementation behind the packed package boundary and remove the fixture's direct parser dependency before making a public packaging claim.
A representative local Node 24 / Chromium 149 / arm64 macOS sample recorded:
| Output | Raw | gzip |
|---|---|---|
| Core-only fixture | 24,056 B | 7,497 B |
| PostgreSQL transitive worker graph | 321,156 B | 67,396 B |
| BigQuery transitive worker graph | 225,309 B | 50,389 B |
| Complete worker fixture | 549,893 B | 118,170 B |
The core module trace contained no node-sql-parser module. No dialect
resource loaded before explicit construction. A single static module worker
loaded PostgreSQL and then BigQuery through separate literal lazy imports;
both parsed successfully without changing the main-window parser sentinel.
The per-dialect figures conservatively include their complete static
transitive closures, with shared chunks also reported separately.
One sequential cold/warm run on the shared worker measured:
| Dialect | Grammar load and initialization | First parse | First round trip | Warm parse | Warm round trip |
|---|---|---|---|---|---|
| PostgreSQL | 8.0 ms | 2.4 ms | 10.6 ms | 0.1 ms | 0.3 ms |
| BigQuery | 4.2 ms | 2.5 ms | 6.7 ms | 0.2 ms | 0.4 ms |
The worker ready handshake took 10.3 ms in that run.
These numbers establish packaging feasibility and initial size guards. They are not percentile claims. Stable latency decisions require repeated, cross-platform samples over the representative and adversarial corpus.
The checked-in harness fails above 68 KiB gzip for the PostgreSQL transitive graph, 50 KiB for the BigQuery transitive graph, or 120 KiB / 570 KiB for the complete worker fixture in gzip/raw form. These ceilings include small measurement headroom and are placement-spike guards, not the final optional-integration bundle budget.
- Add this ADR and the packed-consumer browser placement harness.
- Extract a realm-neutral backend engine and add strict protocol codecs.
- Add the minimal browser worker and private bounded single-lane executor.
- Add the pure statement coordinator, bounded cache, in-flight sharing, and authenticated syntax integration.
- Ship parser-independent relation completion under ADR 0005.
- Design in-worker scoped semantics and protocol v2 before a scope-dependent feature consumes parser evidence.
Every production step is a medium change and receives two independent, commit-bound adversarial reviews.
- Synchronous parser CPU work cannot block the editor main thread.
- Fifty editors on one service do not imply fifty parser workers.
- Realm-local authenticity remains an internal safety boundary.
- Worker failure is explicit and never falls back to unsafe inline parsing.
- The raw AST remains replaceable and private.
- A serial worker may create head-of-line blocking; measurement, queue bounds, and hard deadlines make that tradeoff visible before considering a pool.
- Browser heap exhaustion cannot be fully contained and remains a documented residual risk.
- Browser and Node interactive execution can evolve independently.
Late-result rejection preserves correctness but cannot restore responsiveness while synchronous parsing runs.
Structured cloning loses the package-owned realm authentication required by the syntax contract.
Raw ASTs expose backend coupling and can be very large. Remote handles add leases, eviction, crash invalidation, and release semantics before a semantic consumer exists.
This multiplies grammar and runtime memory and conflicts with the many-editor release target.
Both weaken service ownership and disposal isolation. SharedWorker also
narrows runtime and CSP compatibility.
The first need is one parser with a small closed protocol. A general framework would stabilize abstractions before there is evidence from a second workload.
A pool increases grammar duplication, memory, scheduling, and cancellation complexity. It can be reconsidered only if a measured serial bottleneck outweighs those costs.