@pplethai/cli 0.6.0 → 0.8.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 (51) hide show
  1. package/dist/bin.js +171 -22
  2. package/package.json +3 -2
  3. package/templates/golden-path/.dev.vars.example +19 -1
  4. package/templates/golden-path/CLAUDE.md +74 -7
  5. package/templates/golden-path/README.md +27 -2
  6. package/templates/golden-path/gitignore +3 -0
  7. package/templates/golden-path/overlay-mtime.mjs +30 -0
  8. package/templates/golden-path/package-lock.json +19 -1
  9. package/templates/golden-path/package.json +3 -2
  10. package/templates/golden-path/scripts/assert-dev-compiled-out.mjs +42 -0
  11. package/templates/golden-path/shared/api.ts +42 -2
  12. package/templates/golden-path/src/auth.ts +99 -7
  13. package/templates/golden-path/src/call-service.ts +140 -0
  14. package/templates/golden-path/src/dataset-query.ts +470 -0
  15. package/templates/golden-path/src/dataset-schema.ts +398 -0
  16. package/templates/golden-path/src/dev-call-service.ts +391 -0
  17. package/templates/golden-path/src/dev-personas.ts +105 -7
  18. package/templates/golden-path/src/dev-read-dataset.ts +429 -0
  19. package/templates/golden-path/src/dev-registry.ts +276 -0
  20. package/templates/golden-path/src/env.ts +17 -0
  21. package/templates/golden-path/src/identity-header.ts +17 -0
  22. package/templates/golden-path/src/index.ts +32 -1
  23. package/templates/golden-path/src/legal-identity.ts +180 -0
  24. package/templates/golden-path/src/levels.ts +132 -0
  25. package/templates/golden-path/src/read-dataset.ts +376 -0
  26. package/templates/golden-path/src/registry.ts +122 -0
  27. package/templates/golden-path/src/vite-env.d.ts +13 -0
  28. package/templates/golden-path/test/call-service.test.ts +178 -0
  29. package/templates/golden-path/test/dev-call-service.test.ts +279 -0
  30. package/templates/golden-path/test/dev-read-dataset.test.ts +270 -0
  31. package/templates/golden-path/test/dev-registry.test.ts +375 -0
  32. package/templates/golden-path/test/effective-sub.test.ts +101 -0
  33. package/templates/golden-path/test/fixtures/overlay/directory/branches/entry.md +85 -0
  34. package/templates/golden-path/test/fixtures/overlay/directory/branches/mocks/audience.json +3 -0
  35. package/templates/golden-path/test/fixtures/overlay/directory/branches/mocks/named.json +3 -0
  36. package/templates/golden-path/test/fixtures/overlay/directory/branches/mocks/offices.json +6 -0
  37. package/templates/golden-path/test/fixtures/overlay/directory/branches/mocks/roster.json +3 -0
  38. package/templates/golden-path/test/fixtures/overlay/directory/branches/mocks/summary.json +1 -0
  39. package/templates/golden-path/test/fixtures/overlay/platform/legal-identity/entry.md +8 -0
  40. package/templates/golden-path/test/fixtures/overlay/platform/legal-identity/mocks/responses.json +13 -0
  41. package/templates/golden-path/test/fixtures/overlay/shop/catalog/entry.md +8 -0
  42. package/templates/golden-path/test/fixtures/overlay/shop/catalog/mocks/responses.json +7 -0
  43. package/templates/golden-path/test/fixtures/overlay/shop/orders/entry.md +12 -0
  44. package/templates/golden-path/test/fixtures/overlay/shop/orders/mocks/responses.json +36 -0
  45. package/templates/golden-path/test/harness.ts +101 -16
  46. package/templates/golden-path/test/legal-identity.test.ts +323 -0
  47. package/templates/golden-path/test/membership.test.ts +105 -0
  48. package/templates/golden-path/test/read-dataset.test.ts +214 -0
  49. package/templates/golden-path/test/service-call.test.ts +166 -0
  50. package/templates/golden-path/vite.config.ts +2 -1
  51. package/templates/golden-path/vitest.config.ts +2 -0
@@ -0,0 +1,140 @@
1
+ // A Consumer's call to a Registry Service (ADR-0024). The Builder names the
2
+ // entry and the path tail. This module adds the services address, the App
3
+ // Key, and — only when the caller passes one — the user token. The Builder
4
+ // never sets those.
5
+ //
6
+ // Under `npm run dev`, while `DEV_PERSONA` is not `"none"`, the answer comes
7
+ // from the workspace overlay (ADR-0026, `dev-call-service.ts`). That branch
8
+ // is compiled out of the production build. `getLegalIdentity` still uses its
9
+ // built-in Persona answer before it calls this. Under vitest the fetch is
10
+ // the one the test injected: this module does not read the overlay, which
11
+ // CI does not have.
12
+
13
+ import { answerOfflineCall } from "./dev-call-service";
14
+
15
+ export const PLATFORM_ERROR_HEADER = "x-pple-error";
16
+
17
+ /**
18
+ * Platform refusals, in the order a caller meets them.
19
+ * The first eight are ADR-0024 (a Service call). The last four are ADR-0030
20
+ * (a Dataset read) and are not part of the Service check order.
21
+ */
22
+ export const PLATFORM_ERROR_CODES = [
23
+ "invalid_app_key",
24
+ "not_listed",
25
+ "not_granted",
26
+ "needs_beta",
27
+ "user_token_invalid",
28
+ "identity_unavailable",
29
+ "provider_unavailable",
30
+ "provider_timeout",
31
+ "method_not_allowed",
32
+ "not_found",
33
+ "invalid_query",
34
+ "level_not_met",
35
+ ] as const;
36
+
37
+ export type PlatformErrorCode = (typeof PLATFORM_ERROR_CODES)[number];
38
+
39
+ const PLATFORM_ERROR_SET: ReadonlySet<string> = new Set(PLATFORM_ERROR_CODES);
40
+
41
+ const USER_TOKEN_HEADER = "x-pple-user-token";
42
+
43
+ export interface CallServiceEnv {
44
+ PPLE_SERVICES_URL?: string;
45
+ PPLE_APP_KEY?: string;
46
+ /** `"none"` uses the real service. Anything else, under `npm run dev`, uses the overlay. */
47
+ DEV_PERSONA?: string;
48
+ /**
49
+ * Offline only. `<code>` for every entry, or `<provider>/<name>=<code>`,
50
+ * comma-separated. A per-entry code wins. Unknown codes are ignored.
51
+ */
52
+ DEV_REGISTRY_ERROR?: string;
53
+ }
54
+
55
+ export interface CallServiceOptions {
56
+ /** End-user PPLE ID token. Sent as `x-pple-user-token` only when set. */
57
+ userToken?: string;
58
+ /**
59
+ * Test seam. Under vitest this is required: `callService` does not fall
60
+ * back to `globalThis.fetch`. Production omits it.
61
+ */
62
+ fetchImpl?: typeof fetch;
63
+ }
64
+
65
+ /**
66
+ * `GET`/`POST`/… `services.<domain>/<provider>/<name>/<path>`.
67
+ *
68
+ * `path` `""` is the entry root. A path without a leading `/` or `?` gains
69
+ * one `/`. The App Key is always `Authorization: Bearer`. A `userToken` is
70
+ * the only way the user token is sent; a token on `init` is dropped.
71
+ *
72
+ * The returned `Response` is the platform's or the Provider's, unchanged,
73
+ * except under `npm run dev` (see above), where it is a mock or a dev refusal.
74
+ * Tell a platform refusal apart from a Provider response with `platformError`.
75
+ * `no_mock` is dev-only and is not a platform code.
76
+ */
77
+ export async function callService(
78
+ env: CallServiceEnv,
79
+ entry: string,
80
+ path: string,
81
+ init: RequestInit = {},
82
+ options: CallServiceOptions = {},
83
+ ): Promise<Response> {
84
+ if (!/^[^/\s]+\/[^/\s]+$/.test(entry)) {
85
+ throw new Error('callService entry must be "<provider>/<name>"');
86
+ }
87
+
88
+ // `import.meta.env.DEV` is false in the production build, so this branch
89
+ // and `dev-call-service.ts` are absent from the deployed Worker. Vitest
90
+ // keeps the injected fetch even when a Persona is set.
91
+ if (import.meta.env.DEV && env.DEV_PERSONA !== "none" && !underVitest()) {
92
+ return answerOfflineCall(env, entry, path, init, options);
93
+ }
94
+
95
+ const fetchImpl = resolveFetch(options.fetchImpl);
96
+ if (!env.PPLE_SERVICES_URL || !env.PPLE_APP_KEY) {
97
+ throw new Error("callService needs PPLE_SERVICES_URL and PPLE_APP_KEY");
98
+ }
99
+
100
+ const headers = new Headers(init.headers);
101
+ headers.set("authorization", `Bearer ${env.PPLE_APP_KEY}`);
102
+ if (options.userToken !== undefined) headers.set(USER_TOKEN_HEADER, options.userToken);
103
+ else headers.delete(USER_TOKEN_HEADER);
104
+
105
+ return fetchImpl(serviceUrl(env.PPLE_SERVICES_URL, entry, path), { ...init, headers });
106
+ }
107
+
108
+ /**
109
+ * The platform refusal code on `response`, read from `x-pple-error`.
110
+ *
111
+ * Null when the header is absent or names a code outside ADR-0024 and
112
+ * ADR-0030. A null from an absent header means the Provider answered.
113
+ * `no_mock` is dev-only and stays null here. Callers that must not treat an
114
+ * unknown code as a Provider response check the header itself.
115
+ */
116
+ export function platformError(response: Response): PlatformErrorCode | null {
117
+ const code = response.headers.get(PLATFORM_ERROR_HEADER);
118
+ if (code === null || !PLATFORM_ERROR_SET.has(code)) return null;
119
+ return code as PlatformErrorCode;
120
+ }
121
+
122
+ function serviceUrl(base: string, entry: string, path: string): string {
123
+ const root = `${base.replace(/\/+$/, "")}/${entry}`;
124
+ if (path === "") return root;
125
+ if (path.startsWith("/") || path.startsWith("?")) return `${root}${path}`;
126
+ return `${root}/${path}`;
127
+ }
128
+
129
+ function resolveFetch(fetchImpl: typeof fetch | undefined): typeof fetch {
130
+ if (fetchImpl) return fetchImpl;
131
+ if (underVitest()) {
132
+ throw new Error("callService under vitest uses the injected fetch");
133
+ }
134
+ return globalThis.fetch.bind(globalThis);
135
+ }
136
+
137
+ function underVitest(): boolean {
138
+ const flag = import.meta.env.VITEST;
139
+ return import.meta.env.MODE === "test" || flag === true || flag === "true";
140
+ }
@@ -0,0 +1,470 @@
1
+ // Filter, sort and keyset paging for one Dataset Object Type (ADR-0030).
2
+ //
3
+ // A copy of workers/src/shared/registry/query.ts. This file is copied into
4
+ // a Builder App, which cannot import the platform package. Offline
5
+ // readDataset runs it over mock rows. A cursor binds to the filters and the
6
+ // sort: the same cursor on a different query is invalid_query. A descending
7
+ // sort breaks ties by id descending, matching the warehouse's backward index
8
+ // walk. cli/test/registry-query.test.ts keeps this copy in step with the
9
+ // platform.
10
+
11
+ import { canonicalJson, isPlainObject, valueProblems } from "./dataset-schema.js";
12
+
13
+ export type QueryValue = string | number | boolean;
14
+
15
+ export type DatasetFilterOp = "eq" | "in" | "gt" | "gte" | "lt" | "lte";
16
+
17
+ export interface DatasetFilter {
18
+ field: string;
19
+ op: DatasetFilterOp;
20
+ values: QueryValue[];
21
+ }
22
+
23
+ /** A collection the query module can page. A singleton is not a query. */
24
+ export interface DatasetQuerySpec {
25
+ idType: "string" | "integer";
26
+ index: readonly { name: string; schema: Record<string, unknown> }[];
27
+ }
28
+
29
+ export type DatasetCursor =
30
+ | { kind: "id"; id: string }
31
+ | { kind: "field"; id: string; value: QueryValue | null };
32
+
33
+ export interface DatasetQuery {
34
+ filters: DatasetFilter[];
35
+ /** Null is the default: id ascending. */
36
+ sort: { field: string; direction: "asc" | "desc" } | null;
37
+ limit: number;
38
+ after: DatasetCursor | null;
39
+ /** Hash of the filters and the sort. Limit and the cursor are not part of it. */
40
+ hash: string;
41
+ }
42
+
43
+ export interface DatasetRow {
44
+ id: string;
45
+ object: Record<string, unknown>;
46
+ }
47
+
48
+ const DEFAULT_LIMIT = 100;
49
+ const MAX_LIMIT = 1000;
50
+ const RESERVED = new Set(["limit", "sort", "next"]);
51
+
52
+ function rangeOp(value: string | undefined): "gt" | "gte" | "lt" | "lte" | null {
53
+ if (value === "gt" || value === "gte" || value === "lt" || value === "lte") return value;
54
+ return null;
55
+ }
56
+
57
+ type FieldKind = "string" | "number" | "integer" | "boolean";
58
+
59
+ /**
60
+ * Parse a collection query. `invalid_query` is the only failure: a bad value,
61
+ * a field that is not an index, a limit out of range, or a cursor from
62
+ * another query.
63
+ */
64
+ export function parseDatasetQuery(
65
+ params: URLSearchParams,
66
+ spec: DatasetQuerySpec,
67
+ ): { ok: true; query: DatasetQuery } | { ok: false; error: "invalid_query" } {
68
+ const invalid = { ok: false, error: "invalid_query" } as const;
69
+ if (params.getAll("limit").length > 1 || params.getAll("sort").length > 1 || params.getAll("next").length > 1) {
70
+ return invalid;
71
+ }
72
+
73
+ const limit = parseLimit(params.get("limit"));
74
+ if (limit === null) return invalid;
75
+
76
+ const sort = parseSort(params.get("sort"), spec);
77
+ if (sort === undefined) return invalid;
78
+
79
+ const filters = parseFilters(params, spec);
80
+ if (filters === null) return invalid;
81
+
82
+ const hash = datasetOpaqueHash({
83
+ filters,
84
+ sort: sort ?? { field: "id", direction: "asc" },
85
+ });
86
+
87
+ const rawCursor = params.get("next");
88
+ let after: DatasetCursor | null = null;
89
+ if (rawCursor !== null) {
90
+ if (rawCursor === "") return invalid;
91
+ const cursor = decodeCursor(rawCursor, spec, sort, hash);
92
+ if (cursor === null) return invalid;
93
+ after = cursor;
94
+ }
95
+
96
+ return { ok: true, query: { filters, sort, limit, after, hash } };
97
+ }
98
+
99
+ /** One page of `rows`, in query order. `next` is null on the last page. */
100
+ export function applyDatasetQuery(
101
+ rows: readonly DatasetRow[],
102
+ query: DatasetQuery,
103
+ spec: DatasetQuerySpec,
104
+ ): { objects: Record<string, unknown>[]; next: string | null } {
105
+ const sorted = rows.filter((row) => rowMatches(row, query, spec)).slice();
106
+ sorted.sort((left, right) => compareRows(left, right, query, spec));
107
+ let start = 0;
108
+ if (query.after) {
109
+ const cursor = cursorRow(query);
110
+ const found = sorted.findIndex((row) => compareRows(row, cursor, query, spec) > 0);
111
+ start = found === -1 ? sorted.length : found;
112
+ }
113
+ const page = sorted.slice(start, start + query.limit);
114
+ const last = page[page.length - 1];
115
+ const more = start + query.limit < sorted.length;
116
+ return {
117
+ objects: page.map((row) => row.object),
118
+ next: more && last ? datasetPageCursor(query, last, spec) : null,
119
+ };
120
+ }
121
+
122
+ /** The cursor a caller sends to get the page after `last`. */
123
+ export function datasetPageCursor(query: DatasetQuery, last: DatasetRow, spec: DatasetQuerySpec): string {
124
+ if (query.sort === null) return encodeCursor({ h: query.hash, id: last.id });
125
+ return encodeCursor({
126
+ h: query.hash,
127
+ id: last.id,
128
+ s: 1,
129
+ v: exposedValue(last.object, query.sort.field, spec),
130
+ });
131
+ }
132
+
133
+ /**
134
+ * Hash of everything that changes a page except the snapshot: the query, the
135
+ * page size and the cursor. Two pages therefore do not share an ETag.
136
+ */
137
+ export function datasetPageHash(query: DatasetQuery): string {
138
+ return datasetOpaqueHash({ hash: query.hash, limit: query.limit, after: query.after });
139
+ }
140
+
141
+ /** Stable hash of a JSON value. Used for cursors and ETags, not as a secret. */
142
+ export function datasetOpaqueHash(value: unknown): string {
143
+ const text = canonicalJson(value);
144
+ return `${fnv(text, 0x811c9dc5)}${fnv(text, 0x01000193)}`;
145
+ }
146
+
147
+ function parseLimit(raw: string | null): number | null {
148
+ if (raw === null) return DEFAULT_LIMIT;
149
+ const text = raw.trim();
150
+ if (!/^\d+$/.test(text)) return null;
151
+ const value = Number(text);
152
+ if (!Number.isSafeInteger(value) || value < 1 || value > MAX_LIMIT) return null;
153
+ return value;
154
+ }
155
+
156
+ function parseSort(
157
+ raw: string | null,
158
+ spec: DatasetQuerySpec,
159
+ ): { field: string; direction: "asc" | "desc" } | null | undefined {
160
+ if (raw === null) return null;
161
+ const text = raw.trim();
162
+ if (text === "") return undefined;
163
+ const descending = text.startsWith("-");
164
+ const field = descending ? text.slice(1) : text;
165
+ if (field === "" || !spec.index.some((item) => item.name === field)) return undefined;
166
+ return { field, direction: descending ? "desc" : "asc" };
167
+ }
168
+
169
+ function parseFilters(params: URLSearchParams, spec: DatasetQuerySpec): DatasetFilter[] | null {
170
+ const equals = new Map<string, string[]>();
171
+ const ranges = new Map<string, Partial<Record<"gt" | "gte" | "lt" | "lte", string>>>();
172
+ const known = new Map(spec.index.map((field) => [field.name, field.schema]));
173
+
174
+ for (const [key, raw] of params) {
175
+ if (RESERVED.has(key)) continue;
176
+ const range = /^([^[\]]+)\[(gt|gte|lt|lte)]$/.exec(key);
177
+ if (range) {
178
+ const name = range[1];
179
+ const op = rangeOp(range[2]);
180
+ if (!name || !op) return null;
181
+ if (!known.has(name)) return null;
182
+ const current = ranges.get(name) ?? {};
183
+ // The same bound twice is not a range a caller can mean.
184
+ if (current[op] !== undefined) return null;
185
+ current[op] = raw;
186
+ ranges.set(name, current);
187
+ continue;
188
+ }
189
+ if (key.includes("[") || key.includes("]")) return null;
190
+ if (!known.has(key)) return null;
191
+ const list = equals.get(key) ?? [];
192
+ list.push(raw);
193
+ equals.set(key, list);
194
+ }
195
+
196
+ const filters: DatasetFilter[] = [];
197
+ for (const [field, raws] of equals) {
198
+ const schema = known.get(field);
199
+ if (!schema) return null;
200
+ const parsed = uniqueValues(raws.map((raw) => parseScalar(raw, schema)));
201
+ if (parsed.some((value) => value === undefined)) return null;
202
+ const values = parsed.filter((value): value is QueryValue => value !== undefined);
203
+ filters.push({ field, op: values.length > 1 ? "in" : "eq", values: values.sort(byCanonical) });
204
+ }
205
+ for (const [field, ops] of ranges) {
206
+ const schema = known.get(field);
207
+ if (!schema) return null;
208
+ for (const op of ["gt", "gte", "lt", "lte"] as const) {
209
+ const raw = ops[op];
210
+ if (raw === undefined) continue;
211
+ const value = parseScalar(raw, schema);
212
+ if (value === undefined) return null;
213
+ filters.push({ field, op, values: [value] });
214
+ }
215
+ }
216
+ filters.sort((left, right) => compareText(left.field, right.field) || opRank(left.op) - opRank(right.op));
217
+ return filters;
218
+ }
219
+
220
+ function parseScalar(raw: string, schema: Record<string, unknown>): QueryValue | undefined {
221
+ for (const type of declaredTypes(schema)) {
222
+ const value = coerce(raw, type);
223
+ if (value === undefined) continue;
224
+ if (valueProblems(schema, value).length === 0) return value;
225
+ }
226
+ return undefined;
227
+ }
228
+
229
+ function declaredTypes(schema: Record<string, unknown>): string[] {
230
+ const type = schema.type;
231
+ const list = typeof type === "string" ? [type] : Array.isArray(type) ? type : [];
232
+ return list.filter((item): item is string => typeof item === "string" && item !== "null");
233
+ }
234
+
235
+ function coerce(raw: string, type: string): QueryValue | undefined {
236
+ if (type === "boolean") {
237
+ if (raw === "true") return true;
238
+ if (raw === "false") return false;
239
+ return undefined;
240
+ }
241
+ if (type === "integer") {
242
+ if (!/^-?\d+$/.test(raw)) return undefined;
243
+ const value = Number(raw);
244
+ if (!Number.isSafeInteger(value)) return undefined;
245
+ return value;
246
+ }
247
+ if (type === "number") {
248
+ if (!/^-?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/.test(raw)) return undefined;
249
+ const value = Number(raw);
250
+ if (!Number.isFinite(value)) return undefined;
251
+ return value;
252
+ }
253
+ if (type === "string") return raw;
254
+ return undefined;
255
+ }
256
+
257
+ function uniqueValues(values: readonly (QueryValue | undefined)[]): (QueryValue | undefined)[] {
258
+ const seen = new Set<string>();
259
+ const out: (QueryValue | undefined)[] = [];
260
+ for (const value of values) {
261
+ const key = value === undefined ? "\0" : canonicalJson(value);
262
+ if (seen.has(key)) continue;
263
+ seen.add(key);
264
+ out.push(value);
265
+ }
266
+ return out;
267
+ }
268
+
269
+ function byCanonical(left: QueryValue, right: QueryValue): number {
270
+ return compareText(canonicalJson(left), canonicalJson(right));
271
+ }
272
+
273
+ function opRank(op: DatasetFilterOp): number {
274
+ if (op === "eq") return 0;
275
+ if (op === "in") return 1;
276
+ if (op === "gt") return 2;
277
+ if (op === "gte") return 3;
278
+ if (op === "lt") return 4;
279
+ return 5;
280
+ }
281
+
282
+ function decodeCursor(
283
+ token: string,
284
+ spec: DatasetQuerySpec,
285
+ sort: { field: string; direction: "asc" | "desc" } | null,
286
+ hash: string,
287
+ ): DatasetCursor | null {
288
+ let value: unknown;
289
+ try {
290
+ value = JSON.parse(base64UrlDecode(token));
291
+ } catch {
292
+ return null;
293
+ }
294
+ if (!isPlainObject(value) || typeof value.h !== "string" || value.h !== hash) return null;
295
+ if (typeof value.id !== "string" || !idFits(value.id, spec.idType)) return null;
296
+ const fieldSort = value.s === 1;
297
+ if (sort === null) {
298
+ if (fieldSort || "v" in value) return null;
299
+ return { kind: "id", id: value.id };
300
+ }
301
+ if (!fieldSort) return null;
302
+ const sortValue = "v" in value ? value.v : null;
303
+ if (!sortValueFits(sortValue, sort.field, spec)) return null;
304
+ return { kind: "field", id: value.id, value: sortValue as QueryValue | null };
305
+ }
306
+
307
+ function idFits(id: string, idType: DatasetQuerySpec["idType"]): boolean {
308
+ if (id === "") return false;
309
+ if (idType === "string") return true;
310
+ if (!/^-?\d+$/.test(id)) return false;
311
+ return Number.isSafeInteger(Number(id));
312
+ }
313
+
314
+ function sortValueFits(value: unknown, field: string, spec: DatasetQuerySpec): value is QueryValue | null {
315
+ if (value === null) return true;
316
+ const schema = spec.index.find((item) => item.name === field)?.schema;
317
+ if (!schema) return false;
318
+ const kind = fieldKind(schema);
319
+ if (kind === "boolean") return typeof value === "boolean";
320
+ if (kind === "string") return typeof value === "string";
321
+ if (typeof value !== "number" || !Number.isFinite(value)) return false;
322
+ return kind === "number" || Number.isInteger(value);
323
+ }
324
+
325
+ function encodeCursor(body: { h: string; id: string; s?: 1; v?: QueryValue | null }): string {
326
+ const payload: Record<string, unknown> = { h: body.h, id: body.id };
327
+ if (body.s === 1) {
328
+ payload.s = 1;
329
+ payload.v = body.v ?? null;
330
+ }
331
+ return base64UrlEncode(JSON.stringify(payload));
332
+ }
333
+
334
+ function rowMatches(row: DatasetRow, query: DatasetQuery, spec: DatasetQuerySpec): boolean {
335
+ return query.filters.every((filter) => filterMatches(row.object, filter, spec));
336
+ }
337
+
338
+ function filterMatches(object: Record<string, unknown>, filter: DatasetFilter, spec: DatasetQuerySpec): boolean {
339
+ const schema = spec.index.find((item) => item.name === filter.field)?.schema;
340
+ if (!schema) return false;
341
+ const kind = fieldKind(schema);
342
+ const stored = orderedValue(object, filter.field, kind);
343
+ if (filter.op === "eq" || filter.op === "in") {
344
+ return filter.values.some((value) => compareOrdered(stored, orderQueryValue(value, kind)) === 0);
345
+ }
346
+ const target = filter.values[0];
347
+ if (target === undefined || stored === null) return false;
348
+ const compared = compareOrdered(stored, orderQueryValue(target, kind));
349
+ if (filter.op === "gt") return compared > 0;
350
+ if (filter.op === "gte") return compared >= 0;
351
+ if (filter.op === "lt") return compared < 0;
352
+ return compared <= 0;
353
+ }
354
+
355
+ function compareRows(left: DatasetRow, right: DatasetRow, query: DatasetQuery, spec: DatasetQuerySpec): number {
356
+ if (query.sort) {
357
+ const schema = spec.index.find((item) => item.name === query.sort?.field)?.schema;
358
+ const kind = schema ? fieldKind(schema) : "string";
359
+ const compared = compareOrdered(
360
+ orderedValue(left.object, query.sort.field, kind),
361
+ orderedValue(right.object, query.sort.field, kind),
362
+ query.sort.direction,
363
+ );
364
+ if (compared !== 0) return compared;
365
+ }
366
+ // Ties follow the sort direction, as the warehouse's (ix_n, id) index walk does.
367
+ return compareOrdered(
368
+ orderedId(left.id, spec.idType),
369
+ orderedId(right.id, spec.idType),
370
+ query.sort?.direction ?? "asc",
371
+ );
372
+ }
373
+
374
+ function cursorRow(query: DatasetQuery): DatasetRow {
375
+ if (query.after === null) return { id: "", object: {} };
376
+ if (query.after.kind === "id" || query.sort === null) return { id: query.after.id, object: {} };
377
+ return {
378
+ id: query.after.id,
379
+ object: { [query.sort.field]: query.after.value },
380
+ };
381
+ }
382
+
383
+ function exposedValue(object: Record<string, unknown>, field: string, spec: DatasetQuerySpec): QueryValue | null {
384
+ const schema = spec.index.find((item) => item.name === field)?.schema;
385
+ if (!schema || !Object.hasOwn(object, field)) return null;
386
+ const value = object[field];
387
+ if (value === null || value === undefined) return null;
388
+ const kind = fieldKind(schema);
389
+ if (kind === "boolean") return value === true ? true : value === false ? false : null;
390
+ if (kind === "integer" || kind === "number") return typeof value === "number" && Number.isFinite(value) ? value : null;
391
+ return typeof value === "string" ? value : null;
392
+ }
393
+
394
+ function fieldKind(schema: Record<string, unknown>): FieldKind {
395
+ const names = new Set(declaredTypes(schema));
396
+ if (names.has("boolean")) return "boolean";
397
+ if (names.has("integer")) return "integer";
398
+ if (names.has("number")) return "number";
399
+ return "string";
400
+ }
401
+
402
+ function orderedValue(object: Record<string, unknown>, field: string, kind: FieldKind): string | number | null {
403
+ if (!Object.hasOwn(object, field)) return null;
404
+ const value = object[field];
405
+ if (value === null || value === undefined) return null;
406
+ return orderQueryValue(value as QueryValue, kind);
407
+ }
408
+
409
+ function orderQueryValue(value: QueryValue | null, kind: FieldKind): string | number | null {
410
+ if (value === null) return null;
411
+ if (kind === "boolean") {
412
+ if (value === true || value === 1) return 1;
413
+ if (value === false || value === 0) return 0;
414
+ return null;
415
+ }
416
+ if (kind === "integer" || kind === "number") return typeof value === "number" && Number.isFinite(value) ? value : null;
417
+ return typeof value === "string" ? value : null;
418
+ }
419
+
420
+ function orderedId(id: string, idType: DatasetQuerySpec["idType"]): string | number {
421
+ return idType === "integer" ? Number(id) : id;
422
+ }
423
+
424
+ /**
425
+ * SQLite orders nulls first on ASC and last on DESC. Booleans are 0 and 1,
426
+ * which is how a generated INTEGER column stores JSON true and false.
427
+ */
428
+ function compareOrdered(
429
+ left: string | number | null,
430
+ right: string | number | null,
431
+ direction: "asc" | "desc" = "asc",
432
+ ): number {
433
+ if (left === null && right === null) return 0;
434
+ if (left === null) return direction === "asc" ? -1 : 1;
435
+ if (right === null) return direction === "asc" ? 1 : -1;
436
+ let compared = 0;
437
+ if (typeof left === "number" && typeof right === "number") compared = left - right;
438
+ else compared = compareText(String(left), String(right));
439
+ if (compared === 0) return 0;
440
+ return direction === "asc" ? compared : -compared;
441
+ }
442
+
443
+ function compareText(left: string, right: string): number {
444
+ if (left < right) return -1;
445
+ if (left > right) return 1;
446
+ return 0;
447
+ }
448
+
449
+ function fnv(text: string, seed: number): string {
450
+ let hash = seed >>> 0;
451
+ for (let i = 0; i < text.length; i++) {
452
+ hash ^= text.charCodeAt(i);
453
+ hash = Math.imul(hash, 0x01000193);
454
+ }
455
+ return (hash >>> 0).toString(16).padStart(8, "0");
456
+ }
457
+
458
+ function base64UrlEncode(text: string): string {
459
+ const bytes = new TextEncoder().encode(text);
460
+ let binary = "";
461
+ for (const byte of bytes) binary += String.fromCharCode(byte);
462
+ return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
463
+ }
464
+
465
+ function base64UrlDecode(token: string): string {
466
+ const padded = token.replaceAll("-", "+").replaceAll("_", "/") + "=".repeat((4 - (token.length % 4)) % 4);
467
+ const binary = atob(padded);
468
+ const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
469
+ return new TextDecoder().decode(bytes);
470
+ }