Status: accepted
Date: 2026-07-25
ADR 0004 originally required the isolated parser worker to return normalized
relation facts before the first relation-completion slice. Implementation and
adversarial inspection of the pinned node-sql-parser 5.4.0 builds showed that
this is the wrong dependency.
Relation completion is most valuable for incomplete input such as:
SELECT * FROM |
SELECT * FROM schema.us|
SELECT * FROM users u JOIN |The compatibility parser rejects many of these states. Waiting for it would
make completion least reliable at the exact moment it is requested. It would
also construct a roughly 500 KiB parser-worker graph for a feature that can
recognize a narrow set of partial SELECT query sites without a complete AST.
The backend AST cannot provide a safe shortcut:
- Flat
tableListdata mixes physical relations with CTE references. - Nested wrapper lists can include relations inherited from an ancestor.
- PostgreSQL and BigQuery use different shapes for CTE bodies.
- BigQuery qualified paths have different shapes when backtick quoted.
- PostgreSQL commonly omits relation locations and loses quote provenance.
- DML target and source fields have different read/write roles.
- CTE declaration order, nesting, shadowing, recursion, correlation, and
LATERALrequire scopes rather than a flat list.
A flat value named dependencies, sources, or relations would therefore
encode stronger semantics than the adapter can prove. Parser acceptance is
already compatibility-only evidence under ADR 0003.
The current vNext core has the required parser-independent foundations:
- immutable original and masked analysis source;
- absolute UTF-16 half-open coordinate mapping;
- dialect-owned lexical profiles and identifier rendering;
- exact and explicitly opaque statement slots;
- cursor affinity;
- atomic session revisions and context changes; and
- service ownership shared across many document sessions.
Marimo adds concrete requirements. A notebook can mount many SQL editors,
change connection and dialect dynamically, combine notebook-local and remote
relations, and embed Python expressions in {...} regions. Its Python
completion source must remain independently composable.
The first relation-completion vertical slice will not depend on the parser, parser worker, or parser coordinator.
It will combine:
- a private bounded partial-
SELECTquery-site recognizer; - a private bounded CTE frame and visibility recognizer;
- one asynchronous relation-catalog provider with explicit coverage and epoch state;
- revision-aware session arbitration and deterministic result composition; and
- a separate CodeMirror adapter.
Parser execution remains the selected boundary for syntax evidence and future general scope-dependent semantics. A later semantic protocol will use a scoped IR and will not expose raw ASTs or flat relation-name lists.
The single configured provider may itself be a host-owned composite. The first slice does not define provider fan-out, cross-provider deduplication, or arbitration. Those remain deferred as required by ADR 0001.
The private recognizer is a bounded partial SQL recognizer, not a keyword
scanner. It enters a relation-name state only from a proven supported SELECT
query block and a proven clause state at the current query depth:
- immediately after
FROMorJOIN; - a partial qualified or unqualified relation path following those keywords; and
- a comma-separated relation entry in the same
FROMclause.
The initial recognizer does not claim support for UPDATE, INSERT INTO,
table functions, derived output relations, LATERAL, or column positions.
It fails closed when unsupported syntax could change the clause, expression,
or query-block state. Strings and comments never create sites. FROM ( and a
cursor inside a string, comment, quoted token that cannot be decoded, or
embedded region are unavailable or inactive according to the closed result.
CTE visibility is the one narrow scope-semantics exception; general
parser-derived scope semantics remain deferred.
The recognizer may cross a USING join constraint only after authenticating
the complete bounded grammar USING(identifier [, identifier ...]) with
dialect-owned identifier validation. It does not interpret ON expressions:
encountering ON makes the query site unavailable until a future
parser-backed or separately specified expression recognizer can prove the
boundary.
The conformance corpus includes positive base FROM, qualified prefix,
aliased JOIN, authenticated USING, same-depth comma, and nested
supported-query cases. It includes
negative IS DISTINCT FROM, substring(... FROM ...), extract(... FROM ...), DELETE FROM, COPY ... FROM, set-operation, QUALIFY, WINDOW,
ON, malformed USING, DML, and expression cases. A keyword match alone
never creates a site.
The result distinguishes:
- inactive: the cursor is not at a supported relation site;
- unavailable: an opaque statement, resource limit, or ambiguous construct prevents a safe answer; and
- ready: the replacement range, decoded qualifier and prefix, visible CTEs, and exact or recovered quality are known.
The recognizer authenticates both the final identifier-segment range and the
whole typed relation-path range as statement-relative UTF-16 ranges created
from package-owned source. The session maps them to absolute original-document
ranges before returning a completion item. In the first slice every catalog
completion replaces the authenticated whole typed relation path with the fully
rendered canonical suffix selected by provider-proven completionPathStart.
It never inserts a full path into a final-segment edit, so schema.us cannot
become schema.schema.users.
Incomplete quoted identifiers replace the complete authenticated token or path
rather than creating an unmatched quote suffix. No edit crosses a statement or
embedded-region boundary.
Dialect runtime data, not providers, owns:
- bare-identifier syntax;
- quote delimiters and escaping;
- query-local quoted and unquoted CTE equality;
- reserved-word quoting;
- rendering by path-segment role;
- maximum identifier path depth; and
- supported CTE grammar.
The provider and catalog scope own catalog-path matching, case policy, and addressability. This matters for systems such as BigQuery, where dataset configuration can change case sensitivity, and where rendering rules differ by path segment. Providers receive decoded identifier queries and the public dialect ID, and return decoded path components plus positive matching evidence. They never produce SQL insertion text or choose quoting.
The service does not generic-fold, deduplicate, reject, or infer absence for catalog entities. A dialect-derived normalized string may be used only as a locale-independent deterministic sort key; it never establishes catalog eligibility, equality, addressability, or authority.
DuckDB uses DuckDB lexical and identifier rules for completion. Its use of the PostgreSQL compatibility parser remains separate positive-only syntax evidence.
The CTE recognizer handles a bounded subset of query-block-leading CTE syntax:
WITH [RECURSIVE]
name [(declared columns)] AS [NOT] MATERIALIZED (body),
...
main query
The accepted grammar is dialect-owned and closed:
| Dialect | Declarations per frame | RECURSIVE |
Declared columns | Materialization modifier |
|---|---|---|---|---|
| PostgreSQL | up to the global bound | accepted | accepted | MATERIALIZED and NOT MATERIALIZED |
| DuckDB | up to the global bound | accepted | accepted | MATERIALIZED and NOT MATERIALIZED |
| BigQuery | up to the global bound | accepted | rejected | rejected |
| Dremio | one | rejected | accepted | rejected |
This matrix describes only syntax the bounded recognizer can prove. Rejection does not claim that a database engine rejects every extension or future version; it makes the current completion result explicitly incomplete or unavailable instead of borrowing another dialect's grammar.
The recognizer records only:
- flat query-block frames and their parent frame;
- proven declaration names, exact source spelling and name ranges;
- body ranges and declaration order;
- authenticated main-query
SELECTentrypoints; and - bounded candidate-name and uncertainty evidence for incomplete headers.
It stores no AST, SQL substring, token tape, inferred output columns, or
recursive dependency graph. A declaration is committed only after its AS
body has a proven matching close parenthesis. Seeing WITH name, name AS, or
an opening body alone never invents a visible relation. Once a body opening is
proven, a separate bounded draft record retains its name, ordinal, body range,
and comparison evidence. Drafts can establish the current body phase and
fail-closed shadow uncertainty, but never become completion candidates.
Candidate-name evidence, visible-candidate evidence, and shadow evidence are
distinct. Before a body opens, an active incomplete header has no supported
relation site; direct private projection still reports recovered unknown
coverage rather than exact absence. Once a body opens, its draft name is not
returned as a completion candidate, but it prevents the service from claiming
exact scope or exact non-shadowing where recursive or uncertain equality could
make it visible. Such a result is marked incomplete with
cte-scope-uncertainty. A proven declaration shadows an equivalent outer CTE
or unqualified catalog insertion only over its proven visibility range. A
duplicate equivalence class produces no arbitrary first- or last-wins
candidate and blocks an equivalent outer or catalog name wherever that class
would be visible. Uncertainty in one nested frame does not erase independently
proven candidates outside that frame.
For non-recursive CTEs:
- a declaration is not visible in its own body;
- earlier siblings are visible in later sibling bodies;
- all completed declarations are visible in the main query;
- later declarations are never visible earlier;
- visible outer CTEs flow into nested query blocks;
- a nested declaration shadows an outer name only inside its query block; and
- nested declarations never leak outward.
Identifier comparison follows the dialect and is tri-state: equal,
distinct, or unknown. PostgreSQL non-ASCII folding can depend on server
encoding and locale, while BigQuery and Dremio document case-insensitivity
without giving this package an authoritative general Unicode folding
algorithm. unknown therefore degrades namespace coverage and never means
distinct. The private layout builder consumes the same pairwise
compareCteIdentifiers operation exposed by the relation-completion dialect
runtime; it does not define a second nullable comparison key. Within the
256-name bound it snapshots symmetric pairwise results into frozen
equivalence classes and scoped uncertainty evidence. Throws, invalid values,
asymmetry, non-reflexivity, or inconsistent equivalence results fail closed.
Duplicate detection uses those classes rather than generic case folding.
A declaration retains both its decoded value and exact source token. The decoded value is the completion label; the exact token is the insertion text, so required quoting, case, and escapes are never reconstructed by the catalog renderer. CTEs are considered only for unqualified relation sites. Prefix eligibility is also a dialect-owned tri-state operation distinct from equality; generic locale-sensitive or Unicode case folding is never used.
WITH RECURSIVE may expose proven names in the main query. The initial
recognizer also retains independently proven earlier-sibling and outer
visibility inside a recursive body, but self and forward or mutual-recursive
body candidates remain incomplete and add recursive-cte-uncertainty. Every
known frame-local recursive name still contributes shadow evidence inside a
recursive body, so withholding a self candidate cannot incorrectly reveal an
equivalent outer or catalog relation. The recognizer does not infer a recursive
dependency graph or output columns. DuckDB USING KEY, PostgreSQL
SEARCH/CYCLE, and any Dremio recursive extension remain unsupported.
The index is cursor-independent and cached with the immutable source, statement, embedded-region, and dialect-runtime identities. Visibility is a pure projection over its flat frozen ranges. Building the index and recognizing the relation query site may initially make two independent linear streaming passes over the shared lexer; traversal fusion is allowed only after benchmark evidence. Neither cursor movement nor catalog invalidation rebuilds the index.
Stored source ranges remain half-open. Visibility projection accepts cursor positions, so a cursor exactly before a proven closing delimiter remains in the body or nested frame, and a top-level cursor at statement EOF remains in the main query. A cursor after the delimiter is outside. At the first untrusted boundary the enclosing draft phase may still contribute proven positive evidence, but quality and shadow coverage are recovered rather than exact.
An opaque region in a CTE header or body, an unterminated quoted token, an unsupported dialect modifier, a duplicate declaration, or a structurally plausible but unfinished declaration makes the affected frame partial. The first opaque region terminates exact structural coverage: visible punctuation after an untyped barrier never closes a body or frame that started before it. A resource limit records the same first-untrusted boundary. Partial artifacts may still contribute declarations, entrypoints, and visibility ranges proven entirely before that boundary, but they never produce exact absence or shadow claims across it.
An empty positive-only local-relation list never proves that no CTE is visible.
The first bounded grammar intentionally authenticates only SELECT query
leaders. PostgreSQL VALUES and data-modifying CTE bodies, DuckDB FROM-first
queries, and additionally parenthesized BigQuery recursive terms currently
fail closed. Their query-leader sets will become dialect-owned in a follow-up
before relation completion is declared feature-complete.
The first public template input is a complete set of length-preserving embedded
regions attached atomically to a document revision. openDocument accepts an
optional complete set; omission or an empty set means identity source.
Arbitrary transformer callbacks and generated/reordered source maps remain
deferred.
Every accepted full-text or incremental document update includes the complete ordered, non-overlapping region set for the resulting text. A base-revision transaction may instead change regions alone or together with context while leaving text and document identity unchanged. Without a document mutation, omission preserves the current set and an explicit empty set clears it. Text, context, and regions pass one validation gate and either create one new revision together or leave the session unchanged. The CodeMirror adapter supplies the post-transaction set through a typed effect or a configured pure extractor; it never opens or changes text and follows with a second region update.
An untyped embedded region is an unknown grammar barrier, not an exact expression token. The recognizer:
- returns inactive while the cursor is inside an embedded region;
- never creates an edit or identifier path crossing a region; and
- marks a CTE header containing an opaque region partial rather than inventing a declaration; and
- returns unavailable when proof cannot cross the barrier, or ready with
recovered quality,
isIncomplete: true, and the closed reasonopaque-template-contextwhen source tokens after the barrier heuristically resemble a supported site and the edit coordinates are independently safe.
Therefore generic marimo interpolation such as FROM {df} JOIN | is never
exact: its SQL site semantics remain uncertain even when a recovered completion
is useful. Exact continuation across a region requires a future trusted,
feature-specific syntactic-role contract; a language label alone is
insufficient.
Marimo keeps Python-expression completion as an external CodeMirror source for
positions inside {...}. Its region conformance fixtures cover {df}, nested
Python expressions and strings, {{...}}, unmatched braces, braces in SQL
strings/comments, cursor-inside-region behavior, and edits that create or
remove regions.
The provisional catalog provider is feature-specific and asynchronous. A relation search request contains only copied, recursively frozen plain data:
- catalog scope and an ordered list of search paths, where each path is an ordered list of decoded components with quoted state;
- public dialect ID;
- decoded qualifier and prefix components with quoted state;
- result limit;
- expected epoch when known; and
- an optional bounded continuation token.
The AbortSignal is passed separately. Providers never receive a session,
revision, EditorState, DOM object, credential object, or arbitrary live host
context. Connection identity belongs in the catalog scope; live resources stay
inside the provider closure. The provider object is caller-owned. The service
captures its own-data callbacks once and owns searches, subscriptions, and
their cleanup, but does not imply provider-wide disposal. Provider callbacks
are closure functions with a declared this: void contract and are invoked
with this === undefined; mutable receiver state is not part of the provider
API. Unrelated own configuration fields are ignored, while inherited callbacks
and accessors are rejected. An own optional subscription value of undefined
is normalized as omitted.
A returned relation contains:
- a stable provider-local entity ID;
- relation kind;
- a canonical absolute catalog path whose components contain decoded values and closed semantic roles;
- a
completionPathStartindex selecting an exact suffix of that canonical path, positively proven addressable for the request scope and search path; - a closed provider-proven match quality, initially
exactorequivalent; and - optional bounded plain-text detail.
The initial closed component roles are catalog, schema, project,
dataset, and relation. Each dialect accepts only its documented role
sequences, and the final component is always relation. The provider's
quoted bit is positive catalog evidence that the canonical decoded spelling
is quoted; it is never insertion SQL. The dialect still owns delimiters,
escaping, reserved-word quoting, and rendering.
The service never invents an unqualified candidate from an absolute path. The
provider proves matching and addressability through matchQuality and
completionPathStart; the service validates that the selected suffix and role
sequence are legal for the registered dialect, then produces dialect-correct,
segment-role-aware insertion text. Alias paths that are not canonical suffixes
remain deferred. Each catalog item has one positive catalog provenance
containing provider and entity IDs.
Search responses distinguish:
- ready with complete, partial, or paginated coverage;
- loading; and
- failed with a closed code and retry policy.
A terminal loading response is not cached. A provider that later becomes
ready must publish a strictly higher epoch through its subscription; same-epoch
duplicate invalidation is not a readiness signal. A still-pending search
promise can instead resolve ready under the service-owned response/refresh
rules below. Every completion result carrying catalog-loading, whether caused
by terminal loading or soft expiry, also carries a checked remaining
completion-intent lease. For terminal loading that lease is independent of
in-flight work ownership and is keyed to the exact session, query, and captured
observed or unobserved epoch.
Partial positive entities may be offered. A partial or absent result never proves that a relation does not exist. A complete empty search means only that the exact search produced no matches at that provider epoch; it is not reused as an unknown-object diagnostic.
Provider responses are decoded from unknown using bounded own enumerable
data properties and copied into fresh frozen current-realm objects. Accessors,
throwing proxies, unexpected keys, oversized strings or arrays, and malformed
closed values fail without exposing raw errors. One malformed relation rejects
the whole page: silently retaining other entities while preserving the
provider's coverage claim would create false authority. Entity IDs must be
unique within a page. Their stability across searches and epochs is a provider
promise used for provenance, not authority the service can infer by generic
deduplication. Shared input identities are allowed, but each occurrence is
decoded, budgeted, and copied independently, so aliasing cannot retain mutable
provider state or bypass aggregate limits.
Both the complete canonical role sequence and the suffix selected by
completionPathStart must be legal for the registered dialect. The boundary
pre-renders the selected suffix from fresh decoded data so later scheduling and
composition never touch raw provider objects or provider-supplied SQL.
The initial vertical slice validates one bounded page and reports paginated coverage as incomplete; it does not automatically follow or merge continuation tokens. Continuation tokens are opaque provider state. They are bounded, retained only with their page/work state, and never included in completion items, logs, errors, or revision events.
The core also provides a provisional createInMemoryRelationCatalog helper for
bounded readonly relation data. It indexes stable IDs, kinds, role-bearing
canonical component paths, optional details, scopes, and caller-proven
addressable suffix starts. Each scope explicitly selects a closed matching
policy such as exact code-unit or ASCII case-insensitive matching; the helper
never infers catalog policy from dialect ID. Package dialect utilities decode
and render SQL but do not establish catalog eligibility. The helper returns
complete, frozen generation-zero results and requires no no-op subscription for
a static catalog. It imports no CodeMirror, DOM, React, or host state.
Epochs are monotonic per provider ID and catalog scope and contain:
- a non-negative safe generation; and
- an opaque bounded non-secret token.
They identify observable catalog search state, including loading versus ready availability, not only the underlying database schema snapshot.
Every response and invalidation passes through one serialized gate for the configured provider identity and scope. A subscription callback is always a change event, never an initial snapshot. The first accepted invalidation establishes its epoch and advances all sessions already subscribed to that scope. A first successful search response may establish a baseline without a revision advance only when no invalidation has been accepted since the request captured its revision and no soft-settled refresh observer requires the availability notification defined below.
After a baseline exists, lower generations are discarded with closed stale evidence. Equal generation and token is a duplicate. Equal generation with a different token is malformed provider behavior and causes no state mutation. A higher accepted invalidation or response atomically installs the new epoch, clears older scope cache entries, supersedes affected work, and then advances each subscribed session revision exactly once.
The epoch coordinator accepts one optional package-owned, receiver-free
transition-preparation closure. For an accepted invalidation baseline or
advance, and for a higher response, it snapshots the active revision audience,
installs the epoch and notification sequence, and then calls that closure with
a copied scope and the already-frozen epoch. The closure synchronously detaches
incompatible work, cache entries, and epoch-specific gates and may return one
receiver-free dispatch closure. The no-hook and no-dispatch paths allocate no
transition wrapper state. Only after transition and session preparation
has finished does the coordinator settle the producing response, invoke the
transition dispatch, and dispatch session listeners. Provider aborts and work
settlement therefore observe every prepared session revision, while listener
code observes already-retired catalog work. Reentrant transition dispatch
commands remain behind the current revision dispatch in the epoch FIFO.
A missing or null dispatch is valid. A thrown transition preparation or
non-function result is an internal contract failure and disposes the
coordinator fail-closed. Dispatch is synchronously exact-return: it must return
undefined. A throw or any other return value disposes the coordinator after
state preparation and suppresses later revision listeners; an accidental
Promise result receives best-effort detached rejection draining. Coordinator
disposal revokes the preparation closure before external cleanup. The hook
remains package-private and is not a provider or session extension point.
The combined search coordinator also installs one package-owned disposal
target. Epoch self-quarantine makes the outer coordinator inert before
subscription cleanup continues, so search work cannot outlive its epoch
authority. The target is receiver-free, invoked at most once, and its failure
cannot reopen disposal. It is synchronously exact-return and must return
undefined; any other runtime result is detached and rejection-drained.
A search that discovers a higher epoch supersedes itself instead of publishing against its older captured revision. Pages and cache entries from different epochs are never merged.
Any successfully decoded response status (ready, loading, or failed) may
establish the first epoch baseline. Equal-epoch responses for different exact
searches remain usable; only the epoch observation is a duplicate. A terminal
loading response requires a higher epoch before that exact search can become
ready. Providers without subscriptions may return loading, but they cannot
cause automatic refresh; a later explicit request must observe the higher
epoch. Failed responses are attempt evidence: next-request may recover at
the same epoch, while after-invalidation and never create bounded retry
gates rather than cached search results.
The service reference-counts one provider subscription per provider
configuration and scope, then fans invalidation out to subscribed sessions.
The installed callback closure supplies authenticated provider and scope
identity; the untrusted invalidation payload therefore contains only an epoch.
Membership uses a two-phase prepare/commit protocol: the session stores its
prepared lease before commit can invoke provider code. Commit installs the
membership atomically before a provider can call back synchronously. Because a
subscription callback is always a change event, a valid callback fired during
subscribe advances that committed member after subscription installation
succeeds; it is not reclassified as an initial snapshot. A later joining
session captures an already-observed epoch without a synthetic revision bump.
Disposal removes membership before unsubscribing or running external cleanup.
Retirement also clears the membership's target and coordinator link before
that cleanup, so a consumer retaining a disposed lease retains only inert,
bounded membership metadata. The coordinator stores only the copied provider
ID and captured subscription function; it does not retain the unused search
function. Coordinator disposal revokes that subscription reference before
external cleanup, so a consumer retaining the disposed coordinator handle does
not retain the provider graph.
Fifty sessions sharing a scope do not create fifty provider subscriptions.
The coordinator reserves membership state and capacity before reading the
revision target callback. A throwing or invalid callback getter rolls that
reservation back with invalid-target unavailable evidence. Reentrant
coordinator disposal instead reports disposed and leaves no reserved
membership or captured callback behind.
Each installed subscription has a service-owned incarnation identity captured by its callback. The identity is revoked before external unsubscribe or cleanup, and every callback checks it before entering the serialized epoch gate. Cleanup from a retired incarnation is keyed to that identity and cannot remove, mutate, or notify a reentrantly installed replacement.
The callback closes over a revocable cell rather than the service, scope state,
or sessions. Retirement clears the cell before external cleanup, so a provider
that retains an old callback retains only an inert bounded object. Synchronous
callbacks are decoded into frozen epochs while subscribe is running; raw
payloads and premature audience snapshots are not buffered. After the returned
cleanup closure is validated, the buffered epochs are staged together in FIFO
order. Each accepted baseline or advance snapshots active membership and
installs the epoch as one serialized step with no external call between those
operations. A member joined during one event's dispatch therefore
participates in a later pending event, but not the event already committed.
A thrown subscription or malformed returned cleanup value discards that buffer
and disables automatic invalidation for the live scope; explicit search
remains available.
The returned cleanup closure is itself untrusted: the service captures only a
function and calls it with this === undefined at most once. Its exact
synchronous contract returns undefined; TypeScript therefore rejects async
cleanup functions rather than silently accepting them through void
assignability. A throw or any other return value quarantines the coordinator
after the subscription is inert. Accidental ordinary Promise or thenable
returns receive best-effort detached rejection draining, but a same-realm
callback can always create a poisoned or independent unhandled rejection that
no JavaScript library can retroactively contain. Legitimate asynchronous
teardown must consume or report its own failure before the cleanup closure
returns undefined. The valid undefined path returns without Promise
allocation or a microtask. Detached assimilation uses module-captured Promise
intrinsics, so replacing the global Promise constructor cannot disable the
drain. A closure avoids a structural TypeScript
contract that would accept class instances or inherited methods which the
hostile runtime boundary could not safely validate. Dropping the last owner
removes the complete scope incarnation before cleanup. A later join creates a
new unobserved incarnation and may attempt a fresh subscription.
Malformed, duplicate, stale, or token-conflicting invalidations do not mutate state and do not tear down an otherwise valid subscription. Every raw callback still consumes the callback reset-window allowance. Exceeding that allowance revokes the incarnation and disables automatic invalidation before decoding further payloads. The conservative window resets from a zero-delay timer scheduled by the first callback. It intentionally makes no claim about formal JavaScript task or turn boundaries, while still preventing a provider from evading the allowance with a microtask chain. If hostile payload inspection synchronously reenters the same callback, the nested callback consumes that allowance but is ignored fail-closed; the coordinator does not retain the raw payload or invert callback order.
An epoch capture authenticates the exact provider configuration, scope incarnation, membership, observed epoch, and notification sequence. An equal-current response is publishable only if no accepted invalidation or higher response changed that sequence after capture. Otherwise it is superseded even when its epoch equals the newly current epoch. A capture is a single-use work token: submission claims it before queue admission, and replay is discarded even when the first submission was overloaded.
Epoch commands use one bounded, non-recursive FIFO drain. Its hard admission limit counts all work accepted during that drain, including commands already processed, so a one-at-a-time reentrant chain cannot evade the bound. For every accepted change, active membership is snapshotted and the epoch is immediately installed before revision preparation or any external listener runs. All affected session revisions then change before dispatch. Reentrant commands run only after the current change has reached every still-active member, preserving event order. The core does not coalesce revision events. The CodeMirror adapter may coalesce the resulting UI refresh, as described below. Admission fails before epoch mutation: an overflowing provider callback revokes automatic invalidation for that incarnation, while an overflowing response observation settles with closed overload evidence. Invalid, retired, replayed, disposed, and overflowing response submissions return closed settlement evidence without invoking a callback. Only an admitted FIFO command invokes its completion callback, so rejection cannot recursively reenter the coordinator's drain.
A revision target prepares its state change and returns a listener-dispatch
closure. Returning null, returning a non-function, or throwing retires that
membership. Retirement detaches state immediately, but hostile provider cleanup
is deferred until every remaining target has prepared. A higher response epoch
installed before preparation remains observed; if its submitting membership is
retired during preparation, its response settles as retired while other
successfully prepared members still dispatch. Coordinator disposal takes
precedence over that retired evidence. The outermost cleanup barrier admits and
invokes at most 1,024 captured cleanups. If reentrant work attempts to admit a
1,025th cleanup, the coordinator quarantines itself immediately: all cells and
memberships become inert and all not-yet-invoked cleanup references are
cleared without calling more provider code. Cleanup is never continued from a
timer.
The session exposes a disposable revision-change subscription for service-originated changes:
const subscription = session.onDidChange(({ revision, reason }) => {
// reason is a closed value such as "catalog"
});
subscription.dispose();State and the opaque revision change before listeners run. Listener failures are isolated; disposal is idempotent; and callbacks never run after session or service disposal. The event contains no provider payload. Text and context updates remain observed through the caller's own transaction and do not need a duplicate event.
The framework-independent session returns immutable completion items containing plain strings, one exact absolute original-document text edit, relation kind, and one positive provenance: either a visible CTE with declaration position or a catalog entity with provider and entity IDs.
The list separately records whether it is incomplete and closed reasons such as catalog loading, partial or paginated coverage, provider failure or timeout, query-site recovery, recursive CTE uncertainty, and result limiting.
A recognized site with no candidates is a ready empty list. It remains marked
incomplete when catalog or recognition evidence is incomplete. An inactive or
unavailable site lets the CodeMirror adapter return null so other completion
sources can run.
Malformed synchronous request input throws a stable session contract error. Normal provider, cancellation, supersession, disposal, and unsupported-analysis outcomes settle through a discriminated request result.
Completion is latest-wins per session:
- a new completion request supersedes the previous request;
- a new request that cannot capture an active epoch still supersedes and detaches the previous request before reporting its unavailable outcome;
- same-key supersession atomically attaches the new request consumer, or retags the existing observer as that consumer, before removing old request ownership; different-key supersession revokes the old observer first;
- text, context, embedded-region, relevant catalog, or provider-configuration changes supersede captured work;
- caller cancellation and session/service disposal settle promptly;
- late provider resolution or rejection is drained without publication or an unhandled rejection; and
- the adapter checks revision currency immediately before returning or applying a result.
Shared in-flight catalog work has one atomic owner set containing request consumers and refresh observers. Request cancellation detaches that consumer independently. The service aborts the provider only after one combined mutation observes no owners of either kind. A same-key latest-wins request attaches or transfers its owner before the old owner detaches, then evaluates abort once, so a soft-expiry observer cannot lose and restart identical work. Ownership is removed before any abort or external callback. All cancellation, supersession, disposal, deadline, and completion races pass one settle-once transition and leave no timer or listener behind.
Ranking is independent of asynchronous completion order:
- visible CTEs;
- provider-proven
exactmatches, thenequivalentmatches; - shorter proven completion paths;
- catalog kind in the closed order temporary table, table, view, materialized view, then external relation;
- rendered label and path under code-unit comparison using
<and>, never locale-sensitive comparison; and - CTE declaration offset for local ties or provider entity ID for catalog ties.
A visible CTE shadows an unqualified catalog insertion only when the dialect-owned query-local CTE equality policy proves equivalence. Distinct catalog entities are never merged or deduplicated by a generic fold. The first slice has one provider and does not accept arbitrary floating scores.
Provider invocation is measured against an 8 ms synchronous observation
budget. JavaScript cannot preempt synchronous code; an over-budget return is
therefore discarded as catalog-timeout, and CPU-heavy or untrusted providers
must use a worker or process.
Each shared work item owns one immutable absolute queue deadline from its first enqueue and, once invoked, one immutable absolute execution deadline. Defaults are 100 ms and 250 ms. Configuration is checked at construction and restricted to 10–2,000 ms for queue wait and 10–5,000 ms for execution. Joiners inherit the remaining work budget and never extend either deadline. Hard queue expiry atomically removes the work without invoking the provider and settles every attached request consumer. Hard execution expiry atomically removes the work, settles every attached consumer, removes its refresh observers, aborts once, and drains a late resolve or reject. Earlier caller cancellation detaches only that consumer.
At a recognized site, queue overload or hard expiry still settles each attached
completion request with its composed local/CTE result, isIncomplete: true,
and a closed catalog-overloaded, catalog-queue-timeout, or
catalog-timeout reason. Catalog lifecycle failure never converts proven
query-site evidence into unavailable analysis.
Hard safety deadlines do not define interactive latency. Each completion
request has a checked catalog response budget, default 40 ms and configurable
from 0 through 50 ms, measured from the start of the complete request. Local
and CTE evidence is composed first and never waits past the remaining response
budget. On soft expiry the request settles ready, possibly empty, with
isIncomplete: true, catalog-loading, and bounded remaining
completion-intent lease metadata; its session may atomically retag the request
consumer as a service-owned refresh observer. The intent lease is no longer
than the remaining work lease.
That bounded refresh lease can retain the shared operation only until its
existing hard deadline and active/queued service limits. With no consumers or
live observers, the service removes and aborts the work. An observer is bound
to the captured session revision and exact work key; any session change,
supersession, or disposal removes it.
If leased work resolves ready compatibly with the captured state—either
UNOBSERVED to its first baseline epoch or at the already-observed equal
epoch—the service decodes and caches it, re-keys first-baseline work, removes
the observers, and advances each still-current observing session once with the
closed reason catalog-availability. A higher epoch follows the epoch-change
path and clears the same observers without a second availability notification.
The adapter coalesces the resulting notification into a new completion request.
This is service-owned evidence readiness, not a duplicate provider
invalidation. Hard queue or execution expiry removes observers without a
refresh notification and leaves the already-returned incomplete result valid.
No optional catalog promise can keep complete() pending indefinitely or
block the local baseline past its product response budget.
The first implementation increment of this section is intentionally package-private. It combines an authenticated provider with the epoch coordinator and owns the fixed 8-active/64-queued scheduler, exact-key in-flight sharing, one latest-wins consumer per owner, independent cancellation, absolute queue and execution deadlines, response decoding, and epoch publication. An owner captures its scope and dialect when prepared, so individual requests cannot substitute provider, scope, dialect, or epoch authority. The authenticated dialect runtime owns its canonical provider ID; callers cannot pair an unrelated ID and runtime. Establishing the first baseline re-keys other joinable unobserved work in that scope, allowing newly-observed consumers to join it without duplicating a provider call.
Cache entries, loading/retry policy, refresh observers and their leases, the 40 ms completion-response budget, pagination composition, ranking, session composition, and CodeMirror integration do not belong to that increment. They remain explicit follow-up increments; the coordinator must not expose a premature public surface that makes those deferred semantics difficult to add or test.
The exact structural cache and shared-work key contains:
- service-owned provider configuration identity and unique provider ID;
- exact catalog scope;
- ordered search paths, component values, and quote states;
- dialect runtime configuration identity;
- decoded qualifier and prefix values and quote states;
- clamped result limit;
- continuation token; and
- the captured epoch generation and token, or an explicit
UNOBSERVEDsentinel.
Hashes may accelerate comparison but never replace structural equality. Provider IDs are unique within a service and callers cannot choose configuration identity. The first decoded response can re-key unobserved work to its epoch. Only decoded ready responses are cached under their response epoch. Loading, failure, malformed, timeout, cancellation, supersession, queue overload, and disposal outcomes are not cached. Complete-empty results are reused only for the exact key and never prove an unknown-object diagnostic. Partial and paginated entries retain incomplete coverage; pages combine only for the identical base key and epoch, and each continuation remains a distinct request key. Higher-epoch results may be cached only after older scope entries are cleared and never publish to the revision that observed the older epoch.
Decoded entity IDs are unique for (provider configuration, scope, epoch)
across every page composed into one result. A repeated ID, even with otherwise
identical data, makes the page chain malformed before ranking or caching; array
and page order therefore cannot resolve conflicting identity records.
The initial checked limits are:
| Resource | Limit |
|---|---|
| Active statement scanned | 65,536 UTF-16 units |
| Lexical tokens | 16,384 |
| Parenthesis/query depth | 128 |
| CTE frames | 256 |
| CTE declarations | 256 |
| Identifier path segments | 32 global ceiling; dialect runtime sets the checked limit |
| Identifier segment | 256 UTF-16 units |
| Catalog scope | 512 UTF-16 units |
| Search paths | 32 |
| Components per search path | 4 |
| Total catalog context | 16,384 UTF-16 units |
| Configured relation catalog providers | 1 |
| Live catalog scopes per service | 128 |
| Catalog memberships per service | 1,024 |
| Catalog memberships per scope | 256 |
| Raw subscription callbacks per reset window | 256 |
| Catalog epoch admissions per drain | 1,024 |
| Provider cleanups per outer barrier | 1,024 |
| Catalog results per search | 100 |
| Completion results after composition | 100 |
| Provider ID | 256 UTF-16 units |
| Entity ID | 256 UTF-16 units |
| Epoch token | 256 UTF-16 units |
| Plain-text detail | 1,024 UTF-16 units |
| Continuation token | 2,048 UTF-16 units |
| Decoded response aggregate | 65,536 UTF-16 units |
| Decoded response own keys | 16,384 aggregate traversal occurrences |
| Decoded response nesting depth | 8 |
| Service catalog cache | 256 entries and 2 MiB estimated retained bytes |
| Service-wide active catalog searches | 8 |
| Service-wide queued catalog searches | 64 |
| Active completion consumers | 1 per session |
| Catalog response budget | 40 ms default, 50 ms maximum |
| Catalog refresh observers | 1 per session |
| Completion-intent lease | 1,000 ms default, 5,000 ms maximum |
Limit exhaustion produces an explicit unavailable or incomplete result. An
oversized provider response is rejected; it is never silently truncated while
retaining a false complete claim.
The 65,536-unit scan limit is a safety ceiling, not a latency target. The recognizer must still meet the active-statement product envelope on the representative 10 KiB workload and must return explicit resource unavailability instead of creating a routine main-thread task over 50 ms.
CodeMirror integration ships from a separate entry point. It owns:
- one document session per
EditorView; - conversion of transactions into atomic text, context, and region updates;
- focus, visibility, debounce, and request cancellation;
- conversion to CodeMirror completion results;
- one coalesced subscription to service-originated session revision changes;
- safe rendering and external completion-source composition; and
- session disposal from the view plugin.
The core does not expose CodeMirror Completion, EditorState, EditorView,
facets, DOM nodes, or renderer callbacks. The first adapter omits reusable
validFor caching so CodeMirror cannot reuse a result across a session revision
without revalidation. It checks session.isCurrent() both before returning and
immediately before applying a result.
For every recognized incomplete catalog-loading result, including an empty
list that opens no menu, the adapter retains at most one bounded completion
intent. The one-shot intent records the exact view, document, selection,
context, query, captured epoch, and work identity when work exists. It expires
no later than the service-supplied completion-intent lease. On
catalog-availability or a service higher-epoch catalog revision notification
caused by either an accepted response or invalidation, the adapter aborts stale
captured work and coalesces exactly one scheduled refresh when the matching
view state has any of:
- an active completion invocation that has not yet installed a result;
- an active menu representing the captured loading request; or
- a valid no-menu loading intent.
If notification arrives while the invocation is active, the adapter records a one-shot pending-refresh latch before aborting or allowing the stale invocation to settle. It clears the latch before the scheduled replacement starts. A later loading settlement or intent installation observes the latch and cannot schedule a duplicate refresh.
A newer completion, selection/document/context change, explicit completion cancel or Escape, configured blur policy, lease expiry, or view/session disposal clears the active invocation, latch, and intent. Consuming either one-shot refresh marker clears it before dispatch, preventing reopen loops. The adapter never synchronously dispatches from a CodeMirror update or provider callback.
A supplied language service is caller-owned. Each view plugin owns exactly one
session, revision subscription, debounce set, active request, and any UI
resource. destroy() unsubscribes, aborts, disposes its session and UI, and is
idempotent; it never disposes the shared service or provider. Service disposal
settles all sessions even if views still exist, and later view destruction
remains harmless.
The adapter accepts a custom completion-info resolver/decorator. It receives
only the immutable core item and its provider/entity provenance, and may use a
host closure to resolve live metadata and produce rich CodeMirror information.
Async detail is cancellation-aware, late results are drained, and any returned
UI resource has explicit disposal so marimo can unmount React roots. The
default path creates safe plain text/DOM and never assigns provider strings to
innerHTML. React, Jotai, DataTable, DOM nodes, and live metadata never enter
the core contract.
The marimo acceptance fixture runs 1, 10, and 50 views against one shared service. It proves one session/listener/request owner per view but one provider subscription, cache, and catalog index per provider configuration and scope. Destroying 49 views retains the subscription; destroying the last releases it exactly once. A second scope adds one subscription, not one per view.
The fixture also proves that relation completion constructs no parser worker; epoch invalidation creates one service fan-out and bounded coalesced view refreshes; engine, dialect, and scope switches are atomic; and rapid edits, loading/partial/failing providers, ignored abort signals, late settlement, open-menu invalidation, repeated destruction, and service-before-view disposal leave no stale apply, late dispatch, unresolved promise, unhandled rejection, listener, subscription, React root, or unbounded cache retention. GC-capable runs record retained resources, while 10 KiB documents enforce the capability charter's main-thread and completion-latency envelopes.
Browser cases include empty soft result to first-baseline and same-epoch ready with exactly one no-typing refresh; cursor movement, Escape, blur policy, hard expiry, or destruction before readiness with no reopen; a nonempty CTE plus loading catalog whose open menu refreshes once; and bounded 50-view fan-out. The coordinator suite also covers soft-expiry observer to same-key consumer handoff without abort/restart.
They also cover immediate empty terminal loading followed by a higher ready epoch before intent expiry with exactly one no-typing refresh, and the same event after intent expiry, Escape, movement, blur, or destruction with no reopen. Coordinator/adapter ordering cases include a higher-epoch response before soft expiry and a revision notification delivered before loading-intent installation; each preserves the original invocation with exactly one replacement request.
Worker protocol v1 remains unchanged.
When additional or general parser-derived scope semantics are required for
completion, hover, navigation, or diagnostics, the worker protocol will move
atomically to v2. A parsed
response will keep normalized syntax and semantic availability independent.
The semantic payload will be a bounded scoped IR covering query blocks,
bindings, visibility, and limitations. It will not contain raw ASTs, raw source,
absolute document ranges, flat tableList data, or generic fact bags.
The host and worker will not negotiate or simultaneously support v1 and v2. Mixed cached assets fail the exact version check and retire the generation.
- Accept this ADR as the provisional contract and add a checked-in, runtime-free marimo-shaped type fixture. Its core portion compiles region, search-path, catalog, notification, and completion sketches without DOM imports; its adapter portion compiles the rich-info resolver signature. No production slice starts until both pass.
- Attach embedded regions to session open/update transactions atomically.
- Add the bounded partial-
SELECTquery-site recognizer. - Add the bounded CTE frame and visibility recognizer.
- Add the service-owned scoped epoch/subscription coordinator and its hostile lifecycle contract suite. It does not invoke provider search or retain result pages.
- Add catalog search scheduling, cache, in-flight sharing, cancellation, retry gates, and availability observers over the proven epoch coordinator.
- Add the session completion method and deterministic composition.
- Add the separate CodeMirror adapter and packed/browser fixtures.
- Add the pinned packed/browser/runtime marimo fixture and 1/10/50-editor performance and leak evidence.
- Validate both the in-memory/notebook and hierarchical remote provider shapes, then stabilize the declarations and public export surface.
- Design scoped parser semantics and protocol v2 against PostgreSQL and BigQuery corpora before any additional parser-derived scope feature consumes it.
Breaking refinement remains allowed through step 9. The provider and completion types become stable only after the working vertical slice, reusable provider contract suite, two materially different provider shapes, marimo packed/browser integration, hostile decoding, cancellation, epoch and pagination tests, declaration/API snapshots, and the rich-detail/external completion-source migration decision all pass.
Every production step is a medium change and requires two independent commit-bound adversarial reviews.
- Incomplete relation completion does not wait for parser startup or success.
- Relation-only completion does not construct the parser worker.
- Cursor movement and catalog invalidation do not cause reparsing.
- DuckDB completion is not defined by PostgreSQL parser compatibility.
- Catalog changes do not invalidate statement parsing.
- Parser crashes and safety timeouts do not suppress local/catalog relation completion.
- The query-site recognizer is intentionally narrow and reports uncertainty instead of growing into an implicit general SQL parser.
- CTE visibility has an explicit bounded model rather than a flat query-wide list.
- The public core remains independent of CodeMirror and DOM types.
- Scoped semantic work is deferred, not represented by an incorrect flat contract.
The parser rejects important incomplete editor states, adds cold-start and message latency, and is unnecessary for the narrow first cursor sites.
Flat names cannot distinguish CTE references, physical relations, nested scope, or DML roles and have unreliable path and quote provenance.
Backend-specific data would leak through the stable boundary and is not consistent across the target dialects.
A fully materialized nested object has no asynchronous coverage, pagination, epoch, stable entity identity, or positive addressability contract.
That would couple providers and sessions to editor state, DOM rendering, and CodeMirror lifecycle instead of keeping the language service reusable.