@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.
- package/core/src/aggregate.test.ts +23 -0
- package/core/src/contract-taxonomy.test.ts +22 -0
- package/core/src/core.test.ts +70 -4
- package/core/src/cursor-keyset-ms.test.ts +537 -0
- package/core/src/cursor.ts +76 -6
- package/core/src/hooks.ts +9 -0
- package/core/src/mcp.ts +12 -2
- package/core/src/notes.ts +175 -50
- package/core/src/paths.ts +4 -0
- package/core/src/portable-md.test.ts +161 -0
- package/core/src/portable-md.ts +140 -9
- package/core/src/query-operators.ts +56 -0
- package/core/src/schema.ts +128 -2
- package/core/src/seed-packs.ts +39 -13
- package/core/src/store.ts +20 -2
- package/core/src/tag-hierarchy.ts +13 -0
- package/core/src/txn.test.ts +100 -4
- package/core/src/txn.ts +119 -24
- package/core/src/wikilinks.test.ts +175 -0
- package/core/src/wikilinks.ts +151 -10
- package/package.json +1 -1
- package/src/cli.ts +6 -0
- package/src/mirror-import.ts +5 -0
- package/src/routes.ts +170 -7
- package/src/vault.test.ts +227 -1
|
@@ -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
|
package/core/src/core.test.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
});
|