@b4run/memory 0.8.28

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 (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +49 -0
  3. package/dist/browse-budget.d.ts +40 -0
  4. package/dist/browse-budget.d.ts.map +1 -0
  5. package/dist/browse-budget.js +57 -0
  6. package/dist/browse-cursor.d.ts +22 -0
  7. package/dist/browse-cursor.d.ts.map +1 -0
  8. package/dist/browse-cursor.js +146 -0
  9. package/dist/browse-filter.d.ts +13 -0
  10. package/dist/browse-filter.d.ts.map +1 -0
  11. package/dist/browse-filter.js +16 -0
  12. package/dist/browse-order.d.ts +28 -0
  13. package/dist/browse-order.d.ts.map +1 -0
  14. package/dist/browse-order.js +37 -0
  15. package/dist/browse-range.d.ts +16 -0
  16. package/dist/browse-range.d.ts.map +1 -0
  17. package/dist/browse-range.js +52 -0
  18. package/dist/browse-validate.d.ts +31 -0
  19. package/dist/browse-validate.d.ts.map +1 -0
  20. package/dist/browse-validate.js +263 -0
  21. package/dist/browse.d.ts +15 -0
  22. package/dist/browse.d.ts.map +1 -0
  23. package/dist/browse.js +5 -0
  24. package/dist/distill.d.ts +81 -0
  25. package/dist/distill.d.ts.map +1 -0
  26. package/dist/distill.js +380 -0
  27. package/dist/hybrid.d.ts +26 -0
  28. package/dist/hybrid.d.ts.map +1 -0
  29. package/dist/hybrid.js +86 -0
  30. package/dist/index.d.ts +17 -0
  31. package/dist/index.d.ts.map +1 -0
  32. package/dist/index.js +13 -0
  33. package/dist/namespace.d.ts +18 -0
  34. package/dist/namespace.d.ts.map +1 -0
  35. package/dist/namespace.js +68 -0
  36. package/dist/reconcile.d.ts +51 -0
  37. package/dist/reconcile.d.ts.map +1 -0
  38. package/dist/reconcile.js +110 -0
  39. package/dist/score.d.ts +54 -0
  40. package/dist/score.d.ts.map +1 -0
  41. package/dist/score.js +66 -0
  42. package/dist/sqlite-browse-sql.d.ts +30 -0
  43. package/dist/sqlite-browse-sql.d.ts.map +1 -0
  44. package/dist/sqlite-browse-sql.js +178 -0
  45. package/dist/sqlite-store.d.ts +10 -0
  46. package/dist/sqlite-store.d.ts.map +1 -0
  47. package/dist/sqlite-store.js +521 -0
  48. package/dist/tokenize.d.ts +3 -0
  49. package/dist/tokenize.d.ts.map +1 -0
  50. package/dist/tokenize.js +14 -0
  51. package/dist/tsconfig.tsbuildinfo +1 -0
  52. package/dist/types.d.ts +201 -0
  53. package/dist/types.d.ts.map +1 -0
  54. package/dist/types.js +1 -0
  55. package/dist/vector.d.ts +22 -0
  56. package/dist/vector.d.ts.map +1 -0
  57. package/dist/vector.js +42 -0
  58. package/package.json +67 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) Brian Love
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,49 @@
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/cacheplane/b4run/main/docs/brand/b4-logo-horizontal-black-on-white.png" alt="B4.run" width="180" />
3
+ </p>
4
+
5
+ # @b4run/memory
6
+
7
+ Supported long-term memory storage, ranking, browsing, namespace, and reconciliation primitives. Most route authors should declare memory with `defineMemory()` and use this package when they need direct store access.
8
+
9
+ **Use this when:** You need to access memory stores or ranking primitives directly instead of using only `defineMemory()`.
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ pnpm add @b4run/memory
15
+ ```
16
+
17
+ ## Example
18
+
19
+ ```ts
20
+ import { sqliteMemoryStore } from "@b4run/memory"
21
+
22
+ const store = sqliteMemoryStore({ path: ".b4/memory.sqlite" })
23
+ ```
24
+
25
+ Supported focused entry points are `@b4run/memory/browse`, `@b4run/memory/namespace`, and `@b4run/memory/reconcile`.
26
+
27
+ ## Runtime and stability
28
+
29
+ - `@b4run/memory` is a supported node-only application surface because it includes SQLite.
30
+ - `@b4run/memory/browse` is a supported, dependency-free edge-safe integration surface.
31
+ - `@b4run/memory/namespace` is a supported edge-safe integration surface.
32
+ - `@b4run/memory/reconcile` is a supported edge-safe integration surface.
33
+
34
+ SQLite rows contain plaintext content, data, sources, and tags. Treat the database as sensitive application data and enforce tenant scope at the caller boundary.
35
+
36
+ ## Related
37
+
38
+ - [Memory API reference](https://b4.run/docs/api/memory) — exact store, query, and subpath contracts.
39
+ - [Long-term Memory](https://b4.run/docs/memory/long-term) — application configuration.
40
+ - [Recall and Retrieval](https://b4.run/docs/memory/retrieval) and [Browse and Manage Memory](https://b4.run/docs/memory/browse) — retrieval and administration workflows.
41
+ - [`@b4run/memory-pgvector`](https://www.npmjs.com/package/@b4run/memory-pgvector) — shared Postgres-backed vector memory.
42
+
43
+ ## Maturity and support
44
+
45
+ B4.run is pre-1.0, and its public surface can change. All publishable B4.run packages release together as a fixed group; review the [`@b4run/memory` changelog](https://github.com/cacheplane/b4run/blob/main/packages/memory/CHANGELOG.md) and [upgrading guide](https://b4.run/docs/upgrading) before upgrading. For support, use [GitHub Discussions](https://github.com/cacheplane/b4run/discussions); report defects in [GitHub Issues](https://github.com/cacheplane/b4run/issues).
46
+
47
+ ## License
48
+
49
+ MIT. See the [repository license](https://github.com/cacheplane/b4run/blob/main/LICENSE).
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The §11 server budgets of the server-controlled-exploration design, as data, plus the
3
+ * comparison the bench and its test share. Deliberately NOT exported from `index.ts` or
4
+ * `browse.ts`: this is bench vocabulary, not part of the package's public surface.
5
+ */
6
+ export type BrowseBudgetId = "windowed-fetch" | "filtered-count" | "head-refresh" | "non-default-sort" | "content-contains";
7
+ /** Ceilings approved for SQLite at 100 000 rows; the margin covers real payload decode.
8
+ * Four are grounded in a §5.5 measurement. `head-refresh` is NOT — §5.5 has no row for
9
+ * it, and §11 grounds it by extrapolation ("~3-8 ms + decode"), so this bench is the
10
+ * first measurement it has ever had. */
11
+ export declare const SQLITE_BROWSE_BUDGETS_MS: Readonly<Partial<Record<BrowseBudgetId, number>>>;
12
+ /** Postgres. §11 approves only two numbers, and BOTH are estimates — no container bench
13
+ * has ever run. The other three shapes stay deliberately absent so the checker reports
14
+ * them as unbudgeted rather than inventing a ceiling. */
15
+ export declare const POSTGRES_BROWSE_BUDGETS_MS: Readonly<Partial<Record<BrowseBudgetId, number>>>;
16
+ /** Nearest-rank p95. With 20 samples that is the 19th — no interpolation, so the number
17
+ * reported is a measurement that actually happened. */
18
+ export declare function percentileMs(samples: readonly number[], fraction: number): number;
19
+ export interface BrowseBudgetRow {
20
+ readonly id: BrowseBudgetId;
21
+ /** null only when the shape was never measured. */
22
+ readonly p95Ms: number | null;
23
+ readonly budgetMs: number | null;
24
+ readonly status: "pass" | "fail" | "unbudgeted" | "unmeasured";
25
+ }
26
+ export interface BrowseBudgetReport {
27
+ readonly rows: readonly BrowseBudgetRow[];
28
+ readonly ok: boolean;
29
+ }
30
+ /**
31
+ * A row per measured shape, plus a row per approved ceiling nobody measured. Both holes
32
+ * are reported rather than skipped, for opposite reasons: a measurement with no ceiling
33
+ * must not be counted as approval nobody gave, and a ceiling with no measurement must not
34
+ * let a partial run exit clean — a `--assert` that checked one of five and returned 0
35
+ * would certify four budgets it never looked at. A zero-sample array is a broken run, not
36
+ * an absent one, so it fails.
37
+ */
38
+ export declare function checkBrowseBudgets(measurements: Readonly<Partial<Record<BrowseBudgetId, readonly number[]>>>, budgets: Readonly<Partial<Record<BrowseBudgetId, number>>>): BrowseBudgetReport;
39
+ export declare function formatBrowseBudgetReport(report: BrowseBudgetReport): string;
40
+ //# sourceMappingURL=browse-budget.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"browse-budget.d.ts","sourceRoot":"","sources":["../src/browse-budget.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,MAAM,MAAM,cAAc,GACtB,gBAAgB,GAChB,gBAAgB,GAChB,cAAc,GACd,kBAAkB,GAClB,kBAAkB,CAAA;AAEtB;;;yCAGyC;AACzC,eAAO,MAAM,wBAAwB,EAAE,QAAQ,CAAC,OAAO,CAAC,MAAM,CAAC,cAAc,EAAE,MAAM,CAAC,CAAC,CAMtF,CAAA;AAED;;0DAE0D;AAC1D,eAAO,MAAM,0BAA0B,EAAE,QAAQ,CAAC,OAAO,CAAC,MAAM,CAAC,cAAc,EAAE,MAAM,CAAC,CAAC,CAGxF,CAAA;AAED;wDACwD;AACxD,wBAAgB,YAAY,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAKjF;AAED,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,EAAE,EAAE,cAAc,CAAA;IAC3B,mDAAmD;IACnD,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;IAC7B,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;IAChC,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,YAAY,GAAG,YAAY,CAAA;CAC/D;AAED,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,IAAI,EAAE,SAAS,eAAe,EAAE,CAAA;IACzC,QAAQ,CAAC,EAAE,EAAE,OAAO,CAAA;CACrB;AAED;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAChC,YAAY,EAAE,QAAQ,CAAC,OAAO,CAAC,MAAM,CAAC,cAAc,EAAE,SAAS,MAAM,EAAE,CAAC,CAAC,CAAC,EAC1E,OAAO,EAAE,QAAQ,CAAC,OAAO,CAAC,MAAM,CAAC,cAAc,EAAE,MAAM,CAAC,CAAC,CAAC,GACzD,kBAAkB,CAiBpB;AAED,wBAAgB,wBAAwB,CAAC,MAAM,EAAE,kBAAkB,GAAG,MAAM,CAQ3E"}
@@ -0,0 +1,57 @@
1
+ /** Ceilings approved for SQLite at 100 000 rows; the margin covers real payload decode.
2
+ * Four are grounded in a §5.5 measurement. `head-refresh` is NOT — §5.5 has no row for
3
+ * it, and §11 grounds it by extrapolation ("~3-8 ms + decode"), so this bench is the
4
+ * first measurement it has ever had. */
5
+ export const SQLITE_BROWSE_BUDGETS_MS = {
6
+ "windowed-fetch": 10,
7
+ "filtered-count": 25,
8
+ "head-refresh": 50,
9
+ "non-default-sort": 50,
10
+ "content-contains": 150,
11
+ };
12
+ /** Postgres. §11 approves only two numbers, and BOTH are estimates — no container bench
13
+ * has ever run. The other three shapes stay deliberately absent so the checker reports
14
+ * them as unbudgeted rather than inventing a ceiling. */
15
+ export const POSTGRES_BROWSE_BUDGETS_MS = {
16
+ "windowed-fetch": 30,
17
+ "filtered-count": 100,
18
+ };
19
+ /** Nearest-rank p95. With 20 samples that is the 19th — no interpolation, so the number
20
+ * reported is a measurement that actually happened. */
21
+ export function percentileMs(samples, fraction) {
22
+ if (samples.length === 0)
23
+ return Number.NaN;
24
+ const sorted = [...samples].sort((a, b) => a - b);
25
+ const rank = Math.max(1, Math.ceil(fraction * sorted.length));
26
+ return sorted[rank - 1];
27
+ }
28
+ /**
29
+ * A row per measured shape, plus a row per approved ceiling nobody measured. Both holes
30
+ * are reported rather than skipped, for opposite reasons: a measurement with no ceiling
31
+ * must not be counted as approval nobody gave, and a ceiling with no measurement must not
32
+ * let a partial run exit clean — a `--assert` that checked one of five and returned 0
33
+ * would certify four budgets it never looked at. A zero-sample array is a broken run, not
34
+ * an absent one, so it fails.
35
+ */
36
+ export function checkBrowseBudgets(measurements, budgets) {
37
+ const rows = [];
38
+ for (const [id, samples] of Object.entries(measurements)) {
39
+ const p95Ms = percentileMs(samples, 0.95);
40
+ const budgetMs = budgets[id] ?? null;
41
+ // A zero-sample p95 is NaN, and NaN loses every comparison, so the row falls to "fail".
42
+ const status = budgetMs === null ? "unbudgeted" : p95Ms <= budgetMs ? "pass" : "fail";
43
+ rows.push({ id, p95Ms, budgetMs, status });
44
+ }
45
+ for (const [id, budgetMs] of Object.entries(budgets)) {
46
+ if (id in measurements)
47
+ continue;
48
+ rows.push({ id, p95Ms: null, budgetMs, status: "unmeasured" });
49
+ }
50
+ return { rows, ok: rows.every((row) => row.status === "pass" || row.status === "unbudgeted") };
51
+ }
52
+ export function formatBrowseBudgetReport(report) {
53
+ return report.rows
54
+ .map((row) => `${row.id.padEnd(20)} p95 ${(row.p95Ms === null ? "—" : row.p95Ms.toFixed(2)).padStart(8)} ms ` +
55
+ `budget ${(row.budgetMs === null ? "—" : `${row.budgetMs} ms`).padStart(8)} ${row.status}`)
56
+ .join("\n");
57
+ }
@@ -0,0 +1,22 @@
1
+ import type { ResolvedBrowseSort } from "./browse-order.js";
2
+ import type { BrowseQuery, MemoryRecord } from "./types.js";
3
+ export declare const BROWSE_CURSOR_VERSION = 1;
4
+ export type BrowseCursorValue = string | number;
5
+ export interface BrowseCursorPayload {
6
+ /** Raw stored sort-key values, one per ordered field, in order. */
7
+ readonly key: readonly BrowseCursorValue[];
8
+ readonly id: string;
9
+ }
10
+ /**
11
+ * Fingerprint of the query's DATASET IDENTITY — every field that changes which rows
12
+ * match or in what order, and nothing else. `limit`/`offset`/`cursor` are paging,
13
+ * not identity. Encoded into every cursor so a continuation can never smuggle its
14
+ * own query into a different request.
15
+ */
16
+ export declare function browseQueryFingerprint(query: BrowseQuery): string;
17
+ /** The raw stored values of `record` for the ordered fields, in order. */
18
+ export declare function browseCursorKey(record: MemoryRecord, order: readonly ResolvedBrowseSort[]): readonly BrowseCursorValue[];
19
+ export declare function encodeBrowseCursor(fingerprint: string, payload: BrowseCursorPayload): string;
20
+ /** Decode a continuation and check it against the request's OWN parameters. */
21
+ export declare function decodeBrowseCursor(cursor: string, fingerprint: string, order: readonly ResolvedBrowseSort[]): BrowseCursorPayload;
22
+ //# sourceMappingURL=browse-cursor.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"browse-cursor.d.ts","sourceRoot":"","sources":["../src/browse-cursor.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAA;AAG3D,OAAO,KAAK,EAAgB,WAAW,EAAE,YAAY,EAAE,MAAM,YAAY,CAAA;AAEzE,eAAO,MAAM,qBAAqB,IAAI,CAAA;AAEtC,MAAM,MAAM,iBAAiB,GAAG,MAAM,GAAG,MAAM,CAAA;AAE/C,MAAM,WAAW,mBAAmB;IAClC,mEAAmE;IACnE,QAAQ,CAAC,GAAG,EAAE,SAAS,iBAAiB,EAAE,CAAA;IAC1C,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;CACpB;AAkED;;;;;GAKG;AACH,wBAAgB,sBAAsB,CAAC,KAAK,EAAE,WAAW,GAAG,MAAM,CAcjE;AAED,0EAA0E;AAC1E,wBAAgB,eAAe,CAC7B,MAAM,EAAE,YAAY,EACpB,KAAK,EAAE,SAAS,kBAAkB,EAAE,GACnC,SAAS,iBAAiB,EAAE,CAuB9B;AAED,wBAAgB,kBAAkB,CAAC,WAAW,EAAE,MAAM,EAAE,OAAO,EAAE,mBAAmB,GAAG,MAAM,CAI5F;AAMD,+EAA+E;AAC/E,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,MAAM,EACd,WAAW,EAAE,MAAM,EACnB,KAAK,EAAE,SAAS,kBAAkB,EAAE,GACnC,mBAAmB,CA4BrB"}
@@ -0,0 +1,146 @@
1
+ import { normalizeSetFilter } from "./browse-filter.js";
2
+ import { resolveBrowseOrder } from "./browse-order.js";
3
+ import { BrowseQueryError } from "./browse-validate.js";
4
+ export const BROWSE_CURSOR_VERSION = 1;
5
+ // btoa/atob + TextEncoder are available in Node 24 AND browsers; Buffer is not, and
6
+ // slice 3's hook imports this module from client code.
7
+ function toBase64Url(json) {
8
+ const bytes = new TextEncoder().encode(json);
9
+ let binary = "";
10
+ for (const byte of bytes)
11
+ binary += String.fromCharCode(byte);
12
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
13
+ }
14
+ function fromBase64Url(cursor) {
15
+ const base64 = cursor.replace(/-/g, "+").replace(/_/g, "/");
16
+ const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4);
17
+ const binary = atob(padded);
18
+ return new TextDecoder().decode(Uint8Array.from(binary, (char) => char.charCodeAt(0)));
19
+ }
20
+ // FNV-1a/32 over UTF-8 bytes, the input the algorithm is defined on: the canonical string
21
+ // carries non-ASCII namespaces and filter values, where UTF-16 code units would diverge.
22
+ // A cursor is server-issued over localhost, so this is a mismatch DETECTOR, not a MAC —
23
+ // and staying dependency-free keeps the module isomorphic.
24
+ function fnv1a32(input) {
25
+ let hash = 0x811c9dc5;
26
+ for (const byte of new TextEncoder().encode(input)) {
27
+ hash ^= byte;
28
+ hash = Math.imul(hash, 0x01000193) >>> 0;
29
+ }
30
+ return hash.toString(16).padStart(8, "0");
31
+ }
32
+ function canonicalFilter(filter) {
33
+ switch (filter.field) {
34
+ case "status":
35
+ case "kind":
36
+ return `${filter.field}|${filter.op}|${[...filter.values].sort().join(",")}`;
37
+ case "content":
38
+ case "namespace":
39
+ return `${filter.field}|${filter.op}|${filter.value}`;
40
+ case "confidence":
41
+ return filter.op === "between"
42
+ ? `confidence|between|${filter.min}|${filter.max}`
43
+ : `confidence|${filter.op}|${filter.value}`;
44
+ case "updatedAt":
45
+ return filter.op === "betweenDays"
46
+ ? `updatedAt|betweenDays|${filter.fromDay}|${filter.untilDay}`
47
+ : `updatedAt|${filter.op}|${filter.day}`;
48
+ default: {
49
+ // In-process callers are trusted past validateBrowseQuery, so an unmapped field
50
+ // arrives here unchecked. Borrowing another field's canonical form would hand two
51
+ // different datasets ONE fingerprint — the collision this whole function prevents.
52
+ const unmapped = filter;
53
+ throw new BrowseQueryError(`unknown filter field ${JSON.stringify(unmapped.field)}`);
54
+ }
55
+ }
56
+ }
57
+ /** A set filter is a SET, so member order is not identity. `undefined` (no filter, every
58
+ * row) stays distinct from `[]` (matches nothing) — see `normalizeSetFilter`. */
59
+ function canonicalSet(value) {
60
+ const values = normalizeSetFilter(value);
61
+ return values === undefined ? null : [...values].sort();
62
+ }
63
+ /**
64
+ * Fingerprint of the query's DATASET IDENTITY — every field that changes which rows
65
+ * match or in what order, and nothing else. `limit`/`offset`/`cursor` are paging,
66
+ * not identity. Encoded into every cursor so a continuation can never smuggle its
67
+ * own query into a different request.
68
+ */
69
+ export function browseQueryFingerprint(query) {
70
+ const canonical = JSON.stringify({
71
+ namespace: query.namespace ?? null,
72
+ namespacePrefix: query.namespacePrefix ?? null,
73
+ status: canonicalSet(query.status),
74
+ kind: canonicalSet(query.kind),
75
+ sourceType: query.sourceType ?? null,
76
+ since: query.since ?? null,
77
+ until: query.until ?? null,
78
+ now: query.now ?? null,
79
+ filters: (query.filters ?? []).map(canonicalFilter).sort(),
80
+ order: resolveBrowseOrder(query.orderBy).map((entry) => `${entry.field}:${entry.dir}`),
81
+ });
82
+ return fnv1a32(canonical);
83
+ }
84
+ /** The raw stored values of `record` for the ordered fields, in order. */
85
+ export function browseCursorKey(record, order) {
86
+ return order.map((entry) => {
87
+ switch (entry.field) {
88
+ case "updatedAt":
89
+ return record.updatedAt;
90
+ case "createdAt":
91
+ return record.createdAt;
92
+ case "confidence":
93
+ return record.confidence;
94
+ case "namespace":
95
+ return record.namespace;
96
+ case "kind":
97
+ return record.kind;
98
+ case "status":
99
+ return record.status;
100
+ default: {
101
+ // A stand-in value here is a key read off the WRONG column: the next page then
102
+ // continues from a boundary that never existed in this ordering.
103
+ const unmapped = entry.field;
104
+ throw new BrowseQueryError(`unknown sort field ${JSON.stringify(unmapped)}`);
105
+ }
106
+ }
107
+ });
108
+ }
109
+ export function encodeBrowseCursor(fingerprint, payload) {
110
+ return toBase64Url(JSON.stringify({ v: BROWSE_CURSOR_VERSION, fp: fingerprint, key: payload.key, id: payload.id }));
111
+ }
112
+ function invalid(reason) {
113
+ throw new BrowseQueryError(reason, "continuation-invalid");
114
+ }
115
+ /** Decode a continuation and check it against the request's OWN parameters. */
116
+ export function decodeBrowseCursor(cursor, fingerprint, order) {
117
+ let parsed;
118
+ try {
119
+ parsed = JSON.parse(fromBase64Url(cursor));
120
+ }
121
+ catch {
122
+ invalid("cursor is not decodable");
123
+ }
124
+ const decoded = parsed;
125
+ if (decoded?.v !== BROWSE_CURSOR_VERSION)
126
+ invalid("cursor version is not supported");
127
+ if (decoded.fp !== fingerprint)
128
+ invalid("cursor belongs to a different query");
129
+ if (!Array.isArray(decoded.key) || decoded.key.length !== order.length)
130
+ invalid("cursor key does not match the requested sort order");
131
+ // Checked against the ORDERED FIELD, not merely "some string or number": a cursor is
132
+ // unauthenticated, and a text value bound against a numeric column is compared across
133
+ // SQLite's storage classes — every number sorts below every string, so the keyset
134
+ // boundary silently admits every row — while Postgres rejects the ::real bind outright.
135
+ for (const [index, entry] of order.entries()) {
136
+ const value = decoded.key[index];
137
+ const wellTyped = entry.numeric
138
+ ? typeof value === "number" && Number.isFinite(value)
139
+ : typeof value === "string";
140
+ if (!wellTyped)
141
+ invalid(`cursor key for "${entry.field}" must be ${entry.numeric ? "a finite number" : "a string"}`);
142
+ }
143
+ if (typeof decoded.id !== "string" || decoded.id.length === 0)
144
+ invalid("cursor id is missing");
145
+ return { key: decoded.key, id: decoded.id };
146
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Normalise a `BrowseQuery` filter that accepts either one value or a set.
3
+ *
4
+ * Returns `undefined` when the caller did not filter on the field at all, and
5
+ * an array otherwise — including the empty array, which is a filter that
6
+ * matches nothing rather than an absent one. Backends must keep that
7
+ * distinction: `IN ()` is false, while "no clause" is true for every row.
8
+ *
9
+ * Exported so the sqlite and Postgres stores share one reading of the contract
10
+ * instead of each interpreting the union themselves.
11
+ */
12
+ export declare function normalizeSetFilter<T extends string>(value: T | readonly T[] | undefined): readonly T[] | undefined;
13
+ //# sourceMappingURL=browse-filter.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"browse-filter.d.ts","sourceRoot":"","sources":["../src/browse-filter.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,wBAAgB,kBAAkB,CAAC,CAAC,SAAS,MAAM,EACjD,KAAK,EAAE,CAAC,GAAG,SAAS,CAAC,EAAE,GAAG,SAAS,GAClC,SAAS,CAAC,EAAE,GAAG,SAAS,CAG1B"}
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Normalise a `BrowseQuery` filter that accepts either one value or a set.
3
+ *
4
+ * Returns `undefined` when the caller did not filter on the field at all, and
5
+ * an array otherwise — including the empty array, which is a filter that
6
+ * matches nothing rather than an absent one. Backends must keep that
7
+ * distinction: `IN ()` is false, while "no clause" is true for every row.
8
+ *
9
+ * Exported so the sqlite and Postgres stores share one reading of the contract
10
+ * instead of each interpreting the union themselves.
11
+ */
12
+ export function normalizeSetFilter(value) {
13
+ if (value === undefined)
14
+ return undefined;
15
+ return typeof value === "string" ? [value] : value;
16
+ }
@@ -0,0 +1,28 @@
1
+ import type { BrowseSortEntry, BrowseSortField } from "./types.js";
2
+ export interface ResolvedBrowseSort {
3
+ readonly field: BrowseSortField;
4
+ /** Physical column. Comes from the table below and NOWHERE else — this is the
5
+ * only place a browse sort name becomes a SQL identifier. */
6
+ readonly column: string;
7
+ readonly dir: "asc" | "desc";
8
+ /** Postgres binds JS numbers as float8; a float4 column needs a `::real` cast on
9
+ * the parameter or equality against a stored value is false. Postgres also STORES
10
+ * confidence as float4, so two confidences that differ only below float4 precision
11
+ * are equal at rest there and still distinct on SQLite — ordering by confidence
12
+ * then falls to the id tie-break on one backend and not the other. */
13
+ readonly numeric: boolean;
14
+ /** Postgres needs COLLATE "C" here to match SQLite's BINARY order. Deliberately
15
+ * FALSE for updated_at/created_at: they are uniform ASCII (so every collation
16
+ * agrees) AND the (updated_at DESC, id ASC) index is uncollated — a collated
17
+ * ORDER BY would stop matching it and turn the hot path into a sort. FALSE for
18
+ * kind/status on unrelated grounds: they are closed lowercase-ASCII enums, so no
19
+ * collation can reorder them. Nothing but TypeScript holds that — neither schema
20
+ * has a CHECK — and a member outside `[a-z]+` would need this flipped to true. */
21
+ readonly collateC: boolean;
22
+ }
23
+ /** The documented reset state: newest first. `resolveBrowseOrder` hands out THIS array,
24
+ * so it is frozen — the stores' `id ASC` tie-break goes into a new list, and a store
25
+ * that appends in place fails loudly instead of rewriting the default process-wide. */
26
+ export declare const DEFAULT_BROWSE_ORDER: readonly ResolvedBrowseSort[];
27
+ export declare function resolveBrowseOrder(orderBy?: readonly BrowseSortEntry[]): readonly ResolvedBrowseSort[];
28
+ //# sourceMappingURL=browse-order.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"browse-order.d.ts","sourceRoot":"","sources":["../src/browse-order.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,eAAe,EAAE,eAAe,EAAE,MAAM,YAAY,CAAA;AAElE,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,KAAK,EAAE,eAAe,CAAA;IAC/B;kEAC8D;IAC9D,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,GAAG,EAAE,KAAK,GAAG,MAAM,CAAA;IAC5B;;;;2EAIuE;IACvE,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAA;IACzB;;;;;;uFAMmF;IACnF,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAA;CAC3B;AAgBD;;wFAEwF;AACxF,eAAO,MAAM,oBAAoB,EAAE,SAAS,kBAAkB,EAE5D,CAAA;AAEF,wBAAgB,kBAAkB,CAChC,OAAO,CAAC,EAAE,SAAS,eAAe,EAAE,GACnC,SAAS,kBAAkB,EAAE,CAqB/B"}
@@ -0,0 +1,37 @@
1
+ import { BrowseQueryError } from "./browse-validate.js";
2
+ const COLUMNS = {
3
+ updatedAt: { column: "updated_at", numeric: false, collateC: false },
4
+ createdAt: { column: "created_at", numeric: false, collateC: false },
5
+ confidence: { column: "confidence", numeric: true, collateC: false },
6
+ namespace: { column: "namespace", numeric: false, collateC: true },
7
+ kind: { column: "kind", numeric: false, collateC: false },
8
+ status: { column: "status", numeric: false, collateC: false },
9
+ };
10
+ /** The documented reset state: newest first. `resolveBrowseOrder` hands out THIS array,
11
+ * so it is frozen — the stores' `id ASC` tie-break goes into a new list, and a store
12
+ * that appends in place fails loudly instead of rewriting the default process-wide. */
13
+ export const DEFAULT_BROWSE_ORDER = Object.freeze([
14
+ Object.freeze({ field: "updatedAt", ...COLUMNS.updatedAt, dir: "desc" }),
15
+ ]);
16
+ export function resolveBrowseOrder(orderBy) {
17
+ if (!orderBy || orderBy.length === 0)
18
+ return DEFAULT_BROWSE_ORDER;
19
+ return orderBy.map((entry) => {
20
+ const meta = COLUMNS[entry.field];
21
+ // Defence in depth: validateBrowseQuery already rejected this, but a store
22
+ // must never interpolate an unmapped name.
23
+ if (!meta)
24
+ throw new BrowseQueryError(`unknown sort field ${JSON.stringify(entry.field)}`);
25
+ // Checked HERE rather than trusted to each dialect's `dir === "desc" ? … : …`, so the
26
+ // whitelist is the single gate every part of an ORDER BY passes through.
27
+ if (entry.dir !== "asc" && entry.dir !== "desc")
28
+ throw new BrowseQueryError(`sort direction must be "asc" or "desc", got ${JSON.stringify(entry.dir)}`);
29
+ return {
30
+ field: entry.field,
31
+ column: meta.column,
32
+ dir: entry.dir,
33
+ numeric: meta.numeric,
34
+ collateC: meta.collateC,
35
+ };
36
+ });
37
+ }
@@ -0,0 +1,16 @@
1
+ /** Inclusive lower bound of a UTC day, in the stored full-ISO-Z form. */
2
+ export declare function utcDayStart(day: string): string;
3
+ /** EXCLUSIVE upper bound of a UTC day — the next day's start. UTC has no DST, so
4
+ * adding 24h is exact. */
5
+ export declare function utcDayAfter(day: string): string;
6
+ /**
7
+ * Smallest string strictly greater than every string starting with `prefix`, so a
8
+ * prefix match becomes the sargable range `col >= prefix AND col < succ(prefix)`.
9
+ * Strip trailing maximal code points, increment the last remaining one; an
10
+ * all-maximal prefix has no upper bound (undefined = omit the clause).
11
+ *
12
+ * Defined over CODE POINTS, which is order-equivalent to UTF-8 byte order — the
13
+ * order SQLite's BINARY collation and Postgres's COLLATE "C" both use.
14
+ */
15
+ export declare function namespacePrefixUpperBound(prefix: string): string | undefined;
16
+ //# sourceMappingURL=browse-range.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"browse-range.d.ts","sourceRoot":"","sources":["../src/browse-range.ts"],"names":[],"mappings":"AAkBA,yEAAyE;AACzE,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAG/C;AAED;2BAC2B;AAC3B,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAE/C;AAMD;;;;;;;;GAQG;AACH,wBAAgB,yBAAyB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAY5E"}
@@ -0,0 +1,52 @@
1
+ import { BrowseQueryError } from "./browse-validate.js";
2
+ const DAY_MS = 86_400_000;
3
+ const DAY = /^\d{4}-\d{2}-\d{2}$/;
4
+ /** Defence in depth, the same posture as browse-order: validateBrowseQuery already
5
+ * rejected this, but an unchecked day becomes a bound no stored row can sit at —
6
+ * a silently empty window instead of a 400. */
7
+ function checkDay(day) {
8
+ if (!DAY.test(day))
9
+ throw new BrowseQueryError(`day must be a "YYYY-MM-DD" UTC day, got ${JSON.stringify(day)}`);
10
+ // Out-of-range components ROLL OVER rather than fail to parse: "2026-02-31" reads as
11
+ // March 3, so only a day that round-trips names the date it spells.
12
+ const parsed = Date.parse(`${day}T00:00:00.000Z`);
13
+ if (!Number.isFinite(parsed) || new Date(parsed).toISOString().slice(0, 10) !== day)
14
+ throw new BrowseQueryError(`day ${JSON.stringify(day)} is not a real calendar day`);
15
+ }
16
+ /** Inclusive lower bound of a UTC day, in the stored full-ISO-Z form. */
17
+ export function utcDayStart(day) {
18
+ checkDay(day);
19
+ return `${day}T00:00:00.000Z`;
20
+ }
21
+ /** EXCLUSIVE upper bound of a UTC day — the next day's start. UTC has no DST, so
22
+ * adding 24h is exact. */
23
+ export function utcDayAfter(day) {
24
+ return new Date(Date.parse(utcDayStart(day)) + DAY_MS).toISOString();
25
+ }
26
+ const MAX_CODE_POINT = 0x10ffff;
27
+ const SURROGATE_START = 0xd800;
28
+ const SURROGATE_END = 0xdfff;
29
+ /**
30
+ * Smallest string strictly greater than every string starting with `prefix`, so a
31
+ * prefix match becomes the sargable range `col >= prefix AND col < succ(prefix)`.
32
+ * Strip trailing maximal code points, increment the last remaining one; an
33
+ * all-maximal prefix has no upper bound (undefined = omit the clause).
34
+ *
35
+ * Defined over CODE POINTS, which is order-equivalent to UTF-8 byte order — the
36
+ * order SQLite's BINARY collation and Postgres's COLLATE "C" both use.
37
+ */
38
+ export function namespacePrefixUpperBound(prefix) {
39
+ const points = Array.from(prefix);
40
+ for (let i = points.length - 1; i >= 0; i -= 1) {
41
+ const codePoint = points[i]?.codePointAt(0);
42
+ if (codePoint === undefined || codePoint >= MAX_CODE_POINT)
43
+ continue;
44
+ let next = codePoint + 1;
45
+ // Surrogates are not valid scalar values; skipping the block keeps the bound a
46
+ // legal string while staying an upper bound (nothing sorts between D7FF and E000).
47
+ if (next >= SURROGATE_START && next <= SURROGATE_END)
48
+ next = SURROGATE_END + 1;
49
+ return points.slice(0, i).join("") + String.fromCodePoint(next);
50
+ }
51
+ return undefined;
52
+ }
@@ -0,0 +1,31 @@
1
+ import type { BrowseQuery } from "./types.js";
2
+ /** Largest `limit` the UNTRUSTED boundary accepts. Enforced only when a caller passes
3
+ * `maxLimit` — in-process callers (the CLI's 10 000-row consolidation scan) are
4
+ * trusted and exempt; the HTTP route is not. */
5
+ export declare const BROWSE_MAX_LIMIT = 1000;
6
+ /** Applied by the stores when `limit` is absent. */
7
+ export declare const BROWSE_DEFAULT_LIMIT = 50;
8
+ export declare const BROWSE_SORT_FIELDS: readonly ["updatedAt", "createdAt", "confidence", "namespace", "kind", "status"];
9
+ /** Every rejection this module raises. The Inspector maps it to 400 `{error}`; the
10
+ * stores let it propagate, so a bad query fails loudly instead of silently matching
11
+ * zero rows. */
12
+ export declare class BrowseQueryError extends Error {
13
+ readonly code: string;
14
+ constructor(message: string, code?: string);
15
+ }
16
+ /**
17
+ * The single reading of "is this browse query legal". Runs at the Inspector HTTP
18
+ * boundary (mapped to 400) and defensively inside every store (thrown). Pass
19
+ * `maxLimit` at untrusted boundaries only — see BROWSE_MAX_LIMIT.
20
+ *
21
+ * The empty set is spelled two ways and they do NOT mean the same thing. The shorthand
22
+ * `status: []` / `kind: []` is legal and means "match nothing" (see `BrowseQuery`), while
23
+ * `filters: [{ field: "status", op: "in", values: [] }]` is rejected: a filter entry exists
24
+ * only because a caller constructed one, so an empty value list is a construction bug
25
+ * rather than a narrowing. A caller translating UI state must use the shorthand to say
26
+ * "narrowed to nothing".
27
+ */
28
+ export declare function validateBrowseQuery(query: BrowseQuery, opts?: {
29
+ readonly maxLimit?: number;
30
+ }): void;
31
+ //# sourceMappingURL=browse-validate.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"browse-validate.d.ts","sourceRoot":"","sources":["../src/browse-validate.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAEV,WAAW,EAKZ,MAAM,YAAY,CAAA;AAEnB;;iDAEiD;AACjD,eAAO,MAAM,gBAAgB,OAAO,CAAA;AACpC,oDAAoD;AACpD,eAAO,MAAM,oBAAoB,KAAK,CAAA;AAYtC,eAAO,MAAM,kBAAkB,kFAOgB,CAAA;AAyD/C;;iBAEiB;AACjB,qBAAa,gBAAiB,SAAQ,KAAK;IACzC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,YAAY,OAAO,EAAE,MAAM,EAAE,IAAI,SAAkB,EAIlD;CACF;AAmHD;;;;;;;;;;;GAWG;AACH,wBAAgB,mBAAmB,CACjC,KAAK,EAAE,WAAW,EAClB,IAAI,GAAE;IAAE,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAO,GACxC,IAAI,CA0DN"}