Skip to content

Commit b77db7c

Browse files
authored
feat(vnext): validate relation catalog data (#192)
## Summary - add an authenticated, package-private catalog-provider boundary - decode hostile request, response, invalidation, and epoch data into bounded frozen values - validate canonical and completion paths through authenticated dialect runtimes - define provider closure ownership, alias copying, page atomicity, and epoch semantics in ADR 0005 - add boundary contracts and worst-case performance benchmarks ## Why Catalog metadata crosses an asynchronous host boundary and cannot be trusted as typed data at runtime. This slice gives the upcoming coordinator a small, deterministic input surface without coupling catalog providers to sessions, CodeMirror, DOM state, or provider-supplied SQL. ## Validation - 1,727 unit tests passed plus one governed expected failure - changed coverage: 95.61% statements, 95.10% branches, 100% functions, 95.55% lines - strict and loose optional type configurations, lint, integrity, package smoke, browser, and worker-placement checks passed - three independent adversarial reviews approved exact SHA b9f6d3e - million-unit hostile text rejects in about 0.003 ms; near-limit Dremio response decodes in about 0.27 ms Part of #169.
1 parent 9b7851c commit b77db7c

7 files changed

Lines changed: 2876 additions & 4 deletions

docs/adr/0005-parser-independent-relation-completion.md

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -347,7 +347,14 @@ search request contains only copied, recursively frozen plain data:
347347
The `AbortSignal` is passed separately. Providers never receive a session,
348348
revision, `EditorState`, DOM object, credential object, or arbitrary live host
349349
context. Connection identity belongs in the catalog scope; live resources stay
350-
inside the provider closure.
350+
inside the provider closure. The provider object is caller-owned. The service
351+
captures its own-data callbacks once and owns searches, subscriptions, and
352+
their cleanup, but does not imply provider-wide disposal. Provider callbacks
353+
are closure functions with a declared `this: void` contract and are invoked
354+
with `this === undefined`; mutable receiver state is not part of the provider
355+
API. Unrelated own configuration fields are ignored, while inherited callbacks
356+
and accessors are rejected. An own optional subscription value of `undefined`
357+
is normalized as omitted.
351358

352359
A returned relation contains:
353360

@@ -363,7 +370,10 @@ A returned relation contains:
363370

364371
The initial closed component roles are `catalog`, `schema`, `project`,
365372
`dataset`, and `relation`. Each dialect accepts only its documented role
366-
sequences, and the final component is always `relation`.
373+
sequences, and the final component is always `relation`. The provider's
374+
`quoted` bit is positive catalog evidence that the canonical decoded spelling
375+
is quoted; it is never insertion SQL. The dialect still owns delimiters,
376+
escaping, reserved-word quoting, and rendering.
367377

368378
The service never invents an unqualified candidate from an absolute path. The
369379
provider proves matching and addressability through `matchQuality` and
@@ -397,7 +407,25 @@ as an unknown-object diagnostic.
397407
Provider responses are decoded from `unknown` using bounded own enumerable
398408
data properties and copied into fresh frozen current-realm objects. Accessors,
399409
throwing proxies, unexpected keys, oversized strings or arrays, and malformed
400-
closed values fail without exposing raw errors.
410+
closed values fail without exposing raw errors. One malformed relation rejects
411+
the whole page: silently retaining other entities while preserving the
412+
provider's coverage claim would create false authority. Entity IDs must be
413+
unique within a page. Their stability across searches and epochs is a provider
414+
promise used for provenance, not authority the service can infer by generic
415+
deduplication. Shared input identities are allowed, but each occurrence is
416+
decoded, budgeted, and copied independently, so aliasing cannot retain mutable
417+
provider state or bypass aggregate limits.
418+
419+
Both the complete canonical role sequence and the suffix selected by
420+
`completionPathStart` must be legal for the registered dialect. The boundary
421+
pre-renders the selected suffix from fresh decoded data so later scheduling and
422+
composition never touch raw provider objects or provider-supplied SQL.
423+
424+
The initial vertical slice validates one bounded page and reports paginated
425+
coverage as incomplete; it does not automatically follow or merge continuation
426+
tokens. Continuation tokens are opaque provider state. They are bounded,
427+
retained only with their page/work state, and never included in completion
428+
items, logs, errors, or revision events.
401429

402430
The core also provides a provisional `createInMemoryRelationCatalog` helper for
403431
bounded readonly relation data. It indexes stable IDs, kinds, role-bearing
@@ -439,8 +467,20 @@ A search that discovers a higher epoch supersedes itself instead of publishing
439467
against its older captured revision. Pages and cache entries from different
440468
epochs are never merged.
441469

470+
Any successfully decoded response status (`ready`, `loading`, or `failed`) may
471+
establish the first epoch baseline. Equal-epoch responses for different exact
472+
searches remain usable; only the epoch observation is a duplicate. A terminal
473+
`loading` response requires a higher epoch before that exact search can become
474+
ready. Providers without subscriptions may return `loading`, but they cannot
475+
cause automatic refresh; a later explicit request must observe the higher
476+
epoch. Failed responses are attempt evidence: `next-request` may recover at
477+
the same epoch, while `after-invalidation` and `never` create bounded retry
478+
gates rather than cached search results.
479+
442480
The service reference-counts one provider subscription per provider
443481
configuration and scope, then fans invalidation out to subscribed sessions.
482+
The installed callback closure supplies authenticated provider and scope
483+
identity; the untrusted invalidation payload therefore contains only an epoch.
444484
Subscription membership is installed atomically before a provider can call
445485
back synchronously. A newly joining session captures an already-observed epoch
446486
without a synthetic revision bump. Disposal removes membership before
@@ -639,7 +679,7 @@ The initial checked limits are:
639679
| Plain-text detail | 1,024 UTF-16 units |
640680
| Continuation token | 2,048 UTF-16 units |
641681
| Decoded response aggregate | 65,536 UTF-16 units |
642-
| Decoded response own keys | 1,024 |
682+
| Decoded response own keys | 16,384 aggregate traversal occurrences |
643683
| Decoded response nesting depth | 8 |
644684
| Service catalog cache | 256 entries and 2 MiB estimated retained bytes |
645685
| Service-wide active catalog searches | 8 |

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"test:browser": "vitest run --config vitest.browser.config.ts",
2727
"test:worker-placement": "node ./scripts/worker-placement.mjs",
2828
"test:integrity": "node ./scripts/check-test-integrity.mjs",
29+
"bench:catalog-boundary": "vitest bench --run src/vnext/__tests__/relation-catalog-boundary.bench.ts",
2930
"bench:local-relation-site": "vitest bench --run src/vnext/__tests__/local-relation-site.bench.ts",
3031
"bench:parser-adapter": "vitest bench --run src/vnext/__tests__/node-sql-parser-adapter.bench.ts",
3132
"bench:query-site": "vitest bench --run src/vnext/__tests__/query-site.bench.ts",
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import { bench, describe } from "vitest";
2+
import { decodeSqlCatalogSearchResponse } from "../relation-catalog-boundary.js";
3+
import {
4+
DREMIO_SQL_RELATION_DIALECT,
5+
POSTGRESQL_SQL_RELATION_DIALECT,
6+
} from "../relation-dialect.js";
7+
8+
function relation(index: number) {
9+
return {
10+
canonicalPath: [
11+
{ quoted: false, role: "schema", value: "public" },
12+
{
13+
quoted: false,
14+
role: "relation",
15+
value: `relation_${index}`,
16+
},
17+
],
18+
completionPathStart: 0,
19+
detail: `Relation ${index}`,
20+
entityId: `relation-${index}`,
21+
matchQuality: "exact",
22+
relationKind: "table",
23+
};
24+
}
25+
26+
function response(relations: readonly unknown[]) {
27+
return {
28+
coverage: { kind: "complete" },
29+
epoch: { generation: 1, token: "snapshot-1" },
30+
relations,
31+
status: "ready",
32+
};
33+
}
34+
35+
const emptyResponse = response([]);
36+
const oneResponse = response([relation(0)]);
37+
const hundredResponse = response(
38+
Array.from({ length: 100 }, (_, index) => relation(index)),
39+
);
40+
const malformedLastResponse = response([
41+
...Array.from({ length: 99 }, (_, index) => relation(index)),
42+
{ ...relation(99), completionPathStart: 9 },
43+
]);
44+
const oversizedTextResponse = response([
45+
{ ...relation(0), detail: "x".repeat(1_000_000) },
46+
]);
47+
const maximumDepthResponse = response(
48+
Array.from({ length: 7 }, (_, relationIndex) => ({
49+
...relation(relationIndex),
50+
canonicalPath: Array.from({ length: 32 }, (_, pathIndex) => ({
51+
quoted: true,
52+
role: pathIndex === 31 ? "relation" : "schema",
53+
value: String(relationIndex).repeat(256),
54+
})),
55+
detail: "d".repeat(1_024),
56+
entityId: `maximum-depth-${relationIndex}`,
57+
})),
58+
);
59+
const wideInvalidResponse = Object.fromEntries(
60+
Array.from({ length: 10_000 }, (_, index) => [
61+
`unexpected-${index}`,
62+
index,
63+
]),
64+
);
65+
66+
for (const [candidate, expectedStatus] of [
67+
[emptyResponse, "accepted"],
68+
[oneResponse, "accepted"],
69+
[hundredResponse, "accepted"],
70+
[malformedLastResponse, "malformed"],
71+
[oversizedTextResponse, "malformed"],
72+
] as const) {
73+
const result = decodeSqlCatalogSearchResponse(
74+
candidate,
75+
100,
76+
POSTGRESQL_SQL_RELATION_DIALECT,
77+
);
78+
if (result.status !== expectedStatus) {
79+
throw new Error("Catalog boundary benchmark preflight failed");
80+
}
81+
}
82+
if (
83+
decodeSqlCatalogSearchResponse(
84+
maximumDepthResponse,
85+
100,
86+
DREMIO_SQL_RELATION_DIALECT,
87+
).status !== "accepted" ||
88+
decodeSqlCatalogSearchResponse(
89+
wideInvalidResponse,
90+
100,
91+
POSTGRESQL_SQL_RELATION_DIALECT,
92+
).status !== "malformed"
93+
) {
94+
throw new Error("Catalog boundary benchmark preflight failed");
95+
}
96+
97+
describe("relation catalog boundary", () => {
98+
bench("decode empty ready response", () => {
99+
decodeSqlCatalogSearchResponse(
100+
emptyResponse,
101+
100,
102+
POSTGRESQL_SQL_RELATION_DIALECT,
103+
);
104+
});
105+
106+
bench("decode one relation", () => {
107+
decodeSqlCatalogSearchResponse(
108+
oneResponse,
109+
100,
110+
POSTGRESQL_SQL_RELATION_DIALECT,
111+
);
112+
});
113+
114+
bench("decode 100 relations", () => {
115+
decodeSqlCatalogSearchResponse(
116+
hundredResponse,
117+
100,
118+
POSTGRESQL_SQL_RELATION_DIALECT,
119+
);
120+
});
121+
122+
bench("reject malformed final relation", () => {
123+
decodeSqlCatalogSearchResponse(
124+
malformedLastResponse,
125+
100,
126+
POSTGRESQL_SQL_RELATION_DIALECT,
127+
);
128+
});
129+
130+
bench("reject a million-unit detail before scanning it", () => {
131+
decodeSqlCatalogSearchResponse(
132+
oversizedTextResponse,
133+
100,
134+
POSTGRESQL_SQL_RELATION_DIALECT,
135+
);
136+
});
137+
138+
bench("decode near-limit maximum-depth relations", () => {
139+
decodeSqlCatalogSearchResponse(
140+
maximumDepthResponse,
141+
100,
142+
DREMIO_SQL_RELATION_DIALECT,
143+
);
144+
});
145+
146+
bench("reject a wide unexpected record", () => {
147+
decodeSqlCatalogSearchResponse(
148+
wideInvalidResponse,
149+
100,
150+
POSTGRESQL_SQL_RELATION_DIALECT,
151+
);
152+
});
153+
});

0 commit comments

Comments
 (0)