@openparachute/vault 0.6.0 → 0.6.2-rc.1

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.
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Regression pins for the 2026-06-10 query-perf work:
3
+ *
4
+ * 1. Plain `{field: value}` metadata equality on an INDEXED field now routes
5
+ * through the generated column as an index prefilter, keeping the
6
+ * original json_extract clause as a residual predicate. Results must be
7
+ * IDENTICAL to the JSON-scan path — property-tested here by giving every
8
+ * note twin fields (`*_idx` indexed, `*_raw` not) carrying the SAME
9
+ * value, then asserting every probe returns the same ids through both.
10
+ *
11
+ * 2. Tag membership is a semijoin (no `JOIN note_tags` + `DISTINCT n.*`),
12
+ * so a note matched by SEVERAL tags in one query must still appear once.
13
+ *
14
+ * 3. `getLinksHydratedForNotes` (the batched include_links path) must agree
15
+ * with per-note `getLinksHydrated`, including self-loops and links whose
16
+ * endpoints are both on the page.
17
+ */
18
+ import { describe, it, expect, beforeEach } from "bun:test";
19
+ import { Database } from "bun:sqlite";
20
+ import { SqliteStore } from "./store.js";
21
+ import { queryNotes } from "./notes.js";
22
+ import { getLinksHydrated, getLinksHydratedForNotes } from "./links.js";
23
+
24
+ let db: Database;
25
+ let store: SqliteStore;
26
+
27
+ beforeEach(() => {
28
+ db = new Database(":memory:");
29
+ store = new SqliteStore(db);
30
+ });
31
+
32
+ describe("plain metadata equality: indexed routing is result-identical to the JSON scan", () => {
33
+ /**
34
+ * Edge values exercised through BOTH paths. Some of these never match
35
+ * (today's documented scan behavior — e.g. a JS number binds as its JSON
36
+ * text and SQLite won't equate INTEGER 5 with TEXT '5'); the pin is that
37
+ * indexed routing reproduces the scan's verdict EXACTLY, match or not.
38
+ */
39
+ const STORED_VALUES: unknown[] = [
40
+ "pending",
41
+ "",
42
+ "5", // numeric-looking string
43
+ 5, // JSON number — affinity edge case vs "5"
44
+ 0,
45
+ -1.5,
46
+ true,
47
+ false,
48
+ null,
49
+ "naïve-Ünïcode ✨",
50
+ { nested: { deep: 1 } },
51
+ ["a", "b"],
52
+ undefined, // field absent from metadata
53
+ ];
54
+
55
+ const PROBES: unknown[] = [
56
+ "pending",
57
+ "",
58
+ "5",
59
+ 5,
60
+ 0,
61
+ -1.5,
62
+ true,
63
+ false,
64
+ null,
65
+ "naïve-Ünïcode ✨",
66
+ "missing-everywhere",
67
+ ];
68
+
69
+ async function seedTwinFieldFixture(sqliteType: "string" | "integer") {
70
+ // `status_idx` is indexed (string) via a tag schema; `status_raw` is not.
71
+ await store.upsertTagRecord("thing", {
72
+ fields: { status_idx: { type: sqliteType, indexed: true } },
73
+ });
74
+ for (let i = 0; i < STORED_VALUES.length; i++) {
75
+ const v = STORED_VALUES[i];
76
+ const metadata: Record<string, unknown> = { seq: i };
77
+ if (v !== undefined) {
78
+ metadata.status_idx = v;
79
+ metadata.status_raw = v;
80
+ }
81
+ await store.createNote(`note ${i}`, { tags: ["thing"], metadata });
82
+ }
83
+ }
84
+
85
+ for (const sqliteType of ["string", "integer"] as const) {
86
+ it(`every probe matches the same notes through the indexed and scan paths (${sqliteType} column)`, async () => {
87
+ await seedTwinFieldFixture(sqliteType);
88
+ for (const probe of PROBES) {
89
+ const viaIndexed = queryNotes(db, { metadata: { status_idx: probe } }).map((n) => n.id);
90
+ const viaScan = queryNotes(db, { metadata: { status_raw: probe } }).map((n) => n.id);
91
+ expect(viaIndexed).toEqual(viaScan);
92
+ }
93
+ });
94
+ }
95
+
96
+ it("string probe on the indexed field actually matches (sanity: not vacuous)", async () => {
97
+ await seedTwinFieldFixture("string");
98
+ const hits = queryNotes(db, { metadata: { status_idx: "pending" } });
99
+ expect(hits.length).toBe(1);
100
+ expect(hits[0]!.metadata?.seq).toBe(0);
101
+ });
102
+
103
+ it("indexed plain equality agrees with the operator form for string values", async () => {
104
+ await seedTwinFieldFixture("string");
105
+ for (const probe of ["pending", "", "missing-everywhere", "naïve-Ünïcode ✨"]) {
106
+ const plain = queryNotes(db, { metadata: { status_idx: probe } }).map((n) => n.id);
107
+ const operator = queryNotes(db, { metadata: { status_idx: { eq: probe } } }).map((n) => n.id);
108
+ expect(plain).toEqual(operator);
109
+ }
110
+ });
111
+
112
+ it("plain equality on a non-indexed field still works (no FIELD_NOT_INDEXED error)", async () => {
113
+ await store.createNote("a", { metadata: { color: "blue" } });
114
+ await store.createNote("b", { metadata: { color: "red" } });
115
+ const hits = queryNotes(db, { metadata: { color: "blue" } });
116
+ expect(hits.length).toBe(1);
117
+ });
118
+ });
119
+
120
+ describe("tag semijoin: no duplicate rows without DISTINCT", () => {
121
+ it("a note carrying several matching tags appears once under tag_match=any", async () => {
122
+ await store.upsertTagRecord("capture/voice", { parent_names: ["capture"] });
123
+ await store.upsertTagRecord("capture/text", { parent_names: ["capture"] });
124
+ // Tagged with the parent AND two children — the old JOIN produced 3 rows.
125
+ const note = await store.createNote("multi", {
126
+ tags: ["capture", "capture/voice", "capture/text"],
127
+ });
128
+ await store.createNote("single", { tags: ["capture/voice"] });
129
+
130
+ const viaExpansion = await store.queryNotes({ tags: ["capture"] });
131
+ expect(viaExpansion.map((n) => n.id).filter((id) => id === note.id).length).toBe(1);
132
+ expect(viaExpansion.length).toBe(2);
133
+
134
+ const viaAny = await store.queryNotes({
135
+ tags: ["capture", "capture/voice", "capture/text"],
136
+ tagMatch: "any",
137
+ });
138
+ expect(viaAny.map((n) => n.id).filter((id) => id === note.id).length).toBe(1);
139
+ });
140
+
141
+ it("tag_match=all still requires every input tag", async () => {
142
+ const both = await store.createNote("both", { tags: ["a", "b"] });
143
+ await store.createNote("only-a", { tags: ["a"] });
144
+ const hits = await store.queryNotes({ tags: ["a", "b"], tagMatch: "all" });
145
+ expect(hits.map((n) => n.id)).toEqual([both.id]);
146
+ });
147
+ });
148
+
149
+ describe("getLinksHydratedForNotes agrees with per-note getLinksHydrated", () => {
150
+ it("matches per-note hydration across self-loops, shared links, and isolated notes", async () => {
151
+ const a = await store.createNote("a", { tags: ["x"] });
152
+ const b = await store.createNote("b", { tags: ["y"], metadata: { k: 1 } });
153
+ const c = await store.createNote("c", { path: "Notes/c" });
154
+ const lonely = await store.createNote("lonely");
155
+
156
+ await store.createLink(a.id, b.id, "mentions"); // both endpoints on page
157
+ await store.createLink(b.id, a.id, "replies"); // reverse direction
158
+ await store.createLink(a.id, a.id, "self"); // self-loop
159
+ await store.createLink(c.id, a.id, "references"); // inbound from page note
160
+ await store.createLink(b.id, c.id, "cites", { via: "test" });
161
+
162
+ const pageIds = [a.id, b.id, c.id, lonely.id];
163
+ const batch = getLinksHydratedForNotes(db, pageIds);
164
+
165
+ for (const id of pageIds) {
166
+ const single = getLinksHydrated(db, id);
167
+ const batched = batch.get(id)!;
168
+ const key = (l: { sourceId: string; targetId: string; relationship: string }) =>
169
+ `${l.sourceId}→${l.targetId}:${l.relationship}`;
170
+ // Same link set per note…
171
+ expect(batched.map(key).sort()).toEqual(single.map(key).sort());
172
+ // …ordered newest-first like the single-note SQL.
173
+ const stamps = batched.map((l) => l.createdAt);
174
+ expect([...stamps].sort().reverse()).toEqual(stamps);
175
+ // …with identical hydrated summaries (path, tags, metadata).
176
+ const summaries = (ls: typeof single) =>
177
+ new Map(ls.map((l) => [key(l), JSON.stringify([l.sourceNote, l.targetNote])]));
178
+ expect(summaries(batched)).toEqual(summaries(single));
179
+ }
180
+
181
+ // The self-loop appears once in a's list (not duplicated by the
182
+ // source/target double-walk), matching the single-note query.
183
+ const selfLoops = batch.get(a.id)!.filter((l) => l.sourceId === a.id && l.targetId === a.id);
184
+ expect(selfLoops.length).toBe(1);
185
+
186
+ expect(batch.get(lonely.id)).toEqual([]);
187
+ });
188
+ });
189
+
190
+ describe("v22 keyset index", () => {
191
+ it("exists after initSchema and covers (updated_at, id)", () => {
192
+ const row = db
193
+ .prepare("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_notes_updated'")
194
+ .get();
195
+ expect(row).toBeTruthy();
196
+ const plan = db
197
+ .prepare(
198
+ `EXPLAIN QUERY PLAN
199
+ SELECT n.id FROM notes n
200
+ WHERE (n.updated_at > ? OR (n.updated_at = ? AND n.id > ?))
201
+ ORDER BY n.updated_at ASC, n.id ASC LIMIT 10`,
202
+ )
203
+ .all("2025-01-01", "2025-01-01", "x") as { detail: string }[];
204
+ const details = plan.map((r) => r.detail).join(" | ");
205
+ expect(details).toContain("idx_notes_updated");
206
+ expect(details).not.toContain("TEMP B-TREE");
207
+ });
208
+ });
@@ -2,7 +2,7 @@ import { Database } from "bun:sqlite";
2
2
  import { normalizePath } from "./paths.js";
3
3
  import { rebuildIndexes } from "./indexed-fields.js";
4
4
 
5
- export const SCHEMA_VERSION = 21;
5
+ export const SCHEMA_VERSION = 22;
6
6
 
7
7
  export const SCHEMA_SQL = `
8
8
  -- Notes: the universal record.
@@ -474,6 +474,11 @@ export function initSchema(db: Database): void {
474
474
  // above, so this is a defensive confirmation hook for upgrading vaults.
475
475
  migrateToV21(db);
476
476
 
477
+ // Migrate v21 → v22: composite index notes(updated_at, id) backing cursor
478
+ // keyset pagination and date_filter on updated_at — both were full table
479
+ // scans. See the 2026-06-10 query-perf measurements.
480
+ migrateToV22(db);
481
+
477
482
  // Rebuild any generated columns + indexes declared in indexed_fields.
478
483
  // No-op for a fresh vault; idempotent on existing vaults.
479
484
  rebuildIndexes(db);
@@ -1121,6 +1126,30 @@ function migrateToV21(db: Database): void {
1121
1126
  `);
1122
1127
  }
1123
1128
 
1129
+ /**
1130
+ * Migrate v21 → v22: composite B-tree on notes(updated_at, id).
1131
+ *
1132
+ * Two query shapes ride it (2026-06-10 perf measurements — both were full
1133
+ * table scans + temp-B-tree sorts before):
1134
+ *
1135
+ * 1. Cursor keyset pagination (vault#313): the predicate
1136
+ * `updated_at > ? OR (updated_at = ? AND id > ?)` with
1137
+ * `ORDER BY updated_at ASC, id ASC` matches the composite key exactly,
1138
+ * so a "since last checked" poll seeks straight to the watermark and
1139
+ * streams in order — no scan, no sort.
1140
+ * 2. `date_filter: { field: "updated_at" }` range queries (incremental
1141
+ * rebuild flows, vault#285 1.5).
1142
+ *
1143
+ * Lives here (not SCHEMA_SQL) following the idx_tokens_vault_name precedent:
1144
+ * migrations run after every column-shape change, so the index statement
1145
+ * never races an older table shape. Fresh vaults reach this through the
1146
+ * same initSchema path — CREATE INDEX IF NOT EXISTS is idempotent.
1147
+ */
1148
+ function migrateToV22(db: Database): void {
1149
+ if (!hasTable(db, "notes")) return;
1150
+ db.exec("CREATE INDEX IF NOT EXISTS idx_notes_updated ON notes(updated_at, id)");
1151
+ }
1152
+
1124
1153
  function hasTable(db: Database, name: string): boolean {
1125
1154
  const row = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get(name);
1126
1155
  return !!row;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.6.0",
3
+ "version": "0.6.2-rc.1",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",
package/src/cli.ts CHANGED
@@ -57,6 +57,7 @@ import {
57
57
  buildMcpEntryPlan,
58
58
  chooseHubOrigin,
59
59
  chooseMcpUrl,
60
+ DEFAULT_HUB_LOOPBACK_PORT,
60
61
  detectHubPresence,
61
62
  detectInstallContext,
62
63
  mintHubJwt,
@@ -258,21 +259,60 @@ switch (command) {
258
259
  // Command implementations
259
260
  // ---------------------------------------------------------------------------
260
261
 
262
+ /**
263
+ * Resolve the origin to use for the web setup wizard link (`<origin>/admin/setup`).
264
+ *
265
+ * The wizard is served by the HUB, not by vault, so the loopback fallback must
266
+ * target the hub's fixed loopback port (1939 / $PARACHUTE_HUB_PORT) — NOT
267
+ * vault's listen port. `chooseHubOrigin` returns vault's loopback as its
268
+ * fallback, so we only reuse it when a real (env / expose-state) hub origin is
269
+ * configured; otherwise we synthesize the hub's loopback URL.
270
+ *
271
+ * `vaultPort` is vault's listen port — passed only so `chooseHubOrigin`'s
272
+ * loopback branch is well-formed; we discard that loopback URL in favor of the
273
+ * hub-port one.
274
+ */
275
+ function resolveHubOriginForWizard(vaultPort: number): string {
276
+ const { url, source } = chooseHubOrigin(vaultPort);
277
+ if (source === "loopback") {
278
+ // Guard against a non-numeric PARACHUTE_HUB_PORT producing
279
+ // `http://127.0.0.1:NaN` — mirror detectHubPresence's Number.isFinite guard.
280
+ const envPort = process.env.PARACHUTE_HUB_PORT
281
+ ? Number(process.env.PARACHUTE_HUB_PORT)
282
+ : undefined;
283
+ const hubPort = Number.isFinite(envPort) ? (envPort as number) : DEFAULT_HUB_LOOPBACK_PORT;
284
+ return `http://127.0.0.1:${hubPort}`;
285
+ }
286
+ return url;
287
+ }
288
+
261
289
  async function cmdInit(args: string[] = []) {
262
290
  ensureConfigDirSync();
263
291
 
264
- // Flags: --mcp installs MCP in ~/.claude.json without prompting;
265
- // --no-mcp skips it without prompting. If both passed, --no-mcp wins
266
- // (safer default). Neither prompt in a TTY, default-yes in a
267
- // non-TTY for back-compat with existing piped install scripts.
292
+ // Writing the Claude Code MCP config (~/.claude.json) is now OPT-IN
293
+ // (2026-06-23). init's primary job is to get the operator to the hub's
294
+ // web setup wizard and SURFACE the self-serve connection info (connector
295
+ // URL + a ready-to-paste `claude mcp add` command), NOT to silently write
296
+ // a config file as a side effect of setup. The site no longer claims
297
+ // "Claude Code is auto-configured," so the install code stops doing it by
298
+ // default.
299
+ //
300
+ // Opt in with --configure-claude-code (aliases --mcp-install, --mcp) to
301
+ // have init write the entry for you. --no-mcp is retained as the explicit
302
+ // "definitely don't" form (and wins if both are passed — safer default).
303
+ // The standalone `parachute-vault mcp-install` command is unchanged — it
304
+ // remains the canonical explicit opt-in path.
268
305
  //
269
- // --token / --no-token follow the same pattern for whether the API
270
- // token is surfaced to the user at the end of init (for pasting into
271
- // other MCP clients, scripts, or curl).
306
+ // --token / --no-token control whether init ALSO mints + surfaces a
307
+ // header-auth API token (for pasting into non-OAuth MCP clients, scripts,
308
+ // or curl). Default stays off.
272
309
  //
273
310
  // --vault-name <name> skips the name prompt for non-interactive installs
274
311
  // (validated up front; exits non-zero on invalid input).
275
- const flagMcpOn = args.includes("--mcp");
312
+ const flagMcpOn =
313
+ args.includes("--configure-claude-code") ||
314
+ args.includes("--mcp-install") ||
315
+ args.includes("--mcp");
276
316
  const flagMcpOff = args.includes("--no-mcp");
277
317
  const flagTokenOn = args.includes("--token");
278
318
  const flagTokenOff = args.includes("--no-token");
@@ -507,19 +547,28 @@ async function cmdInit(args: string[] = []) {
507
547
  const bindHost = resolveBindHostname(process.env);
508
548
  console.log(` Listening on http://${bindHost}:${globalConfig.port || DEFAULT_PORT}`);
509
549
 
510
- // 7. Install MCP for Claude Code (with token for auth) user confirms
511
- // unless --mcp / --no-mcp explicitly passed. Writing to ~/.claude.json
512
- // is a side effect some users don't want; default-yes in a TTY since
513
- // most users installing vault want Claude Code to see it, but ask.
550
+ // 7. Optionally write the Claude Code MCP config (~/.claude.json). This is
551
+ // OPT-IN as of 2026-06-23 (see the flag-parsing note above). init's job is
552
+ // to point the operator at the web wizard + surface the self-serve connect
553
+ // info; it does NOT write ~/.claude.json by default. Resolution:
554
+ // --no-mcp → false (explicit "don't")
555
+ // --configure-claude-code / --mcp → true (explicit opt-in)
556
+ // TTY, no flag → ask (default NO — opt-in, not -out)
557
+ // non-TTY, no flag → false (no silent side effect in
558
+ // piped installs; the connect info is
559
+ // printed for copy-paste instead)
514
560
  let addMcp: boolean;
515
561
  if (flagMcpOff) {
516
562
  addMcp = false;
517
563
  } else if (flagMcpOn) {
518
564
  addMcp = true;
519
565
  } else if (process.stdin.isTTY) {
520
- addMcp = await confirm("Install Vault as an MCP server in Claude Code (~/.claude.json)?", true);
566
+ addMcp = await confirm(
567
+ "Also write the Claude Code MCP config now (~/.claude.json)? (you can always copy-paste the command below later)",
568
+ false,
569
+ );
521
570
  } else {
522
- addMcp = true; // non-interactive: preserve the installable-via-pipe default
571
+ addMcp = false; // non-interactive: no silent ~/.claude.json write
523
572
  }
524
573
 
525
574
  // 7b. Mint an API token for the header-auth / script use case? (Codex,
@@ -583,14 +632,22 @@ async function cmdInit(args: string[] = []) {
583
632
  if (!apiKey) {
584
633
  console.log(` No token baked in — you'll sign in via OAuth on first connect.`);
585
634
  }
586
- } else {
587
- console.log(" Skipped adding MCP to ~/.claude.json.");
588
- console.log(" Run `parachute-vault mcp-install` later if you want it.");
589
635
  }
636
+ // No else: when the operator didn't opt in, the init summary below surfaces
637
+ // the connector URL + a copy-paste `claude mcp add` command instead of a
638
+ // "skipped" line — that's the self-serve path.
590
639
 
591
640
  // 8. Summary
592
641
  const port = globalConfig.port || DEFAULT_PORT;
593
- const mcpUrl = `http://127.0.0.1:${port}/vault/${defaultVault}/mcp`;
642
+ // Connector URL surfaced for self-serve copy-paste. Hub-origin / expose-state
643
+ // aware (chooseMcpUrl), so it's the URL a remote Claude Code / other client
644
+ // actually reaches, not a bare loopback. The init-summary prints a
645
+ // ready-to-paste `claude mcp add ...` built from this.
646
+ const { url: connectorUrl } = chooseMcpUrl(defaultVault, port);
647
+ // Web setup wizard lives on the hub at <hub-origin>/admin/setup. Resolve the
648
+ // hub origin the same way (env / expose-state / loopback); the loopback form
649
+ // points at the co-located hub's fixed port (1939), not vault's listen port.
650
+ const wizardUrl = `${resolveHubOriginForWizard(port)}/admin/setup`;
594
651
  // Probe whether a hub is present so the summary's "opted into a token but
595
652
  // none minted" copy reflects reality: under a hub the vault is reachable via
596
653
  // browser OAuth even with no header-auth token (#445). Only matters for the
@@ -603,7 +660,8 @@ async function cmdInit(args: string[] = []) {
603
660
  configDir: CONFIG_DIR,
604
661
  bindHost,
605
662
  port,
606
- mcpUrl,
663
+ mcpUrl: connectorUrl,
664
+ wizardUrl,
607
665
  vaultName: defaultVault,
608
666
  noTokenGuidance: credentialGuidance,
609
667
  hubPresent,
@@ -3714,12 +3772,19 @@ data, and debugging.
3714
3772
  ── Standard use ───────────────────────────────────────────────────────
3715
3773
 
3716
3774
  Setup:
3717
- parachute-vault init [--mcp|--no-mcp] [--token|--no-token] [--vault-name <name>]
3718
- [--autostart|--no-autostart]
3719
- Set up everything (one command, idempotent).
3720
- --mcp/--no-mcp controls the Claude Code MCP entry (written
3721
- for per-user OAuth by default no baked token; sign in on
3722
- first connect). --token opts into ALSO minting a scope-narrow
3775
+ parachute-vault init [--configure-claude-code|--no-mcp] [--token|--no-token]
3776
+ [--vault-name <name>] [--autostart|--no-autostart]
3777
+ Set up everything (one command, idempotent). init's
3778
+ job is to get you to the web setup wizard and surface
3779
+ your connector URL + a ready-to-paste \`claude mcp add\`
3780
+ command it does NOT write the Claude Code MCP config
3781
+ (~/.claude.json) by default. Pass --configure-claude-code
3782
+ (alias --mcp-install / --mcp) to opt in and have init
3783
+ write that entry for you (per-user OAuth — no baked token;
3784
+ sign in on first connect); --no-mcp is the explicit "don't".
3785
+ The standalone \`parachute-vault mcp-install\` command remains
3786
+ the canonical way to wire Claude Code anytime.
3787
+ --token opts into ALSO minting a scope-narrow
3723
3788
  header-auth token (vault:<name>:read) for non-OAuth clients /
3724
3789
  scripts; --no-token (the default) skips minting entirely.
3725
3790
  --vault-name skips the prompt and names the vault
@@ -0,0 +1,178 @@
1
+ /**
2
+ * REST face of content range / pagination (bounded reads for large notes).
3
+ *
4
+ * Exercises all four GET shapes that accept `content_offset` /
5
+ * `content_length`:
6
+ * - GET /notes?id=… (single, folded into the collection route)
7
+ * - GET /notes/:idOrPath (single point read)
8
+ * - GET /notes?… (structured list)
9
+ * - GET /notes?search=… (full-text list)
10
+ *
11
+ * Slice mechanics (codepoint boundaries, reassembly invariant) are pinned
12
+ * in core/src/content-range.test.ts; this suite covers the HTTP wiring:
13
+ * param parsing, 400s, per-shape application, and the no-params
14
+ * regression. Fully sandboxed — in-memory SQLite, no daemon.
15
+ */
16
+
17
+ import { describe, it, expect, beforeEach, afterEach } from "bun:test";
18
+ import { Database } from "bun:sqlite";
19
+ import { BunStore } from "./vault-store.ts";
20
+ import { handleNotes } from "./routes.ts";
21
+
22
+ let db: Database;
23
+ let store: BunStore;
24
+
25
+ beforeEach(() => {
26
+ db = new Database(":memory:");
27
+ store = new BunStore(db);
28
+ });
29
+
30
+ afterEach(() => {
31
+ db.close();
32
+ });
33
+
34
+ const BASE = "http://localhost/api";
35
+
36
+ function get(path: string): Promise<Response> {
37
+ return handleNotes(new Request(`${BASE}/notes${path}`, { method: "GET" }), store, pathSub(path));
38
+ }
39
+
40
+ /** Extract the handleNotes subpath ("/<id>" for point reads, "" otherwise). */
41
+ function pathSub(path: string): string {
42
+ const m = path.match(/^\/([^?/]+)/);
43
+ return m ? `/${m[1]}` : "";
44
+ }
45
+
46
+ describe("REST content range — single note", () => {
47
+ it("GET /notes?id=… honors content_length and adds the range fields", async () => {
48
+ const note = await store.createNote("0123456789");
49
+ const res = await get(`?id=${note.id}&content_length=4`);
50
+ expect(res.status).toBe(200);
51
+ const body: any = await res.json();
52
+ expect(body.content).toBe("0123");
53
+ expect(body.content_offset).toBe(0);
54
+ expect(body.content_total_length).toBe(10);
55
+ expect(body.content_next_offset).toBe(4);
56
+ });
57
+
58
+ it("GET /notes/:id honors content_offset (tail read to the end)", async () => {
59
+ const note = await store.createNote("hello world", { path: "tail-note" });
60
+ const res = await get(`/${note.id}?content_offset=6`);
61
+ expect(res.status).toBe(200);
62
+ const body: any = await res.json();
63
+ expect(body.content).toBe("world");
64
+ expect(body.content_total_length).toBe(11);
65
+ expect(body.content_next_offset).toBeNull();
66
+ });
67
+
68
+ it("paged loop over REST reassembles multi-byte content byte-identically", async () => {
69
+ const content = "ab\u{1F600}cd 你好 ".repeat(20).trim();
70
+ const note = await store.createNote(content);
71
+ let offset = 0;
72
+ let assembled = "";
73
+ for (;;) {
74
+ const res = await get(`/${note.id}?content_offset=${offset}&content_length=16`);
75
+ expect(res.status).toBe(200);
76
+ const body: any = await res.json();
77
+ expect(Buffer.byteLength(body.content, "utf8")).toBeLessThanOrEqual(16);
78
+ assembled += body.content;
79
+ if (body.content_next_offset === null) break;
80
+ offset = body.content_next_offset;
81
+ }
82
+ expect(assembled).toBe(content);
83
+ });
84
+
85
+ it("offset past end → 200 with empty slice and null next_offset", async () => {
86
+ const note = await store.createNote("abc");
87
+ const res = await get(`?id=${note.id}&content_offset=500`);
88
+ expect(res.status).toBe(200);
89
+ const body: any = await res.json();
90
+ expect(body.content).toBe("");
91
+ expect(body.content_next_offset).toBeNull();
92
+ expect(body.content_total_length).toBe(3);
93
+ });
94
+
95
+ it("include_content=false + range params → 400 INVALID_QUERY", async () => {
96
+ const note = await store.createNote("abc");
97
+ const res = await get(`?id=${note.id}&include_content=false&content_length=8`);
98
+ expect(res.status).toBe(400);
99
+ const body: any = await res.json();
100
+ expect(body.code).toBe("INVALID_QUERY");
101
+ expect(body.error).toContain("include_content");
102
+ });
103
+
104
+ it("zero / sub-minimum / malformed budgets → 400 INVALID_QUERY", async () => {
105
+ const note = await store.createNote("abc");
106
+ for (const qs of ["content_length=0", "content_length=2", "content_length=abc", "content_offset=-1"]) {
107
+ const res = await get(`?id=${note.id}&${qs}`);
108
+ expect(res.status).toBe(400);
109
+ const body: any = await res.json();
110
+ expect(body.code).toBe("INVALID_QUERY");
111
+ }
112
+ });
113
+
114
+ it("no range params → response shape unchanged (regression)", async () => {
115
+ const note = await store.createNote("plain body", { path: "plain" });
116
+ const res = await get(`/${note.id}`);
117
+ expect(res.status).toBe(200);
118
+ const body: any = await res.json();
119
+ expect(body.content).toBe("plain body");
120
+ expect("content_total_length" in body).toBe(false);
121
+ expect("content_next_offset" in body).toBe(false);
122
+ expect("content_offset" in body).toBe(false);
123
+ });
124
+ });
125
+
126
+ describe("REST content range — list shapes", () => {
127
+ it("structured list with include_content=true applies the window per note", async () => {
128
+ await store.createNote("first body first body", { tags: ["paged"] });
129
+ await store.createNote("second body second body", { tags: ["paged"] });
130
+ const res = await get(`?tag=paged&include_content=true&content_length=6`);
131
+ expect(res.status).toBe(200);
132
+ const body: any[] = await res.json();
133
+ expect(body.length).toBe(2);
134
+ for (const n of body) {
135
+ expect(Buffer.byteLength(n.content, "utf8")).toBeLessThanOrEqual(6);
136
+ expect(typeof n.content_total_length).toBe("number");
137
+ expect(n.content_next_offset).toBe(6);
138
+ }
139
+ });
140
+
141
+ it("structured list on the lean default + range params → 400", async () => {
142
+ await store.createNote("body", { tags: ["paged"] });
143
+ const res = await get(`?tag=paged&content_length=8`);
144
+ expect(res.status).toBe(400);
145
+ const body: any = await res.json();
146
+ expect(body.code).toBe("INVALID_QUERY");
147
+ expect(body.error).toContain("include_content");
148
+ });
149
+
150
+ it("search list with include_content=true applies the window per note", async () => {
151
+ await store.createNote("the quick brown fox jumps over the lazy dog");
152
+ const res = await get(`?search=fox&include_content=true&content_length=9`);
153
+ expect(res.status).toBe(200);
154
+ const body: any[] = await res.json();
155
+ expect(body.length).toBe(1);
156
+ expect(Buffer.byteLength(body[0].content, "utf8")).toBeLessThanOrEqual(9);
157
+ expect(body[0].content_total_length).toBe(
158
+ Buffer.byteLength("the quick brown fox jumps over the lazy dog", "utf8"),
159
+ );
160
+ });
161
+
162
+ it("search list on the lean default + range params → 400", async () => {
163
+ await store.createNote("the quick brown fox");
164
+ const res = await get(`?search=fox&content_length=8`);
165
+ expect(res.status).toBe(400);
166
+ const body: any = await res.json();
167
+ expect(body.code).toBe("INVALID_QUERY");
168
+ });
169
+
170
+ it("list without range params → no range fields injected (regression)", async () => {
171
+ await store.createNote("body here", { tags: ["paged"] });
172
+ const res = await get(`?tag=paged&include_content=true`);
173
+ expect(res.status).toBe(200);
174
+ const body: any[] = await res.json();
175
+ expect("content_total_length" in body[0]).toBe(false);
176
+ expect("content_next_offset" in body[0]).toBe(false);
177
+ });
178
+ });