Skip to content

Commit 345ea3b

Browse files
committed
feat(vnext): add namespace catalog completion
1 parent c20bcf6 commit 345ea3b

8 files changed

Lines changed: 2327 additions & 0 deletions
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# vNext Namespace Catalog Authority
2+
3+
Status: integration-ready internal vertical slice
4+
5+
Namespace completion searches catalog, schema, project, and dataset containers.
6+
One query site produces one bounded provider request containing its qualifier,
7+
prefix, search paths, result limit, dialect, scope, and expected catalog epoch.
8+
The API does not recursively walk a provider tree or issue one request per
9+
container.
10+
11+
Each result has a stable container entity ID, a canonical role-tagged identifier
12+
path, provider-rendered insertion text, match quality, and immutable provenance.
13+
The hostile boundary rejects accessors, sparse arrays, extra properties,
14+
oversized data, invalid epochs, and conflicting duplicate identities. Identical
15+
duplicate identities are collapsed and results receive deterministic code-unit
16+
ordering.
17+
18+
The composer applies the dialect's prefix matcher, deduplicates by authority
19+
identity, and produces deterministic completion edits. Unknown or throwing
20+
prefix comparison is reported as incomplete instead of guessing.
21+
22+
## Epoch, cache, and lifecycle
23+
24+
A cold request uses `expectedEpoch: null`; the returned epoch becomes the
25+
owner's observed epoch. Complete ready searches are held in a bounded LRU keyed
26+
by provider instance, scope, dialect, epoch, query site, search paths, and limit.
27+
Partial, loading, and failed responses remain visible but are never cached.
28+
29+
A newer request supersedes and aborts prior work for the same owner. Explicit
30+
cancellation and owner/coordinator disposal abort pending work. Provider throws,
31+
rejections, malformed data, and late settlements are contained.
32+
33+
Session integration should prepare one owner for each live scope/dialect and
34+
dispose it when catalog authority changes. Query-site integration calls
35+
`prepareSqlNamespaceCatalogSearch`, submits the result through the owner, and
36+
passes the outcome plus the site's replacement range and dialect prefix matcher
37+
to `composeSqlNamespaceCompletion`.
Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
import {
3+
captureSqlNamespaceCatalogProvider,
4+
createSqlNamespaceCatalogSearchRequest,
5+
decodeSqlNamespaceCatalogSearchResponse,
6+
MAX_NAMESPACE_RESULTS,
7+
resolveSqlNamespaceCatalogProvider,
8+
} from "../namespace-catalog-boundary.js";
9+
10+
const epoch = Object.freeze({ generation: 1, token: "one" });
11+
12+
function request(expectedEpoch = epoch) {
13+
const result = createSqlNamespaceCatalogSearchRequest({
14+
dialectId: "duckdb",
15+
expectedEpoch,
16+
limit: 10,
17+
prefix: { quoted: false, value: "ma" },
18+
qualifier: [{ quoted: false, value: "memory" }],
19+
scope: "connection",
20+
searchPaths: [[{ quoted: false, value: "main" }]],
21+
});
22+
if (result.status !== "accepted") throw new Error("request");
23+
return result.value;
24+
}
25+
26+
function captured(search: unknown = vi.fn()) {
27+
const result = captureSqlNamespaceCatalogProvider({
28+
id: "namespaces",
29+
search,
30+
});
31+
if (result.status !== "accepted") throw new Error("provider");
32+
return result.value;
33+
}
34+
35+
function container(
36+
id: string,
37+
role: "catalog" | "dataset" | "project" | "schema",
38+
value: string,
39+
) {
40+
return {
41+
canonicalPath: [{
42+
quoted: false,
43+
role,
44+
value,
45+
}],
46+
containerEntityId: id,
47+
detail: `${role} detail`,
48+
insertText: value,
49+
matchQuality: "exact",
50+
};
51+
}
52+
53+
describe("namespace catalog boundary", () => {
54+
it("captures provider methods without exposing a receiver", () => {
55+
const receivers: unknown[] = [];
56+
const search = function (this: unknown) {
57+
receivers.push(this);
58+
};
59+
const handle = captured(search);
60+
const context = resolveSqlNamespaceCatalogProvider(handle);
61+
expect(context?.id).toBe("namespaces");
62+
context?.search(request(), new AbortController().signal);
63+
expect(receivers).toEqual([undefined]);
64+
expect(Object.isFrozen(handle)).toBe(true);
65+
});
66+
67+
it("rejects hostile or invalid providers", () => {
68+
const accessor = {};
69+
Object.defineProperty(accessor, "id", { get: vi.fn() });
70+
expect(captureSqlNamespaceCatalogProvider(accessor).status)
71+
.toBe("malformed");
72+
expect(captureSqlNamespaceCatalogProvider(null).status)
73+
.toBe("malformed");
74+
expect(captureSqlNamespaceCatalogProvider({
75+
id: "",
76+
search() {},
77+
}).status).toBe("malformed");
78+
expect(captureSqlNamespaceCatalogProvider({
79+
id: "x",
80+
search: 1,
81+
}).status).toBe("malformed");
82+
expect(captureSqlNamespaceCatalogProvider({
83+
extra: true,
84+
id: "x",
85+
search() {},
86+
}).status).toBe("malformed");
87+
expect(captureSqlNamespaceCatalogProvider(
88+
new Proxy({}, { ownKeys: () => { throw new Error("trap"); } }),
89+
).status).toBe("malformed");
90+
});
91+
92+
it("normalizes and freezes one bounded query-site search", () => {
93+
const value = request();
94+
expect(value).toEqual({
95+
dialectId: "duckdb",
96+
expectedEpoch: epoch,
97+
limit: 10,
98+
prefix: { quoted: false, value: "ma" },
99+
qualifier: [{ quoted: false, value: "memory" }],
100+
scope: "connection",
101+
searchPaths: [[{ quoted: false, value: "main" }]],
102+
});
103+
expect(Object.isFrozen(value)).toBe(true);
104+
expect(Object.isFrozen(value.prefix)).toBe(true);
105+
expect(Object.isFrozen(value.qualifier)).toBe(true);
106+
expect(Object.isFrozen(value.searchPaths[0])).toBe(true);
107+
expect(createSqlNamespaceCatalogSearchRequest({
108+
...value,
109+
expectedEpoch: null,
110+
}).status).toBe("accepted");
111+
});
112+
113+
it("rejects malformed, sparse, accessor, and oversized requests", () => {
114+
const valid = request();
115+
const sparse: unknown[] = [];
116+
sparse.length = 1;
117+
const cases: unknown[] = [
118+
null,
119+
{ ...valid, extra: true },
120+
{ ...valid, dialectId: "" },
121+
{ ...valid, scope: "" },
122+
{ ...valid, expectedEpoch: undefined },
123+
{ ...valid, expectedEpoch: { generation: -1, token: "x" } },
124+
{ ...valid, expectedEpoch: { generation: 1.5, token: "x" } },
125+
{ ...valid, expectedEpoch: { generation: 1, token: "" } },
126+
{ ...valid, limit: 0 },
127+
{ ...valid, limit: MAX_NAMESPACE_RESULTS + 1 },
128+
{ ...valid, limit: 1.5 },
129+
{ ...valid, prefix: { quoted: "no", value: "x" } },
130+
{ ...valid, prefix: { quoted: false, value: "x", extra: 1 } },
131+
{ ...valid, qualifier: sparse },
132+
{ ...valid, searchPaths: sparse },
133+
{ ...valid, qualifier: [{ quoted: false, value: "x".repeat(257) }] },
134+
{ ...valid, searchPaths: Array.from({ length: 33 }, () => []) },
135+
];
136+
const accessor = { ...valid };
137+
Object.defineProperty(accessor, "prefix", { get: vi.fn() });
138+
cases.push(accessor);
139+
for (const value of cases) {
140+
expect(createSqlNamespaceCatalogSearchRequest(value).status)
141+
.toBe("malformed");
142+
}
143+
});
144+
145+
it("decodes, deduplicates, orders, and freezes all container roles", () => {
146+
const provider = captured();
147+
const decoded = decodeSqlNamespaceCatalogSearchResponse(
148+
provider,
149+
request(),
150+
{
151+
containers: [
152+
container("schema", "schema", "main"),
153+
{ ...container("schema", "schema", "main") },
154+
container("project", "project", "alpha"),
155+
container("dataset", "dataset", "events"),
156+
container("catalog", "catalog", "memory"),
157+
],
158+
coverage: "partial",
159+
epoch,
160+
status: "ready",
161+
},
162+
);
163+
expect(decoded.status).toBe("accepted");
164+
if (decoded.status !== "accepted" ||
165+
decoded.value.status !== "ready") return;
166+
expect(decoded.value.containers.map((value) =>
167+
value.canonicalPath[0]?.role
168+
)).toEqual(["catalog", "dataset", "project", "schema"]);
169+
expect(decoded.value.containers[0]?.provenance).toEqual({
170+
containerEntityId: "catalog",
171+
epoch,
172+
providerId: "namespaces",
173+
scope: "connection",
174+
});
175+
expect(Object.isFrozen(decoded.value)).toBe(true);
176+
expect(Object.isFrozen(decoded.value.containers)).toBe(true);
177+
expect(Object.isFrozen(
178+
decoded.value.containers[0]?.canonicalPath,
179+
)).toBe(true);
180+
});
181+
182+
it("decodes loading and normalized failures", () => {
183+
const provider = captured();
184+
expect(decodeSqlNamespaceCatalogSearchResponse(
185+
provider,
186+
request(),
187+
{ epoch, status: "loading" },
188+
)).toMatchObject({
189+
status: "accepted",
190+
value: { status: "loading" },
191+
});
192+
expect(decodeSqlNamespaceCatalogSearchResponse(
193+
provider,
194+
request(),
195+
{
196+
code: "rate-limited",
197+
epoch,
198+
retry: "next-request",
199+
status: "failed",
200+
},
201+
)).toMatchObject({
202+
status: "accepted",
203+
value: {
204+
code: "rate-limited",
205+
retry: "next-request",
206+
status: "failed",
207+
},
208+
});
209+
});
210+
211+
it("rejects malformed, conflicting, stale, and oversized responses", () => {
212+
const provider = captured();
213+
const valid = {
214+
containers: [container("one", "schema", "main")],
215+
coverage: "complete",
216+
epoch,
217+
status: "ready",
218+
};
219+
const conflicting = {
220+
...valid,
221+
containers: [
222+
container("same", "schema", "main"),
223+
container("same", "schema", "other"),
224+
],
225+
};
226+
const malformedContainers = [
227+
{ ...container("x", "schema", "x"), canonicalPath: [] },
228+
{ ...container("x", "schema", "x"), canonicalPath: [{
229+
quoted: false,
230+
role: "table",
231+
value: "x",
232+
}] },
233+
{ ...container("x", "schema", "x"), containerEntityId: "" },
234+
{ ...container("x", "schema", "x"), insertText: "" },
235+
{ ...container("x", "schema", "x"), matchQuality: "bad" },
236+
{ ...container("x", "schema", "x"), detail: 1 },
237+
];
238+
const cases: unknown[] = [
239+
null,
240+
{ ...valid, extra: true },
241+
{ ...valid, epoch: { generation: 2, token: "two" } },
242+
{ ...valid, coverage: "unknown" },
243+
{ ...valid, containers: Array.from(
244+
{ length: 11 },
245+
(_, index) => container(String(index), "schema", String(index)),
246+
) },
247+
conflicting,
248+
{ epoch, extra: true, status: "loading" },
249+
{ code: "bad", epoch, retry: "never", status: "failed" },
250+
{ code: "unknown", epoch, retry: "bad", status: "failed" },
251+
{ code: "unknown", epoch, retry: "never", status: "failed", x: 1 },
252+
...malformedContainers.map((value) => ({
253+
...valid,
254+
containers: [value],
255+
})),
256+
];
257+
for (const value of cases) {
258+
expect(decodeSqlNamespaceCatalogSearchResponse(
259+
provider,
260+
request(),
261+
value,
262+
).status).toBe("malformed");
263+
}
264+
});
265+
});
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { bench, describe } from "vitest";
2+
import {
3+
createSqlNamespaceCatalogCoordinator,
4+
} from "../namespace-catalog-coordinator.js";
5+
import type {
6+
SqlNamespaceCatalogSearchRequest,
7+
} from "../namespace-catalog-types.js";
8+
9+
const epoch = Object.freeze({ generation: 1, token: "bench" });
10+
11+
function response(request: SqlNamespaceCatalogSearchRequest) {
12+
return {
13+
containers: Array.from({ length: 100 }, (_, index) => ({
14+
canonicalPath: [
15+
{ quoted: false, role: "catalog", value: "memory" },
16+
{ quoted: false, role: "schema", value: `schema_${index}` },
17+
],
18+
containerEntityId: `schema:${index}`,
19+
insertText: `schema_${index}`,
20+
matchQuality: "exact",
21+
})),
22+
coverage: "complete",
23+
epoch: request.expectedEpoch ?? epoch,
24+
status: "ready",
25+
};
26+
}
27+
28+
function owner() {
29+
const created = createSqlNamespaceCatalogCoordinator({
30+
provider: {
31+
id: "benchmark",
32+
search: (request: SqlNamespaceCatalogSearchRequest) =>
33+
Promise.resolve(response(request)),
34+
},
35+
});
36+
if (created.status !== "created") throw new Error("coordinator");
37+
const prepared = created.coordinator.prepareOwner({
38+
dialectId: "duckdb",
39+
scope: "benchmark",
40+
});
41+
if (prepared.status !== "prepared") throw new Error("owner");
42+
return prepared.owner;
43+
}
44+
45+
const search = Object.freeze({
46+
expectedEpoch: epoch,
47+
limit: 100,
48+
prefix: Object.freeze({ quoted: false, value: "schema" }),
49+
qualifier: Object.freeze([
50+
Object.freeze({ quoted: false, value: "memory" }),
51+
]),
52+
searchPaths: Object.freeze([]),
53+
});
54+
55+
describe("namespace catalog coordinator", () => {
56+
bench("cold 100-container search", async () => {
57+
const current = owner();
58+
await current.request(search).result;
59+
current.dispose();
60+
});
61+
62+
const warm = owner();
63+
const primed = warm.request(search).result;
64+
65+
bench("warm 100-container cache lookup", async () => {
66+
await primed;
67+
await warm.request(search).result;
68+
});
69+
});

0 commit comments

Comments
 (0)