Skip to content

Commit 99dbd84

Browse files
authored
feat(vnext): add incremental statement indexing (#175)
## Summary - update the dialect-aware statement index incrementally from trusted analysis-coordinate edits while preserving the full scan as the oracle - add a private lazy per-session index cache with atomic invalidation/reuse rules - replace structural dialect definitions with opaque built-in factory handles, keeping lexical profiles and indexes out of the public API ## Correctness and safety - conservative restart at the old slot at or left of the earliest edit - convergence only at an exact terminated boundary after the final edit - exact full-oracle fallback for inconsistent metadata or absent convergence - global 10,000-slot and opaque-suffix behavior preserved - monotonic convergence cursor keeps resource-cap recovery linear - no source text or history retained by the cache ## Validation - 833 unit tests passed; 1 governed expected failure - 33 incremental tests with deterministic differential sequences across all four profiles - 80,000 additional adversarial differential edits matched the full oracle - changed coverage: 98.78% statements, 97.00% branches, 100% functions, 98.77% lines - typecheck, oxlint, integrity, browser, demo, and packed-package smoke passed - 1 MiB benchmark: full scan about 2.2-2.5 ms, middle edit about 0.028 ms, prefix shift about 0.11 ms - resource-cap recovery about 1.35-1.46 ms after fixing the reviewed quadratic path ## Review Two independent exact-SHA reviewers approve 2e83d17 with no unresolved blocker, high, or medium findings. <!-- This is an auto-generated description by cubic. --> --- ## Summary by cubic Adds incremental statement indexing with a lazy, per-session cache so edits update indexes in microseconds, while a full scan remains the correctness fallback. Replaces caller-defined dialect objects with built-in opaque dialect handles that bind lexical behavior internally. - New Features - Incremental updates from trusted analysis-coordinate edits; reuse unchanged prefix and reuse/shift suffix at exact terminated boundaries, with full-oracle fallback. - Private per-session statement-index cache: reused on context-only updates (same profile) and same-text replacements; incrementally updated on trusted identity-source changes; cleared on profile change or changed replacements; no source text or history retained. - Built-in dialect factories return frozen opaque singletons: `duckdbDialect()`, `postgresDialect()`, `bigQueryDialect()`, `dremioDialect()`; handles are validated, local to the package instance, and don’t cross workers/processes. - Bench and tests: `pnpm run bench:statement-index`; extensive unit, randomized differential, and session cache tests. - Migration - Replace `defineSqlDialect(...)` and `SqlDialectDefinition` with built-in handles and `SqlDialect`. - Before: `createSqlLanguageService({ dialects: [defineSqlDialect({ id: "duckdb", displayName: "DuckDB" })] })` - After: `createSqlLanguageService({ dialects: [duckdbDialect()] })` - Keep using the handle’s `.id` string in document context: `{ dialect: dialect.id }`. - Do not copy/serialize/fabricate dialects; pass authentic handles from the same `@marimo-team/codemirror-sql/vnext` instance (handles don’t cross workers/processes). <sup>Written for commit ce94176. Summary will update on new commits.</sup> <a href="https://cubic.dev/pr/marimo-team/codemirror-sql/pull/175?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
1 parent aa846cf commit 99dbd84

15 files changed

Lines changed: 1405 additions & 189 deletions

SQL_EDITOR_RESEARCH.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -708,8 +708,12 @@ Features could degrade honestly based on capabilities instead of treating
708708
A dialect should be more than CodeMirror highlighting and a
709709
`node-sql-parser` database name:
710710

711+
The stable API should expose a package-owned opaque dialect handle; the
712+
following is the kind of internal bundle that handle resolves to, not a
713+
caller-constructed public object:
714+
711715
```ts
712-
interface SqlDialectDefinition {
716+
interface InternalSqlDialectBundle {
713717
id: string;
714718
codeMirrorDialect: SQLDialect;
715719
parserDialect: string;

docs/adr/0001-language-service-and-session.md

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -110,9 +110,17 @@ interface SqlDocumentContext {
110110

111111
The service resolves dialect IDs through its immutable registry and rejects
112112
unknown or duplicate IDs. Duplicate registration is rejected even when the
113-
definitions appear equivalent. Dialect identity includes an internal
114-
configuration revision; calling a dialect factory again does not change the
115-
identity of an already registered dialect.
113+
definitions appear equivalent. The built-in dialect factories return frozen
114+
opaque singletons backed by package-owned `WeakMap` metadata; repeated calls to
115+
one factory return the same handle and internal configuration identity. A
116+
copied, serialized, fabricated, or different-package-instance handle is
117+
rejected.
118+
119+
Dialect handles are in-process service configuration and do not cross cloning,
120+
worker, or provider boundaries. Serializable context contains only the
121+
registered handle's string ID. Another realm or package instance reconstructs
122+
configuration with its own built-in factory. IDs perform registry lookup but
123+
never infer lexical behavior.
116124

117125
Hosts can add fields such as engine or SQL mode. Contexts must be
118126
structured-cloneable plain data. On open/update, the session creates and owns a
@@ -213,8 +221,15 @@ validity, or AST data. It materializes at most 10,000 slots and collapses an
213221
unscanned remainder into an opaque slot on resource limits or unsupported
214222
delimiter/procedural constructs. Opaque slots cannot be passed to later parsing
215223
as exact source. Dialect lexical behavior uses internal configuration identity,
216-
never a caller-controlled dialect ID. Incremental reuse and session attachment
217-
remain a separate change tested against the full-scan oracle.
224+
never a caller-controlled dialect ID.
225+
226+
The full scanner remains the correctness oracle. Incremental indexing restarts
227+
conservatively at the old slot at or to the left of the earliest trusted
228+
analysis-coordinate change and converges only at an exact terminated boundary
229+
mapped to an unchanged old suffix. It reuses the unchanged prefix and either
230+
reuses or shifts the suffix. Inconsistent metadata falls back to the full
231+
oracle; absent convergence scans through EOF, and resource or
232+
unsupported-syntax opacity remains fail-closed.
218233

219234
## Request outcomes
220235

@@ -409,6 +424,12 @@ SPI. Sessions create a new source snapshot for document updates and reuse the
409424
existing snapshot for context-only updates. Non-identity generated/reordered
410425
source remains deferred until a concrete consumer validates the segment model.
411426

427+
Current session edits use identity sources, so validated original-document
428+
changes are also trusted analysis-coordinate changes. A future transformed
429+
source must provide its own validated analysis-coordinate changes for
430+
incremental statement indexing. Without them, changed analysis text
431+
invalidates the index and is rebuilt lazily.
432+
412433
Edits crossing an ambiguous or unmapped boundary are rejected. Mapping failure
413434
is unavailable analysis, not invalid SQL.
414435

@@ -498,6 +519,15 @@ dialect, parser/configuration, and template identities. Catalog changes do not
498519
invalidate parsing. Renderer, theme, focus, and cursor changes do not invalidate
499520
semantic artifacts.
500521

522+
The current statement-index cache is private, lazy, and per session. It stores
523+
only the current index, document sequence, and lexical-profile identity.
524+
Context-only updates reuse it for the same profile; equal analysis text reuses
525+
it across a new document revision; trusted identity-source changes update it
526+
incrementally. A changed replacement, profile change, or transformed source
527+
without trusted analysis changes clears it for a later full build. Invalid
528+
updates do not mutate it, disposal clears it, and it retains no source text or
529+
revision history.
530+
501531
In-flight deduplication uses the same complete key. Cancelling one consumer
502532
detaches it; shared underlying work is aborted only when no consumers remain.
503533
Caches are bounded by both item count and estimated retained bytes.

docs/vnext/session-primitives.md

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,23 +16,22 @@ contract and the internal immutable source-snapshot model.
1616
```ts
1717
import {
1818
createSqlLanguageService,
19-
defineSqlDialect,
19+
duckdbDialect,
2020
type SqlDocumentContext,
2121
} from "@marimo-team/codemirror-sql/vnext";
2222

2323
interface AppSqlContext extends SqlDocumentContext {
2424
readonly engine: string;
2525
}
2626

27+
const dialect = duckdbDialect();
2728
const service = createSqlLanguageService<AppSqlContext>({
28-
dialects: [
29-
defineSqlDialect({ id: "duckdb", displayName: "DuckDB" }),
30-
],
29+
dialects: [dialect],
3130
});
3231

3332
const session = service.openDocument({
3433
text: "SELECT * FROM users",
35-
context: { dialect: "duckdb", engine: "local" },
34+
context: { dialect: dialect.id, engine: "local" },
3635
});
3736

3837
const revision = session.update({
@@ -55,6 +54,20 @@ service.dispose();
5554
Use `{ kind: "replace", text }` for full replacement and
5655
`{ kind: "context", baseRevision, context }` for a context-only update.
5756

57+
## Dialect registration
58+
59+
`duckdbDialect()`, `postgresDialect()`, `bigQueryDialect()`, and
60+
`dremioDialect()` return frozen, opaque built-in handles. Each factory returns
61+
the same singleton on repeated calls. The service resolves a handle through
62+
package-owned runtime metadata, so a copied, serialized, fabricated, or
63+
different-package-instance handle is rejected.
64+
65+
Handles are local service configuration, not transport data. Document context
66+
contains only the handle's serializable string `id`; a worker or separate
67+
package instance must call its own built-in factory rather than receive a
68+
handle through cloning or messaging. The ID selects a registered handle but
69+
does not itself infer lexical behavior.
70+
5871
## Contract
5972

6073
- Every accepted update creates a new frozen, service-issued revision.
@@ -72,6 +85,15 @@ Use `{ kind: "replace", text }` for full replacement and
7285
- Synchronous public failures use `SqlSessionError` and a stable `code`; caught
7386
platform errors are not exposed as causes.
7487

88+
The internal statement index is built lazily per session. Context-only updates
89+
reuse it when the lexical-profile identity is unchanged. A no-op document
90+
update or same-text replacement advances the public revision and document
91+
sequence but can reuse the index. Trusted incremental identity-source changes
92+
update a populated cache; a changed replacement, profile change, or changed
93+
transformed source without analysis-coordinate changes clears it for a later
94+
full build. Invalid updates leave both the session snapshot and cache
95+
unchanged, and disposal clears the cache.
96+
7597
The safety envelope currently permits at most 10,000 changes in one update, a
7698
16 Mi-code-unit document, 1,000 registered dialects, and bounded context graph
7799
depth, size, properties, and string data. These are defensive walking-skeleton

docs/vnext/source-coordinates.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,16 @@ public.
4848
The internal [statement index](./statement-index.md) scans `analysisText` and
4949
uses its length-preserving offsets without publishing analysis-coordinate
5050
ranges.
51+
52+
Statement-index reuse depends on `analysisText` value and internal lexical
53+
profile identity, not source-object identity. A document update therefore
54+
still creates a fresh source snapshot and public revision even when equal
55+
analysis text permits reuse. The index does not retain either the old or
56+
current source text.
57+
58+
Current sessions create identity sources, so their validated document changes
59+
are also trusted analysis-coordinate changes. The masking primitive is not yet
60+
attached to session updates. A future transformed-source pipeline must produce
61+
validated analysis-coordinate changes before it can use incremental indexing;
62+
otherwise a changed analysis document invalidates the cache and receives a
63+
fresh full build on demand.

docs/vnext/statement-index.md

Lines changed: 42 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# vNext Statement Index
22

3-
Status: internal full-scan correctness oracle
3+
Status: internal full-scan oracle with incremental session reuse
44

55
The statement index is a synchronous, parser-free partition of
66
`analysisText`. It does not classify, parse, validate, or copy statements, and
@@ -41,8 +41,9 @@ run-current-statement commands need different policies.
4141
## Dialect profiles
4242

4343
Lexical behavior is carried by immutable internal profile identity. It is never
44-
inferred from a caller-controlled dialect ID. This first oracle owns profiles
45-
for:
44+
inferred from a caller-controlled dialect ID. Frozen built-in dialect
45+
singletons select package-owned profiles through private runtime metadata. This
46+
first oracle owns profiles for:
4647

4748
- PostgreSQL: doubled quotes, `E'...'`, dollar-quoted strings, and nested block
4849
comments, following the
@@ -66,14 +67,48 @@ Unterminated lexical constructs consume the remainder and report their opening
6667
offset. Regular BigQuery strings also fail closed at a line break, because only
6768
triple-quoted strings may span lines.
6869

69-
## Complexity and sequencing
70+
## Incremental updates
71+
72+
The full build remains the correctness oracle. When a session already has an
73+
index and receives trusted ordered changes in analysis coordinates, the
74+
incremental path restarts conservatively at the beginning of the old slot at or
75+
to the left of the earliest change. Slot starts are safe checkpoints because
76+
lexical and prefix state is normal there.
77+
78+
Scanning continues until an exact terminated boundary maps to the start of an
79+
unchanged old suffix after the final change. The unchanged prefix is retained;
80+
the suffix is reused directly when its offset is unchanged or copied with
81+
shifted frozen ranges when the net edit length changes. Convergence is rejected
82+
if the combined result would violate the slot limit or make a prior
83+
resource-limit suffix unsafe to reuse.
84+
85+
Inconsistent change metadata falls back to a fresh full build. If no safe
86+
boundary converges, scanning continues through EOF. Unsupported procedural or
87+
custom-delimiter syntax and resource limits remain scanner-produced opaque
88+
suffixes; the incremental layer never guesses a boundary or creates a new
89+
opacity reason.
90+
91+
## Session cache and complexity
92+
93+
The index cache is private, lazy, and per session. It retains only the current
94+
index, document sequence, and lexical-profile identity, not source text or
95+
history. Context-only updates reuse it for the same profile. Equal analysis
96+
text reuses it across a new document revision. Trusted identity-source changes
97+
update it incrementally; changed replacements, profile changes, and transformed
98+
sources without trusted analysis-coordinate changes invalidate it. Disposal
99+
clears it.
70100

71101
A full build is linear in UTF-16 code units, retains only slot records and a
72102
bounded dollar delimiter, and creates no statement substrings. Point lookup is
73103
logarithmic in slot count. The scanner operates on `analysisText`; the current
74104
length-preserving source transform makes its analysis ranges valid at the same
75105
offsets in `originalText`.
76106

77-
This implementation remains the correctness oracle. Incremental rescanning,
78-
change mapping, cache reuse, and session attachment belong to a later slice and
79-
must be tested against a fresh full build after arbitrary edits.
107+
Incremental work is proportional to the rescanned region plus any shifted
108+
suffix records, with a worst case equal to a full linear scan. Randomized edit
109+
tests compare every incremental result with a fresh oracle build.
110+
111+
`pnpm run bench:statement-index` measures full and incremental paths on a
112+
roughly 1 MiB, 1,000-statement document, including a local middle edit and a
113+
prefix insertion that shifts the reusable suffix. It also measures recovery
114+
from a resource-limited 10,000-slot index to guard the linear convergence path.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
"test:coverage:changed": "node ./scripts/changed-coverage.mjs",
2525
"test:browser": "vitest run --config vitest.browser.config.ts",
2626
"test:integrity": "node ./scripts/check-test-integrity.mjs",
27+
"bench:statement-index": "vitest bench --run src/vnext/__tests__/statement-index.bench.ts",
2728
"test:package": "node ./scripts/clean.mjs && tsc && node ./scripts/package-smoke.mjs",
2829
"demo": "vite build",
2930
"build": "node ./scripts/clean.mjs && tsc",

scripts/package-smoke.mjs

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -174,11 +174,11 @@ try {
174174
join(temporaryDirectory, "vnext-consumer.mjs"),
175175
`import {
176176
createSqlLanguageService,
177-
defineSqlDialect,
177+
duckdbDialect,
178178
} from "@marimo-team/codemirror-sql/vnext";
179179
180180
const service = createSqlLanguageService({
181-
dialects: [defineSqlDialect({ displayName: "DuckDB", id: "duckdb" })],
181+
dialects: [duckdbDialect()],
182182
});
183183
const session = service.openDocument({
184184
context: { dialect: "duckdb" },
@@ -208,7 +208,7 @@ import {
208208
} from "@marimo-team/codemirror-sql/dialects";
209209
import {
210210
createSqlLanguageService,
211-
defineSqlDialect,
211+
duckdbDialect,
212212
type SqlDocumentContext,
213213
type SqlTextRange,
214214
} from "@marimo-team/codemirror-sql/vnext";
@@ -225,7 +225,7 @@ const extensions: Extension[] = [
225225
];
226226
const parser = new NodeSqlParser();
227227
const service = createSqlLanguageService<HostContext>({
228-
dialects: [defineSqlDialect({ displayName: "DuckDB", id: "duckdb" })],
228+
dialects: [duckdbDialect()],
229229
});
230230
const session = service.openDocument({
231231
context: { dialect: "duckdb", engine: "local" },
@@ -266,7 +266,10 @@ if (!dialects.BigQueryDialect || !dialects.DremioDialect || !dialects.DuckDBDial
266266
}
267267
if (
268268
typeof vnext.createSqlLanguageService !== "function" ||
269-
typeof vnext.defineSqlDialect !== "function"
269+
typeof vnext.bigQueryDialect !== "function" ||
270+
typeof vnext.dremioDialect !== "function" ||
271+
typeof vnext.duckdbDialect !== "function" ||
272+
typeof vnext.postgresDialect !== "function"
270273
) {
271274
throw new Error("vNext package exports are incomplete");
272275
}
@@ -281,7 +284,7 @@ if (!parseResult.success || !parseResult.ast) {
281284
}
282285
283286
const service = vnext.createSqlLanguageService({
284-
dialects: [vnext.defineSqlDialect({ displayName: "DuckDB", id: "duckdb" })],
287+
dialects: [vnext.duckdbDialect()],
285288
});
286289
const session = service.openDocument({
287290
context: { dialect: "duckdb" },

0 commit comments

Comments
 (0)