@volter/twin-turbopuffer 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +145 -0
  3. package/dist/src/cli.d.ts +2 -0
  4. package/dist/src/cli.js +27 -0
  5. package/dist/src/generated/surface.gen.json +1 -0
  6. package/dist/src/generated/ui.gen.json +1 -0
  7. package/dist/src/index.d.ts +11 -0
  8. package/dist/src/index.js +65 -0
  9. package/dist/src/key-gate.d.ts +3 -0
  10. package/dist/src/key-gate.js +39 -0
  11. package/dist/src/manifest.d.ts +2 -0
  12. package/dist/src/manifest.js +28 -0
  13. package/dist/src/screens/dashboard.d.ts +11 -0
  14. package/dist/src/screens/dashboard.js +191 -0
  15. package/dist/src/semantics/namespaces.d.ts +5 -0
  16. package/dist/src/semantics/namespaces.js +17 -0
  17. package/dist/src/turbopuffer-capabilities.d.ts +6 -0
  18. package/dist/src/turbopuffer-capabilities.js +442 -0
  19. package/dist/src/turbopuffer-conformance.d.ts +8 -0
  20. package/dist/src/turbopuffer-conformance.js +102 -0
  21. package/dist/src/turbopuffer-connector.d.ts +34 -0
  22. package/dist/src/turbopuffer-connector.js +152 -0
  23. package/dist/src/turbopuffer-filter.d.ts +57 -0
  24. package/dist/src/turbopuffer-filter.js +286 -0
  25. package/dist/src/turbopuffer-server.d.ts +23 -0
  26. package/dist/src/turbopuffer-server.js +72 -0
  27. package/dist/src/turbopuffer-stem.d.ts +1 -0
  28. package/dist/src/turbopuffer-stem.js +133 -0
  29. package/dist/src/turbopuffer-store.d.ts +80 -0
  30. package/dist/src/turbopuffer-store.js +1304 -0
  31. package/dist/src/turbopuffer-text.d.ts +44 -0
  32. package/dist/src/turbopuffer-text.js +189 -0
  33. package/dist/src/turbopuffer-twin.d.ts +25 -0
  34. package/dist/src/turbopuffer-twin.js +406 -0
  35. package/package.json +56 -0
  36. package/src/cli.ts +28 -0
  37. package/src/generated/surface.gen.json +1 -0
  38. package/src/generated/ui.gen.json +1 -0
  39. package/src/index.ts +92 -0
  40. package/src/key-gate.ts +39 -0
  41. package/src/manifest.ts +63 -0
  42. package/src/screens/dashboard.tsx +214 -0
  43. package/src/semantics/namespaces.ts +30 -0
  44. package/src/turbopuffer-capabilities.ts +455 -0
  45. package/src/turbopuffer-conformance.ts +101 -0
  46. package/src/turbopuffer-connector.ts +153 -0
  47. package/src/turbopuffer-filter.ts +277 -0
  48. package/src/turbopuffer-server.ts +81 -0
  49. package/src/turbopuffer-stem.ts +104 -0
  50. package/src/turbopuffer-store.ts +1157 -0
  51. package/src/turbopuffer-text.ts +204 -0
  52. package/src/turbopuffer-twin.ts +429 -0
@@ -0,0 +1,152 @@
1
+ // Turbopuffer's half of the real state system (protocol 2): the PERFORM adapter that replays one
2
+ // recorded write against the vendor, and the REFRESH adapter that observes a real account into
3
+ // the tree. Both run over the kernel's injected `RemoteExecute` — the kernel applies the sealed
4
+ // credential (the placeholder `authorization` below is replaced), so this pack never holds a key
5
+ // and issues no network call of its own.
6
+ import { observeResource, observeResources } from '@volter/world-core';
7
+ import { documentSubjectId, SERVICE } from "./turbopuffer-store.js";
8
+ import { DEFAULT_FTS } from "./turbopuffer-text.js";
9
+ const HEADERS = { accept: 'application/json', 'content-type': 'application/json', authorization: 'Bearer twin' };
10
+ /** Bounds on one refresh, so a huge account cannot turn a sync into an unbounded crawl. */
11
+ export const REFRESH_MAX_NAMESPACE_PAGES = 50;
12
+ export const REFRESH_MAX_DOCUMENTS_PER_NAMESPACE = 100_000;
13
+ const DOC_PAGE = 1000;
14
+ async function call(execute, method, path, body) {
15
+ const res = await execute({ method, path, headers: { ...HEADERS }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
16
+ // A refusal is thrown, never read as an empty account (adding-a-twin.md §6).
17
+ if (res.status < 200 || res.status >= 300)
18
+ throw new Error(`turbopuffer ${method} ${path} refused: HTTP ${res.status} ${res.body.slice(0, 200)}`);
19
+ try {
20
+ return JSON.parse(res.body || '{}');
21
+ }
22
+ catch {
23
+ throw new Error(`turbopuffer ${method} ${path} answered a body that is not JSON`);
24
+ }
25
+ }
26
+ /**
27
+ * Perform ONE recorded entry against the vendor. A write request is recorded whole (its body is the
28
+ * action's `input`), so the perform replays exactly what the caller sent; Turbopuffer ids are
29
+ * caller-chosen, so there is no local→vendor id to rebind.
30
+ */
31
+ export async function performTurbopufferAction(execute, action, _ctx) {
32
+ const ns = action.subject.id;
33
+ const path = `/v2/namespaces/${encodeURIComponent(ns)}`;
34
+ const input = (action.input ?? {});
35
+ switch (action.operation) {
36
+ case 'namespace.write':
37
+ return { externalId: ns, data: await call(execute, 'POST', path, input) };
38
+ case 'namespace.delete_all':
39
+ return { externalId: ns, data: await call(execute, 'DELETE', path) };
40
+ case 'namespace.update_schema':
41
+ return { externalId: ns, data: await call(execute, 'POST', `/v1/namespaces/${encodeURIComponent(ns)}/schema`, input.schema ?? {}) };
42
+ }
43
+ throw new Error(`turbopuffer perform: no vendor operation for ${action.operation ?? '(unnamed)'} on ${action.subject.type}:${ns}`);
44
+ }
45
+ /** Map one attribute of the vendor's schema wire into the stored form (lenient: the vendor is authoritative). */
46
+ export function mapSchemaAttr(raw) {
47
+ const r = (typeof raw === 'string' ? { type: raw } : raw ?? {});
48
+ const fts = r.full_text_search === undefined || r.full_text_search === false || r.full_text_search === null
49
+ ? false
50
+ : { ...DEFAULT_FTS, ...(typeof r.full_text_search === 'object' ? r.full_text_search : {}) };
51
+ return {
52
+ type: typeof r.type === 'string' ? r.type : 'string',
53
+ filterable: typeof r.filterable === 'boolean' ? r.filterable : fts === false,
54
+ full_text_search: fts,
55
+ ...(r.glob === true ? { glob: true } : {}),
56
+ ...(r.regex === true ? { regex: true } : {}),
57
+ };
58
+ }
59
+ /** The refresh adapter (the descriptor's `stateSystem.refresh`): the D7 pull over the kernel's executor. */
60
+ export async function syncTurbopufferFromRemote(execute, opts = {}) {
61
+ return syncTurbopufferFromReal(execute, opts);
62
+ }
63
+ /**
64
+ * D7 consumer-facing pull, through the injected executor (the kernel's in production, a fake in
65
+ * tests): list the account's namespaces, then for each read its schema, metadata and every document
66
+ * (paged by id), and OBSERVE them — the kernel diffs and folds. Read-only.
67
+ */
68
+ export async function syncTurbopufferFromReal(execute, opts = {}) {
69
+ const at = opts.occurredAt ?? new Date().toISOString();
70
+ const target = { ...(opts.root !== undefined ? { root: opts.root } : {}), at };
71
+ let observed = 0;
72
+ let truncated = false;
73
+ const names = [];
74
+ let cursor = '';
75
+ for (let page = 0; page < REFRESH_MAX_NAMESPACE_PAGES; page++) {
76
+ const listed = await call(execute, 'GET', `/v1/namespaces?page_size=1000${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`);
77
+ for (const n of listed.namespaces ?? [])
78
+ if (typeof n.id === 'string')
79
+ names.push(n.id);
80
+ cursor = typeof listed.next_cursor === 'string' ? listed.next_cursor : '';
81
+ if (cursor === '')
82
+ break;
83
+ }
84
+ if (cursor !== '')
85
+ truncated = true;
86
+ for (const ns of names) {
87
+ const enc = encodeURIComponent(ns);
88
+ const meta = await call(execute, 'GET', `/v2/namespaces/${enc}/metadata`);
89
+ const wireSchema = (meta.schema ?? {});
90
+ const schema = {};
91
+ let distance = null;
92
+ let dims = null;
93
+ for (const [attr, raw] of Object.entries(wireSchema)) {
94
+ schema[attr] = mapSchemaAttr(raw);
95
+ const m = /^\[(\d+)\]f(16|32)$/.exec(schema[attr].type);
96
+ if (attr === 'vector' && m) {
97
+ dims = Number(m[1]);
98
+ const ann = raw?.ann;
99
+ if (typeof ann?.distance_metric === 'string')
100
+ distance = ann.distance_metric;
101
+ }
102
+ }
103
+ observeResource(SERVICE, {
104
+ type: 'namespace',
105
+ id: ns,
106
+ fields: {
107
+ name: ns, schema, distance_metric: distance, vector_dims: dims,
108
+ created_at: typeof meta.created_at === 'string' ? meta.created_at : at,
109
+ updated_at: typeof meta.updated_at === 'string' ? meta.updated_at : at,
110
+ last_write_at: typeof meta.last_write_at === 'string' ? meta.last_write_at : typeof meta.updated_at === 'string' ? meta.updated_at : at,
111
+ gone: false,
112
+ },
113
+ }, target);
114
+ observed++;
115
+ let last = null;
116
+ for (let seen = 0; seen < REFRESH_MAX_DOCUMENTS_PER_NAMESPACE;) {
117
+ const page = await call(execute, 'POST', `/v2/namespaces/${enc}/query`, {
118
+ rank_by: ['id', 'asc'],
119
+ top_k: DOC_PAGE,
120
+ include_attributes: true,
121
+ ...(last === null ? {} : { filters: ['id', 'Gt', last] }),
122
+ });
123
+ const rows = page.rows ?? [];
124
+ const docs = [];
125
+ for (const row of rows) {
126
+ const { id, vector, $dist: _d, ...attributes } = row;
127
+ if (typeof id !== 'string' && typeof id !== 'number')
128
+ continue;
129
+ for (const k of Object.keys(attributes))
130
+ if (attributes[k] === null)
131
+ delete attributes[k];
132
+ docs.push({
133
+ type: 'document',
134
+ id: documentSubjectId(ns, id),
135
+ fields: { namespace: ns, doc_id: id, attributes, vector: Array.isArray(vector) ? vector : null },
136
+ });
137
+ last = id;
138
+ }
139
+ if (docs.length > 0)
140
+ observeResources(SERVICE, docs, target);
141
+ observed += docs.length;
142
+ seen += rows.length;
143
+ if (rows.length < DOC_PAGE)
144
+ break;
145
+ if (seen >= REFRESH_MAX_DOCUMENTS_PER_NAMESPACE)
146
+ truncated = true;
147
+ }
148
+ }
149
+ // A listing cut short by the bounds is not the whole account, so it never claims completeness
150
+ // (the kernel removes what a COMPLETE refresh no longer saw).
151
+ return { observed, ...(truncated ? {} : { complete: ['namespace', 'document'] }) };
152
+ }
@@ -0,0 +1,57 @@
1
+ import { type FtsConfig } from './turbopuffer-text.js';
2
+ /** A vendor-shaped API failure: served as `{ "status": "error", "error": message }`. */
3
+ export declare class TurbopufferError extends Error {
4
+ readonly status: number;
5
+ constructor(status: number, message: string);
6
+ }
7
+ /** 400: a request outside the shape turbopuffer's pages give it (a malformed filter, schema, write or query, a value of
8
+ * the wrong type, a parameter out of its documented range). The messages are the twin's; the pages name the rules, not
9
+ * the text of their refusals. */
10
+ export declare function badRequest(message: string): TurbopufferError;
11
+ export type AttrConfig = {
12
+ /** Transient: an `ann.distance_metric` on a vector schema entry, moved onto the namespace. */
13
+ ann_distance_metric?: string;
14
+ type: string;
15
+ filterable: boolean;
16
+ full_text_search: FtsConfig | false;
17
+ glob?: boolean;
18
+ regex?: boolean;
19
+ fuzzy?: boolean;
20
+ /** a `{}f16` attribute's `sparse_knn` config (`distance_metric: dot_product`) */
21
+ sparse_knn?: {
22
+ distance_metric: string;
23
+ };
24
+ /** a `[][N]f32` attribute's `ann: {late_interaction: true}` (false: stored without the index) */
25
+ late_interaction?: boolean;
26
+ /** native embedding: the model, the vector attribute the embeddings are stored in, and its dimensions */
27
+ embed?: {
28
+ model: string;
29
+ attribute: string;
30
+ dims: number;
31
+ };
32
+ };
33
+ export type Doc = {
34
+ /** `n:<id>` or `s:<id>` — the id's JSON type is part of its identity (7 and "7" are two docs). */
35
+ key: string;
36
+ id: string | number;
37
+ attributes: Record<string, unknown>;
38
+ vector: number[] | null;
39
+ };
40
+ export type FilterContext = {
41
+ schema: Record<string, AttrConfig>;
42
+ /** Per (attribute, doc key) token cache, shared across one query. */
43
+ tokenCache: Map<string, string[]>;
44
+ };
45
+ export type Predicate = (doc: Doc) => boolean;
46
+ /**
47
+ * A namespace observed from a real account may carry a full-text config the twin does not model
48
+ * (the vendor default `word_v4`, stemming, another language). Such an attribute is refused at
49
+ * query time rather than tokenized as if it were `word_v2`.
50
+ */
51
+ export declare function assertModelledFts(attr: string, fts: FtsConfig): void;
52
+ export declare function attributeValue(doc: Doc, attr: string): unknown;
53
+ /** -1/0/1 for two comparable scalars (number↔number, string↔string), NaN otherwise. */
54
+ export declare function compareScalars(a: unknown, b: unknown): number;
55
+ /** Compile a filter into a predicate, refusing a malformed one with a 400. */
56
+ export declare function compileFilter(f: unknown, ctx: FilterContext): Predicate;
57
+ export declare function docTokens(ctx: FilterContext, attr: string, fts: FtsConfig, d: Doc): string[];
@@ -0,0 +1,286 @@
1
+ // THE FILTER LANGUAGE — Turbopuffer's array-encoded `filters` (a SQL WHERE clause as JSON), compiled
2
+ // into a predicate over the namespace's documents.
3
+ //
4
+ // The operator set is the one the installed SDK declares (`Filter` in
5
+ // @turbopuffer/turbopuffer@2.8.0 src/resources/custom.ts). Modelled: Eq, NotEq, In, NotIn,
6
+ // Contains, NotContains, ContainsAny, NotContainsAny, Lt, Lte, Gt, Gte, AnyLt, AnyLte, AnyGt,
7
+ // AnyGte, Glob, NotGlob, IGlob, NotIGlob, ContainsAllTokens, ContainsAnyToken, And, Or, Not.
8
+ // Refused with a 400 naming the gap: Regex, Fuzzy, ContainsTokenSequence.
9
+ //
10
+ // Semantics a caller depends on (Dub's provider relies on the first one explicitly): an attribute a
11
+ // document does not carry reads as null, so `NotIn` / `NotEq` / `NotContainsAny` MATCH it and
12
+ // `In` / `Eq x` / `ContainsAny` do not. Comparison operators are refused on an attribute whose
13
+ // schema says `filterable: false` (the default for a full-text-search attribute, per the SDK's
14
+ // `AttributeSchemaConfig.full_text_search` doc); the token operators read the BM25 index instead
15
+ // and require `full_text_search` on the attribute.
16
+ import { MODELED_TOKENIZERS, queryTerms, tokenize, tokensMatch } from "./turbopuffer-text.js";
17
+ /** A vendor-shaped API failure: served as `{ "status": "error", "error": message }`. */
18
+ export class TurbopufferError extends Error {
19
+ status;
20
+ constructor(status, message) {
21
+ super(message);
22
+ this.status = status;
23
+ this.name = 'TurbopufferError';
24
+ }
25
+ }
26
+ /** 400: a request outside the shape turbopuffer's pages give it (a malformed filter, schema, write or query, a value of
27
+ * the wrong type, a parameter out of its documented range). The messages are the twin's; the pages name the rules, not
28
+ * the text of their refusals. */
29
+ export function badRequest(message) {
30
+ return new TurbopufferError(400, message);
31
+ }
32
+ const COMPARISON = new Set(['Eq', 'NotEq', 'In', 'NotIn', 'Contains', 'NotContains', 'ContainsAny', 'NotContainsAny', 'Lt', 'Lte', 'Gt', 'Gte', 'AnyLt', 'AnyLte', 'AnyGt', 'AnyGte', 'Glob', 'NotGlob', 'IGlob', 'NotIGlob']);
33
+ const TOKEN_OPS = new Set(['ContainsAllTokens', 'ContainsAnyToken']);
34
+ const UNMODELED_OPS = {
35
+ ContainsTokenSequence: 'turbopuffer.filters.contains_token_sequence',
36
+ };
37
+ /**
38
+ * A namespace observed from a real account may carry a full-text config the twin does not model
39
+ * (the vendor default `word_v4`, stemming, another language). Such an attribute is refused at
40
+ * query time rather than tokenized as if it were `word_v2`.
41
+ */
42
+ export function assertModelledFts(attr, fts) {
43
+ if (!MODELED_TOKENIZERS.includes(fts.tokenizer))
44
+ throw badRequest(`turbopuffer twin: attribute "${attr}" uses tokenizer "${fts.tokenizer}", which this twin does not model yet (turbopuffer.fts.tokenizers is the filed gap)`);
45
+ if (fts.language !== 'english')
46
+ throw badRequest(`turbopuffer twin: attribute "${attr}" uses language "${fts.language}", which this twin does not model (turbopuffer.fts.languages is the filed gap)`);
47
+ }
48
+ export function attributeValue(doc, attr) {
49
+ if (attr === 'id')
50
+ return doc.id;
51
+ if (attr === 'vector')
52
+ return doc.vector;
53
+ const v = doc.attributes[attr];
54
+ return v === undefined ? null : v;
55
+ }
56
+ function sameValue(a, b) {
57
+ if (a === b)
58
+ return true;
59
+ if (a === null || b === null || typeof a !== 'object' || typeof b !== 'object')
60
+ return false;
61
+ return JSON.stringify(a) === JSON.stringify(b);
62
+ }
63
+ /** -1/0/1 for two comparable scalars (number↔number, string↔string), NaN otherwise. */
64
+ export function compareScalars(a, b) {
65
+ if (typeof a === 'number' && typeof b === 'number')
66
+ return a < b ? -1 : a > b ? 1 : 0;
67
+ if (typeof a === 'string' && typeof b === 'string')
68
+ return a < b ? -1 : a > b ? 1 : 0;
69
+ if (typeof a === 'boolean' && typeof b === 'boolean')
70
+ return a === b ? 0 : a ? 1 : -1;
71
+ return Number.NaN;
72
+ }
73
+ function globRegex(pattern, insensitive) {
74
+ let out = '';
75
+ for (let i = 0; i < pattern.length; i++) {
76
+ const c = pattern[i];
77
+ if (c === '*')
78
+ out += '.*';
79
+ else if (c === '?')
80
+ out += '.';
81
+ else if (c === '[') {
82
+ const close = pattern.indexOf(']', i + 1);
83
+ if (close === -1) {
84
+ out += '\\[';
85
+ continue;
86
+ }
87
+ let body = pattern.slice(i + 1, close);
88
+ if (body.startsWith('!'))
89
+ body = `^${body.slice(1)}`;
90
+ out += `[${body.replace(/\\/g, '\\\\')}]`;
91
+ i = close;
92
+ }
93
+ else
94
+ out += c.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
95
+ }
96
+ return new RegExp(`^${out}$`, insensitive ? 'is' : 's');
97
+ }
98
+ function describe(f) {
99
+ const s = JSON.stringify(f);
100
+ return s.length > 120 ? `${s.slice(0, 117)}...` : s;
101
+ }
102
+ /** Compile a filter into a predicate, refusing a malformed one with a 400. */
103
+ export function compileFilter(f, ctx) {
104
+ if (!Array.isArray(f) || f.length === 0)
105
+ throw badRequest(`invalid filter: expected a non-empty array, got ${describe(f)}`);
106
+ const head = f[0];
107
+ if (head === 'And' || head === 'Or') {
108
+ if (f.length !== 2 || !Array.isArray(f[1]))
109
+ throw badRequest(`invalid filter: ${head} takes one array of filters, got ${describe(f)}`);
110
+ const parts = f[1].map((p) => compileFilter(p, ctx));
111
+ return head === 'And' ? (d) => parts.every((p) => p(d)) : (d) => parts.some((p) => p(d));
112
+ }
113
+ if (head === 'Not') {
114
+ if (f.length !== 2)
115
+ throw badRequest(`invalid filter: Not takes one filter, got ${describe(f)}`);
116
+ const inner = compileFilter(f[1], ctx);
117
+ return (d) => !inner(d);
118
+ }
119
+ if (typeof head !== 'string')
120
+ throw badRequest(`invalid filter: expected an attribute name, got ${describe(f)}`);
121
+ const attr = head;
122
+ const op = f[1];
123
+ if (typeof op !== 'string')
124
+ throw badRequest(`invalid filter: expected an operator for attribute "${attr}", got ${describe(f)}`);
125
+ const gap = UNMODELED_OPS[op];
126
+ if (gap !== undefined)
127
+ throw badRequest(`turbopuffer twin: the ${op} filter is real Turbopuffer surface this twin does not model yet (${gap} is the filed gap)`);
128
+ const config = ctx.schema[attr];
129
+ if (TOKEN_OPS.has(op)) {
130
+ if (f.length < 3 || f.length > 4)
131
+ throw badRequest(`invalid filter: ${op} takes a query and optional params, got ${describe(f)}`);
132
+ if (config === undefined || config.full_text_search === false)
133
+ throw badRequest(`invalid filter: ${op} requires full-text search to be enabled on attribute "${attr}"`);
134
+ const fts = config.full_text_search;
135
+ assertModelledFts(attr, fts);
136
+ const params = (f[3] ?? {});
137
+ if (typeof params !== 'object' || params === null || Array.isArray(params))
138
+ throw badRequest(`invalid filter: ${op} params must be an object`);
139
+ for (const k of Object.keys(params))
140
+ if (k !== 'last_as_prefix')
141
+ throw badRequest(`invalid filter: unknown ${op} param "${k}"`);
142
+ const query = f[2];
143
+ if (typeof query !== 'string' && !(Array.isArray(query) && query.every((q) => typeof q === 'string')))
144
+ throw badRequest(`invalid filter: ${op} query must be a string or an array of strings`);
145
+ const terms = queryTerms(query, fts, params.last_as_prefix === true);
146
+ const mode = op === 'ContainsAllTokens' ? 'all' : 'any';
147
+ return (d) => tokensMatch(docTokens(ctx, attr, fts, d), terms, mode);
148
+ }
149
+ if (op === 'Regex')
150
+ return regexFilter(attr, config, f);
151
+ if (op === 'Fuzzy')
152
+ return fuzzyFilter(attr, config, f);
153
+ if (!COMPARISON.has(op))
154
+ throw badRequest(`invalid filter: unknown operator "${op}" in ${describe(f)}`);
155
+ if (f.length !== 3)
156
+ throw badRequest(`invalid filter: ${op} takes exactly one value, got ${describe(f)}`);
157
+ // Glob "Requires the glob (or for backwards compatibility, filterable) schema attribute" (https://turbopuffer.com/docs/query)
158
+ const globbed = /^(Not)?I?Glob$/.test(op) && config?.glob === true;
159
+ if (config !== undefined && !config.filterable && !globbed && attr !== 'id') {
160
+ throw badRequest(`invalid filter: attribute "${attr}" is not filterable (set filterable: true in its schema)`);
161
+ }
162
+ const value = f[2];
163
+ const needsArray = op === 'In' || op === 'NotIn' || op === 'ContainsAny' || op === 'NotContainsAny';
164
+ if (needsArray && !Array.isArray(value))
165
+ throw badRequest(`invalid filter: ${op} requires an array value, got ${describe(f)}`);
166
+ const list = needsArray ? value : [];
167
+ // Ids order numbers before strings (the order rank_by [id, asc] serves), so paging by id never skips a type.
168
+ const cmp = attr === 'id' ? compareIdValues : compareScalars;
169
+ switch (op) {
170
+ case 'Eq': return (d) => sameValue(attributeValue(d, attr), value);
171
+ case 'NotEq': return (d) => !sameValue(attributeValue(d, attr), value);
172
+ case 'In': return (d) => { const v = attributeValue(d, attr); return v !== null && list.some((x) => sameValue(v, x)); };
173
+ case 'NotIn': return (d) => { const v = attributeValue(d, attr); return v === null || !list.some((x) => sameValue(v, x)); };
174
+ case 'Contains': return (d) => { const v = attributeValue(d, attr); return Array.isArray(v) && v.some((x) => sameValue(x, value)); };
175
+ case 'NotContains': return (d) => { const v = attributeValue(d, attr); return !(Array.isArray(v) && v.some((x) => sameValue(x, value))); };
176
+ case 'ContainsAny': return (d) => { const v = attributeValue(d, attr); return Array.isArray(v) && v.some((x) => list.some((y) => sameValue(x, y))); };
177
+ case 'NotContainsAny': return (d) => { const v = attributeValue(d, attr); return !(Array.isArray(v) && v.some((x) => list.some((y) => sameValue(x, y)))); };
178
+ case 'Lt': return (d) => cmp(attributeValue(d, attr), value) < 0;
179
+ case 'Lte': return (d) => cmp(attributeValue(d, attr), value) <= 0;
180
+ case 'Gt': return (d) => cmp(attributeValue(d, attr), value) > 0;
181
+ case 'Gte': return (d) => cmp(attributeValue(d, attr), value) >= 0;
182
+ case 'AnyLt': return (d) => anyOf(attributeValue(d, attr), (x) => compareScalars(x, value) < 0);
183
+ case 'AnyLte': return (d) => anyOf(attributeValue(d, attr), (x) => compareScalars(x, value) <= 0);
184
+ case 'AnyGt': return (d) => anyOf(attributeValue(d, attr), (x) => compareScalars(x, value) > 0);
185
+ case 'AnyGte': return (d) => anyOf(attributeValue(d, attr), (x) => compareScalars(x, value) >= 0);
186
+ case 'Glob':
187
+ case 'NotGlob':
188
+ case 'IGlob':
189
+ case 'NotIGlob': {
190
+ if (typeof value !== 'string')
191
+ throw badRequest(`invalid filter: ${op} requires a string pattern`);
192
+ let re;
193
+ try {
194
+ re = globRegex(value, op === 'IGlob' || op === 'NotIGlob');
195
+ }
196
+ catch {
197
+ throw badRequest(`invalid filter: ${op} pattern ${JSON.stringify(value)} is not a valid glob`);
198
+ }
199
+ const negate = op.startsWith('Not');
200
+ return (d) => { const v = attributeValue(d, attr); const hit = typeof v === 'string' && re.test(v); return negate ? !hit : hit; };
201
+ }
202
+ }
203
+ throw badRequest(`invalid filter: unknown operator "${op}"`);
204
+ }
205
+ /** Regex (https://turbopuffer.com/docs/query): "Regular expression match against `string` attribute values. Requires the
206
+ * regex schema attribute to be enabled before use"; its example `["text", "Regex", "\\w+fish"]` "matches "swordfish",
207
+ * "pufferfish", "clownfish"", so a pattern matches anywhere in the value. Where the docs stop: the twin runs the pattern
208
+ * as a JavaScript regular expression (Unicode mode), which agrees with Rust's regex syntax for the common classes. */
209
+ function regexFilter(attr, config, f) {
210
+ if (config === undefined || !config.regex)
211
+ throw badRequest(`invalid filter: Regex requires regex: true in the schema of attribute "${attr}"`);
212
+ if (f.length !== 3 || typeof f[2] !== 'string')
213
+ throw badRequest('invalid filter: Regex takes a pattern string');
214
+ let re;
215
+ try {
216
+ re = new RegExp(f[2], 'u');
217
+ }
218
+ catch {
219
+ throw badRequest(`invalid filter: Regex pattern ${JSON.stringify(f[2])} is not a valid regular expression`);
220
+ }
221
+ return (d) => { const v = attributeValue(d, attr); return typeof v === 'string' && re.test(v); };
222
+ }
223
+ /** Fuzzy (https://turbopuffer.com/docs/query and /docs/fts, Fuzzy matching): "Fuzzy substring match against `string` or
224
+ * `[]string` attribute values. Requires the fuzzy schema attribute". `max_edit_distance` "sets how many edits to
225
+ * tolerate by the query length in characters (uses Levenshtein distance). `distance` can be `0`, `1`, or `2`, and
226
+ * `min_query_chars` must be at least 3 · (`distance` + 1). Queries shorter than the first `min_query_chars` threshold
227
+ * return no matches"; `case_sensitive` "defaults to `true`", and "A missing or added character, incorrect character,
228
+ * missing or added diacritic (e.g. ü), or case difference will add 1 to the edit distance". The twin measures the
229
+ * fewest edits between the query and any substring of the value, over NFC characters. */
230
+ function fuzzyFilter(attr, config, f) {
231
+ if (config === undefined || !config.fuzzy)
232
+ throw badRequest(`invalid filter: Fuzzy requires fuzzy: true in the schema of attribute "${attr}"`);
233
+ if (f.length !== 4 || typeof f[2] !== 'string' || f[3] === null || typeof f[3] !== 'object' || Array.isArray(f[3]))
234
+ throw badRequest('invalid filter: Fuzzy takes a query string and { max_edit_distance, case_sensitive? }');
235
+ const opts = f[3];
236
+ const steps = opts.max_edit_distance;
237
+ if (!Array.isArray(steps) || steps.length === 0)
238
+ throw badRequest('invalid filter: Fuzzy requires max_edit_distance');
239
+ const table = steps.map((s) => {
240
+ const e = s;
241
+ if (e === null || typeof e !== 'object' || ![0, 1, 2].includes(e.distance) || typeof e.min_query_chars !== 'number' || e.min_query_chars < 3 * (e.distance + 1)) {
242
+ throw badRequest('invalid filter: each max_edit_distance entry is {min_query_chars, distance}, distance 0, 1 or 2 and min_query_chars at least 3 · (distance + 1)');
243
+ }
244
+ return { min: e.min_query_chars, distance: e.distance };
245
+ }).sort((a, b) => a.min - b.min);
246
+ const sensitive = opts.case_sensitive !== false;
247
+ const norm = (s) => [...(sensitive ? s.normalize('NFC') : s.normalize('NFC').toLowerCase())];
248
+ const q = norm(f[2]);
249
+ const allowed = table.filter((t) => q.length >= t.min).at(-1)?.distance;
250
+ if (allowed === undefined)
251
+ return () => false;
252
+ const within = (value) => substringDistance(q, norm(value)) <= allowed;
253
+ return (d) => { const v = attributeValue(d, attr); return typeof v === 'string' ? within(v) : Array.isArray(v) && v.some((x) => typeof x === 'string' && within(x)); };
254
+ }
255
+ /** The fewest Levenshtein edits turning the query into some substring of the text (Sellers' algorithm). */
256
+ function substringDistance(q, t) {
257
+ let prev = new Array(t.length + 1).fill(0);
258
+ for (let i = 1; i <= q.length; i++) {
259
+ const cur = [i];
260
+ for (let j = 1; j <= t.length; j++)
261
+ cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (q[i - 1] === t[j - 1] ? 0 : 1));
262
+ prev = cur;
263
+ }
264
+ return Math.min(...prev);
265
+ }
266
+ function compareIdValues(a, b) {
267
+ const ta = typeof a;
268
+ const tb = typeof b;
269
+ if ((ta !== 'number' && ta !== 'string') || (tb !== 'number' && tb !== 'string'))
270
+ return Number.NaN;
271
+ if (ta !== tb)
272
+ return ta === 'number' ? -1 : 1;
273
+ return compareScalars(a, b);
274
+ }
275
+ function anyOf(v, test) {
276
+ return Array.isArray(v) && v.some(test);
277
+ }
278
+ export function docTokens(ctx, attr, fts, d) {
279
+ const key = `${attr}\u0000${d.key}`;
280
+ let tokens = ctx.tokenCache.get(key);
281
+ if (tokens === undefined) {
282
+ tokens = tokenize(attributeValue(d, attr), fts);
283
+ ctx.tokenCache.set(key, tokens);
284
+ }
285
+ return tokens;
286
+ }
@@ -0,0 +1,23 @@
1
+ import { type DerivedFetch } from '@volter/world-core';
2
+ export interface TurbopufferTwinFetchOptions {
3
+ root?: string;
4
+ readOnly?: boolean;
5
+ /** The API key the twin demands. Omit to accept any non-empty key (a missing one still 401s). */
6
+ token?: string;
7
+ }
8
+ /**
9
+ * The pack's wire (docs/contributing/architecture.md, "Protocol 3"): the twin's discovery door (`GET /twin`) in
10
+ * front, turbopuffer's dashboard on turbopuffer.com (screens/dashboard.tsx: sign-in and API keys), then the key gate
11
+ * (key-gate.ts) and the derived dispatch over turbopuffer's spec. A leading region segment (`/aws-us-east-1/v2/...`) is
12
+ * dropped first: it is how a World points an unmodified client here through the SDK's own `TURBOPUFFER_BASE_URL`,
13
+ * whose `{region}` placeholder the client fills in (the pack descriptor's `endpointEnv`). The operations the twin
14
+ * serves go to their handlers (semantics/namespaces.ts); every other one, and a path the spec does not have, is the
15
+ * gap.
16
+ */
17
+ export declare function createTurbopufferTwinFetch(options?: TurbopufferTwinFetchOptions): DerivedFetch;
18
+ export declare function createTurbopufferTwinServer(options?: TurbopufferTwinFetchOptions & {
19
+ port?: number;
20
+ }): Promise<{
21
+ port: number;
22
+ stop: () => void;
23
+ }>;
@@ -0,0 +1,72 @@
1
+ // FETCH-FIRST (runtime contract R12b): the pack's HTTP surface is a plain `(Request) => Promise<Response>`, and the
2
+ // local server is the kernel's serve seam around the SAME closure. The SDK never gzips a request body unless the
3
+ // caller opts into `compression: true` (client.ts: `options.compression === undefined ? false`), so the handler's
4
+ // text read of the body is faithful.
5
+ import { compileSurface, createDerivedFetch, createTwinFetchFromHandler, matchOperation, semanticsContext, serveHttp, statefulTwinManifest } from '@volter/world-core';
6
+ import surface from './generated/surface.gen.json' with { type: 'json' };
7
+ import { keyGate } from "./key-gate.js";
8
+ import { manifest } from "./manifest.js";
9
+ import { DASHBOARD_HOST, dashboard } from "./screens/dashboard.js";
10
+ import { turbopufferHandlers } from "./semantics/namespaces.js";
11
+ import { handleTurbopufferTwinRequest, normalizeTurbopufferPath } from "./turbopuffer-twin.js";
12
+ // the operation a context is opened under for the dashboard's pages, which answer no operation of the spec
13
+ const DASHBOARD = { id: 'TurbopufferDashboard', method: 'GET', path: '/dashboard', class: 'action' };
14
+ const routes = compileSurface(surface);
15
+ /**
16
+ * The pack's wire (docs/contributing/architecture.md, "Protocol 3"): the twin's discovery door (`GET /twin`) in
17
+ * front, turbopuffer's dashboard on turbopuffer.com (screens/dashboard.tsx: sign-in and API keys), then the key gate
18
+ * (key-gate.ts) and the derived dispatch over turbopuffer's spec. A leading region segment (`/aws-us-east-1/v2/...`) is
19
+ * dropped first: it is how a World points an unmodified client here through the SDK's own `TURBOPUFFER_BASE_URL`,
20
+ * whose `{region}` placeholder the client fills in (the pack descriptor's `endpointEnv`). The operations the twin
21
+ * serves go to their handlers (semantics/namespaces.ts); every other one, and a path the spec does not have, is the
22
+ * gap.
23
+ */
24
+ export function createTurbopufferTwinFetch(options = {}) {
25
+ const answer = createTwinFetchFromHandler(handleTurbopufferTwinRequest, {
26
+ ...(options.root !== undefined ? { root: options.root } : {}),
27
+ ...(options.readOnly !== undefined ? { readOnly: options.readOnly } : {}),
28
+ ...(options.token !== undefined ? { handlerOptions: { token: options.token } } : {}),
29
+ manifest: statefulTwinManifest({
30
+ vendor: 'turbopuffer',
31
+ twinOf: 'the Turbopuffer API (v2 namespaces: write, query and multi-query, deleteAll, metadata, schema, namespace listing, cache warm hint)',
32
+ stores: 'namespaces (schema, distance metric) and their documents; queries filter, BM25-rank, order and count them deterministically',
33
+ identity: 'Send `Authorization: Bearer <any non-empty key>`, as the SDK does from TURBOPUFFER_API_KEY. A leading region path segment (/aws-us-east-1/v2/...) is accepted, which is how TURBOPUFFER_BASE_URL=<twin>/{region} reaches the twin.',
34
+ }),
35
+ });
36
+ const derived = createDerivedFetch({
37
+ surface: surface,
38
+ handlers: turbopufferHandlers(answer),
39
+ gap: (request) => turbopufferGap(request),
40
+ });
41
+ const scope = options.root !== undefined ? { root: options.root } : {};
42
+ const contextFor = (request, operation = DASHBOARD) => semanticsContext(manifest, request, operation, scope);
43
+ return Object.assign(async (request) => {
44
+ const url = new URL(request.url);
45
+ // the vendor host a redirected request names (the injector's fetch path, a hosted World), else its own
46
+ const host = (request.headers.get('x-volter-twin-original-host') ?? url.host).split(':')[0].toLowerCase();
47
+ if (host === DASHBOARD_HOST)
48
+ return (await dashboard(request, (r) => contextFor(r))) ?? new Response('Not Found', { status: 404, headers: { 'content-type': 'text/plain' } });
49
+ if (request.method === 'GET' && url.pathname.replace(/\/+$/, '') === '/twin')
50
+ return answer(request);
51
+ const path = normalizeTurbopufferPath(url.pathname);
52
+ const routed = path === url.pathname ? request : new Request(Object.assign(url, { pathname: path }), request);
53
+ const matched = matchOperation(routes, routed.method, new URL(routed.url).pathname, new URL(routed.url).searchParams, routed.headers);
54
+ const refused = matched ? keyGate(await contextFor(new Request(routed.url), matched.operation), routed, matched.operation.id) : undefined;
55
+ return refused ?? derived(routed);
56
+ }, { owners: () => derived.owners() });
57
+ }
58
+ /** What the twin answers a request it has no operation for, in turbopuffer's error body
59
+ * (https://turbopuffer.com/docs/auth, "Error responses"). Where the documentation stops: no page prints the answer
60
+ * to an unknown path or an operation the twin does not model; 404 is the twin's. */
61
+ function turbopufferGap(request) {
62
+ const url = new URL(request.url);
63
+ return Response.json({ status: 'error', error: `not found: ${request.method} ${url.pathname}` }, { status: 404 });
64
+ }
65
+ export async function createTurbopufferTwinServer(options = {}) {
66
+ const server = await serveHttp({
67
+ port: options.port ?? 0,
68
+ idleTimeout: 60,
69
+ fetch: createTurbopufferTwinFetch(options),
70
+ });
71
+ return { port: server.port ?? options.port ?? 0, stop: () => server.stop(true) };
72
+ }
@@ -0,0 +1 @@
1
+ export declare function stemEnglish(word: string): string;