@openparachute/vault 0.6.4-rc.9 → 0.6.5-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.
- package/README.md +6 -4
- package/core/src/__fixtures__/golden-vault.ts +125 -0
- package/core/src/__fixtures__/portable-export-golden.json +10 -0
- package/core/src/bare-tag-guard.test.ts +246 -0
- package/core/src/conformance.test.ts +112 -0
- package/core/src/conformance.ts +214 -0
- package/core/src/mcp.ts +313 -316
- package/core/src/notes.ts +73 -57
- package/core/src/portable-md-batching.test.ts +67 -0
- package/core/src/portable-md-golden.test.ts +55 -0
- package/core/src/portable-md.ts +579 -435
- package/core/src/schema.ts +21 -43
- package/core/src/store.ts +79 -11
- package/core/src/tag-hierarchy.ts +26 -0
- package/core/src/txn.test.ts +161 -0
- package/core/src/txn.ts +78 -0
- package/core/src/types.ts +24 -0
- package/package.json +1 -1
- package/src/bare-tag-guard-routes.test.ts +72 -0
- package/src/cli.ts +1 -1
- package/src/first-boot-create.test.ts +155 -0
- package/src/routes.ts +198 -32
- package/src/routing.test.ts +125 -0
- package/src/routing.ts +10 -2
- package/src/self-register.test.ts +4 -4
- package/src/self-register.ts +1 -2
- package/src/server.ts +58 -38
- package/src/storage.test.ts +75 -8
- package/web/ui/dist/assets/{index-UlvD8KHr.css → index-C5XqQM-c.css} +1 -1
- package/web/ui/dist/assets/index-TJ-XN4OZ.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-DwYo23aY.js +0 -61
package/README.md
CHANGED
|
@@ -943,7 +943,7 @@ cp .env.example .env # edit with your config
|
|
|
943
943
|
docker compose up -d
|
|
944
944
|
```
|
|
945
945
|
|
|
946
|
-
|
|
946
|
+
Set `PARACHUTE_VAULT_NAME` to auto-create a first vault on startup (name: lowercase alphanumeric + hyphens or underscores, 2–32 chars). Without it the server boots with zero vaults — create one explicitly via the admin wizard, `parachute init --vault-name <name>`, or `parachute-vault create <name>`.
|
|
947
947
|
|
|
948
948
|
### Cloud platforms
|
|
949
949
|
|
|
@@ -968,9 +968,11 @@ typical v0.6 self-host wants. If you're not sure, use the hub-managed
|
|
|
968
968
|
path above. The standalone Blueprint stays in tree because some
|
|
969
969
|
operators specifically want this shape (vault#341).
|
|
970
970
|
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
971
|
+
Set `PARACHUTE_VAULT_NAME` to auto-create a first vault on startup (name:
|
|
972
|
+
lowercase alphanumeric + hyphens or underscores, 2–32 chars). Without it
|
|
973
|
+
the server boots with zero vaults — create one explicitly via the admin
|
|
974
|
+
wizard, `parachute init --vault-name <name>`, or `parachute-vault create
|
|
975
|
+
<name>`.
|
|
974
976
|
|
|
975
977
|
#### Other platforms
|
|
976
978
|
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared deterministic vault builder + export-tree serializer for the
|
|
3
|
+
* portable-md "old bytes == new bytes" golden fixture (Phase-1 streaming
|
|
4
|
+
* export refactor).
|
|
5
|
+
*
|
|
6
|
+
* Both the one-shot capture script (scripts/capture-portable-golden.ts,
|
|
7
|
+
* run against the PRE-refactor code) and the regression test
|
|
8
|
+
* (portable-md-golden.test.ts, run against the POST-refactor code) build
|
|
9
|
+
* the SAME vault here and serialize the export tree the SAME way — so a
|
|
10
|
+
* byte diff between the committed golden JSON and a fresh export is a real
|
|
11
|
+
* drift, not a fixture-vs-test mismatch.
|
|
12
|
+
*
|
|
13
|
+
* Determinism requirements met here:
|
|
14
|
+
* - explicit note ids + pinned created_at/updated_at (no wall-clock)
|
|
15
|
+
* - export called with `exportedAt` + `caseSensitiveOverride: true`
|
|
16
|
+
* (so the bytes don't depend on the runner's filesystem)
|
|
17
|
+
* - content that exercises every format field: pathed / unpathed /
|
|
18
|
+
* empty-content notes, a non-md (csv) note that forces a notes-meta
|
|
19
|
+
* sidecar, tags with schema + relationships, typed links, multi-line
|
|
20
|
+
* + control-character metadata, and created_at != updated_at.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { readdirSync, readFileSync, statSync } from "fs";
|
|
24
|
+
import { join } from "path";
|
|
25
|
+
import type { Store } from "../types.js";
|
|
26
|
+
|
|
27
|
+
/** Fixed export timestamp — pins vault.yaml's `exported_at`. */
|
|
28
|
+
export const GOLDEN_EXPORTED_AT = "2026-07-02T00:00:00.000Z";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Populate `store` with a deterministic, format-exhaustive vault. Callers
|
|
32
|
+
* export it with `{ exportedAt: GOLDEN_EXPORTED_AT, caseSensitiveOverride: true }`.
|
|
33
|
+
*/
|
|
34
|
+
export async function buildGoldenVault(store: Store): Promise<void> {
|
|
35
|
+
// Tag schema + opaque relationship vocabulary (vault#428 shape).
|
|
36
|
+
await store.upsertTagSchema("project", {
|
|
37
|
+
description: "A long-running effort",
|
|
38
|
+
fields: { status: { type: "string", enum: ["active", "done"], indexed: true } },
|
|
39
|
+
});
|
|
40
|
+
await store.upsertTagRecord("project", {
|
|
41
|
+
relationships: {
|
|
42
|
+
"works-on": { from: "person", to: "project" },
|
|
43
|
+
"based-at": { from: "project", to: "place", note: "freeform" },
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const t0 = "2026-01-01T00:00:00.000Z";
|
|
48
|
+
const t1 = "2026-01-01T00:01:00.000Z";
|
|
49
|
+
|
|
50
|
+
await store.createNote("alpha body", {
|
|
51
|
+
id: "01HX001",
|
|
52
|
+
path: "Inbox/alpha",
|
|
53
|
+
tags: ["project", "z-other"],
|
|
54
|
+
metadata: {
|
|
55
|
+
priority: "high",
|
|
56
|
+
notes: "line1\nline2\nline3",
|
|
57
|
+
// control character exercises the \xNN escape path (vault#317 F1)
|
|
58
|
+
ctrl: "a\tb",
|
|
59
|
+
status: "active",
|
|
60
|
+
},
|
|
61
|
+
created_at: t0,
|
|
62
|
+
});
|
|
63
|
+
await store.createNote("beta body", {
|
|
64
|
+
id: "01HX002",
|
|
65
|
+
path: "Inbox/beta",
|
|
66
|
+
tags: ["project"],
|
|
67
|
+
metadata: { status: "done" },
|
|
68
|
+
created_at: t0,
|
|
69
|
+
});
|
|
70
|
+
await store.createNote("unpathed jot", { id: "01HX003", created_at: t0 });
|
|
71
|
+
// Empty-content skeleton note (vault#323).
|
|
72
|
+
await store.createNote("", {
|
|
73
|
+
id: "01HX004",
|
|
74
|
+
path: "Inbox/skeleton",
|
|
75
|
+
tags: ["project"],
|
|
76
|
+
created_at: t0,
|
|
77
|
+
});
|
|
78
|
+
// Non-md note → forces a .parachute/notes-meta/<id>.yaml sidecar.
|
|
79
|
+
await store.createNote("col1,col2\n1,2\n", {
|
|
80
|
+
id: "01HX005",
|
|
81
|
+
path: "Data/table",
|
|
82
|
+
extension: "csv",
|
|
83
|
+
tags: ["z-other"],
|
|
84
|
+
metadata: { rows: 2 },
|
|
85
|
+
created_at: t0,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
await store.createLink("01HX001", "01HX002", "derived-from", { source: "git://example" });
|
|
89
|
+
|
|
90
|
+
// Pin every timestamp so the export bytes are wall-clock-independent.
|
|
91
|
+
// n1 gets a divergent updated_at to exercise restoreNoteTimestamps.
|
|
92
|
+
await store.restoreNoteTimestamps("01HX001", t0, t1);
|
|
93
|
+
for (const id of ["01HX002", "01HX003", "01HX004", "01HX005"]) {
|
|
94
|
+
await store.restoreNoteTimestamps(id, t0, t0);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Recursively serialize an export directory into a sorted map of
|
|
100
|
+
* `<relative-posix-path>` → file content. Directories are recorded as
|
|
101
|
+
* entries with a trailing `/` and an empty value so an intentionally
|
|
102
|
+
* empty dir (e.g. `.parachute/schemas/` on a schema-less vault) still
|
|
103
|
+
* shows up in the byte comparison.
|
|
104
|
+
*/
|
|
105
|
+
export function serializeExportTree(dir: string): Record<string, string> {
|
|
106
|
+
const out: Record<string, string> = {};
|
|
107
|
+
const walk = (abs: string, rel: string): void => {
|
|
108
|
+
const entries = readdirSync(abs).sort();
|
|
109
|
+
if (entries.length === 0 && rel !== "") {
|
|
110
|
+
out[rel + "/"] = "";
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
for (const entry of entries) {
|
|
114
|
+
const childAbs = join(abs, entry);
|
|
115
|
+
const childRel = rel === "" ? entry : `${rel}/${entry}`;
|
|
116
|
+
if (statSync(childAbs).isDirectory()) {
|
|
117
|
+
walk(childAbs, childRel);
|
|
118
|
+
} else {
|
|
119
|
+
out[childRel] = readFileSync(childAbs, "utf-8");
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
walk(dir, "");
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
".parachute/notes-meta/01HX005.yaml": "id: 01HX005\npath: Data/table\nextension: csv\ntags:\n - z-other\nmetadata:\n rows: 2\ncreated_at: 2026-01-01T00:00:00.000Z\nupdated_at: 2026-01-01T00:00:00.000Z\n",
|
|
3
|
+
".parachute/schemas/project.yaml": "description: A long-running effort\nfields:\n status:\n enum:\n - active\n - done\n indexed: true\n type: string\nname: project\nrelationships:\n based-at:\n from: project\n note: freeform\n to: place\n works-on:\n from: person\n to: project\n",
|
|
4
|
+
".parachute/vault.yaml": "description: portable-md byte-stability fixture\nexport_format_version: 1\nexported_at: 2026-07-02T00:00:00.000Z\nname: golden\n",
|
|
5
|
+
"Data/table.csv": "col1,col2\n1,2\n",
|
|
6
|
+
"Inbox/alpha.md": "---\nid: 01HX001\npath: Inbox/alpha\ntags:\n - project\n - z-other\nmetadata:\n ctrl: \"a\\tb\"\n notes: \"line1\\nline2\\nline3\"\n priority: high\n status: active\nlinks:\n - metadata:\n source: git://example\n relationship: derived-from\n target: 01HX002\ncreated_at: 2026-01-01T00:00:00.000Z\nupdated_at: 2026-01-01T00:01:00.000Z\n---\nalpha body\n",
|
|
7
|
+
"Inbox/beta.md": "---\nid: 01HX002\npath: Inbox/beta\ntags:\n - project\nmetadata:\n status: done\ncreated_at: 2026-01-01T00:00:00.000Z\nupdated_at: 2026-01-01T00:00:00.000Z\n---\nbeta body\n",
|
|
8
|
+
"Inbox/skeleton.md": "---\nid: 01HX004\npath: Inbox/skeleton\ntags:\n - project\ncreated_at: 2026-01-01T00:00:00.000Z\nupdated_at: 2026-01-01T00:00:00.000Z\n---\n",
|
|
9
|
+
"_unpathed/01HX003.md": "---\nid: 01HX003\ncreated_at: 2026-01-01T00:00:00.000Z\nupdated_at: 2026-01-01T00:00:00.000Z\n---\nunpathed jot\n"
|
|
10
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "bun:test";
|
|
2
|
+
import { Database } from "bun:sqlite";
|
|
3
|
+
import { SqliteStore } from "./store.js";
|
|
4
|
+
import { generateMcpTools } from "./mcp.js";
|
|
5
|
+
import { stripTagHash } from "./tag-hierarchy.js";
|
|
6
|
+
|
|
7
|
+
// Canonical-bare-tag guard (PR #516). A `#`-prefixed tag must never be stored
|
|
8
|
+
// or mismatched: the agent module stored `#agent/message/inbound` literally and
|
|
9
|
+
// consumers querying the bare `agent/message/inbound` convention found nothing.
|
|
10
|
+
// The guard strips a leading `#` (idempotently) on BOTH the write path and the
|
|
11
|
+
// query path, while preserving casing + structure (it is NOT normalizeTagValue).
|
|
12
|
+
|
|
13
|
+
let store: SqliteStore;
|
|
14
|
+
let db: Database;
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
db = new Database(":memory:");
|
|
18
|
+
store = new SqliteStore(db);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
/** Raw read of a note's stored tag rows (bypasses any hydration). */
|
|
22
|
+
function storedTags(noteId: string): string[] {
|
|
23
|
+
return (
|
|
24
|
+
db
|
|
25
|
+
.prepare("SELECT tag_name FROM note_tags WHERE note_id = ? ORDER BY tag_name")
|
|
26
|
+
.all(noteId) as { tag_name: string }[]
|
|
27
|
+
).map((r) => r.tag_name);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe("stripTagHash helper", () => {
|
|
31
|
+
it("strips a single leading #", () => {
|
|
32
|
+
expect(stripTagHash("#agent/message")).toBe("agent/message");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("is idempotent across one or more leading # (##x → x, x → x)", () => {
|
|
36
|
+
expect(stripTagHash("##x")).toBe("x");
|
|
37
|
+
expect(stripTagHash("###deep")).toBe("deep");
|
|
38
|
+
expect(stripTagHash("x")).toBe("x");
|
|
39
|
+
expect(stripTagHash(stripTagHash("#x"))).toBe("x");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("does NOT lowercase or otherwise transform — casing + structure preserved", () => {
|
|
43
|
+
expect(stripTagHash("#Agent/Message/Inbound")).toBe("Agent/Message/Inbound");
|
|
44
|
+
expect(stripTagHash("CamelCase")).toBe("CamelCase");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("only strips LEADING # — a mid-string # (e.g. c#) is left intact", () => {
|
|
48
|
+
expect(stripTagHash("c#")).toBe("c#");
|
|
49
|
+
expect(stripTagHash("#c#")).toBe("c#");
|
|
50
|
+
expect(stripTagHash("a#b")).toBe("a#b");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("trims surrounding whitespace before stripping", () => {
|
|
54
|
+
expect(stripTagHash(" #tag ")).toBe("tag");
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("write path — tags stored bare", () => {
|
|
59
|
+
it("createNote: #agent/message stored as agent/message", async () => {
|
|
60
|
+
const note = await store.createNote("inbound", { tags: ["#agent/message"] });
|
|
61
|
+
expect(storedTags(note.id)).toEqual(["agent/message"]);
|
|
62
|
+
expect(note.tags).toEqual(["agent/message"]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("##x stored as x; a bare foo is unchanged", async () => {
|
|
66
|
+
const note = await store.createNote("memo", { tags: ["##x", "foo"] });
|
|
67
|
+
expect(storedTags(note.id).sort()).toEqual(["foo", "x"]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("a # mid-string (c#) is NOT mangled", async () => {
|
|
71
|
+
const note = await store.createNote("memo", { tags: ["c#"] });
|
|
72
|
+
expect(storedTags(note.id)).toEqual(["c#"]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("casing is preserved through the write path", async () => {
|
|
76
|
+
const note = await store.createNote("memo", { tags: ["#Agent/Inbound"] });
|
|
77
|
+
expect(storedTags(note.id)).toEqual(["Agent/Inbound"]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("tagNote: #-prefixed add lands on the bare row", async () => {
|
|
81
|
+
const note = await store.createNote("memo");
|
|
82
|
+
await store.tagNote(note.id, ["#later"]);
|
|
83
|
+
expect(storedTags(note.id)).toEqual(["later"]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("untagNote: removing #tag deletes the bare row", async () => {
|
|
87
|
+
const note = await store.createNote("memo", { tags: ["later"] });
|
|
88
|
+
await store.untagNote(note.id, ["#later"]);
|
|
89
|
+
expect(storedTags(note.id)).toEqual([]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("batchTag/batchUntag normalize independently of tagNote", async () => {
|
|
93
|
+
const a = await store.createNote("a");
|
|
94
|
+
const b = await store.createNote("b");
|
|
95
|
+
await store.batchTag([a.id, b.id], ["#batch"]);
|
|
96
|
+
expect(storedTags(a.id)).toEqual(["batch"]);
|
|
97
|
+
expect(storedTags(b.id)).toEqual(["batch"]);
|
|
98
|
+
await store.batchUntag([a.id], ["#batch"]);
|
|
99
|
+
expect(storedTags(a.id)).toEqual([]);
|
|
100
|
+
expect(storedTags(b.id)).toEqual(["batch"]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("create-note via MCP also stores bare", async () => {
|
|
104
|
+
const tools = generateMcpTools(store);
|
|
105
|
+
const createNote = tools.find((t) => t.name === "create-note")!;
|
|
106
|
+
const res = (await createNote.execute({
|
|
107
|
+
content: "mcp inbound",
|
|
108
|
+
tags: ["#agent/message/inbound"],
|
|
109
|
+
})) as { id: string };
|
|
110
|
+
expect(storedTags(res.id)).toEqual(["agent/message/inbound"]);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe("query path — # and bare forms equivalent", () => {
|
|
115
|
+
it("query tag:#agent/message and tag:agent/message both match the same bare-stored note", async () => {
|
|
116
|
+
const note = await store.createNote("inbound", { tags: ["agent/message"] });
|
|
117
|
+
|
|
118
|
+
const byHash = await store.queryNotes({ tags: ["#agent/message"] });
|
|
119
|
+
expect(byHash.map((n) => n.id)).toContain(note.id);
|
|
120
|
+
|
|
121
|
+
const byBare = await store.queryNotes({ tags: ["agent/message"] });
|
|
122
|
+
expect(byBare.map((n) => n.id)).toContain(note.id);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("an old #-form query reaches a #-decorated WRITE (both normalize to the same bare row)", async () => {
|
|
126
|
+
// The non-breaking guarantee: a client that writes AND queries with the
|
|
127
|
+
// legacy #-form still works — both sides converge on the bare row. (Truly
|
|
128
|
+
// pre-fix #-STORED data is migrated away via renameTag, not auto-reached.)
|
|
129
|
+
const note = await store.createNote("inbound", { tags: ["#agent/message"] });
|
|
130
|
+
const byHash = await store.queryNotes({ tags: ["#agent/message"] });
|
|
131
|
+
expect(byHash.map((n) => n.id)).toContain(note.id);
|
|
132
|
+
const byBare = await store.queryNotes({ tags: ["agent/message"] });
|
|
133
|
+
expect(byBare.map((n) => n.id)).toContain(note.id);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("exclude_tags #x excludes x-tagged notes", async () => {
|
|
137
|
+
const kept = await store.createNote("kept", { tags: ["keep"] });
|
|
138
|
+
const dropped = await store.createNote("dropped", { tags: ["keep", "x"] });
|
|
139
|
+
|
|
140
|
+
const results = await store.queryNotes({ tags: ["keep"], excludeTags: ["#x"] });
|
|
141
|
+
const ids = results.map((n) => n.id);
|
|
142
|
+
expect(ids).toContain(kept.id);
|
|
143
|
+
expect(ids).not.toContain(dropped.id);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("searchNotes treats #tag and bare tag identically", async () => {
|
|
147
|
+
const note = await store.createNote("findable text here", { tags: ["agent/message"] });
|
|
148
|
+
const hits = await store.searchNotes("findable", { tags: ["#agent/message"] });
|
|
149
|
+
expect(hits.map((n) => n.id)).toContain(note.id);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("query-notes via MCP: #-form matches bare-stored note", async () => {
|
|
153
|
+
const note = await store.createNote("mcp query", { tags: ["agent/message/inbound"] });
|
|
154
|
+
const tools = generateMcpTools(store);
|
|
155
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
156
|
+
// query-notes returns a plain array when no cursor is requested.
|
|
157
|
+
const res = (await query.execute({ tag: "#agent/message/inbound" })) as { id: string }[];
|
|
158
|
+
expect(res.map((n) => n.id)).toContain(note.id);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe("tag schema path — update-tag name + parent_names stored bare", () => {
|
|
163
|
+
it("update-tag with name #foo + parent_names [#bar] → stored bare; inheritance works", async () => {
|
|
164
|
+
const tools = generateMcpTools(store);
|
|
165
|
+
const updateTag = tools.find((t) => t.name === "update-tag")!;
|
|
166
|
+
|
|
167
|
+
// Establish `bar` as a parent, then `foo` as its child via #-decorated input.
|
|
168
|
+
await updateTag.execute({ tag: "#bar", description: "parent" });
|
|
169
|
+
await updateTag.execute({ tag: "#foo", parent_names: ["#bar"] });
|
|
170
|
+
|
|
171
|
+
const fooRecord = await store.getTagRecord("foo");
|
|
172
|
+
expect(fooRecord).not.toBeNull();
|
|
173
|
+
expect(fooRecord!.parent_names).toEqual(["bar"]);
|
|
174
|
+
// The #-decorated upsert must NOT have created a phantom `#foo` row.
|
|
175
|
+
expect(await store.getTagRecord("#foo")).toBeNull();
|
|
176
|
+
|
|
177
|
+
// Inheritance: tag:bar expands to foo (foo's parent is bar).
|
|
178
|
+
const fooNote = await store.createNote("a foo note", { tags: ["foo"] });
|
|
179
|
+
const expanded = await store.queryNotes({ tags: ["bar"] });
|
|
180
|
+
expect(expanded.map((n) => n.id)).toContain(fooNote.id);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("update-tag with #-name preserves the existing bare record's fields (merge correctness)", async () => {
|
|
184
|
+
const tools = generateMcpTools(store);
|
|
185
|
+
const updateTag = tools.find((t) => t.name === "update-tag")!;
|
|
186
|
+
|
|
187
|
+
await updateTag.execute({ tag: "thing", description: "first" });
|
|
188
|
+
// A #-decorated follow-up upsert must read the EXISTING bare record so the
|
|
189
|
+
// partial merge doesn't clobber the prior description.
|
|
190
|
+
await updateTag.execute({ tag: "#thing", parent_names: [] });
|
|
191
|
+
|
|
192
|
+
const record = await store.getTagRecord("thing");
|
|
193
|
+
expect(record!.description).toBe("first");
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
describe("rename carve-out — source name stays literal (migration escape hatch)", () => {
|
|
198
|
+
it("renameTag(#agent/message, agent/message) finds + renames the #-prefixed row", async () => {
|
|
199
|
+
// Plant a legacy #-prefixed tag row + a note carrying it (simulating the
|
|
200
|
+
// pre-fix agent data the migration must rename away).
|
|
201
|
+
db.prepare("INSERT OR IGNORE INTO tags (name) VALUES ('#agent/message')").run();
|
|
202
|
+
const note = await store.createNote("legacy inbound");
|
|
203
|
+
db.prepare("INSERT INTO note_tags (note_id, tag_name) VALUES (?, '#agent/message')").run(note.id);
|
|
204
|
+
|
|
205
|
+
const result = await store.renameTag("#agent/message", "agent/message");
|
|
206
|
+
expect("renamed" in result).toBe(true);
|
|
207
|
+
|
|
208
|
+
// The literal #-row is gone; the note now carries the bare tag.
|
|
209
|
+
expect(storedTags(note.id)).toEqual(["agent/message"]);
|
|
210
|
+
const tags = await store.listTags();
|
|
211
|
+
expect(tags.map((t) => t.name)).not.toContain("#agent/message");
|
|
212
|
+
expect(tags.map((t) => t.name)).toContain("agent/message");
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("renameTag normalizes the TARGET — renaming TO a #-name stores bare (can't re-introduce #)", async () => {
|
|
216
|
+
db.prepare("INSERT OR IGNORE INTO tags (name) VALUES ('plainsrc')").run();
|
|
217
|
+
const note = await store.createNote("x");
|
|
218
|
+
db.prepare("INSERT INTO note_tags (note_id, tag_name) VALUES (?, 'plainsrc')").run(note.id);
|
|
219
|
+
|
|
220
|
+
await store.renameTag("plainsrc", "#shiny");
|
|
221
|
+
|
|
222
|
+
// The target #-prefix is stripped — no `#shiny` row, the note carries `shiny`.
|
|
223
|
+
expect(storedTags(note.id)).toEqual(["shiny"]);
|
|
224
|
+
expect((await store.listTags()).map((t) => t.name)).not.toContain("#shiny");
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("mergeTags normalizes the TARGET — merging INTO a #-name stores bare", async () => {
|
|
228
|
+
db.prepare("INSERT OR IGNORE INTO tags (name) VALUES ('m1')").run();
|
|
229
|
+
const note = await store.createNote("y");
|
|
230
|
+
db.prepare("INSERT INTO note_tags (note_id, tag_name) VALUES (?, 'm1')").run(note.id);
|
|
231
|
+
|
|
232
|
+
await store.mergeTags(["m1"], "#dest");
|
|
233
|
+
|
|
234
|
+
expect(storedTags(note.id)).toEqual(["dest"]);
|
|
235
|
+
expect((await store.listTags()).map((t) => t.name)).not.toContain("#dest");
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
describe("regression — existing bare-tag behavior unchanged", () => {
|
|
240
|
+
it("bare write + bare query still works end to end", async () => {
|
|
241
|
+
const note = await store.createNote("plain", { tags: ["alpha", "beta"] });
|
|
242
|
+
expect(storedTags(note.id).sort()).toEqual(["alpha", "beta"]);
|
|
243
|
+
const results = await store.queryNotes({ tags: ["alpha"] });
|
|
244
|
+
expect(results.map((n) => n.id)).toContain(note.id);
|
|
245
|
+
});
|
|
246
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conformance check (vault#283) — counting existing notes that would violate
|
|
3
|
+
* a proposed tightening of a tag's field spec. Backs the Schema editor's
|
|
4
|
+
* "N existing notes violate this" warning.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, it, expect, beforeEach } from "bun:test";
|
|
7
|
+
import { Database } from "bun:sqlite";
|
|
8
|
+
import { SqliteStore } from "./store.js";
|
|
9
|
+
import { countConformanceViolations } from "./conformance.js";
|
|
10
|
+
|
|
11
|
+
let db: Database;
|
|
12
|
+
let store: SqliteStore;
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
db = new Database(":memory:");
|
|
16
|
+
store = new SqliteStore(db);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe("countConformanceViolations", () => {
|
|
20
|
+
it("returns zero violations when proposed fields is empty", async () => {
|
|
21
|
+
await store.createNote("n", { tags: ["task"], metadata: { status: "open" } });
|
|
22
|
+
const r = countConformanceViolations(db, "task", {});
|
|
23
|
+
expect(r.total_notes).toBe(1);
|
|
24
|
+
expect(r.violating_notes).toBe(0);
|
|
25
|
+
expect(r.checked_fields).toEqual([]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("counts notes missing a newly-required field", async () => {
|
|
29
|
+
await store.createNote("has", { tags: ["task"], metadata: { due: "2026-01-01" } });
|
|
30
|
+
await store.createNote("missing-1", { tags: ["task"], metadata: {} });
|
|
31
|
+
await store.createNote("missing-2", { tags: ["task"] });
|
|
32
|
+
|
|
33
|
+
const r = countConformanceViolations(db, "task", {
|
|
34
|
+
due: { type: "string", required: true },
|
|
35
|
+
});
|
|
36
|
+
expect(r.total_notes).toBe(3);
|
|
37
|
+
expect(r.violating_notes).toBe(2);
|
|
38
|
+
expect(r.checked_fields).toEqual(["due"]);
|
|
39
|
+
// missing_required reasons surface in the sample.
|
|
40
|
+
expect(r.sample.every((s) => s.fields.some((f) => f.reason === "missing_required"))).toBe(true);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("counts notes whose value falls outside a narrowed enum", async () => {
|
|
44
|
+
await store.createNote("ok", { tags: ["task"], metadata: { status: "open" } });
|
|
45
|
+
await store.createNote("bad", { tags: ["task"], metadata: { status: "archived" } });
|
|
46
|
+
|
|
47
|
+
const r = countConformanceViolations(db, "task", {
|
|
48
|
+
status: { type: "string", enum: ["open", "done"] },
|
|
49
|
+
});
|
|
50
|
+
expect(r.violating_notes).toBe(1);
|
|
51
|
+
expect(r.sample[0]?.fields[0]?.reason).toBe("enum_mismatch");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("counts notes whose value contradicts a changed type", async () => {
|
|
55
|
+
await store.createNote("num", { tags: ["task"], metadata: { count: 3 } });
|
|
56
|
+
await store.createNote("str", { tags: ["task"], metadata: { count: "three" } });
|
|
57
|
+
|
|
58
|
+
const r = countConformanceViolations(db, "task", {
|
|
59
|
+
count: { type: "integer" },
|
|
60
|
+
});
|
|
61
|
+
expect(r.violating_notes).toBe(1);
|
|
62
|
+
expect(r.sample[0]?.fields[0]?.reason).toBe("type_mismatch");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("includes descendant-tag notes via inheritance", async () => {
|
|
66
|
+
// dev/log declares dev as a parent → a strict field on dev applies to
|
|
67
|
+
// dev/log notes too.
|
|
68
|
+
await store.upsertTagRecord("dev/log", { parent_names: ["dev"] });
|
|
69
|
+
await store.createNote("parent-note", { tags: ["dev"], metadata: {} });
|
|
70
|
+
await store.createNote("child-note", { tags: ["dev/log"], metadata: {} });
|
|
71
|
+
|
|
72
|
+
const r = countConformanceViolations(db, "dev", {
|
|
73
|
+
kind: { type: "string", required: true },
|
|
74
|
+
});
|
|
75
|
+
expect(r.total_notes).toBe(2);
|
|
76
|
+
expect(r.violating_notes).toBe(2);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("ignores fields the proposal does not touch", async () => {
|
|
80
|
+
await store.createNote("n", { tags: ["task"], metadata: { other: 123 } });
|
|
81
|
+
// We only propose `status`; `other` being weird is irrelevant.
|
|
82
|
+
const r = countConformanceViolations(db, "task", {
|
|
83
|
+
status: { type: "string" },
|
|
84
|
+
});
|
|
85
|
+
// `status` absent + not required → no violation.
|
|
86
|
+
expect(r.violating_notes).toBe(0);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("caps the sample at sampleLimit but counts all violations", async () => {
|
|
90
|
+
for (let i = 0; i < 5; i++) {
|
|
91
|
+
await store.createNote(`n${i}`, { tags: ["task"], metadata: {} });
|
|
92
|
+
}
|
|
93
|
+
const r = countConformanceViolations(
|
|
94
|
+
db,
|
|
95
|
+
"task",
|
|
96
|
+
{ req: { type: "string", required: true } },
|
|
97
|
+
{ sampleLimit: 2 },
|
|
98
|
+
);
|
|
99
|
+
expect(r.violating_notes).toBe(5);
|
|
100
|
+
expect(r.sample.length).toBe(2);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("store.countTagConformance", () => {
|
|
105
|
+
it("proxies to the core helper", async () => {
|
|
106
|
+
await store.createNote("n", { tags: ["task"], metadata: {} });
|
|
107
|
+
const r = await store.countTagConformance("task", {
|
|
108
|
+
due: { type: "string", required: true },
|
|
109
|
+
});
|
|
110
|
+
expect(r.violating_notes).toBe(1);
|
|
111
|
+
});
|
|
112
|
+
});
|