diff --git a/docs/adr/0005-parser-independent-relation-completion.md b/docs/adr/0005-parser-independent-relation-completion.md index 87d9ab2..f6266dd 100644 --- a/docs/adr/0005-parser-independent-relation-completion.md +++ b/docs/adr/0005-parser-independent-relation-completion.md @@ -347,7 +347,14 @@ search request contains only copied, recursively frozen plain data: 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. +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: @@ -363,7 +370,10 @@ A returned relation contains: 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`. +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 @@ -397,7 +407,25 @@ 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. +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 @@ -439,8 +467,20 @@ 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. Subscription membership is installed atomically before a provider can call back synchronously. A newly joining session captures an already-observed epoch without a synthetic revision bump. Disposal removes membership before @@ -639,7 +679,7 @@ The initial checked limits are: | 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 | 1,024 | +| 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 | diff --git a/package.json b/package.json index b57fcc8..8832267 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "test:browser": "vitest run --config vitest.browser.config.ts", "test:worker-placement": "node ./scripts/worker-placement.mjs", "test:integrity": "node ./scripts/check-test-integrity.mjs", + "bench:catalog-boundary": "vitest bench --run src/vnext/__tests__/relation-catalog-boundary.bench.ts", "bench:local-relation-site": "vitest bench --run src/vnext/__tests__/local-relation-site.bench.ts", "bench:parser-adapter": "vitest bench --run src/vnext/__tests__/node-sql-parser-adapter.bench.ts", "bench:query-site": "vitest bench --run src/vnext/__tests__/query-site.bench.ts", diff --git a/src/vnext/__tests__/relation-catalog-boundary.bench.ts b/src/vnext/__tests__/relation-catalog-boundary.bench.ts new file mode 100644 index 0000000..f8acb10 --- /dev/null +++ b/src/vnext/__tests__/relation-catalog-boundary.bench.ts @@ -0,0 +1,153 @@ +import { bench, describe } from "vitest"; +import { decodeSqlCatalogSearchResponse } from "../relation-catalog-boundary.js"; +import { + DREMIO_SQL_RELATION_DIALECT, + POSTGRESQL_SQL_RELATION_DIALECT, +} from "../relation-dialect.js"; + +function relation(index: number) { + return { + canonicalPath: [ + { quoted: false, role: "schema", value: "public" }, + { + quoted: false, + role: "relation", + value: `relation_${index}`, + }, + ], + completionPathStart: 0, + detail: `Relation ${index}`, + entityId: `relation-${index}`, + matchQuality: "exact", + relationKind: "table", + }; +} + +function response(relations: readonly unknown[]) { + return { + coverage: { kind: "complete" }, + epoch: { generation: 1, token: "snapshot-1" }, + relations, + status: "ready", + }; +} + +const emptyResponse = response([]); +const oneResponse = response([relation(0)]); +const hundredResponse = response( + Array.from({ length: 100 }, (_, index) => relation(index)), +); +const malformedLastResponse = response([ + ...Array.from({ length: 99 }, (_, index) => relation(index)), + { ...relation(99), completionPathStart: 9 }, +]); +const oversizedTextResponse = response([ + { ...relation(0), detail: "x".repeat(1_000_000) }, +]); +const maximumDepthResponse = response( + Array.from({ length: 7 }, (_, relationIndex) => ({ + ...relation(relationIndex), + canonicalPath: Array.from({ length: 32 }, (_, pathIndex) => ({ + quoted: true, + role: pathIndex === 31 ? "relation" : "schema", + value: String(relationIndex).repeat(256), + })), + detail: "d".repeat(1_024), + entityId: `maximum-depth-${relationIndex}`, + })), +); +const wideInvalidResponse = Object.fromEntries( + Array.from({ length: 10_000 }, (_, index) => [ + `unexpected-${index}`, + index, + ]), +); + +for (const [candidate, expectedStatus] of [ + [emptyResponse, "accepted"], + [oneResponse, "accepted"], + [hundredResponse, "accepted"], + [malformedLastResponse, "malformed"], + [oversizedTextResponse, "malformed"], +] as const) { + const result = decodeSqlCatalogSearchResponse( + candidate, + 100, + POSTGRESQL_SQL_RELATION_DIALECT, + ); + if (result.status !== expectedStatus) { + throw new Error("Catalog boundary benchmark preflight failed"); + } +} +if ( + decodeSqlCatalogSearchResponse( + maximumDepthResponse, + 100, + DREMIO_SQL_RELATION_DIALECT, + ).status !== "accepted" || + decodeSqlCatalogSearchResponse( + wideInvalidResponse, + 100, + POSTGRESQL_SQL_RELATION_DIALECT, + ).status !== "malformed" +) { + throw new Error("Catalog boundary benchmark preflight failed"); +} + +describe("relation catalog boundary", () => { + bench("decode empty ready response", () => { + decodeSqlCatalogSearchResponse( + emptyResponse, + 100, + POSTGRESQL_SQL_RELATION_DIALECT, + ); + }); + + bench("decode one relation", () => { + decodeSqlCatalogSearchResponse( + oneResponse, + 100, + POSTGRESQL_SQL_RELATION_DIALECT, + ); + }); + + bench("decode 100 relations", () => { + decodeSqlCatalogSearchResponse( + hundredResponse, + 100, + POSTGRESQL_SQL_RELATION_DIALECT, + ); + }); + + bench("reject malformed final relation", () => { + decodeSqlCatalogSearchResponse( + malformedLastResponse, + 100, + POSTGRESQL_SQL_RELATION_DIALECT, + ); + }); + + bench("reject a million-unit detail before scanning it", () => { + decodeSqlCatalogSearchResponse( + oversizedTextResponse, + 100, + POSTGRESQL_SQL_RELATION_DIALECT, + ); + }); + + bench("decode near-limit maximum-depth relations", () => { + decodeSqlCatalogSearchResponse( + maximumDepthResponse, + 100, + DREMIO_SQL_RELATION_DIALECT, + ); + }); + + bench("reject a wide unexpected record", () => { + decodeSqlCatalogSearchResponse( + wideInvalidResponse, + 100, + POSTGRESQL_SQL_RELATION_DIALECT, + ); + }); +}); diff --git a/src/vnext/__tests__/relation-catalog-boundary.test.ts b/src/vnext/__tests__/relation-catalog-boundary.test.ts new file mode 100644 index 0000000..93ef31a --- /dev/null +++ b/src/vnext/__tests__/relation-catalog-boundary.test.ts @@ -0,0 +1,1388 @@ +import { describe, expect, it } from "vitest"; +import { + captureSqlRelationCatalogProvider, + compareSqlCatalogEpoch, + createSqlCatalogSearchRequest, + decodeSqlCatalogInvalidation, + decodeSqlCatalogSearchResponse, + MAX_CATALOG_CONTINUATION_TOKEN_LENGTH, + MAX_CATALOG_DETAIL_LENGTH, + MAX_CATALOG_ENTITY_ID_LENGTH, + MAX_CATALOG_EPOCH_TOKEN_LENGTH, + MAX_CATALOG_IDENTIFIER_LENGTH, + MAX_CATALOG_PROVIDER_ID_LENGTH, + MAX_CATALOG_RELATIONS, + MAX_CATALOG_REQUEST_TEXT_LENGTH, + MAX_CATALOG_RESPONSE_TEXT_LENGTH, + MAX_CATALOG_SCOPE_LENGTH, + resolveSqlRelationCatalogProvider, + type SqlCatalogBoundaryResult, +} from "../relation-catalog-boundary.js"; +import { + BIGQUERY_SQL_RELATION_DIALECT, + DREMIO_SQL_RELATION_DIALECT, + DUCKDB_SQL_RELATION_DIALECT, + POSTGRESQL_SQL_RELATION_DIALECT, + type SqlRelationDialectRuntime, +} from "../relation-dialect.js"; + +function accepted( + result: SqlCatalogBoundaryResult, +): Value { + expect(result.status).toBe("accepted"); + if (result.status !== "accepted") { + throw new Error(`Expected accepted, received ${result.reason}`); + } + return result.value; +} + +function epoch( + generation = 1, + token = "snapshot-1", +): { + readonly generation: number; + readonly token: string; +} { + return { generation, token }; +} + +function component( + value: string, + quoted = false, +): { + readonly quoted: boolean; + readonly value: string; +} { + return { quoted, value }; +} + +function pathComponent( + role: "catalog" | "dataset" | "project" | "relation" | "schema", + value: string, + quoted = false, +): { + readonly quoted: boolean; + readonly role: + | "catalog" + | "dataset" + | "project" + | "relation" + | "schema"; + readonly value: string; +} { + return { quoted, role, value }; +} + +function relation( + canonicalPath: readonly unknown[] = [ + pathComponent("schema", "public"), + pathComponent("relation", "users"), + ], + entityId = "relation-1", + completionPathStart = 0, +) { + return { + canonicalPath, + completionPathStart, + detail: "User accounts", + entityId, + matchQuality: "exact", + relationKind: "table", + }; +} + +function readyResponse( + relations: readonly unknown[] = [relation()], +) { + return { + coverage: { kind: "complete" }, + epoch: epoch(), + relations, + status: "ready", + }; +} + +function request( + overrides: Readonly> = {}, +) { + return { + continuationToken: null, + dialectId: "postgresql", + expectedEpoch: null, + limit: 20, + prefix: component("us"), + qualifier: [component("public")], + scope: "connection:primary", + searchPaths: [[component("public")]], + ...overrides, + }; +} + +function requestWithAggregateText( + textLength: number, +): ReturnType { + const fixedTextLength = "s".length + "postgresql".length; + let remaining = textLength - fixedTextLength; + if (remaining < 1) { + throw new Error("Aggregate request text fixture is too small"); + } + const components: ReturnType[] = []; + while (remaining > 0) { + const length = Math.min( + remaining, + MAX_CATALOG_IDENTIFIER_LENGTH, + ); + components.push(component("x".repeat(length), true)); + remaining -= length; + } + const searchPaths: ReturnType[][] = []; + for (let index = 0; index < components.length; index += 4) { + searchPaths.push(components.slice(index, index + 4)); + } + return request({ + dialectId: "postgresql", + prefix: component(""), + qualifier: [], + scope: "s", + searchPaths, + }); +} + +function paddedUniqueId(index: number, length: number): string { + const prefix = `id-${index}-`; + if (prefix.length > length) { + throw new Error("Entity ID fixture length is too small"); + } + return prefix + "x".repeat(length - prefix.length); +} + +function responseAtAggregateTextLimit( + extraTextLength = 0, +) { + const relations = Array.from({ length: 50 }, (_, index) => ({ + canonicalPath: [ + pathComponent("schema", "s".repeat(256), true), + pathComponent( + "relation", + "r".repeat(20 + (index === 0 ? extraTextLength : 0)), + true, + ), + ], + completionPathStart: 0, + detail: "d".repeat(MAX_CATALOG_DETAIL_LENGTH), + entityId: paddedUniqueId(index, index < 35 ? 11 : 10), + matchQuality: "exact", + relationKind: "table", + })); + return { + coverage: { kind: "complete" }, + epoch: { generation: 1, token: "e" }, + relations, + status: "ready", + }; +} + +function expectMalformed( + value: SqlCatalogBoundaryResult, + reason?: + | "duplicate-entity-id" + | "illegal-relation-path" + | "invalid-shape" + | "resource-limit", +): void { + expect(value.status).toBe("malformed"); + if (reason !== undefined) { + expect(value).toEqual({ reason, status: "malformed" }); + } +} + +describe("relation catalog provider capture", () => { + it("captures own callbacks once as this-free closures", () => { + const calls: unknown[][] = []; + const provider = { + hostState: { connection: "caller-owned" }, + id: "catalog", + search(this: unknown, ...args: unknown[]) { + calls.push([this, ...args]); + return Promise.resolve(readyResponse()); + }, + subscribe(this: unknown, ...args: unknown[]) { + calls.push([this, ...args]); + return { dispose() {} }; + }, + }; + const captured = accepted( + captureSqlRelationCatalogProvider(provider), + ); + provider.id = "changed"; + provider.search = function replacementSearch() { + throw new Error("replacement search must not run"); + }; + provider.subscribe = function replacementSubscribe() { + throw new Error("replacement subscription must not run"); + }; + const context = resolveSqlRelationCatalogProvider(captured); + expect(context?.id).toBe("catalog"); + const signal = new AbortController().signal; + const normalizedRequest = accepted( + createSqlCatalogSearchRequest(request()), + ); + context?.search(normalizedRequest, signal); + const listener = () => {}; + context?.subscribe?.("scope", listener); + expect(calls).toEqual([ + [undefined, normalizedRequest, signal], + [undefined, "scope", listener], + ]); + expect(Object.isFrozen(captured)).toBe(true); + expect(Object.isFrozen(context)).toBe(true); + }); + + it("supports static providers without subscriptions", () => { + const captured = accepted( + captureSqlRelationCatalogProvider({ + id: "static", + search: async () => readyResponse(), + subscribe: undefined, + }), + ); + expect( + resolveSqlRelationCatalogProvider(captured)?.subscribe, + ).toBeNull(); + expect(resolveSqlRelationCatalogProvider(null)).toBeNull(); + }); + + it("ignores extra config while rejecting copied, inherited, accessor, and hostile providers", () => { + const valid = { + id: "catalog", + search: async () => readyResponse(), + }; + const captured = accepted( + captureSqlRelationCatalogProvider(valid), + ); + expect(resolveSqlRelationCatalogProvider({ ...captured })).toBeNull(); + + const inherited = Object.create(valid); + expectMalformed(captureSqlRelationCatalogProvider(inherited)); + expect( + captureSqlRelationCatalogProvider({ ...valid, extra: true }) + .status, + ).toBe("accepted"); + class PrototypeProvider { + readonly id = "prototype"; + + async search() { + return readyResponse(); + } + } + expectMalformed( + captureSqlRelationCatalogProvider(new PrototypeProvider()), + ); + for (const candidate of [ + null, + [], + { id: "catalog", search: 1 }, + { ...valid, subscribe: null }, + new Date(), + ]) { + expectMalformed(captureSqlRelationCatalogProvider(candidate)); + } + + let getterInvoked = false; + const accessor = { + get id() { + getterInvoked = true; + return "catalog"; + }, + search: valid.search, + }; + expectMalformed(captureSqlRelationCatalogProvider(accessor)); + expect(getterInvoked).toBe(false); + + const revoked = Proxy.revocable(valid, {}); + revoked.revoke(); + expectMalformed( + captureSqlRelationCatalogProvider(revoked.proxy), + ); + expectMalformed( + captureSqlRelationCatalogProvider( + new Proxy(valid, { + getOwnPropertyDescriptor() { + throw new Error("hostile"); + }, + }), + ), + ); + }); + + it("enforces bounded well-formed provider IDs", () => { + const search = async () => readyResponse(); + expect( + captureSqlRelationCatalogProvider({ + id: "x".repeat(MAX_CATALOG_PROVIDER_ID_LENGTH), + search, + }).status, + ).toBe("accepted"); + expectMalformed( + captureSqlRelationCatalogProvider({ + id: "x".repeat(MAX_CATALOG_PROVIDER_ID_LENGTH + 1), + search, + }), + "resource-limit", + ); + for (const id of ["", "bad\0id", "\ud800", "\udc00"]) { + expectMalformed( + captureSqlRelationCatalogProvider({ id, search }), + "invalid-shape", + ); + } + expect( + captureSqlRelationCatalogProvider({ + id: "catalog-\ud83d\ude80", + search, + }).status, + ).toBe("accepted"); + }); +}); + +describe("catalog search request snapshots", () => { + it("copies and recursively freezes the exact provider request", () => { + const raw = request(); + const normalized = accepted( + createSqlCatalogSearchRequest(raw), + ); + expect(normalized).toEqual(raw); + expect(normalized).not.toBe(raw); + expect(Object.isFrozen(normalized)).toBe(true); + expect(Object.isFrozen(normalized.searchPaths)).toBe(true); + expect(Object.isFrozen(normalized.searchPaths[0])).toBe(true); + expect(Object.isFrozen(normalized.searchPaths[0]?.[0])).toBe(true); + expect(Object.isFrozen(normalized.qualifier)).toBe(true); + expect(Object.isFrozen(normalized.prefix)).toBe(true); + }); + + it("copies shared request identities into independent frozen data", () => { + const sharedComponent = { + quoted: false, + value: "public", + }; + const sharedPath = [sharedComponent]; + const normalized = accepted( + createSqlCatalogSearchRequest( + request({ + qualifier: sharedPath, + searchPaths: [sharedPath, sharedPath], + }), + ), + ); + + expect(normalized.qualifier).not.toBe(sharedPath); + expect(normalized.searchPaths[0]).not.toBe(sharedPath); + expect(normalized.searchPaths[1]).not.toBe(sharedPath); + expect(normalized.qualifier[0]).not.toBe(sharedComponent); + expect(normalized.searchPaths[0]?.[0]).not.toBe( + sharedComponent, + ); + expect(normalized.searchPaths[0]?.[0]).not.toBe( + normalized.searchPaths[1]?.[0], + ); + expect(Object.isFrozen(normalized.qualifier[0])).toBe(true); + expect(Object.isFrozen(normalized.searchPaths[0]?.[0])).toBe( + true, + ); + + sharedComponent.value = "mutated"; + sharedPath.push({ quoted: true, value: "later" }); + expect(normalized.qualifier).toEqual([ + { quoted: false, value: "public" }, + ]); + expect(normalized.searchPaths).toEqual([ + [{ quoted: false, value: "public" }], + [{ quoted: false, value: "public" }], + ]); + }); + + it("accepts empty prefix, qualifier, and search-path list", () => { + const normalized = accepted( + createSqlCatalogSearchRequest( + request({ + prefix: component(""), + qualifier: [], + searchPaths: [], + }), + ), + ); + expect(normalized.prefix.value).toBe(""); + expect(normalized.qualifier).toEqual([]); + expect(normalized.searchPaths).toEqual([]); + }); + + it("preserves quoted order, epochs, and continuation tokens", () => { + const normalized = accepted( + createSqlCatalogSearchRequest( + request({ + continuationToken: "page-2", + expectedEpoch: epoch(4, "four"), + searchPaths: [ + [component("first", true), component("second")], + [component("third")], + ], + }), + ), + ); + expect(normalized).toMatchObject({ + continuationToken: "page-2", + expectedEpoch: { generation: 4, token: "four" }, + searchPaths: [ + [ + { quoted: true, value: "first" }, + { quoted: false, value: "second" }, + ], + [{ quoted: false, value: "third" }], + ], + }); + }); + + it("rejects missing, extra, inherited, and sparse data", () => { + const { scope: _scope, ...missing } = request(); + expectMalformed(createSqlCatalogSearchRequest(missing)); + expectMalformed( + createSqlCatalogSearchRequest({ ...request(), extra: true }), + ); + expectMalformed( + createSqlCatalogSearchRequest( + Object.create(request()), + ), + ); + + const sparse: unknown[] = []; + sparse.length = 2; + sparse[1] = component("public"); + expectMalformed( + createSqlCatalogSearchRequest( + request({ qualifier: sparse }), + ), + ); + const customArray = [component("public")]; + Object.defineProperty(customArray, "extra", { + enumerable: true, + value: true, + }); + expectMalformed( + createSqlCatalogSearchRequest( + request({ qualifier: customArray }), + ), + ); + expectMalformed( + createSqlCatalogSearchRequest( + request({ qualifier: Object.setPrototypeOf([], null) }), + ), + ); + expectMalformed( + createSqlCatalogSearchRequest( + request({ qualifier: {} }), + ), + ); + expectMalformed( + createSqlCatalogSearchRequest( + request({ searchPaths: [[]] }), + ), + ); + + const withSymbol = request(); + Object.defineProperty(withSymbol, Symbol("extra"), { + enumerable: true, + value: true, + }); + expectMalformed(createSqlCatalogSearchRequest(withSymbol)); + + const nonEnumerable = request(); + Object.defineProperty(nonEnumerable, "scope", { + enumerable: false, + value: "scope", + }); + expectMalformed(createSqlCatalogSearchRequest(nonEnumerable)); + }); + + it("does not invoke accessors or ordinary proxy get traps", () => { + let getterInvoked = false; + const accessor = { + ...request(), + get scope() { + getterInvoked = true; + return "scope"; + }, + }; + expectMalformed(createSqlCatalogSearchRequest(accessor)); + expect(getterInvoked).toBe(false); + + let getInvoked = false; + const proxied = new Proxy(request(), { + get() { + getInvoked = true; + throw new Error("hostile"); + }, + }); + expect( + createSqlCatalogSearchRequest(proxied).status, + ).toBe("accepted"); + expect(getInvoked).toBe(false); + + expectMalformed( + createSqlCatalogSearchRequest( + new Proxy(request(), { + getOwnPropertyDescriptor() { + throw new Error("hostile"); + }, + }), + ), + ); + }); + + it("enforces numeric and string resource boundaries", () => { + for (const limit of [ + 0, + MAX_CATALOG_RELATIONS + 1, + 1.5, + Number.NaN, + ]) { + expectMalformed( + createSqlCatalogSearchRequest(request({ limit })), + ); + } + expect( + createSqlCatalogSearchRequest( + request({ + continuationToken: "x".repeat( + MAX_CATALOG_CONTINUATION_TOKEN_LENGTH, + ), + scope: "x".repeat(MAX_CATALOG_SCOPE_LENGTH), + }), + ).status, + ).toBe("accepted"); + expectMalformed( + createSqlCatalogSearchRequest( + request({ + continuationToken: "x".repeat( + MAX_CATALOG_CONTINUATION_TOKEN_LENGTH + 1, + ), + }), + ), + "resource-limit", + ); + expectMalformed( + createSqlCatalogSearchRequest( + request({ + scope: "x".repeat(MAX_CATALOG_SCOPE_LENGTH + 1), + }), + ), + "resource-limit", + ); + expectMalformed( + createSqlCatalogSearchRequest( + request({ continuationToken: 1 }), + ), + ); + expectMalformed( + createSqlCatalogSearchRequest( + request({ expectedEpoch: { generation: -1, token: "bad" } }), + ), + ); + expectMalformed( + createSqlCatalogSearchRequest( + request({ prefix: { quoted: "no", value: "x" } }), + ), + ); + }); + + it("enforces the aggregate request text boundary", () => { + expect( + createSqlCatalogSearchRequest( + requestWithAggregateText(MAX_CATALOG_REQUEST_TEXT_LENGTH), + ).status, + ).toBe("accepted"); + expectMalformed( + createSqlCatalogSearchRequest( + requestWithAggregateText( + MAX_CATALOG_REQUEST_TEXT_LENGTH + 1, + ), + ), + "resource-limit", + ); + }); +}); + +describe("catalog response decoding", () => { + it.each([ + [ + "PostgreSQL", + POSTGRESQL_SQL_RELATION_DIALECT, + [ + pathComponent("schema", "public"), + pathComponent("relation", "users"), + ], + 0, + "public.users", + ], + [ + "DuckDB", + DUCKDB_SQL_RELATION_DIALECT, + [ + pathComponent("catalog", "memory"), + pathComponent("schema", "main"), + pathComponent("relation", "users"), + ], + 1, + "main.users", + ], + [ + "BigQuery", + BIGQUERY_SQL_RELATION_DIALECT, + [ + pathComponent("project", "my-project"), + pathComponent("dataset", "analytics"), + pathComponent("relation", "users"), + ], + 1, + "analytics.users", + ], + [ + "Dremio", + DREMIO_SQL_RELATION_DIALECT, + [ + pathComponent("catalog", "lake"), + pathComponent("schema", "raw"), + pathComponent("schema", "crm"), + pathComponent("relation", "users"), + ], + 2, + "crm.users", + ], + ] as const)( + "validates and pre-renders a %s canonical suffix", + ( + _name, + dialect, + canonicalPath, + completionPathStart, + completionText, + ) => { + const decoded = accepted( + decodeSqlCatalogSearchResponse( + readyResponse([ + relation( + canonicalPath, + "relation-1", + completionPathStart, + ), + ]), + 20, + dialect, + ), + ); + if (decoded.status !== "ready") { + throw new Error("Expected a ready catalog page"); + } + expect(decoded.relations[0]).toMatchObject({ + completionPath: canonicalPath.slice(completionPathStart), + completionText, + }); + expect(Object.isFrozen(decoded)).toBe(true); + expect(Object.isFrozen(decoded.relations)).toBe(true); + expect(Object.isFrozen(decoded.relations[0])).toBe(true); + expect( + Object.isFrozen(decoded.relations[0]?.canonicalPath), + ).toBe(true); + }, + ); + + it("renders quoted and reserved relation components with the dialect", () => { + const decoded = accepted( + decodeSqlCatalogSearchResponse( + readyResponse([ + relation( + [ + pathComponent("schema", "select"), + pathComponent("relation", "has space", true), + ], + "quoted", + ), + ]), + 20, + POSTGRESQL_SQL_RELATION_DIALECT, + ), + ); + if (decoded.status !== "ready") { + throw new Error("Expected a ready catalog page"); + } + expect(decoded.relations[0]?.completionText).toBe( + '"select"."has space"', + ); + }); + + it("accepts relations without optional detail", () => { + const { detail: _detail, ...withoutDetail } = relation(); + const decoded = accepted( + decodeSqlCatalogSearchResponse( + readyResponse([withoutDetail]), + 20, + POSTGRESQL_SQL_RELATION_DIALECT, + ), + ); + if (decoded.status !== "ready") { + throw new Error("Expected ready"); + } + expect(decoded.relations[0]).not.toHaveProperty("detail"); + }); + + it("copies shared response identities into independent frozen data", () => { + const sharedSchema = { + quoted: false, + role: "schema" as const, + value: "public", + }; + const sharedRelation = { + quoted: false, + role: "relation" as const, + value: "users", + }; + const sharedPath = [sharedSchema, sharedRelation]; + const decoded = accepted( + decodeSqlCatalogSearchResponse( + readyResponse([ + relation(sharedPath, "one"), + relation(sharedPath, "two"), + ]), + 20, + POSTGRESQL_SQL_RELATION_DIALECT, + ), + ); + if (decoded.status !== "ready") { + throw new Error("Expected a ready catalog page"); + } + + expect(decoded.relations[0]?.canonicalPath).not.toBe(sharedPath); + expect(decoded.relations[1]?.canonicalPath).not.toBe(sharedPath); + expect(decoded.relations[0]?.canonicalPath).not.toBe( + decoded.relations[1]?.canonicalPath, + ); + expect(decoded.relations[0]?.canonicalPath[0]).not.toBe( + sharedSchema, + ); + expect(decoded.relations[0]?.canonicalPath[0]).not.toBe( + decoded.relations[1]?.canonicalPath[0], + ); + expect( + Object.isFrozen(decoded.relations[0]?.canonicalPath[0]), + ).toBe(true); + + sharedSchema.value = "mutated"; + sharedRelation.value = "changed"; + sharedPath.push({ + quoted: false, + role: "relation", + value: "later", + }); + expect(decoded.relations[0]?.canonicalPath).toEqual([ + { quoted: false, role: "schema", value: "public" }, + { quoted: false, role: "relation", value: "users" }, + ]); + expect(decoded.relations[1]?.completionText).toBe( + "public.users", + ); + }); + + it("decodes all coverage and terminal response variants", () => { + for (const coverage of [ + { kind: "complete" }, + { kind: "partial" }, + { continuationToken: "next", kind: "paginated" }, + ]) { + const value = accepted( + decodeSqlCatalogSearchResponse( + { ...readyResponse([]), coverage }, + 20, + POSTGRESQL_SQL_RELATION_DIALECT, + ), + ); + expect(value).toMatchObject({ coverage, status: "ready" }); + } + expect( + accepted( + decodeSqlCatalogSearchResponse( + { epoch: epoch(), status: "loading" }, + 20, + POSTGRESQL_SQL_RELATION_DIALECT, + ), + ), + ).toEqual({ epoch: epoch(), status: "loading" }); + for (const code of [ + "authentication", + "authorization", + "invalid-configuration", + "rate-limited", + "unavailable", + "unknown", + ]) { + for (const retry of [ + "after-invalidation", + "never", + "next-request", + ]) { + expect( + accepted( + decodeSqlCatalogSearchResponse( + { code, epoch: epoch(), retry, status: "failed" }, + 20, + POSTGRESQL_SQL_RELATION_DIALECT, + ), + ), + ).toMatchObject({ code, retry, status: "failed" }); + } + } + }); + + it("rejects dialect-illegal canonical paths", () => { + expectMalformed( + decodeSqlCatalogSearchResponse( + readyResponse([ + relation([ + pathComponent("catalog", "wrong"), + pathComponent("relation", "users"), + ]), + ]), + 20, + POSTGRESQL_SQL_RELATION_DIALECT, + ), + "illegal-relation-path", + ); + }); + + it("rejects malformed paths and completion offsets", () => { + const malformedPaths = [ + [], + [pathComponent("schema", "public")], + [ + pathComponent("relation", "users"), + pathComponent("relation", "again"), + ], + [ + { quoted: false, role: "unknown", value: "x" }, + pathComponent("relation", "users"), + ], + ]; + for (const canonicalPath of malformedPaths) { + expectMalformed( + decodeSqlCatalogSearchResponse( + readyResponse([relation(canonicalPath)]), + 20, + POSTGRESQL_SQL_RELATION_DIALECT, + ), + ); + } + for (const completionPathStart of [-1, 2, 0.5]) { + expectMalformed( + decodeSqlCatalogSearchResponse( + readyResponse([ + relation(undefined, "bad-offset", completionPathStart), + ]), + 20, + POSTGRESQL_SQL_RELATION_DIALECT, + ), + ); + } + for (const canonicalPath of [ + [ + { quoted: "no", role: "schema", value: "public" }, + pathComponent("relation", "users"), + ], + [ + { quoted: false, role: "schema", value: 1 }, + pathComponent("relation", "users"), + ], + [ + { quoted: false, value: "public" }, + pathComponent("relation", "users"), + ], + [ + pathComponent("schema", ""), + pathComponent("relation", "users"), + ], + ]) { + expectMalformed( + decodeSqlCatalogSearchResponse( + readyResponse([relation(canonicalPath)]), + 20, + POSTGRESQL_SQL_RELATION_DIALECT, + ), + ); + } + }); + + it("rejects duplicate IDs and the entire malformed page atomically", () => { + expectMalformed( + decodeSqlCatalogSearchResponse( + readyResponse([ + relation(undefined, "duplicate"), + relation( + [pathComponent("relation", "other")], + "duplicate", + ), + ]), + 20, + POSTGRESQL_SQL_RELATION_DIALECT, + ), + "duplicate-entity-id", + ); + expectMalformed( + decodeSqlCatalogSearchResponse( + readyResponse([ + relation(), + { ...relation(), completionPathStart: 99 }, + ]), + 20, + POSTGRESQL_SQL_RELATION_DIALECT, + ), + ); + }); + + it("rejects extra/missing/status-specific fields and closed values", () => { + const malformedResponses = [ + { ...readyResponse(), extra: true }, + { ...readyResponse(), status: "unknown" }, + { epoch: epoch(), relations: [], status: "loading" }, + { + code: "wrong", + epoch: epoch(), + retry: "never", + status: "failed", + }, + { + code: "unknown", + epoch: epoch(), + extra: true, + retry: "never", + status: "failed", + }, + { + code: "unknown", + epoch: epoch(), + retry: "wrong", + status: "failed", + }, + { epoch: { generation: -1, token: "bad" }, status: "loading" }, + { epoch: epoch(), status: "failed" }, + { + ...readyResponse(), + coverage: { continuationToken: "", kind: "paginated" }, + }, + { + ...readyResponse(), + coverage: { + continuationToken: 1, + kind: "paginated", + }, + }, + { ...readyResponse(), coverage: null }, + { + ...readyResponse(), + coverage: { continuationToken: "wrong", kind: "complete" }, + }, + ]; + for (const value of malformedResponses) { + expectMalformed( + decodeSqlCatalogSearchResponse( + value, + 20, + POSTGRESQL_SQL_RELATION_DIALECT, + ), + ); + } + }); + + it("rejects malformed relation fields and record shapes", () => { + const malformedRelations = [ + null, + [], + { ...relation(), extra: true }, + { ...relation(), entityId: 1 }, + { ...relation(), matchQuality: "approximate" }, + { ...relation(), relationKind: "sequence" }, + { ...relation(), completionPathStart: "0" }, + new Date(), + ]; + for (const candidate of malformedRelations) { + expectMalformed( + decodeSqlCatalogSearchResponse( + readyResponse([candidate]), + 20, + POSTGRESQL_SQL_RELATION_DIALECT, + ), + ); + } + }); + + it("rejects present undefined detail and enforces relation bounds", () => { + expectMalformed( + decodeSqlCatalogSearchResponse( + readyResponse([{ ...relation(), detail: undefined }]), + 20, + POSTGRESQL_SQL_RELATION_DIALECT, + ), + ); + expect( + decodeSqlCatalogSearchResponse( + readyResponse([ + { + ...relation(), + detail: "x".repeat(MAX_CATALOG_DETAIL_LENGTH), + entityId: "x".repeat(MAX_CATALOG_ENTITY_ID_LENGTH), + }, + ]), + 20, + POSTGRESQL_SQL_RELATION_DIALECT, + ).status, + ).toBe("accepted"); + expectMalformed( + decodeSqlCatalogSearchResponse( + readyResponse([ + { + ...relation(), + detail: "x".repeat(MAX_CATALOG_DETAIL_LENGTH + 1), + }, + ]), + 20, + POSTGRESQL_SQL_RELATION_DIALECT, + ), + "resource-limit", + ); + expectMalformed( + decodeSqlCatalogSearchResponse( + readyResponse([ + { + ...relation(), + detail: "\0".repeat(1_000_000), + }, + ]), + 20, + POSTGRESQL_SQL_RELATION_DIALECT, + ), + "resource-limit", + ); + expectMalformed( + decodeSqlCatalogSearchResponse( + readyResponse([ + { + ...relation(), + entityId: "x".repeat( + MAX_CATALOG_ENTITY_ID_LENGTH + 1, + ), + }, + ]), + 20, + POSTGRESQL_SQL_RELATION_DIALECT, + ), + "resource-limit", + ); + }); + + it("enforces the aggregate response text boundary", () => { + expect( + decodeSqlCatalogSearchResponse( + responseAtAggregateTextLimit(), + MAX_CATALOG_RELATIONS, + POSTGRESQL_SQL_RELATION_DIALECT, + ).status, + ).toBe("accepted"); + expectMalformed( + decodeSqlCatalogSearchResponse( + responseAtAggregateTextLimit(1), + MAX_CATALOG_RELATIONS, + POSTGRESQL_SQL_RELATION_DIALECT, + ), + "resource-limit", + ); + expect(MAX_CATALOG_RESPONSE_TEXT_LENGTH).toBe( + 1 + 50 * (256 + 20 + MAX_CATALOG_DETAIL_LENGTH) + 535, + ); + }); + + it("rejects over-limit pages instead of truncating", () => { + expectMalformed( + decodeSqlCatalogSearchResponse( + readyResponse([ + relation(undefined, "one"), + relation(undefined, "two"), + ]), + 1, + POSTGRESQL_SQL_RELATION_DIALECT, + ), + ); + const customRelations = [relation()]; + Object.defineProperty(customRelations, "extra", { + enumerable: true, + value: true, + }); + expectMalformed( + decodeSqlCatalogSearchResponse( + readyResponse(customRelations), + 20, + POSTGRESQL_SQL_RELATION_DIALECT, + ), + ); + }); + + it("does not invoke accessors or ordinary get traps", () => { + let invoked = false; + const accessor = { + ...readyResponse(), + get status() { + invoked = true; + return "ready"; + }, + }; + expectMalformed( + decodeSqlCatalogSearchResponse( + accessor, + 20, + POSTGRESQL_SQL_RELATION_DIALECT, + ), + ); + expect(invoked).toBe(false); + + const proxied = new Proxy(readyResponse(), { + get() { + invoked = true; + throw new Error("hostile"); + }, + }); + expect( + decodeSqlCatalogSearchResponse( + proxied, + 20, + POSTGRESQL_SQL_RELATION_DIALECT, + ).status, + ).toBe("accepted"); + expect(invoked).toBe(false); + }); + + it("normalizes hostile proxies without leaking thrown values", () => { + const throwing = new Proxy(readyResponse(), { + getOwnPropertyDescriptor() { + throw new Error("hostile"); + }, + }); + expectMalformed( + decodeSqlCatalogSearchResponse( + throwing, + 20, + POSTGRESQL_SQL_RELATION_DIALECT, + ), + ); + expectMalformed( + decodeSqlCatalogSearchResponse( + [], + 20, + POSTGRESQL_SQL_RELATION_DIALECT, + ), + ); + expectMalformed( + decodeSqlCatalogSearchResponse( + new Date(), + 20, + POSTGRESQL_SQL_RELATION_DIALECT, + ), + ); + const revoked = Proxy.revocable(readyResponse(), {}); + revoked.revoke(); + expectMalformed( + decodeSqlCatalogSearchResponse( + revoked.proxy, + 20, + POSTGRESQL_SQL_RELATION_DIALECT, + ), + ); + }); + + it("rejects foreign dialect aggregates and invalid trusted limits", () => { + const mixed: SqlRelationDialectRuntime = { + ...POSTGRESQL_SQL_RELATION_DIALECT, + completion: DUCKDB_SQL_RELATION_DIALECT.completion, + }; + expectMalformed( + decodeSqlCatalogSearchResponse(readyResponse(), 20, mixed), + ); + for (const limit of [0, 101, 1.5]) { + expectMalformed( + decodeSqlCatalogSearchResponse( + readyResponse(), + limit, + POSTGRESQL_SQL_RELATION_DIALECT, + ), + ); + } + }); + + it("enforces component and epoch token boundaries", () => { + expect( + decodeSqlCatalogSearchResponse( + { + ...readyResponse(), + epoch: epoch( + 1, + "x".repeat(MAX_CATALOG_EPOCH_TOKEN_LENGTH), + ), + relations: [ + relation([ + pathComponent( + "relation", + "x".repeat(MAX_CATALOG_IDENTIFIER_LENGTH), + ), + ]), + ], + }, + 20, + POSTGRESQL_SQL_RELATION_DIALECT, + ).status, + ).toBe("accepted"); + expectMalformed( + decodeSqlCatalogSearchResponse( + { + ...readyResponse(), + epoch: epoch( + 1, + "x".repeat(MAX_CATALOG_EPOCH_TOKEN_LENGTH + 1), + ), + }, + 20, + POSTGRESQL_SQL_RELATION_DIALECT, + ), + "resource-limit", + ); + expectMalformed( + decodeSqlCatalogSearchResponse( + readyResponse([ + relation([ + pathComponent( + "relation", + "x".repeat(MAX_CATALOG_IDENTIFIER_LENGTH + 1), + ), + ]), + ]), + 20, + POSTGRESQL_SQL_RELATION_DIALECT, + ), + "resource-limit", + ); + }); +}); + +describe("catalog invalidation and epoch comparison", () => { + it("decodes fresh frozen invalidations", () => { + const raw = { epoch: epoch(2, "two") }; + const decoded = accepted( + decodeSqlCatalogInvalidation(raw), + ); + expect(decoded).toEqual(raw); + expect(decoded).not.toBe(raw); + expect(decoded.epoch).not.toBe(raw.epoch); + expect(Object.isFrozen(decoded)).toBe(true); + expect(Object.isFrozen(decoded.epoch)).toBe(true); + }); + + it("rejects malformed invalidations without invoking accessors", () => { + let invoked = false; + const invalidation = { + get epoch() { + invoked = true; + return epoch(); + }, + }; + expectMalformed(decodeSqlCatalogInvalidation(invalidation)); + expect(invoked).toBe(false); + expectMalformed( + decodeSqlCatalogInvalidation({ + epoch: epoch(), + scope: "spoofed", + }), + ); + expectMalformed( + decodeSqlCatalogInvalidation( + new Proxy({ epoch: epoch() }, { + ownKeys() { + throw new Error("hostile"); + }, + }), + ), + ); + expectMalformed(decodeSqlCatalogInvalidation(null)); + }); + + it("classifies baseline, equal, advance, stale, and conflicts", () => { + expect(compareSqlCatalogEpoch(null, epoch(1, "one"))).toEqual({ + epoch: epoch(1, "one"), + kind: "baseline", + }); + expect( + compareSqlCatalogEpoch(epoch(1, "one"), epoch(1, "one")), + ).toEqual({ + epoch: epoch(1, "one"), + kind: "equal", + }); + expect( + compareSqlCatalogEpoch(epoch(1, "one"), epoch(2, "two")), + ).toEqual({ + epoch: epoch(2, "two"), + kind: "advance", + previous: epoch(1, "one"), + }); + expect( + compareSqlCatalogEpoch(epoch(2, "two"), epoch(1, "one")), + ).toEqual({ + kind: "stale", + observed: epoch(2, "two"), + received: epoch(1, "one"), + }); + expect( + compareSqlCatalogEpoch(epoch(1, "one"), epoch(1, "other")), + ).toEqual({ + kind: "token-conflict", + observed: epoch(1, "one"), + received: epoch(1, "other"), + }); + }); + + it("canonicalizes negative zero and rejects invalid epochs", () => { + expect(compareSqlCatalogEpoch(null, epoch(-0, "zero"))).toEqual({ + epoch: epoch(0, "zero"), + kind: "baseline", + }); + for (const generation of [ + -1, + 0.5, + Number.NaN, + Number.POSITIVE_INFINITY, + Number.MAX_SAFE_INTEGER + 1, + ]) { + expect( + compareSqlCatalogEpoch(null, epoch(generation)), + ).toEqual({ kind: "malformed" }); + } + expect( + compareSqlCatalogEpoch( + { generation: 1, token: "" }, + epoch(), + ), + ).toEqual({ kind: "malformed" }); + expect( + compareSqlCatalogEpoch( + epoch(), + new Proxy(epoch(), { + ownKeys() { + throw new Error("hostile"); + }, + }), + ), + ).toEqual({ kind: "malformed" }); + }); +}); diff --git a/src/vnext/relation-catalog-boundary.ts b/src/vnext/relation-catalog-boundary.ts new file mode 100644 index 0000000..274f5a8 --- /dev/null +++ b/src/vnext/relation-catalog-boundary.ts @@ -0,0 +1,1269 @@ +import type { + SqlCatalogContainerComponent, + SqlCatalogContainerRole, + SqlCatalogEpoch, + SqlCatalogFailureCode, + SqlCatalogInvalidation, + SqlCatalogReadyCoverage, + SqlCatalogRelationKind, + SqlCatalogRetryPolicy, + SqlCatalogSearchRequest, + SqlCanonicalRelationPath, +} from "./relation-completion-types.js"; +import type { SqlRelationDialectRuntime } from "./relation-dialect.js"; +import { isSqlRelationDialectRuntime } from "./relation-runtime-auth.js"; +import type { + SqlIdentifierComponent, + SqlIdentifierPath, +} from "./types.js"; + +export const MAX_CATALOG_PROVIDER_ID_LENGTH = 256; +export const MAX_CATALOG_SCOPE_LENGTH = 512; +export const MAX_CATALOG_DIALECT_ID_LENGTH = 128; +export const MAX_CATALOG_EPOCH_TOKEN_LENGTH = 256; +export const MAX_CATALOG_CONTINUATION_TOKEN_LENGTH = 2_048; +export const MAX_CATALOG_ENTITY_ID_LENGTH = 256; +export const MAX_CATALOG_DETAIL_LENGTH = 1_024; +export const MAX_CATALOG_IDENTIFIER_LENGTH = 256; +export const MAX_CATALOG_SEARCH_PATHS = 32; +export const MAX_CATALOG_SEARCH_PATH_COMPONENTS = 4; +export const MAX_CATALOG_RELATION_PATH_COMPONENTS = 32; +export const MAX_CATALOG_RELATIONS = 100; +export const MAX_CATALOG_REQUEST_TEXT_LENGTH = 16_384; +export const MAX_CATALOG_RESPONSE_TEXT_LENGTH = 65_536; +export const MAX_CATALOG_RESPONSE_OWN_KEYS = 16_384; +export const MAX_CATALOG_DECODE_DEPTH = 8; + +const MAX_CATALOG_REQUEST_OBJECTS = 1_024; +const MAX_CATALOG_REQUEST_OWN_KEYS = 1_024; +const MAX_CATALOG_RESPONSE_OBJECTS = 4_096; + +const capturedProviderBrand: unique symbol = Symbol( + "CapturedSqlRelationCatalogProvider", +); + +export interface CapturedSqlRelationCatalogProvider { + readonly [capturedProviderBrand]: + "CapturedSqlRelationCatalogProvider"; +} + +export interface SqlCapturedRelationCatalogProviderContext { + readonly id: string; + readonly search: ( + this: void, + request: SqlCatalogSearchRequest, + signal: AbortSignal, + ) => unknown; + readonly subscribe: + | (( + this: void, + scope: string, + listener: (event: unknown) => void, + ) => unknown) + | null; +} + +export type SqlCatalogBoundaryFailureReason = + | "duplicate-entity-id" + | "illegal-relation-path" + | "invalid-shape" + | "resource-limit"; + +export type SqlCatalogBoundaryResult = + | { + readonly status: "accepted"; + readonly value: Value; + } + | { + readonly status: "malformed"; + readonly reason: SqlCatalogBoundaryFailureReason; + }; + +export interface SqlValidatedCatalogRelation { + readonly canonicalPath: SqlCanonicalRelationPath; + readonly completionPath: SqlCanonicalRelationPath; + readonly completionPathStart: number; + readonly completionText: string; + readonly detail?: string; + readonly entityId: string; + readonly matchQuality: "exact" | "equivalent"; + readonly relationKind: SqlCatalogRelationKind; +} + +export type SqlValidatedCatalogSearchResponse = + | { + readonly status: "ready"; + readonly coverage: SqlCatalogReadyCoverage; + readonly epoch: SqlCatalogEpoch; + readonly relations: readonly SqlValidatedCatalogRelation[]; + } + | { + readonly status: "loading"; + readonly epoch: SqlCatalogEpoch; + } + | { + readonly status: "failed"; + readonly code: SqlCatalogFailureCode; + readonly epoch: SqlCatalogEpoch; + readonly retry: SqlCatalogRetryPolicy; + }; + +export type SqlCatalogEpochComparison = + | { + readonly kind: "baseline"; + readonly epoch: SqlCatalogEpoch; + } + | { + readonly kind: "equal"; + readonly epoch: SqlCatalogEpoch; + } + | { + readonly kind: "advance"; + readonly epoch: SqlCatalogEpoch; + readonly previous: SqlCatalogEpoch; + } + | { + readonly kind: "stale"; + readonly observed: SqlCatalogEpoch; + readonly received: SqlCatalogEpoch; + } + | { + readonly kind: "token-conflict"; + readonly observed: SqlCatalogEpoch; + readonly received: SqlCatalogEpoch; + } + | { + readonly kind: "malformed"; + }; + +interface DecodeBudget { + objects: number; + properties: number; + textLength: number; +} + +interface DecodeLimits { + readonly maximumDepth: number; + readonly maximumObjects: number; + readonly maximumProperties: number; + readonly maximumTextLength: number; +} + +interface DecodeState { + readonly budget: DecodeBudget; + exhausted: boolean; + readonly limits: DecodeLimits; +} + +interface DataRecord { + readonly fields: ReadonlyMap; +} + +interface CapturedProviderFunctions { + readonly id: string; + readonly search: Function; + readonly subscribe: Function | null; +} + +const capturedProviders = new WeakMap< + object, + CapturedProviderFunctions +>(); + +const FAILURE_CODES: ReadonlySet = new Set([ + "authentication", + "authorization", + "invalid-configuration", + "rate-limited", + "unavailable", + "unknown", +]); + +const RETRY_POLICIES: ReadonlySet = new Set([ + "after-invalidation", + "never", + "next-request", +]); + +const RELATION_KINDS: ReadonlySet = new Set([ + "external-relation", + "materialized-view", + "table", + "temporary-table", + "view", +]); + +const CONTAINER_ROLES: ReadonlySet = new Set([ + "catalog", + "dataset", + "project", + "schema", +]); + +function isCatalogContainerRole( + value: string, +): value is SqlCatalogContainerRole { + return CONTAINER_ROLES.has(value); +} + +function isCatalogFailureCode( + value: string, +): value is SqlCatalogFailureCode { + return FAILURE_CODES.has(value); +} + +function isCatalogRetryPolicy( + value: string, +): value is SqlCatalogRetryPolicy { + return RETRY_POLICIES.has(value); +} + +function isCatalogRelationKind( + value: string, +): value is SqlCatalogRelationKind { + return RELATION_KINDS.has(value); +} + +function accepted( + value: Value, +): SqlCatalogBoundaryResult { + return Object.freeze({ status: "accepted", value }); +} + +function malformed( + reason: SqlCatalogBoundaryFailureReason, +): SqlCatalogBoundaryResult { + return Object.freeze({ reason, status: "malformed" }); +} + +function malformedFromState( + state: DecodeState, + reason: SqlCatalogBoundaryFailureReason = "invalid-shape", +): SqlCatalogBoundaryResult { + return malformed(state.exhausted ? "resource-limit" : reason); +} + +const PROVIDER_DECODE_LIMITS: DecodeLimits = Object.freeze({ + maximumDepth: 1, + maximumObjects: 1, + maximumProperties: 3, + maximumTextLength: MAX_CATALOG_PROVIDER_ID_LENGTH, +}); + +const REQUEST_DECODE_LIMITS: DecodeLimits = Object.freeze({ + maximumDepth: MAX_CATALOG_DECODE_DEPTH, + maximumObjects: MAX_CATALOG_REQUEST_OBJECTS, + maximumProperties: MAX_CATALOG_REQUEST_OWN_KEYS, + maximumTextLength: MAX_CATALOG_REQUEST_TEXT_LENGTH, +}); + +const RESPONSE_DECODE_LIMITS: DecodeLimits = Object.freeze({ + maximumDepth: MAX_CATALOG_DECODE_DEPTH, + maximumObjects: MAX_CATALOG_RESPONSE_OBJECTS, + maximumProperties: MAX_CATALOG_RESPONSE_OWN_KEYS, + maximumTextLength: MAX_CATALOG_RESPONSE_TEXT_LENGTH, +}); + +function createDecodeState(limits: DecodeLimits): DecodeState { + return { + budget: { + objects: 0, + properties: 0, + textLength: 0, + }, + exhausted: false, + limits, + }; +} + +function consumeObject( + state: DecodeState, + propertyCount: number, + depth: number, +): boolean { + if (depth > state.limits.maximumDepth) { + state.exhausted = true; + return false; + } + state.budget.objects += 1; + state.budget.properties += propertyCount; + if ( + state.budget.objects > state.limits.maximumObjects || + state.budget.properties > state.limits.maximumProperties + ) { + state.exhausted = true; + return false; + } + return true; +} + +function consumeText( + state: DecodeState, + value: string, + minimumLength: number, + maximumLength: number, +): string | null { + if (value.length > maximumLength) { + state.exhausted = true; + return null; + } + if ( + value.length > + state.limits.maximumTextLength - state.budget.textLength + ) { + state.exhausted = true; + return null; + } + if ( + value.length < minimumLength || + value.includes("\0") || + !isWellFormed(value) + ) { + return null; + } + state.budget.textLength += value.length; + return value; +} + +function isWellFormed(value: string): boolean { + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code >= 0xd800 && code <= 0xdbff) { + const next = value.charCodeAt(index + 1); + if (!(next >= 0xdc00 && next <= 0xdfff)) { + return false; + } + index += 1; + } else if (code >= 0xdc00 && code <= 0xdfff) { + return false; + } + } + return true; +} + +function readRecord( + state: DecodeState, + value: unknown, + maximumOwnKeys: number, + depth: number, +): DataRecord | null { + if ( + value === null || + typeof value !== "object" || + Array.isArray(value) + ) { + return null; + } + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + return null; + } + const keys = Reflect.ownKeys(value); + if ( + keys.length > maximumOwnKeys || + !consumeObject(state, keys.length, depth) + ) { + return null; + } + const fields = new Map(); + for (const key of keys) { + if (typeof key !== "string" || fields.has(key)) { + return null; + } + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if ( + !descriptor || + !descriptor.enumerable || + !("value" in descriptor) + ) { + return null; + } + fields.set(key, descriptor.value); + } + return { fields }; +} + +function readArray( + state: DecodeState, + value: unknown, + maximumLength: number, + depth: number, +): readonly unknown[] | null { + if (!Array.isArray(value)) { + return null; + } + if (Object.getPrototypeOf(value) !== Array.prototype) { + return null; + } + const lengthDescriptor = Object.getOwnPropertyDescriptor( + value, + "length", + ); + if ( + !lengthDescriptor || + !("value" in lengthDescriptor) || + typeof lengthDescriptor.value !== "number" || + !Number.isSafeInteger(lengthDescriptor.value) || + lengthDescriptor.value < 0 + ) { + return null; + } + const length = lengthDescriptor.value; + if (length > maximumLength) { + state.exhausted = true; + return null; + } + const keys = Reflect.ownKeys(value); + if ( + keys.length !== length + 1 || + !consumeObject(state, keys.length, depth) + ) { + return null; + } + const output: unknown[] = []; + for (let index = 0; index < length; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor( + value, + String(index), + ); + if ( + !descriptor || + !descriptor.enumerable || + !("value" in descriptor) + ) { + return null; + } + output.push(descriptor.value); + } + for (const key of keys) { + if ( + typeof key !== "string" || + (key !== "length" && + (!/^(0|[1-9]\d*)$/.test(key) || + Number(key) >= length)) + ) { + return null; + } + } + return output; +} + +function hasExactKeys( + record: DataRecord, + keys: readonly string[], +): boolean { + if (record.fields.size !== keys.length) { + return false; + } + return keys.every((key) => record.fields.has(key)); +} + +function decodeEpoch( + state: DecodeState, + value: unknown, + depth: number, +): SqlCatalogEpoch | null { + const record = readRecord(state, value, 2, depth); + if (!record || !hasExactKeys(record, ["generation", "token"])) { + return null; + } + const generation = record.fields.get("generation"); + const tokenValue = record.fields.get("token"); + if ( + typeof generation !== "number" || + !Number.isSafeInteger(generation) || + generation < 0 || + typeof tokenValue !== "string" + ) { + return null; + } + const token = consumeText( + state, + tokenValue, + 1, + MAX_CATALOG_EPOCH_TOKEN_LENGTH, + ); + return token + ? Object.freeze({ + generation: generation === 0 ? 0 : generation, + token, + }) + : null; +} + +function decodeIdentifierComponent( + state: DecodeState, + value: unknown, + allowEmpty: boolean, + depth: number, +): SqlIdentifierComponent | null { + const record = readRecord(state, value, 2, depth); + if (!record || !hasExactKeys(record, ["quoted", "value"])) { + return null; + } + const quoted = record.fields.get("quoted"); + const rawValue = record.fields.get("value"); + if (typeof quoted !== "boolean" || typeof rawValue !== "string") { + return null; + } + const decodedValue = consumeText( + state, + rawValue, + allowEmpty ? 0 : 1, + MAX_CATALOG_IDENTIFIER_LENGTH, + ); + return decodedValue === null + ? null + : Object.freeze({ quoted, value: decodedValue }); +} + +function decodeIdentifierPath( + state: DecodeState, + value: unknown, + maximumLength: number, + allowEmpty: boolean, + depth: number, +): SqlIdentifierPath | null { + const values = readArray(state, value, maximumLength, depth); + if (!values || (!allowEmpty && values.length === 0)) { + return null; + } + const components: SqlIdentifierComponent[] = []; + for (const candidate of values) { + const component = decodeIdentifierComponent( + state, + candidate, + false, + depth + 1, + ); + if (!component) { + return null; + } + components.push(component); + } + return Object.freeze(components); +} + +function decodeSearchPaths( + state: DecodeState, + value: unknown, + depth: number, +): readonly SqlIdentifierPath[] | null { + const paths = readArray( + state, + value, + MAX_CATALOG_SEARCH_PATHS, + depth, + ); + if (!paths) { + return null; + } + const output: SqlIdentifierPath[] = []; + for (const path of paths) { + const decoded = decodeIdentifierPath( + state, + path, + MAX_CATALOG_SEARCH_PATH_COMPONENTS, + false, + depth + 1, + ); + if (!decoded) { + return null; + } + output.push(decoded); + } + return Object.freeze(output); +} + +function decodeCoverage( + state: DecodeState, + value: unknown, + depth: number, +): SqlCatalogReadyCoverage | null { + const record = readRecord(state, value, 2, depth); + if (!record) { + return null; + } + const kind = record.fields.get("kind"); + if (kind === "complete" || kind === "partial") { + return hasExactKeys(record, ["kind"]) + ? Object.freeze({ kind }) + : null; + } + if ( + kind !== "paginated" || + !hasExactKeys(record, ["continuationToken", "kind"]) + ) { + return null; + } + const tokenValue = record.fields.get("continuationToken"); + if (typeof tokenValue !== "string") { + return null; + } + const continuationToken = consumeText( + state, + tokenValue, + 1, + MAX_CATALOG_CONTINUATION_TOKEN_LENGTH, + ); + return continuationToken + ? Object.freeze({ continuationToken, kind }) + : null; +} + +function decodeCanonicalPath( + state: DecodeState, + value: unknown, + depth: number, +): SqlCanonicalRelationPath | null { + const values = readArray( + state, + value, + MAX_CATALOG_RELATION_PATH_COMPONENTS, + depth, + ); + if (!values || values.length === 0) { + return null; + } + const containers: SqlCatalogContainerComponent[] = []; + let relation: + | { + readonly quoted: boolean; + readonly role: "relation"; + readonly value: string; + } + | null = null; + for (let index = 0; index < values.length; index += 1) { + const record = readRecord( + state, + values[index], + 3, + depth + 1, + ); + if ( + !record || + !hasExactKeys(record, ["quoted", "role", "value"]) + ) { + return null; + } + const quoted = record.fields.get("quoted"); + const role = record.fields.get("role"); + const rawValue = record.fields.get("value"); + if ( + typeof quoted !== "boolean" || + typeof role !== "string" || + typeof rawValue !== "string" + ) { + return null; + } + const decodedValue = consumeText( + state, + rawValue, + 1, + MAX_CATALOG_IDENTIFIER_LENGTH, + ); + if (!decodedValue) { + return null; + } + const final = index === values.length - 1; + if (final) { + if (role !== "relation") { + return null; + } + relation = Object.freeze({ + quoted, + role, + value: decodedValue, + }); + } else { + if (!isCatalogContainerRole(role)) { + return null; + } + const container: SqlCatalogContainerComponent = { + quoted, + role, + value: decodedValue, + }; + containers.push(Object.freeze(container)); + } + } + return relation + ? Object.freeze([...containers, relation]) + : null; +} + +function completionSuffix( + path: SqlCanonicalRelationPath, + start: number, +): SqlCanonicalRelationPath | null { + const relation = path[path.length - 1]; + if (!relation || relation.role !== "relation") { + return null; + } + const containers: SqlCatalogContainerComponent[] = []; + for (let index = start; index < path.length - 1; index += 1) { + const component = path[index]; + if (!component || component.role === "relation") { + return null; + } + containers.push(component); + } + return Object.freeze([...containers, relation]); +} + +function decodeRelation( + state: DecodeState, + value: unknown, + dialect: SqlRelationDialectRuntime, + depth: number, +): SqlCatalogBoundaryResult { + const record = readRecord(state, value, 6, depth); + if (!record) { + return malformedFromState(state); + } + const keys = record.fields.has("detail") + ? [ + "canonicalPath", + "completionPathStart", + "detail", + "entityId", + "matchQuality", + "relationKind", + ] + : [ + "canonicalPath", + "completionPathStart", + "entityId", + "matchQuality", + "relationKind", + ]; + if (!hasExactKeys(record, keys)) { + return malformedFromState(state); + } + const entityIdValue = record.fields.get("entityId"); + const relationKind = record.fields.get("relationKind"); + const matchQuality = record.fields.get("matchQuality"); + const completionPathStart = record.fields.get( + "completionPathStart", + ); + if ( + typeof entityIdValue !== "string" || + typeof relationKind !== "string" || + !isCatalogRelationKind(relationKind) || + (matchQuality !== "exact" && + matchQuality !== "equivalent") || + typeof completionPathStart !== "number" || + !Number.isSafeInteger(completionPathStart) + ) { + return malformedFromState(state); + } + const entityId = consumeText( + state, + entityIdValue, + 1, + MAX_CATALOG_ENTITY_ID_LENGTH, + ); + if (!entityId) { + return malformedFromState(state); + } + const path = decodeCanonicalPath( + state, + record.fields.get("canonicalPath"), + depth + 1, + ); + if ( + !path || + completionPathStart < 0 || + completionPathStart >= path.length + ) { + return malformedFromState(state); + } + const suffix = completionSuffix(path, completionPathStart); + if (!suffix) { + return malformedFromState(state, "illegal-relation-path"); + } + const fullRender = dialect.completion.renderRelationPath(path); + const completionRender = + dialect.completion.renderRelationPath(suffix); + if ( + fullRender.status !== "rendered" || + completionRender.status !== "rendered" + ) { + return malformedFromState(state, "illegal-relation-path"); + } + const detailValue = record.fields.get("detail"); + let detail: string | undefined; + if (record.fields.has("detail")) { + if (typeof detailValue !== "string") { + return malformedFromState(state); + } + const decodedDetail = consumeText( + state, + detailValue, + 0, + MAX_CATALOG_DETAIL_LENGTH, + ); + if (decodedDetail === null) { + return malformedFromState(state); + } + detail = decodedDetail; + } + const base: Omit = { + canonicalPath: path, + completionPath: suffix, + completionPathStart, + completionText: completionRender.text, + entityId, + matchQuality, + relationKind, + }; + return accepted( + Object.freeze( + detail === undefined ? base : { ...base, detail }, + ), + ); +} + +export function captureSqlRelationCatalogProvider( + candidate: unknown, +): SqlCatalogBoundaryResult { + try { + const state = createDecodeState(PROVIDER_DECODE_LIMITS); + if ( + candidate === null || + typeof candidate !== "object" || + (Object.getPrototypeOf(candidate) !== Object.prototype && + Object.getPrototypeOf(candidate) !== null) + ) { + return malformed("invalid-shape"); + } + const idDescriptor = Object.getOwnPropertyDescriptor( + candidate, + "id", + ); + const searchDescriptor = Object.getOwnPropertyDescriptor( + candidate, + "search", + ); + const subscribeDescriptor = Object.getOwnPropertyDescriptor( + candidate, + "subscribe", + ); + if ( + !idDescriptor || + !idDescriptor.enumerable || + !("value" in idDescriptor) || + !searchDescriptor || + !searchDescriptor.enumerable || + !("value" in searchDescriptor) || + (subscribeDescriptor !== undefined && + (!subscribeDescriptor.enumerable || + !("value" in subscribeDescriptor))) || + !consumeObject( + state, + subscribeDescriptor === undefined ? 2 : 3, + 1, + ) + ) { + return malformedFromState(state); + } + const idValue = idDescriptor.value; + const search = searchDescriptor.value; + const subscribeValue = subscribeDescriptor?.value; + if ( + typeof idValue !== "string" || + typeof search !== "function" || + (subscribeDescriptor !== undefined && + subscribeValue !== undefined && + typeof subscribeValue !== "function") + ) { + return malformedFromState(state); + } + const id = consumeText( + state, + idValue, + 1, + MAX_CATALOG_PROVIDER_ID_LENGTH, + ); + if (!id) { + return malformedFromState(state); + } + const provider: CapturedSqlRelationCatalogProvider = + Object.freeze({ + [capturedProviderBrand]: + "CapturedSqlRelationCatalogProvider" as const, + }); + capturedProviders.set( + provider, + Object.freeze({ + id, + search, + subscribe: + typeof subscribeValue === "function" + ? subscribeValue + : null, + }), + ); + return accepted(provider); + } catch { + return malformed("invalid-shape"); + } +} + +export function resolveSqlRelationCatalogProvider( + candidate: unknown, +): SqlCapturedRelationCatalogProviderContext | null { + if ( + candidate === null || + typeof candidate !== "object" + ) { + return null; + } + const captured = capturedProviders.get(candidate); + if (!captured) { + return null; + } + const subscribe = captured.subscribe; + return Object.freeze({ + id: captured.id, + search: ( + request: SqlCatalogSearchRequest, + signal: AbortSignal, + ): unknown => + Reflect.apply(captured.search, undefined, [ + request, + signal, + ]), + subscribe: subscribe + ? ( + scope: string, + listener: (event: unknown) => void, + ): unknown => + Reflect.apply(subscribe, undefined, [ + scope, + listener, + ]) + : null, + }); +} + +export function createSqlCatalogSearchRequest( + candidate: unknown, +): SqlCatalogBoundaryResult { + try { + const state = createDecodeState(REQUEST_DECODE_LIMITS); + const record = readRecord(state, candidate, 8, 1); + if ( + !record || + !hasExactKeys(record, [ + "continuationToken", + "dialectId", + "expectedEpoch", + "limit", + "prefix", + "qualifier", + "scope", + "searchPaths", + ]) + ) { + return malformedFromState(state); + } + const scopeValue = record.fields.get("scope"); + const dialectIdValue = record.fields.get("dialectId"); + const limit = record.fields.get("limit"); + if ( + typeof scopeValue !== "string" || + typeof dialectIdValue !== "string" || + typeof limit !== "number" || + !Number.isSafeInteger(limit) || + limit < 1 || + limit > MAX_CATALOG_RELATIONS + ) { + return malformedFromState(state); + } + const scope = consumeText( + state, + scopeValue, + 1, + MAX_CATALOG_SCOPE_LENGTH, + ); + const dialectId = consumeText( + state, + dialectIdValue, + 1, + MAX_CATALOG_DIALECT_ID_LENGTH, + ); + const searchPaths = decodeSearchPaths( + state, + record.fields.get("searchPaths"), + 2, + ); + const qualifier = decodeIdentifierPath( + state, + record.fields.get("qualifier"), + MAX_CATALOG_RELATION_PATH_COMPONENTS - 1, + true, + 2, + ); + const prefix = decodeIdentifierComponent( + state, + record.fields.get("prefix"), + true, + 2, + ); + if ( + !scope || + !dialectId || + !searchPaths || + !qualifier || + !prefix + ) { + return malformedFromState(state); + } + const expectedEpochValue = record.fields.get("expectedEpoch"); + const expectedEpoch = + expectedEpochValue === null + ? null + : decodeEpoch(state, expectedEpochValue, 2); + if (expectedEpochValue !== null && !expectedEpoch) { + return malformedFromState(state); + } + const continuationValue = record.fields.get( + "continuationToken", + ); + let continuationToken: string | null = null; + if (continuationValue !== null) { + if (typeof continuationValue !== "string") { + return malformedFromState(state); + } + continuationToken = consumeText( + state, + continuationValue, + 1, + MAX_CATALOG_CONTINUATION_TOKEN_LENGTH, + ); + if (!continuationToken) { + return malformedFromState(state); + } + } + return accepted( + Object.freeze({ + continuationToken, + dialectId, + expectedEpoch, + limit, + prefix, + qualifier, + scope, + searchPaths, + }), + ); + } catch { + return malformed("invalid-shape"); + } +} + +export function decodeSqlCatalogSearchResponse( + candidate: unknown, + requestLimit: number, + dialect: SqlRelationDialectRuntime, +): SqlCatalogBoundaryResult { + try { + if ( + !Number.isSafeInteger(requestLimit) || + requestLimit < 1 || + requestLimit > MAX_CATALOG_RELATIONS || + !isSqlRelationDialectRuntime(dialect) + ) { + return malformed("invalid-shape"); + } + const state = createDecodeState(RESPONSE_DECODE_LIMITS); + const record = readRecord(state, candidate, 4, 1); + if (!record) { + return malformedFromState(state); + } + const status = record.fields.get("status"); + if (status === "loading") { + if (!hasExactKeys(record, ["epoch", "status"])) { + return malformedFromState(state); + } + const epoch = decodeEpoch( + state, + record.fields.get("epoch"), + 2, + ); + return epoch + ? accepted(Object.freeze({ epoch, status })) + : malformedFromState(state); + } + if (status === "failed") { + if ( + !hasExactKeys(record, [ + "code", + "epoch", + "retry", + "status", + ]) + ) { + return malformedFromState(state); + } + const epoch = decodeEpoch( + state, + record.fields.get("epoch"), + 2, + ); + const code = record.fields.get("code"); + const retry = record.fields.get("retry"); + if ( + !epoch || + typeof code !== "string" || + !isCatalogFailureCode(code) || + typeof retry !== "string" || + !isCatalogRetryPolicy(retry) + ) { + return malformedFromState(state); + } + const response: Extract< + SqlValidatedCatalogSearchResponse, + { readonly status: "failed" } + > = { + code, + epoch, + retry, + status, + }; + return accepted(Object.freeze(response)); + } + if ( + status !== "ready" || + !hasExactKeys(record, [ + "coverage", + "epoch", + "relations", + "status", + ]) + ) { + return malformedFromState(state); + } + const epoch = decodeEpoch( + state, + record.fields.get("epoch"), + 2, + ); + const coverage = decodeCoverage( + state, + record.fields.get("coverage"), + 2, + ); + const relations = readArray( + state, + record.fields.get("relations"), + requestLimit, + 2, + ); + if (!epoch || !coverage || !relations) { + return malformedFromState(state); + } + const entityIds = new Set(); + const decodedRelations: SqlValidatedCatalogRelation[] = []; + for (const relation of relations) { + const decoded = decodeRelation( + state, + relation, + dialect, + 3, + ); + if (decoded.status === "malformed") { + return decoded; + } + if (entityIds.has(decoded.value.entityId)) { + return malformed("duplicate-entity-id"); + } + entityIds.add(decoded.value.entityId); + decodedRelations.push(decoded.value); + } + return accepted( + Object.freeze({ + coverage, + epoch, + relations: Object.freeze(decodedRelations), + status, + }), + ); + } catch { + return malformed("invalid-shape"); + } +} + +export function decodeSqlCatalogInvalidation( + candidate: unknown, +): SqlCatalogBoundaryResult { + try { + const state = createDecodeState(RESPONSE_DECODE_LIMITS); + const record = readRecord(state, candidate, 1, 1); + if (!record || !hasExactKeys(record, ["epoch"])) { + return malformedFromState(state); + } + const epoch = decodeEpoch( + state, + record.fields.get("epoch"), + 2, + ); + return epoch + ? accepted(Object.freeze({ epoch })) + : malformedFromState(state); + } catch { + return malformed("invalid-shape"); + } +} + +export function compareSqlCatalogEpoch( + observedCandidate: unknown, + receivedCandidate: unknown, +): SqlCatalogEpochComparison { + try { + const received = decodeEpoch( + createDecodeState(RESPONSE_DECODE_LIMITS), + receivedCandidate, + 1, + ); + if (!received) { + return Object.freeze({ kind: "malformed" }); + } + if (observedCandidate === null) { + return Object.freeze({ epoch: received, kind: "baseline" }); + } + const observed = decodeEpoch( + createDecodeState(RESPONSE_DECODE_LIMITS), + observedCandidate, + 1, + ); + if (!observed) { + return Object.freeze({ kind: "malformed" }); + } + if (received.generation < observed.generation) { + return Object.freeze({ + kind: "stale", + observed, + received, + }); + } + if (received.generation > observed.generation) { + return Object.freeze({ + epoch: received, + kind: "advance", + previous: observed, + }); + } + if (received.token !== observed.token) { + return Object.freeze({ + kind: "token-conflict", + observed, + received, + }); + } + return Object.freeze({ epoch: received, kind: "equal" }); + } catch { + return Object.freeze({ kind: "malformed" }); + } +} diff --git a/src/vnext/relation-completion-types.ts b/src/vnext/relation-completion-types.ts index 0c8ded8..6a7094a 100644 --- a/src/vnext/relation-completion-types.ts +++ b/src/vnext/relation-completion-types.ts @@ -117,10 +117,12 @@ export interface SqlCatalogInvalidation { export interface SqlRelationCatalogProvider { readonly id: string; readonly search: ( + this: void, request: SqlCatalogSearchRequest, signal: AbortSignal, ) => Promise; readonly subscribe?: ( + this: void, scope: string, onInvalidation: (event: SqlCatalogInvalidation) => void, ) => SqlDisposable; diff --git a/test/vnext-types/marimo-relation-completion.test-d.ts b/test/vnext-types/marimo-relation-completion.test-d.ts index 002f997..8656ba1 100644 --- a/test/vnext-types/marimo-relation-completion.test-d.ts +++ b/test/vnext-types/marimo-relation-completion.test-d.ts @@ -130,6 +130,24 @@ const provider: SqlRelationCatalogProvider = { }; void provider; +const receiverDependentSearch = async function ( + this: { readonly id: string }, + _request: SqlCatalogSearchRequest, + _signal: AbortSignal, +): Promise { + return { + coverage: { kind: "complete" }, + epoch: { generation: 0, token: this.id }, + relations: [], + status: "ready", + }; +}; +const receiverDependentProvider: SqlRelationCatalogProvider = { + id: "receiver-dependent", + // @ts-expect-error provider callbacks are this-free closures + search: receiverDependentSearch, +}; + const relationPath = [ { quoted: false, role: "catalog", value: "memory" }, { quoted: false, role: "schema", value: "main" }, @@ -309,5 +327,6 @@ void mismatchedItem; void openWithRegions; void openWithoutRegions; void providerRenderedSql; +void receiverDependentProvider; void synchronousProvider; void undefinedContext;