@openparachute/vault 0.6.4-rc.5 → 0.6.4-rc.7

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/README.md CHANGED
@@ -586,16 +586,12 @@ Range params require content in the response — with `include_content=false` (o
586
586
 
587
587
  ### Incremental rebuilds: "what changed since X"
588
588
 
589
- The SSG / sync pattern. Two equivalent forms — bracket-style is canonical going forward; the flat form is the same shape that ships through the REST/MCP date filter today.
589
+ The SSG / sync pattern. Bracket-style is the query-string date filter. (The flat `date_field` / `date_from` / `date_to` params were removed in 0.6.4 vault#288 — and are now ignored.)
590
590
 
591
591
  ```bash
592
- # Bracket-style (canonical)
592
+ # Bracket-style (the query-string date filter)
593
593
  curl -H "Authorization: Bearer $VAULT_TOKEN" \
594
594
  "http://localhost:1940/vault/default/api/notes?meta[updated_at][gte]=2026-04-01T00:00:00Z"
595
-
596
- # Flat form (DEPRECATED in 0.4.3; planned removal in a later 0.x per vault#288)
597
- curl -H "Authorization: Bearer $VAULT_TOKEN" \
598
- "http://localhost:1940/vault/default/api/notes?date_field=updated_at&date_from=2026-04-01T00:00:00Z"
599
595
  ```
600
596
 
601
597
  ```jsonc
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.6.4-rc.5",
3
+ "version": "0.6.4-rc.7",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",
@@ -15,30 +15,21 @@ import { resolve } from "path";
15
15
  import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "fs";
16
16
  import { tmpdir } from "os";
17
17
  import { join } from "path";
18
+ import { runSubprocess } from "./test-support/spawn.ts";
18
19
 
19
20
  const CLI = resolve(import.meta.dir, "cli.ts");
20
21
 
22
+ // Async spawn — the daemon-busy probe inside the CLI fetches the test
23
+ // process's stub server, so the parent event loop must keep servicing
24
+ // requests while the child runs. `Bun.spawnSync` blocks the event loop and
25
+ // the in-test server can't answer, which makes every probe time out into
26
+ // the "not listening" branch (vault#324). `runSubprocess` is the shared
27
+ // safe primitive (vault#325 Part 1) — see src/test-support/spawn.ts.
21
28
  async function runCli(
22
29
  args: string[],
23
30
  env: Record<string, string>,
24
31
  ): Promise<{ exitCode: number; stdout: string; stderr: string }> {
25
- // Async spawn the daemon-busy probe inside the CLI fetches the test
26
- // process's stub server, so the parent event loop must keep servicing
27
- // requests while the child runs. Bun.spawnSync blocks the event loop
28
- // and the in-test server can't answer, which makes every probe time
29
- // out into the "not listening" branch.
30
- const proc = Bun.spawn({
31
- cmd: ["bun", CLI, ...args],
32
- stdout: "pipe",
33
- stderr: "pipe",
34
- env: { ...process.env, ...env },
35
- });
36
- const [stdout, stderr, exitCode] = await Promise.all([
37
- new Response(proc.stdout).text(),
38
- new Response(proc.stderr).text(),
39
- proc.exited,
40
- ]);
41
- return { exitCode: exitCode ?? -1, stdout, stderr };
32
+ return runSubprocess({ cmd: ["bun", CLI, ...args], env });
42
33
  }
43
34
 
44
35
  let home: string;
package/src/routes.ts CHANGED
@@ -328,7 +328,7 @@ function parseMetaBrackets(url: URL): {
328
328
  return {
329
329
  error: json(
330
330
  {
331
- error: `bracket-date filter on \`${field}\` supports only \`gte\` (inclusive lower bound) and \`lt\` (exclusive upper bound). Got: \`${op}\`. The dateFilter contract uses these two ops because the equivalent flat shape (\`date_field=${field}&date_from=…&date_to=…\`) is half-open by design.`,
331
+ error: `bracket-date filter on \`${field}\` supports only \`gte\` (inclusive lower bound) and \`lt\` (exclusive upper bound). Got: \`${op}\`. The dateFilter contract is half-open by design.`,
332
332
  code: "INVALID_QUERY",
333
333
  },
334
334
  400,
@@ -623,20 +623,13 @@ export function parseNotesQueryOpts(url: URL): {
623
623
  lastUpdatedBy: parseQuery(url, "last_updated_by") ?? undefined,
624
624
  createdVia: parseQuery(url, "created_via") ?? undefined,
625
625
  lastUpdatedVia: parseQuery(url, "last_updated_via") ?? undefined,
626
- ...(bracket.dateFilter
627
- ? { dateFilter: bracket.dateFilter }
628
- : parseQuery(url, "date_field")
629
- ? {
630
- dateFilter: {
631
- field: parseQuery(url, "date_field")!,
632
- from: parseQuery(url, "date_from") ?? undefined,
633
- to: parseQuery(url, "date_to") ?? undefined,
634
- },
635
- }
636
- : {
637
- dateFrom: parseQuery(url, "date_from") ?? undefined,
638
- dateTo: parseQuery(url, "date_to") ?? undefined,
639
- }),
626
+ // Date-range filtering on the query string is bracket-style only:
627
+ // `?meta[created_at][gte]=…&meta[created_at][lt]=…` (see
628
+ // `parseMetaBrackets`). The flat `date_field` / `date_from` / `date_to`
629
+ // params were removed in 0.6.4 (vault#288, breaking) — they are now
630
+ // ignored. The MCP `date_from` / `date_to` shorthand is a separate,
631
+ // supported convenience and is unaffected.
632
+ ...(bracket.dateFilter ? { dateFilter: bracket.dateFilter } : {}),
640
633
  sort: (parseQuery(url, "sort") as "asc" | "desc") ?? undefined,
641
634
  orderBy: parseQuery(url, "order_by") ?? undefined,
642
635
  limit: parseInt10(parseQuery(url, "limit")) ?? 50,
@@ -907,7 +900,7 @@ async function handleNotesInner(
907
900
 
908
901
  // Structured query
909
902
  //
910
- // Two filter syntaxes coexist on this endpoint:
903
+ // Date-range filtering on the query string uses one syntax:
911
904
  //
912
905
  // - **Bracket-style** (canonical, vault#285 friction point 1.3):
913
906
  // `?meta[field][op]=value` / `?meta[created_at][gte]=…`. Exposes
@@ -915,21 +908,18 @@ async function handleNotesInner(
915
908
  // not_in/exists) and the dateFilter bridge through one consistent
916
909
  // shape. See `parseMetaBrackets` for the grammar.
917
910
  //
918
- // - **Flat date params** (DEPRECATED): `?date_field=created_at&
919
- // date_from=…&date_to=…` and the legacy `?date_from=…&date_to=…`.
920
- // Still functional through 0.5.x; planned removal in a later 0.x
921
- // (vault#288). New consumers should use bracket-style.
922
- //
923
- // Precedence on overlap: bracket-style wins. If a caller passes both
924
- // `meta[created_at][gte]=X` and `date_field=created_at&date_from=Y`,
925
- // the bracket form is the dateFilter the engine sees; the flat
926
- // params are silently dropped. We don't error — the bracket form is
927
- // documented as canonical, and rejecting the overlap would block a
928
- // realistic migration path where a caller half-converted their code.
911
+ // The flat date params (`?date_field=created_at&date_from=…&date_to=…`
912
+ // and the legacy bare `?date_from=…&date_to=…`) were REMOVED in 0.6.4
913
+ // (vault#288, breaking change). They are now silently ignored — a
914
+ // request that passes only flat date params comes back unfiltered.
915
+ // Bracket-style is functionally complete (full operator set), so no
916
+ // capability was lost. Migrate `date_field=created_at&date_from=X` to
917
+ // `meta[created_at][gte]=X` (and `date_to=Y` to `meta[created_at][lt]=Y`).
929
918
  //
930
919
  // Surface asymmetry: REST flattens to a query string; MCP takes a
931
- // nested `date_filter: { field, from, to }` object directly. Both
932
- // lower to the same store-level `dateFilter` shape.
920
+ // nested `date_filter: { field, from, to }` object directly (plus a
921
+ // top-level `date_from` / `date_to` shorthand, which is unaffected by
922
+ // this removal). Both lower to the same store-level `dateFilter` shape.
933
923
  // Structured-query parsing is shared with the live `/subscribe` route
934
924
  // (see `parseNotesQueryOpts`) so both endpoints lower an identical query
935
925
  // string to the same `QueryOpts` — predicate parity by construction.
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Opt-in scale smoke (vault#325 Part 3).
3
+ *
4
+ * Guards the synthetic scale harness against bit-rot WITHOUT slowing the
5
+ * default `bun test` gate. The real benchmark lives in
6
+ * `scripts/scale-bench.ts` (10k / 50k / 100k); this is a fast, tiny-size
7
+ * functional smoke of the same hot-path code so a green CI run still
8
+ * proves the harness paths execute end-to-end.
9
+ *
10
+ * SKIPPED BY DEFAULT. To run it:
11
+ *
12
+ * VAULT_SCALE_BENCH=1 bun test ./src/scale.bench.test.ts
13
+ *
14
+ * For real timings + the documented ceiling, run the standalone harness:
15
+ *
16
+ * bun scripts/scale-bench.ts # 10000 50000 100000
17
+ *
18
+ * See docs/SCALE.md.
19
+ */
20
+
21
+ import { describe, test, expect } from "bun:test";
22
+ import { Database } from "bun:sqlite";
23
+ import { BunSqliteStore } from "../core/src/store.ts";
24
+ import { generateMcpTools } from "../core/src/mcp.ts";
25
+ import { exportVaultToDir } from "../core/src/portable-md.ts";
26
+ import type { BulkNoteInput } from "../core/src/notes.ts";
27
+ import { mkdtempSync, rmSync } from "fs";
28
+ import { tmpdir } from "os";
29
+ import { join } from "path";
30
+
31
+ const ENABLED = process.env.VAULT_SCALE_BENCH === "1";
32
+
33
+ // `describe.skipIf` keeps the suite registered (so it shows in the test list)
34
+ // but contributes zero runtime to the default gate. Size is intentionally
35
+ // tiny — this is a functional smoke, not a timing run.
36
+ describe.skipIf(!ENABLED)("scale harness smoke (opt-in: VAULT_SCALE_BENCH=1)", () => {
37
+ test("seed + hot-path queries + export run at small N", async () => {
38
+ const dir = mkdtempSync(join(tmpdir(), "vault-scale-smoke-"));
39
+ const db = new Database(join(dir, "smoke.db"));
40
+ const store = new BunSqliteStore(db);
41
+ try {
42
+ const N = 500;
43
+
44
+ // Declare the indexed field via the update-tag tool (the owner of the
45
+ // indexed_fields lifecycle — mirrors scripts/scale-bench.ts).
46
+ const tools = generateMcpTools(store);
47
+ const updateTag = tools.find((t) => t.name === "update-tag");
48
+ expect(updateTag).toBeDefined();
49
+ await updateTag!.execute({
50
+ tag: "work",
51
+ fields: { status: { type: "string", indexed: true } },
52
+ });
53
+
54
+ const inputs: BulkNoteInput[] = Array.from({ length: N }, (_, i) => ({
55
+ id: `note-${i}`,
56
+ path: `notes/note-${i}`,
57
+ content: `# Note ${i}\n\nconsectetur body ${i}`,
58
+ tags: i % 3 === 0 ? ["work", "work/meeting"] : ["health"],
59
+ metadata: { status: i % 2 === 0 ? "active" : "done", seq: i },
60
+ }));
61
+ await store.createNotes(inputs);
62
+
63
+ // Hot paths — same shapes the standalone harness times.
64
+ expect((await store.queryNotes({ tags: ["health"], limit: 50 })).length).toBeGreaterThan(0);
65
+ expect(
66
+ (await store.queryNotes({ metadata: { status: { eq: "active" } }, limit: 50 })).length,
67
+ ).toBeGreaterThan(0);
68
+ expect((await store.queryNotes({ orderBy: "status", limit: 50 })).length).toBe(50);
69
+ expect((await store.searchNotes("consectetur", { limit: 50 })).length).toBeGreaterThan(0);
70
+
71
+ const stats = await exportVaultToDir(store, {
72
+ outDir: join(dir, "export"),
73
+ vaultName: "smoke",
74
+ exportedAt: "2026-01-01T00:00:00Z",
75
+ });
76
+ expect(stats.notes).toBe(N);
77
+ } finally {
78
+ db.close();
79
+ rmSync(dir, { recursive: true, force: true });
80
+ }
81
+ });
82
+ });
@@ -416,9 +416,12 @@ describe("handleSubscribe — rejected query shapes", () => {
416
416
  expect(body.error).toContain("has_links");
417
417
  });
418
418
 
419
- it("date filter → 400 (M1) — legacy date_from", async () => {
419
+ it("date filter → 400 (M1) — bracket date_filter on created_at", async () => {
420
+ // The flat `date_from` param was removed in 0.6.4 (vault#288) and is now
421
+ // ignored, so it no longer reaches the M1 date guard. Bracket-style is the
422
+ // only query-string date filter; assert it's still rejected for live subs.
420
423
  const res = await handleSubscribe(
421
- subscribeReq("date_from=2026-01-01"),
424
+ subscribeReq("meta%5Bcreated_at%5D%5Bgte%5D=2026-01-01"),
422
425
  store,
423
426
  VAULT,
424
427
  unscopedScope(),
@@ -430,6 +433,24 @@ describe("handleSubscribe — rejected query shapes", () => {
430
433
  expect(body.error).toContain("date");
431
434
  });
432
435
 
436
+ it("flat date_from is ignored → subscription created (vault#288 removal)", async () => {
437
+ // Documents the breaking change: the removed flat param no longer reaches
438
+ // the date guard, so a sub that ONLY carries it is now a valid (unfiltered)
439
+ // subscription rather than a 400.
440
+ const res = await handleSubscribe(
441
+ subscribeReq("date_from=2026-01-01"),
442
+ store,
443
+ VAULT,
444
+ unscopedScope(),
445
+ manager,
446
+ );
447
+ expect(res.status).toBe(200);
448
+ expect(res.headers.get("Content-Type")).toBe("text/event-stream");
449
+ const r = sseReader(res);
450
+ await r.pump();
451
+ await r.close();
452
+ });
453
+
433
454
  it("date filter → 400 (M1) — bracket date_filter on updated_at", async () => {
434
455
  const res = await handleSubscribe(
435
456
  subscribeReq("meta%5Bupdated_at%5D%5Bgte%5D=2026-01-01"),
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Shared subprocess helper for tests (vault#325 Part 1).
3
+ *
4
+ * THE RULE — never `Bun.spawnSync` in a test that keeps an in-process
5
+ * `Bun.serve` listening for the child to probe.
6
+ *
7
+ * `Bun.spawnSync` blocks the parent's event loop until the child exits.
8
+ * If the child makes an HTTP request back to a server the *same test
9
+ * process* is hosting (e.g. the CLI's daemon-busy pre-flight fetching a
10
+ * stub `/health`), the parent can't answer — the child's request times
11
+ * out into the "nothing listening" branch and the assertion silently
12
+ * tests the wrong path (the guard never fires, or fails for a non-obvious
13
+ * reason). This bit the daemon-busy import guard; the fix landed in
14
+ * vault#324 (`src/import-daemon-busy.test.ts`) and this helper is the
15
+ * institutional-memory follow-up so future subprocess tests reach for the
16
+ * safe primitive by default.
17
+ *
18
+ * `runSubprocess` uses async `Bun.spawn` + `await proc.exited`, so the
19
+ * parent's event loop keeps servicing requests while the child runs.
20
+ *
21
+ * NOTE on what's *fine*: `Bun.spawnSync` is safe in tests that don't run
22
+ * an in-test server the child talks to over HTTP — e.g. shelling out to
23
+ * `git` to build a fixture repo (`mirror-*.test.ts`), or a `Bun.serve`
24
+ * that merely *holds a port* so the child's `lsof`/availability probe
25
+ * sees it occupied (`doctor.test.ts`). The deadlock is specifically
26
+ * "child fetches the parent test process's server." When in doubt, use
27
+ * this helper — it's never wrong, only sometimes unnecessary.
28
+ */
29
+
30
+ export interface RunSubprocessResult {
31
+ exitCode: number;
32
+ stdout: string;
33
+ stderr: string;
34
+ }
35
+
36
+ export interface RunSubprocessOptions {
37
+ cmd: string[];
38
+ cwd?: string;
39
+ /** Extra env merged over `process.env`. `undefined` values delete the key. */
40
+ env?: Record<string, string | undefined>;
41
+ /** Optional bytes piped to the child's stdin. */
42
+ stdin?: string;
43
+ }
44
+
45
+ /**
46
+ * Run a subprocess WITHOUT blocking the event loop.
47
+ *
48
+ * Drop-in for the `Bun.spawnSync({ cmd, env, stdout, stderr })` pattern
49
+ * that recurs across the CLI integration tests, but built on async
50
+ * `Bun.spawn` so an in-process `Bun.serve` the child probes can still
51
+ * answer. Returns once the child has exited, with decoded stdout/stderr.
52
+ */
53
+ export async function runSubprocess(
54
+ opts: RunSubprocessOptions,
55
+ ): Promise<RunSubprocessResult> {
56
+ const env: Record<string, string> = { ...(process.env as Record<string, string>) };
57
+ if (opts.env) {
58
+ for (const [k, v] of Object.entries(opts.env)) {
59
+ if (v === undefined) delete env[k];
60
+ else env[k] = v;
61
+ }
62
+ }
63
+
64
+ const proc = Bun.spawn({
65
+ cmd: opts.cmd,
66
+ cwd: opts.cwd,
67
+ env,
68
+ stdin: opts.stdin === undefined ? undefined : "pipe",
69
+ stdout: "pipe",
70
+ stderr: "pipe",
71
+ });
72
+
73
+ if (opts.stdin !== undefined) {
74
+ proc.stdin!.write(opts.stdin);
75
+ await proc.stdin!.end();
76
+ }
77
+
78
+ const [stdout, stderr, exitCode] = await Promise.all([
79
+ new Response(proc.stdout).text(),
80
+ new Response(proc.stderr).text(),
81
+ proc.exited,
82
+ ]);
83
+
84
+ return { exitCode: exitCode ?? -1, stdout, stderr };
85
+ }
package/src/vault.test.ts CHANGED
@@ -1393,6 +1393,99 @@ describe("scoped MCP wrapper", async () => {
1393
1393
  closeAllStores();
1394
1394
  });
1395
1395
 
1396
+ // -- Q6 through the MCP TRANSPORT (vault#325 Part 2) --------------------
1397
+ //
1398
+ // The two tests above call `tool.execute()` directly — that exercises the
1399
+ // tag-scope WRAPPER but bypasses `handleScopedMcp`: the JSON-RPC transport,
1400
+ // the `hasScopeForVault` tool-visibility gate, and the tools/call dispatch.
1401
+ // The HTTP-layer twin lives in `routing.test.ts` (Q6 read-path). What was
1402
+ // missing — and what this pins — is the orphan-sub-tag fail-open driven
1403
+ // through the actual MCP `tools/call` path a real client hits. Same fixture
1404
+ // as the HTTP test (`#health/food` with no `_tags/health/food` schema,
1405
+ // token allowlisted for `health`), different transport.
1406
+
1407
+ test("MCP query-notes (tools/call) sees orphan sub-tag via string-form root (vault#325 Part 2)", async () => {
1408
+ const { handleScopedMcp } = await import("./mcp-http.ts");
1409
+ const { writeVaultConfig } = await import("./config.ts");
1410
+ const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
1411
+
1412
+ const vaultName = `mcp-orphan-query-${Date.now()}`;
1413
+ writeVaultConfig({ name: vaultName, api_keys: [], created_at: new Date().toISOString() });
1414
+ const store = getVaultStore(vaultName);
1415
+ // No `_tags/health/food` schema — this is the orphan case. The hierarchy
1416
+ // is implicit, so authorization must fall back to the string-form root.
1417
+ const orphan = await store.createNote("orphan", { tags: ["health/food"] });
1418
+
1419
+ const auth = {
1420
+ permission: "read" as const,
1421
+ scopes: ["vault:read"],
1422
+ legacyDerived: false,
1423
+ scoped_tags: ["health"],
1424
+ };
1425
+
1426
+ // The URL is inert here — handleScopedMcp hands it to the SDK
1427
+ // transport, which only reads the body; the `vaultName` route wiring
1428
+ // is exercised by routing.test.ts, not this transport-level test. The
1429
+ // `accept` header is load-bearing: `enableJsonResponse` returns JSON
1430
+ // only when text/event-stream is also acceptable, else it streams SSE.
1431
+ const callReq = (id: number, name: string, args: Record<string, unknown>) =>
1432
+ new Request(`http://localhost:1940/vault/${vaultName}/mcp`, {
1433
+ method: "POST",
1434
+ headers: {
1435
+ "content-type": "application/json",
1436
+ accept: "application/json, text/event-stream",
1437
+ },
1438
+ body: JSON.stringify({
1439
+ jsonrpc: "2.0",
1440
+ id,
1441
+ method: "tools/call",
1442
+ params: { name, arguments: args },
1443
+ }),
1444
+ });
1445
+
1446
+ // Fetch the orphan by id through the MCP transport with a token scoped
1447
+ // to ["health"]. The orphan is tagged `health/food` (no schema) → the
1448
+ // string-form fallback resolves the root `health` → in allowlist → the
1449
+ // note must come back rather than 404/forbidden.
1450
+ const res = await handleScopedMcp(callReq(1, "query-notes", { id: orphan.id }), vaultName, auth as any);
1451
+ expect(res.status).toBe(200);
1452
+ const body = (await res.json()) as any;
1453
+ // tools/call returns content[].text with the JSON-stringified tool result.
1454
+ expect(body.result?.isError).toBeFalsy();
1455
+ const text: string = body.result.content[0].text;
1456
+ const parsed = JSON.parse(text);
1457
+ expect(parsed.id).toBe(orphan.id);
1458
+ expect(parsed.tags).toContain("health/food");
1459
+
1460
+ // Control: a token scoped to a DIFFERENT root must NOT see the orphan
1461
+ // through the same transport — proves the green result above is the
1462
+ // fail-open fallback firing, not scoping being inert.
1463
+ const denied = {
1464
+ permission: "read" as const,
1465
+ scopes: ["vault:read"],
1466
+ legacyDerived: false,
1467
+ scoped_tags: ["work"],
1468
+ };
1469
+ const resDenied = await handleScopedMcp(
1470
+ callReq(2, "query-notes", { id: orphan.id }),
1471
+ vaultName,
1472
+ denied as any,
1473
+ );
1474
+ expect(resDenied.status).toBe(200);
1475
+ const deniedBody = (await resDenied.json()) as any;
1476
+ const deniedText: string = deniedBody.result.content[0].text;
1477
+ const deniedParsed = JSON.parse(deniedText);
1478
+ // Out-of-scope single-note fetch fails closed: the wrapper replaces the
1479
+ // note body with `{ error: "Note not found" }` (no content/tags leak).
1480
+ // This proves the `health`-scoped green result above is the fail-open
1481
+ // string-form fallback firing, not the wrapper being a no-op.
1482
+ expect(deniedParsed.error).toBe("Note not found");
1483
+ expect(deniedParsed.content).toBeUndefined();
1484
+ expect(deniedParsed.tags).toBeUndefined();
1485
+
1486
+ closeAllStores();
1487
+ });
1488
+
1396
1489
  // -- Q5: MCP delete-tag dependency check -------------------------------
1397
1490
 
1398
1491
  test("MCP delete-tag returns tag_in_use_by_tokens when a vestigial tag-scoped token row references the tag", async () => {
@@ -1769,13 +1862,15 @@ describe("HTTP /notes", async () => {
1769
1862
  expect(body.map((n) => n.content)).toEqual(["plain"]);
1770
1863
  });
1771
1864
 
1772
- // ---- updated_at filter via date_field (vault#285 friction point 1.5) ----
1865
+ // ---- updated_at filter via bracket date (vault#285 friction point 1.5) ----
1773
1866
  //
1774
- // HTTP plumbing routes `date_field=updated_at&date_from=…` straight to
1775
- // the core `dateFilter` resolver, which now recognizes `updated_at` as
1776
- // a real column. Smoke-tests the end-to-end HTTP path; the engine-side
1777
- // semantics are exercised in core.test.ts.
1778
- test("GET /notes?date_field=updated_at filters by last-write time", async () => {
1867
+ // HTTP plumbing routes `meta[updated_at][gte]=…` straight to the core
1868
+ // `dateFilter` resolver, which recognizes `updated_at` as a real column.
1869
+ // Smoke-tests the end-to-end HTTP path; the engine-side semantics are
1870
+ // exercised in core.test.ts. (The flat `date_field=updated_at&date_from=…`
1871
+ // shape was removed in 0.6.4 vault#288 bracket-style is the only
1872
+ // query-string date filter.)
1873
+ test("GET /notes?meta[updated_at][gte]=… filters by last-write time", async () => {
1779
1874
  const a = await store.createNote("untouched", { id: "ua", path: "ua" });
1780
1875
  const b = await store.createNote("modified", { id: "ub", path: "ub" });
1781
1876
  // Bump b's updated_at into the test window, leave a's at its createdAt.
@@ -1785,7 +1880,7 @@ describe("HTTP /notes", async () => {
1785
1880
  .run("2026-04-25T00:00:00.000Z", b.id);
1786
1881
 
1787
1882
  const res = await handleNotes(
1788
- mkReq("GET", "/notes?date_field=updated_at&date_from=2026-04-01&include_content=true"),
1883
+ mkReq("GET", "/notes?meta[updated_at][gte]=2026-04-01&include_content=true"),
1789
1884
  store,
1790
1885
  "",
1791
1886
  );
@@ -2787,7 +2882,7 @@ describe("HTTP /notes", async () => {
2787
2882
  });
2788
2883
 
2789
2884
  // ---- Bridge: created_at / updated_at via brackets route to dateFilter ----
2790
- test("`meta[created_at][gte]=…` routes to dateFilter (same result as flat date_field)", async () => {
2885
+ test("`meta[created_at][gte]=…` routes to dateFilter", async () => {
2791
2886
  await store.createNote("old", { created_at: "2026-01-15T00:00:00.000Z" });
2792
2887
  await store.createNote("new", { created_at: "2026-04-15T00:00:00.000Z" });
2793
2888
 
@@ -2797,14 +2892,37 @@ describe("HTTP /notes", async () => {
2797
2892
  "",
2798
2893
  );
2799
2894
  const bracketBody = await bracketRes.json() as any[];
2800
- const flatRes = await handleNotes(
2895
+ expect(bracketBody.map((n) => n.content)).toEqual(["new"]);
2896
+ });
2897
+
2898
+ // ---- Removed: flat date params are now ignored (vault#288, breaking) ----
2899
+ // The flat `date_field` / `date_from` / `date_to` query params were
2900
+ // removed in 0.6.4. A request that passes ONLY the flat shape is no
2901
+ // longer date-filtered — it comes back unfiltered. Use bracket-style
2902
+ // (`meta[created_at][gte]=…`) instead. (The MCP `date_from`/`date_to`
2903
+ // shorthand is a separate, supported path and is unaffected.)
2904
+ test("flat date params (date_field/date_from/date_to) are ignored", async () => {
2905
+ await store.createNote("old", { created_at: "2026-01-15T00:00:00.000Z" });
2906
+ await store.createNote("new", { created_at: "2026-04-15T00:00:00.000Z" });
2907
+
2908
+ const targetedRes = await handleNotes(
2801
2909
  mkReq("GET", "/notes?date_field=created_at&date_from=2026-04-01&include_content=true"),
2802
2910
  store,
2803
2911
  "",
2804
2912
  );
2805
- const flatBody = await flatRes.json() as any[];
2806
- expect(bracketBody.map((n) => n.content)).toEqual(["new"]);
2807
- expect(bracketBody.map((n) => n.content)).toEqual(flatBody.map((n) => n.content));
2913
+ const targetedBody = await targetedRes.json() as any[];
2914
+ expect(targetedRes.status).toBe(200);
2915
+ // Both notes returned — the flat param did not filter.
2916
+ expect(targetedBody.map((n) => n.content).sort()).toEqual(["new", "old"]);
2917
+
2918
+ const bareRes = await handleNotes(
2919
+ mkReq("GET", "/notes?date_from=2026-04-01&include_content=true"),
2920
+ store,
2921
+ "",
2922
+ );
2923
+ const bareBody = await bareRes.json() as any[];
2924
+ expect(bareRes.status).toBe(200);
2925
+ expect(bareBody.map((n) => n.content).sort()).toEqual(["new", "old"]);
2808
2926
  });
2809
2927
 
2810
2928
  test("`meta[updated_at][gte]=…` routes to dateFilter on n.updated_at", async () => {
@@ -2954,13 +3072,14 @@ describe("HTTP /notes", async () => {
2954
3072
  expect(body.error).toContain("not_in");
2955
3073
  });
2956
3074
 
2957
- // ---- Precedence on overlap ----
2958
- test("when both flat and bracket date params overlap, bracket wins", async () => {
3075
+ // ---- Bracket applies; co-passed flat params are ignored (vault#288) ----
3076
+ test("bracket date filter applies even when removed flat params are also passed", async () => {
2959
3077
  await store.createNote("old", { created_at: "2026-01-15T00:00:00.000Z" });
2960
3078
  await store.createNote("new", { created_at: "2026-04-15T00:00:00.000Z" });
2961
- // Bracket says "from 2026-04-01"; flat says "from 2020-01-01". If
2962
- // flat won, both notes would match. The bracket-wins precedence is
2963
- // verified by getting back only the post-April note.
3079
+ // Bracket says "from 2026-04-01"; the (removed) flat params say "from
3080
+ // 2020-01-01". The flat params are now inert, so only the bracket
3081
+ // filter applies back comes only the post-April note. (Pre-0.6.4
3082
+ // this exercised bracket-wins precedence; flat is now simply ignored.)
2964
3083
  const res = await handleNotes(
2965
3084
  mkReq(
2966
3085
  "GET",