@openparachute/vault 0.4.7-rc.2 → 0.4.8-rc.10
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.
- package/.parachute/module.json +1 -1
- package/README.md +78 -41
- package/core/src/connection-pragmas.test.ts +232 -0
- package/core/src/core.test.ts +257 -0
- package/core/src/cursor.test.ts +160 -0
- package/core/src/cursor.ts +272 -0
- package/core/src/mcp.ts +51 -7
- package/core/src/notes.ts +164 -2
- package/core/src/schema.ts +106 -5
- package/core/src/store.ts +11 -1
- package/core/src/types.ts +32 -0
- package/package.json +7 -3
- package/src/auth-status.ts +4 -0
- package/src/auth.test.ts +5 -112
- package/src/auto-transcribe.test.ts +116 -0
- package/src/auto-transcribe.ts +48 -0
- package/src/backup.ts +17 -3
- package/src/cli.ts +95 -66
- package/src/config.test.ts +26 -0
- package/src/config.ts +53 -1
- package/src/db.ts +15 -2
- package/src/export-watch.test.ts +21 -0
- package/src/mcp-install-interactive.test.ts +23 -2
- package/src/mcp-install-interactive.ts +21 -2
- package/src/mcp-install.test.ts +40 -0
- package/src/mcp-tools.ts +17 -1
- package/src/module-config.ts +70 -14
- package/src/module-manifest.test.ts +114 -0
- package/src/module-manifest.ts +104 -0
- package/src/oauth-discovery.ts +95 -0
- package/src/owner-auth.ts +22 -149
- package/src/routes.ts +268 -51
- package/src/routing.test.ts +102 -99
- package/src/routing.ts +33 -47
- package/src/scribe-discovery.test.ts +77 -0
- package/src/scribe-discovery.ts +91 -0
- package/src/scribe-env.test.ts +66 -1
- package/src/scribe-env.ts +42 -1
- package/src/self-register.test.ts +412 -0
- package/src/self-register.ts +247 -0
- package/src/server.ts +47 -23
- package/src/transcript-note.test.ts +171 -0
- package/src/transcript-note.ts +189 -0
- package/src/transcription-registry.ts +22 -0
- package/src/transcription-worker.test.ts +250 -0
- package/src/transcription-worker.ts +186 -27
- package/src/vault-name.ts +3 -2
- package/src/vault.test.ts +347 -0
- package/web/ui/dist/assets/index-BOa-JJtV.css +1 -0
- package/web/ui/dist/assets/index-BzA5LgE3.js +60 -0
- package/web/ui/dist/index.html +14 -0
- package/web/ui/tsconfig.json +21 -0
- package/src/oauth.test.ts +0 -2156
- package/src/oauth.ts +0 -973
package/core/src/core.test.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { Database } from "bun:sqlite";
|
|
|
3
3
|
import { SqliteStore } from "./store.js";
|
|
4
4
|
import { generateMcpTools } from "./mcp.js";
|
|
5
5
|
import { initSchema } from "./schema.js";
|
|
6
|
+
import { decodeCursor } from "./cursor.js";
|
|
6
7
|
|
|
7
8
|
let store: SqliteStore;
|
|
8
9
|
let db: Database;
|
|
@@ -1312,6 +1313,262 @@ describe("queryNotes", async () => {
|
|
|
1312
1313
|
expect(desc[0].content).toBe("Second");
|
|
1313
1314
|
});
|
|
1314
1315
|
|
|
1316
|
+
// ---- Cursor pagination (vault#313) ----
|
|
1317
|
+
//
|
|
1318
|
+
// Opaque cursors for "since last checked" agent loops. The cursor binds
|
|
1319
|
+
// to the query's filters via sha256 of the result-set-affecting params;
|
|
1320
|
+
// a mismatched cursor raises CursorError. Pagination is keyset on
|
|
1321
|
+
// (updated_at, id) so two notes sharing a millisecond don't get skipped
|
|
1322
|
+
// or doubled across the page boundary.
|
|
1323
|
+
describe("cursor pagination", () => {
|
|
1324
|
+
// Helper: pin a note's updated_at to a known value so cursor math
|
|
1325
|
+
// doesn't race wall-clock writes from the test harness.
|
|
1326
|
+
function pinUpdatedAt(id: string, iso: string) {
|
|
1327
|
+
db.prepare("UPDATE notes SET updated_at = ? WHERE id = ?").run(iso, id);
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
it("first call returns notes + a next_cursor string", async () => {
|
|
1331
|
+
await store.createNote("A", { id: "na" });
|
|
1332
|
+
await store.createNote("B", { id: "nb" });
|
|
1333
|
+
const page = await store.queryNotesPaged({ tags: [], limit: 50 });
|
|
1334
|
+
expect(page.notes.length).toBe(2);
|
|
1335
|
+
expect(typeof page.next_cursor).toBe("string");
|
|
1336
|
+
expect(page.next_cursor.length).toBeGreaterThan(0);
|
|
1337
|
+
});
|
|
1338
|
+
|
|
1339
|
+
it("subsequent call with cursor returns only newer notes", async () => {
|
|
1340
|
+
const a = await store.createNote("first", { id: "p1" });
|
|
1341
|
+
pinUpdatedAt(a.id, "2026-04-01T00:00:00.000Z");
|
|
1342
|
+
const b = await store.createNote("second", { id: "p2" });
|
|
1343
|
+
pinUpdatedAt(b.id, "2026-04-02T00:00:00.000Z");
|
|
1344
|
+
|
|
1345
|
+
const page1 = await store.queryNotesPaged({});
|
|
1346
|
+
// Both notes returned, cursor advances to "second"'s watermark.
|
|
1347
|
+
expect(page1.notes.map((n) => n.id).sort()).toEqual(["p1", "p2"]);
|
|
1348
|
+
|
|
1349
|
+
// No new writes yet — second call should be empty.
|
|
1350
|
+
const page2 = await store.queryNotesPaged({ cursor: page1.next_cursor });
|
|
1351
|
+
expect(page2.notes).toHaveLength(0);
|
|
1352
|
+
|
|
1353
|
+
// Now write a new note; third call should return only it.
|
|
1354
|
+
const c = await store.createNote("third", { id: "p3" });
|
|
1355
|
+
pinUpdatedAt(c.id, "2026-04-03T00:00:00.000Z");
|
|
1356
|
+
const page3 = await store.queryNotesPaged({ cursor: page2.next_cursor });
|
|
1357
|
+
expect(page3.notes.map((n) => n.id)).toEqual(["p3"]);
|
|
1358
|
+
});
|
|
1359
|
+
|
|
1360
|
+
it("next_cursor is always returned, even on an empty result page", async () => {
|
|
1361
|
+
// No notes at all — first call returns empty array but still a cursor.
|
|
1362
|
+
const page = await store.queryNotesPaged({});
|
|
1363
|
+
expect(page.notes).toHaveLength(0);
|
|
1364
|
+
expect(typeof page.next_cursor).toBe("string");
|
|
1365
|
+
expect(page.next_cursor.length).toBeGreaterThan(0);
|
|
1366
|
+
|
|
1367
|
+
// Decode it: empty-page sentinel watermark is millis 0 + empty id.
|
|
1368
|
+
const decoded = decodeCursor(page.next_cursor);
|
|
1369
|
+
expect(decoded.last_updated_at).toBe(0);
|
|
1370
|
+
expect(decoded.last_id).toBe("");
|
|
1371
|
+
});
|
|
1372
|
+
|
|
1373
|
+
it("cursor with stale query_hash raises CursorError (cursor_query_mismatch)", async () => {
|
|
1374
|
+
await store.createNote("a", { tags: ["x"], id: "qm1" });
|
|
1375
|
+
await store.createNote("b", { tags: ["y"], id: "qm2" });
|
|
1376
|
+
|
|
1377
|
+
const page1 = await store.queryNotesPaged({ tags: ["x"] });
|
|
1378
|
+
expect(page1.notes.map((n) => n.id)).toEqual(["qm1"]);
|
|
1379
|
+
|
|
1380
|
+
// Reuse the cursor with a different query — must reject loudly.
|
|
1381
|
+
try {
|
|
1382
|
+
await store.queryNotesPaged({ tags: ["y"], cursor: page1.next_cursor });
|
|
1383
|
+
throw new Error("expected CursorError");
|
|
1384
|
+
} catch (err: any) {
|
|
1385
|
+
expect(err.name).toBe("CursorError");
|
|
1386
|
+
expect(err.code).toBe("cursor_query_mismatch");
|
|
1387
|
+
}
|
|
1388
|
+
});
|
|
1389
|
+
|
|
1390
|
+
it("cursor with malformed payload raises CursorError (cursor_invalid)", async () => {
|
|
1391
|
+
try {
|
|
1392
|
+
await store.queryNotesPaged({ cursor: "not-a-real-cursor-!!!" });
|
|
1393
|
+
throw new Error("expected CursorError");
|
|
1394
|
+
} catch (err: any) {
|
|
1395
|
+
expect(err.name).toBe("CursorError");
|
|
1396
|
+
expect(err.code).toBe("cursor_invalid");
|
|
1397
|
+
}
|
|
1398
|
+
});
|
|
1399
|
+
|
|
1400
|
+
it("tiebreaker: two notes at the same updated_at use id as the secondary sort key", async () => {
|
|
1401
|
+
const ts = "2026-04-15T00:00:00.000Z";
|
|
1402
|
+
const a = await store.createNote("alpha", { id: "tb-a" });
|
|
1403
|
+
const b = await store.createNote("beta", { id: "tb-b" });
|
|
1404
|
+
const c = await store.createNote("gamma", { id: "tb-c" });
|
|
1405
|
+
pinUpdatedAt(a.id, ts);
|
|
1406
|
+
pinUpdatedAt(b.id, ts);
|
|
1407
|
+
pinUpdatedAt(c.id, ts);
|
|
1408
|
+
|
|
1409
|
+
// Page 1 with limit=2: should return a + b (id-ascending tiebreaker).
|
|
1410
|
+
const page1 = await store.queryNotesPaged({ limit: 2 });
|
|
1411
|
+
expect(page1.notes.map((n) => n.id)).toEqual(["tb-a", "tb-b"]);
|
|
1412
|
+
|
|
1413
|
+
// Page 2 with the cursor: should return c (id > "tb-b" at same ms).
|
|
1414
|
+
// Note: c is queried with the same query_hash since limit is
|
|
1415
|
+
// excluded from the hash inputs by design.
|
|
1416
|
+
const page2 = await store.queryNotesPaged({ limit: 2, cursor: page1.next_cursor });
|
|
1417
|
+
expect(page2.notes.map((n) => n.id)).toEqual(["tb-c"]);
|
|
1418
|
+
});
|
|
1419
|
+
|
|
1420
|
+
it("cursor advances correctly when notes share a millisecond on the page boundary", async () => {
|
|
1421
|
+
// Two notes share the exact updated_at the cursor was minted at.
|
|
1422
|
+
// The keyset predicate must include the larger-id one but NOT
|
|
1423
|
+
// duplicate the cursor's own note.
|
|
1424
|
+
const ts = "2026-04-15T12:34:56.789Z";
|
|
1425
|
+
const a = await store.createNote("first-at-ts", { id: "ms-a" });
|
|
1426
|
+
pinUpdatedAt(a.id, ts);
|
|
1427
|
+
|
|
1428
|
+
const page1 = await store.queryNotesPaged({ limit: 50 });
|
|
1429
|
+
expect(page1.notes.map((n) => n.id)).toEqual(["ms-a"]);
|
|
1430
|
+
|
|
1431
|
+
// Now write a note that lands at the EXACT same updated_at (race
|
|
1432
|
+
// window between pages). Its id sorts AFTER "ms-a".
|
|
1433
|
+
const b = await store.createNote("second-at-same-ts", { id: "ms-b" });
|
|
1434
|
+
pinUpdatedAt(b.id, ts);
|
|
1435
|
+
|
|
1436
|
+
const page2 = await store.queryNotesPaged({ cursor: page1.next_cursor });
|
|
1437
|
+
// Must NOT include "ms-a" (we already saw it), MUST include "ms-b"
|
|
1438
|
+
// (its id sorts after the cursor's last_id at the same timestamp).
|
|
1439
|
+
expect(page2.notes.map((n) => n.id)).toEqual(["ms-b"]);
|
|
1440
|
+
});
|
|
1441
|
+
|
|
1442
|
+
it("cursor invariance across reboots: a serialized cursor still resumes correctly", async () => {
|
|
1443
|
+
// Cursors are self-contained — they don't reference any server-side
|
|
1444
|
+
// state. Simulate a reboot by tearing down the DB+store and replaying
|
|
1445
|
+
// the same data; the cursor minted on instance #1 must work against
|
|
1446
|
+
// instance #2 with the same query.
|
|
1447
|
+
const a1 = await store.createNote("note-1", { id: "reboot-1", path: "n1" });
|
|
1448
|
+
pinUpdatedAt(a1.id, "2026-04-01T00:00:00.000Z");
|
|
1449
|
+
const a2 = await store.createNote("note-2", { id: "reboot-2", path: "n2" });
|
|
1450
|
+
pinUpdatedAt(a2.id, "2026-04-02T00:00:00.000Z");
|
|
1451
|
+
|
|
1452
|
+
const page1 = await store.queryNotesPaged({ limit: 1 });
|
|
1453
|
+
expect(page1.notes.map((n) => n.id)).toEqual(["reboot-1"]);
|
|
1454
|
+
const cursor = page1.next_cursor;
|
|
1455
|
+
|
|
1456
|
+
// Simulate a process restart with a fresh DB seeded the same way.
|
|
1457
|
+
db.close();
|
|
1458
|
+
db = new Database(":memory:");
|
|
1459
|
+
store = new SqliteStore(db);
|
|
1460
|
+
const a1b = await store.createNote("note-1", { id: "reboot-1", path: "n1" });
|
|
1461
|
+
pinUpdatedAt(a1b.id, "2026-04-01T00:00:00.000Z");
|
|
1462
|
+
const a2b = await store.createNote("note-2", { id: "reboot-2", path: "n2" });
|
|
1463
|
+
pinUpdatedAt(a2b.id, "2026-04-02T00:00:00.000Z");
|
|
1464
|
+
|
|
1465
|
+
// The cursor is opaque-but-portable: encodes only millis + id + hash,
|
|
1466
|
+
// none of which depend on server-side session state.
|
|
1467
|
+
const page2 = await store.queryNotesPaged({ limit: 1, cursor });
|
|
1468
|
+
expect(page2.notes.map((n) => n.id)).toEqual(["reboot-2"]);
|
|
1469
|
+
});
|
|
1470
|
+
|
|
1471
|
+
it("cursor mode rejects sort: desc (descending iteration would skip new writes)", async () => {
|
|
1472
|
+
await store.createNote("a");
|
|
1473
|
+
const page = await store.queryNotesPaged({});
|
|
1474
|
+
try {
|
|
1475
|
+
await store.queryNotesPaged({ cursor: page.next_cursor, sort: "desc" });
|
|
1476
|
+
throw new Error("expected QueryError");
|
|
1477
|
+
} catch (err: any) {
|
|
1478
|
+
expect(err.name).toBe("QueryError");
|
|
1479
|
+
expect(err.code).toBe("INVALID_QUERY");
|
|
1480
|
+
expect(err.message.toLowerCase()).toContain("ascending");
|
|
1481
|
+
}
|
|
1482
|
+
});
|
|
1483
|
+
|
|
1484
|
+
it("cursor mode rejects orderBy (mutually exclusive with updated_at keyset)", async () => {
|
|
1485
|
+
const { declareField } = await import("./indexed-fields.js");
|
|
1486
|
+
declareField(db, "priority", "INTEGER", "task");
|
|
1487
|
+
await store.createNote("a", { metadata: { priority: 1 } });
|
|
1488
|
+
const page = await store.queryNotesPaged({});
|
|
1489
|
+
try {
|
|
1490
|
+
await store.queryNotesPaged({ cursor: page.next_cursor, orderBy: "priority" });
|
|
1491
|
+
throw new Error("expected QueryError");
|
|
1492
|
+
} catch (err: any) {
|
|
1493
|
+
expect(err.name).toBe("QueryError");
|
|
1494
|
+
expect(err.code).toBe("INVALID_QUERY");
|
|
1495
|
+
expect(err.message.toLowerCase()).toContain("order_by");
|
|
1496
|
+
}
|
|
1497
|
+
});
|
|
1498
|
+
|
|
1499
|
+
it("cursor + tag filter: only notes matching the filter advance the watermark", async () => {
|
|
1500
|
+
// Cursor pagination composes with the rest of the query — the
|
|
1501
|
+
// watermark tracks the last note that matched the filter, not the
|
|
1502
|
+
// last note in the vault.
|
|
1503
|
+
const a = await store.createNote("task-1", { tags: ["task"], id: "ct-1" });
|
|
1504
|
+
pinUpdatedAt(a.id, "2026-04-01T00:00:00.000Z");
|
|
1505
|
+
const b = await store.createNote("not-a-task", { tags: ["other"], id: "ct-2" });
|
|
1506
|
+
pinUpdatedAt(b.id, "2026-04-02T00:00:00.000Z");
|
|
1507
|
+
const c = await store.createNote("task-2", { tags: ["task"], id: "ct-3" });
|
|
1508
|
+
pinUpdatedAt(c.id, "2026-04-03T00:00:00.000Z");
|
|
1509
|
+
|
|
1510
|
+
const page1 = await store.queryNotesPaged({ tags: ["task"] });
|
|
1511
|
+
expect(page1.notes.map((n) => n.id).sort()).toEqual(["ct-1", "ct-3"]);
|
|
1512
|
+
|
|
1513
|
+
const page2 = await store.queryNotesPaged({
|
|
1514
|
+
tags: ["task"],
|
|
1515
|
+
cursor: page1.next_cursor,
|
|
1516
|
+
});
|
|
1517
|
+
expect(page2.notes).toHaveLength(0);
|
|
1518
|
+
});
|
|
1519
|
+
|
|
1520
|
+
it("query_hash is stable across key-order permutations of the same query", async () => {
|
|
1521
|
+
// Two semantically-equivalent queries differing only in JS object key
|
|
1522
|
+
// order must produce the same cursor binding — otherwise an SDK that
|
|
1523
|
+
// reshuffles parameters between calls would silently invalidate the
|
|
1524
|
+
// cursor.
|
|
1525
|
+
const { computeQueryHash } = await import("./cursor.js");
|
|
1526
|
+
const h1 = computeQueryHash({
|
|
1527
|
+
tags: ["a", "b"],
|
|
1528
|
+
path: "Projects",
|
|
1529
|
+
metadata: { status: "open" },
|
|
1530
|
+
});
|
|
1531
|
+
const h2 = computeQueryHash({
|
|
1532
|
+
metadata: { status: "open" },
|
|
1533
|
+
path: "Projects",
|
|
1534
|
+
tags: ["a", "b"],
|
|
1535
|
+
});
|
|
1536
|
+
expect(h1).toBe(h2);
|
|
1537
|
+
|
|
1538
|
+
// ...and tag-array order is irrelevant (different SDKs may sort).
|
|
1539
|
+
const h3 = computeQueryHash({
|
|
1540
|
+
tags: ["b", "a"],
|
|
1541
|
+
path: "Projects",
|
|
1542
|
+
metadata: { status: "open" },
|
|
1543
|
+
});
|
|
1544
|
+
expect(h3).toBe(h1);
|
|
1545
|
+
});
|
|
1546
|
+
|
|
1547
|
+
it("MCP query-notes: cursor mode returns {notes, next_cursor} envelope", async () => {
|
|
1548
|
+
await store.createNote("a", { id: "mcp-a" });
|
|
1549
|
+
await store.createNote("b", { id: "mcp-b" });
|
|
1550
|
+
|
|
1551
|
+
const tools = generateMcpTools(store);
|
|
1552
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
1553
|
+
|
|
1554
|
+
// First call without cursor returns flat array (legacy shape).
|
|
1555
|
+
const firstResult = await query.execute({}) as unknown;
|
|
1556
|
+
expect(Array.isArray(firstResult)).toBe(true);
|
|
1557
|
+
|
|
1558
|
+
// Get a cursor by minting one ourselves via the store.
|
|
1559
|
+
const seed = await store.queryNotesPaged({});
|
|
1560
|
+
|
|
1561
|
+
// Second call with cursor returns the wrapped envelope.
|
|
1562
|
+
const envelope = await query.execute({ cursor: seed.next_cursor }) as any;
|
|
1563
|
+
expect(envelope).toHaveProperty("notes");
|
|
1564
|
+
expect(envelope).toHaveProperty("next_cursor");
|
|
1565
|
+
expect(Array.isArray(envelope.notes)).toBe(true);
|
|
1566
|
+
// No new writes since seed → empty page, cursor still advances.
|
|
1567
|
+
expect(envelope.notes).toHaveLength(0);
|
|
1568
|
+
expect(typeof envelope.next_cursor).toBe("string");
|
|
1569
|
+
});
|
|
1570
|
+
});
|
|
1571
|
+
|
|
1315
1572
|
it("limits results", async () => {
|
|
1316
1573
|
for (let i = 0; i < 5; i++) await store.createNote(`Note ${i}`);
|
|
1317
1574
|
const results = await store.queryNotes({ limit: 3 });
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the opaque-cursor primitives (vault#313).
|
|
3
|
+
*
|
|
4
|
+
* Integration tests against `queryNotesPaged` live in core.test.ts under
|
|
5
|
+
* `describe("cursor pagination")`. This file pins down the
|
|
6
|
+
* encode/decode/hash invariants directly so a regression in the codec
|
|
7
|
+
* surfaces here before it reaches the wider query pipeline.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, it, expect } from "bun:test";
|
|
11
|
+
import {
|
|
12
|
+
CURSOR_VERSION,
|
|
13
|
+
CursorError,
|
|
14
|
+
computeQueryHash,
|
|
15
|
+
decodeCursor,
|
|
16
|
+
encodeCursor,
|
|
17
|
+
isoToMillis,
|
|
18
|
+
millisToIso,
|
|
19
|
+
} from "./cursor.js";
|
|
20
|
+
|
|
21
|
+
describe("cursor codec", () => {
|
|
22
|
+
it("encodes + decodes round-trips a payload", () => {
|
|
23
|
+
const payload = {
|
|
24
|
+
v: CURSOR_VERSION,
|
|
25
|
+
last_updated_at: 1714000000000,
|
|
26
|
+
last_id: "note-xyz",
|
|
27
|
+
query_hash: "a".repeat(64),
|
|
28
|
+
};
|
|
29
|
+
const cursor = encodeCursor(payload);
|
|
30
|
+
expect(typeof cursor).toBe("string");
|
|
31
|
+
expect(cursor.length).toBeGreaterThan(0);
|
|
32
|
+
// base64url has no `+`, `/`, or `=` padding.
|
|
33
|
+
expect(cursor).not.toMatch(/[+/=]/);
|
|
34
|
+
|
|
35
|
+
const decoded = decodeCursor(cursor);
|
|
36
|
+
expect(decoded).toEqual(payload);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("decodeCursor rejects an empty string", () => {
|
|
40
|
+
try {
|
|
41
|
+
decodeCursor("");
|
|
42
|
+
throw new Error("expected throw");
|
|
43
|
+
} catch (err: any) {
|
|
44
|
+
expect(err).toBeInstanceOf(CursorError);
|
|
45
|
+
expect(err.code).toBe("cursor_invalid");
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("decodeCursor rejects non-JSON inside a valid base64url", () => {
|
|
50
|
+
const bogus = Buffer.from("not-json", "utf8").toString("base64url");
|
|
51
|
+
try {
|
|
52
|
+
decodeCursor(bogus);
|
|
53
|
+
throw new Error("expected throw");
|
|
54
|
+
} catch (err: any) {
|
|
55
|
+
expect(err).toBeInstanceOf(CursorError);
|
|
56
|
+
expect(err.code).toBe("cursor_invalid");
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("decodeCursor rejects a wrong version number", () => {
|
|
61
|
+
const cursor = encodeCursor({
|
|
62
|
+
v: 999,
|
|
63
|
+
last_updated_at: 0,
|
|
64
|
+
last_id: "",
|
|
65
|
+
query_hash: "abc",
|
|
66
|
+
} as any);
|
|
67
|
+
try {
|
|
68
|
+
decodeCursor(cursor);
|
|
69
|
+
throw new Error("expected throw");
|
|
70
|
+
} catch (err: any) {
|
|
71
|
+
expect(err.code).toBe("cursor_invalid");
|
|
72
|
+
expect(err.message).toContain("schema version");
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("decodeCursor rejects missing or wrong-type fields", () => {
|
|
77
|
+
// Missing last_id.
|
|
78
|
+
const missing = Buffer.from(
|
|
79
|
+
JSON.stringify({ v: CURSOR_VERSION, last_updated_at: 0, query_hash: "x" }),
|
|
80
|
+
).toString("base64url");
|
|
81
|
+
expect(() => decodeCursor(missing)).toThrow();
|
|
82
|
+
|
|
83
|
+
// last_updated_at NaN.
|
|
84
|
+
const nan = encodeCursor({
|
|
85
|
+
v: CURSOR_VERSION,
|
|
86
|
+
last_updated_at: NaN,
|
|
87
|
+
last_id: "",
|
|
88
|
+
query_hash: "x",
|
|
89
|
+
});
|
|
90
|
+
expect(() => decodeCursor(nan)).toThrow();
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("computeQueryHash", () => {
|
|
95
|
+
it("is stable across key-order permutations", () => {
|
|
96
|
+
const h1 = computeQueryHash({
|
|
97
|
+
tags: ["alpha"],
|
|
98
|
+
path: "p",
|
|
99
|
+
metadata: { status: "open" },
|
|
100
|
+
});
|
|
101
|
+
const h2 = computeQueryHash({
|
|
102
|
+
metadata: { status: "open" },
|
|
103
|
+
path: "p",
|
|
104
|
+
tags: ["alpha"],
|
|
105
|
+
});
|
|
106
|
+
expect(h1).toBe(h2);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("is stable across tag-array order permutations", () => {
|
|
110
|
+
const h1 = computeQueryHash({ tags: ["a", "b", "c"] });
|
|
111
|
+
const h2 = computeQueryHash({ tags: ["c", "b", "a"] });
|
|
112
|
+
expect(h1).toBe(h2);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("treats `tags: []` and missing `tags` as equivalent (both mean 'no tag filter')", () => {
|
|
116
|
+
const h1 = computeQueryHash({});
|
|
117
|
+
const h2 = computeQueryHash({ tags: [] });
|
|
118
|
+
expect(h1).toBe(h2);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("changes when the query filters change", () => {
|
|
122
|
+
const h1 = computeQueryHash({ tags: ["a"] });
|
|
123
|
+
const h2 = computeQueryHash({ tags: ["b"] });
|
|
124
|
+
expect(h1).not.toBe(h2);
|
|
125
|
+
|
|
126
|
+
const h3 = computeQueryHash({ path: "p" });
|
|
127
|
+
expect(h3).not.toBe(h1);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("returns a 64-char hex string (sha256)", () => {
|
|
131
|
+
const h = computeQueryHash({ tags: ["x"] });
|
|
132
|
+
expect(h).toMatch(/^[0-9a-f]{64}$/);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("nested metadata operator clauses hash stably under key reorder", () => {
|
|
136
|
+
// `{gte: 5, lt: 10}` and `{lt: 10, gte: 5}` are semantically identical
|
|
137
|
+
// at the SQL layer (AND-conjunction of clauses); the hash must match.
|
|
138
|
+
const h1 = computeQueryHash({ metadata: { priority: { gte: 5, lt: 10 } } });
|
|
139
|
+
const h2 = computeQueryHash({ metadata: { priority: { lt: 10, gte: 5 } } });
|
|
140
|
+
expect(h1).toBe(h2);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("dateFilter contributes to the hash", () => {
|
|
144
|
+
const h1 = computeQueryHash({ dateFilter: { field: "created_at", from: "2026-01-01" } });
|
|
145
|
+
const h2 = computeQueryHash({ dateFilter: { field: "created_at", from: "2026-02-01" } });
|
|
146
|
+
expect(h1).not.toBe(h2);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe("isoToMillis / millisToIso", () => {
|
|
151
|
+
it("round-trips", () => {
|
|
152
|
+
const iso = "2026-04-15T12:34:56.789Z";
|
|
153
|
+
const ms = isoToMillis(iso);
|
|
154
|
+
expect(millisToIso(ms)).toBe(iso);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("rejects malformed ISO", () => {
|
|
158
|
+
expect(() => isoToMillis("not-a-date")).toThrow();
|
|
159
|
+
});
|
|
160
|
+
});
|