@openparachute/vault 0.7.1 → 0.7.2-rc.4

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.
@@ -214,6 +214,29 @@ describe("aggregateNotes — errors", () => {
214
214
  }
215
215
  });
216
216
 
217
+ // LB — a `type: "number"` (float) field can NEVER be `indexed: true`
218
+ // (only integer/boolean are indexable numeric shapes), so it legitimately
219
+ // never appears in `indexed_fields`. Before the fix, the FIELD_NOT_INDEXED
220
+ // thrown here carried the SAME generic "declare it via update-tag with
221
+ // indexed: true" hint every other unindexed field gets — advice that's
222
+ // impossible to satisfy for `number` specifically, dead-ending an agent
223
+ // that tries it and hits the SEPARATE "unsupported type ... for indexing"
224
+ // error. The hint must instead name the real fix: store as an integer.
225
+ it("FIELD_NOT_INDEXED on a type: \"number\" sum field carries an actionable hint, not the impossible generic \"declare indexed: true\" advice", async () => {
226
+ await store.upsertTagRecord("expense", { fields: { amount: { type: "number" } } });
227
+ await store.createNote("a", { tags: ["expense"], metadata: { amount: 9.99 } });
228
+ try {
229
+ aggregateNotes(db, { aggregate: { group_by: "tag", op: "sum", field: "amount" } });
230
+ throw new Error("expected throw");
231
+ } catch (e: any) {
232
+ expect(e.code).toBe("FIELD_NOT_INDEXED");
233
+ expect(e.message).not.toContain("declare it via update-tag with indexed: true");
234
+ const guidance = `${e.message} ${e.hint ?? ""}`.toLowerCase();
235
+ expect(guidance).toContain("integer");
236
+ expect(guidance).toMatch(/float|number|decimal/);
237
+ }
238
+ });
239
+
217
240
  it("throws INVALID_QUERY when the sum field is indexed but not numeric (TEXT-backed)", async () => {
218
241
  await store.upsertTagRecord("task", {
219
242
  fields: {
@@ -295,6 +295,28 @@ describe("contract: taxonomy — #552 (flipped from test.todo)", () => {
295
295
  expect(cleanReport.summary).toContain("clean");
296
296
  });
297
297
 
298
+ // LB — a DIRECT self-parent (`parent_names: [tag]` on tag itself) is a
299
+ // degenerate one-node cycle: `getTagDescendants` starts its traversal
300
+ // result at `{tag}`, and the moment it walks the tag's own self-edge it
301
+ // finds `tag` already `result.has(child)` and skips re-expanding it — so
302
+ // `descendants` never grows past size 1. `findHierarchyCycles`'s original
303
+ // gate (`descendants.has(tag) && descendants.size > 1`) requires size>1,
304
+ // so this exact case reported clean despite being a real cycle. Only
305
+ // reachable via guard-bypassing data (direct DB write / import) since
306
+ // `upsertTagRecord` rejects a self-parent at write time (see the "bare
307
+ // self-parent: also caught" assertion above, which exercises the WRITE
308
+ // guard — this test exercises the READ-side doctor scan on data that
309
+ // skipped that guard).
310
+ it("doctor flags a bare self-parent cycle (X declares parent_names: [X]), which the size>1 gate alone misses", async () => {
311
+ await store.upsertTagRecord("self-cyc", {});
312
+ db.prepare("UPDATE tags SET parent_names = ? WHERE name = ?").run(JSON.stringify(["self-cyc"]), "self-cyc");
313
+
314
+ const report = await store.doctor();
315
+ const finding = report.findings.find((f) => f.type === "parent_names_cycle" && f.subject === "self-cyc");
316
+ expect(finding).toBeDefined();
317
+ expect(finding?.severity).toBe("error");
318
+ });
319
+
298
320
  it("dead_tag_metadata_reference does not false-positive on a schema-declared enum field whose values coincide with an unrelated live tag (vault#570)", async () => {
299
321
  // "archived" is a real, unrelated live tag — coincidentally sharing a
300
322
  // name with one of "status"'s declared enum values. Pre-fix, this ONE
@@ -8,7 +8,7 @@ import { traverseLinks } from "./links.js";
8
8
  import * as indexedFieldOps from "./indexed-fields.js";
9
9
  import { resolveLinkTarget } from "./wikilinks.js";
10
10
  import { generateUlid, ULID_REGEX } from "./ulid.js";
11
- import { getVaultMap, extractH1Title, findNotesByTitle, getNoteByTitle } from "./notes.js";
11
+ import { getVaultMap, extractH1Title, findNotesByTitle, getNoteByTitle, validatePath, PathValidationError } from "./notes.js";
12
12
 
13
13
  let store: SqliteStore;
14
14
  let db: Database;
@@ -663,9 +663,13 @@ describe("renameTag cascade (vault#240 + #247)", async () => {
663
663
  // vault#555 fix 2 — a rename cascade's inline content rewrite is a real
664
664
  // persisted-state change; `updated_at` must move or a cursor/sync-poll
665
665
  // loop never sees the rewritten note.
666
- it("1b. content rewrite bumps updated_at (vault#555)", async () => {
666
+ it("1b. content rewrite bumps updated_at (vault#555) AND updated_at_ms (vault#586)", async () => {
667
667
  const note = await store.createNote("Today's #task is important.", { tags: ["task"] });
668
668
  const before = note.updatedAt;
669
+ const msOf = (id: string) =>
670
+ (db.prepare("SELECT updated_at_ms FROM notes WHERE id = ?").get(id) as { updated_at_ms: number })
671
+ .updated_at_ms;
672
+ const beforeMs = msOf(note.id);
669
673
  await new Promise((r) => setTimeout(r, 5));
670
674
 
671
675
  await store.renameTag("task", "todo");
@@ -674,6 +678,12 @@ describe("renameTag cascade (vault#240 + #247)", async () => {
674
678
  expect(fresh!.content).toContain("#todo");
675
679
  expect(fresh!.updatedAt).not.toBe(before);
676
680
  expect(new Date(fresh!.updatedAt) > new Date(before)).toBe(true);
681
+ // vault#586: the integer keyset key must move in lockstep with updated_at —
682
+ // else a cursor sync-poll never surfaces the rewritten note. Pins the
683
+ // renameTag content-cascade ms hunk (a revert leaves ms stale → RED).
684
+ const afterMs = msOf(note.id);
685
+ expect(afterMs).toBeGreaterThan(beforeMs);
686
+ expect(afterMs).toBe(Date.parse(fresh!.updatedAt!));
677
687
  });
678
688
 
679
689
  it("2. cascades sub-tags recursively (task → todo, task/work → todo/work, task/work/client → todo/work/client)", async () => {
@@ -1543,7 +1553,16 @@ describe("queryNotes", async () => {
1543
1553
  // Helper: pin a note's updated_at to a known value so cursor math
1544
1554
  // doesn't race wall-clock writes from the test harness.
1545
1555
  function pinUpdatedAt(id: string, iso: string) {
1546
- db.prepare("UPDATE notes SET updated_at = ? WHERE id = ?").run(iso, id);
1556
+ // Mirror production: EVERY write maintains updated_at_ms in lockstep with
1557
+ // updated_at (vault#586 — the integer column is the keyset-ordering
1558
+ // authority). A helper that pinned only the string would leave a stale ms
1559
+ // and mis-order the cursor. The pinned values here are canonical `...Z`,
1560
+ // so `Date.parse` is exact.
1561
+ db.prepare("UPDATE notes SET updated_at = ?, updated_at_ms = ? WHERE id = ?").run(
1562
+ iso,
1563
+ Date.parse(iso),
1564
+ id,
1565
+ );
1547
1566
  }
1548
1567
 
1549
1568
  it("first call returns notes + a next_cursor string", async () => {
@@ -1790,7 +1809,12 @@ describe("queryNotes", async () => {
1790
1809
 
1791
1810
  describe("ULID ids for new notes (existing IDs unchanged)", () => {
1792
1811
  function pinUpdatedAt(id: string, iso: string) {
1793
- db.prepare("UPDATE notes SET updated_at = ? WHERE id = ?").run(iso, id);
1812
+ // Keep updated_at_ms in lockstep see the sibling helper's note (vault#586).
1813
+ db.prepare("UPDATE notes SET updated_at = ?, updated_at_ms = ? WHERE id = ?").run(
1814
+ iso,
1815
+ Date.parse(iso),
1816
+ id,
1817
+ );
1794
1818
  }
1795
1819
 
1796
1820
  it("new notes get ULID-format ids (26-char Crockford base32)", async () => {
@@ -7652,3 +7676,45 @@ describe("vault projection (vault#271)", async () => {
7652
7676
  expect(approxTokens).toBeLessThan(5000);
7653
7677
  });
7654
7678
  });
7679
+
7680
+ // ---- Path write-validation (vault#589 / FIX 2) ----
7681
+
7682
+ describe("validatePath — reject NUL / '..' at the write surface", () => {
7683
+ const NUL = String.fromCharCode(0);
7684
+
7685
+ it("rejects a NUL byte in the path", () => {
7686
+ expect(() => validatePath(`bad${NUL}path`)).toThrow(PathValidationError);
7687
+ try {
7688
+ validatePath(`x${NUL}y`);
7689
+ throw new Error("expected validatePath to throw");
7690
+ } catch (e: any) {
7691
+ expect(e.code).toBe("INVALID_PATH");
7692
+ expect(e.error_type).toBe("invalid_path");
7693
+ expect(e.reason).toMatch(/NUL/);
7694
+ }
7695
+ });
7696
+
7697
+ it("rejects a '..' path segment (traversal-shaped)", () => {
7698
+ expect(() => validatePath("..")).toThrow(PathValidationError);
7699
+ expect(() => validatePath("../secrets")).toThrow(PathValidationError);
7700
+ expect(() => validatePath("a/../b")).toThrow(PathValidationError);
7701
+ expect(() => validatePath("a/..")).toThrow(PathValidationError);
7702
+ // Backslash form normalizes to '/', so it's caught too.
7703
+ expect(() => validatePath("a\\..\\b")).toThrow(PathValidationError);
7704
+ });
7705
+
7706
+ it("accepts legitimate paths, including ones that merely contain dots", () => {
7707
+ expect(() => validatePath("Projects/Parachute/README")).not.toThrow();
7708
+ expect(() => validatePath("notes/2026-07-09")).not.toThrow();
7709
+ expect(() => validatePath("weird...name")).not.toThrow(); // three literal dots, not a '..' segment
7710
+ expect(() => validatePath("a/.hidden/b")).not.toThrow(); // single-dot-prefixed segment is fine
7711
+ expect(() => validatePath("with spaces and : colon?")).not.toThrow(); // FORBIDDEN_CHARS stay un-enforced at write
7712
+ });
7713
+
7714
+ it("treats a null / undefined / empty path as valid (notes need no path)", () => {
7715
+ expect(() => validatePath(null)).not.toThrow();
7716
+ expect(() => validatePath(undefined)).not.toThrow();
7717
+ expect(() => validatePath("")).not.toThrow();
7718
+ expect(() => validatePath(" ")).not.toThrow(); // normalizes to null
7719
+ });
7720
+ });