@openparachute/vault 0.6.5-rc.9 → 0.7.0-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/.parachute/module.json +1 -0
- package/core/src/contract-concurrency.test.ts +117 -0
- package/core/src/contract-taxonomy.test.ts +119 -0
- package/core/src/contract-typed-index.test.ts +106 -0
- package/core/src/core.test.ts +7 -7
- package/core/src/mcp.ts +1 -1
- package/core/src/schema.ts +5 -5
- package/core/src/seed-packs.test.ts +222 -56
- package/core/src/seed-packs.ts +334 -70
- package/core/src/tag-hierarchy.ts +1 -1
- package/core/src/tag-schemas.ts +2 -2
- package/core/src/types.ts +1 -1
- package/package.json +1 -1
- package/src/admin-spa.ts +2 -1
- package/src/auth.ts +1 -1
- package/src/cli.ts +317 -53
- package/src/contract-errors.test.ts +98 -0
- package/src/contract-honest-queries.test.ts +100 -0
- package/src/contract-search.test.ts +127 -0
- package/src/export-watch.ts +1 -1
- package/src/live-frame-parity.test.ts +201 -0
- package/src/module-manifest.ts +8 -0
- package/src/onboarding-seed.test.ts +82 -20
- package/src/onboarding-seed.ts +2 -2
- package/src/routes.ts +3 -3
- package/src/routing.test.ts +2 -2
- package/src/routing.ts +18 -6
- package/src/self-register.test.ts +19 -0
- package/src/self-register.ts +6 -1
- package/src/server.ts +56 -0
- package/src/services-manifest.ts +8 -0
- package/src/subscriptions.ts +100 -42
- package/src/tag-scope.ts +3 -3
- package/src/test-support/live-frame-corpus.ts +72 -0
- package/src/token-store.ts +2 -2
- package/src/transcription/build.test.ts +86 -5
- package/src/transcription/build.ts +87 -7
- package/src/transcription/capability.test.ts +25 -0
- package/src/transcription/capability.ts +27 -2
- package/src/transcription/download.test.ts +101 -0
- package/src/transcription/download.ts +71 -0
- package/src/transcription/install-python.test.ts +366 -0
- package/src/transcription/install-python.ts +471 -0
- package/src/transcription/providers/onnx-asr.test.ts +229 -0
- package/src/transcription/providers/onnx-asr.ts +239 -0
- package/src/transcription/providers/parakeet-mlx.test.ts +290 -0
- package/src/transcription/providers/parakeet-mlx.ts +242 -0
- package/src/transcription/select.test.ts +166 -1
- package/src/transcription/select.ts +234 -1
- package/src/transcription/tiers.test.ts +197 -0
- package/src/transcription/tiers.ts +184 -0
- package/src/vault-create.test.ts +19 -14
- package/src/vault.test.ts +1 -1
- package/src/ws-server.ts +408 -0
- package/src/ws-subscribe.test.ts +474 -0
- package/src/ws-subscribe.ts +242 -0
- package/src/subscribe.test.ts +0 -609
- package/src/subscribe.ts +0 -248
package/.parachute/module.json
CHANGED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contract suite — concurrency + write-path strengths (Wave 1 of the
|
|
3
|
+
* Reliability & Usability Program, umbrella #556). Encodes the 2026-07-09
|
|
4
|
+
* nine-persona deep test's verdict that "the storage/concurrency core is
|
|
5
|
+
* trustworthy — zero server errors, zero corruption, zero lost writes across
|
|
6
|
+
* ~100 hostile operations and a live two-writer session" (#556, #555 work
|
|
7
|
+
* item "document the strong contracts the deep test verified but that are
|
|
8
|
+
* currently unwritten selling points"). Every test here is a PASSING lock-in
|
|
9
|
+
* — there is no known-broken behavior in this file, only undocumented
|
|
10
|
+
* correctness worth pinning as contract before later waves touch adjacent
|
|
11
|
+
* code paths.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, it, expect, beforeEach } from "bun:test";
|
|
15
|
+
import { Database } from "bun:sqlite";
|
|
16
|
+
import { SqliteStore } from "./store.js";
|
|
17
|
+
import { ConflictError, PathConflictError } from "./notes.js";
|
|
18
|
+
|
|
19
|
+
let store: SqliteStore;
|
|
20
|
+
let db: Database;
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
db = new Database(":memory:");
|
|
24
|
+
store = new SqliteStore(db);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("contract: concurrency — passing (lock in verified strengths)", () => {
|
|
28
|
+
it("sequential interleaved appends from two writers preserve call order and lose nothing (25-line pattern)", async () => {
|
|
29
|
+
const note = await store.createNote("", { id: "log" });
|
|
30
|
+
|
|
31
|
+
// Simulate two independent writers interleaving 25 appends each.
|
|
32
|
+
// Sequential (not truly parallel) — the contract under test is that
|
|
33
|
+
// SQL-level `content = content || ?` concatenation never drops or
|
|
34
|
+
// reorders a write relative to the order calls actually landed in,
|
|
35
|
+
// which is exactly what a real two-writer race also guarantees
|
|
36
|
+
// (see core.test.ts's "update-note append is atomic under concurrent
|
|
37
|
+
// calls" for the Promise.all interleaving twin of this test).
|
|
38
|
+
const expectedLines: string[] = [];
|
|
39
|
+
for (let i = 1; i <= 25; i++) {
|
|
40
|
+
const lineA = `writer-A line ${i}\n`;
|
|
41
|
+
await store.updateNote(note.id, { append: lineA });
|
|
42
|
+
expectedLines.push(lineA);
|
|
43
|
+
|
|
44
|
+
const lineB = `writer-B line ${i}\n`;
|
|
45
|
+
await store.updateNote(note.id, { append: lineB });
|
|
46
|
+
expectedLines.push(lineB);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const final = await store.getNote(note.id);
|
|
50
|
+
expect(final!.content).toBe(expectedLines.join(""));
|
|
51
|
+
// 50 appends, none lost: every planted line is present exactly once.
|
|
52
|
+
expect(final!.content.match(/writer-A line/g)?.length).toBe(25);
|
|
53
|
+
expect(final!.content.match(/writer-B line/g)?.length).toBe(25);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("two updates racing on the same if_updated_at token: exactly one succeeds, the other gets a ConflictError", async () => {
|
|
57
|
+
const note = await store.createNote("v0", { id: "n1" });
|
|
58
|
+
const token = note.updatedAt!;
|
|
59
|
+
|
|
60
|
+
const first = await store.updateNote(note.id, { content: "v1 (writer A)", if_updated_at: token });
|
|
61
|
+
expect(first.content).toBe("v1 (writer A)");
|
|
62
|
+
|
|
63
|
+
let err: any;
|
|
64
|
+
try {
|
|
65
|
+
// Writer B read the note before writer A's update landed and is still
|
|
66
|
+
// holding the stale token — this must conflict, not silently overwrite.
|
|
67
|
+
await store.updateNote(note.id, { content: "v1 (writer B)", if_updated_at: token });
|
|
68
|
+
} catch (e) {
|
|
69
|
+
err = e;
|
|
70
|
+
}
|
|
71
|
+
expect(err).toBeInstanceOf(ConflictError);
|
|
72
|
+
expect(err.note_id).toBe(note.id);
|
|
73
|
+
expect(err.expected_updated_at).toBe(token);
|
|
74
|
+
expect(err.current_updated_at).toBe(first.updatedAt);
|
|
75
|
+
|
|
76
|
+
// Writer A's write is the one that stuck.
|
|
77
|
+
const final = await store.getNote(note.id);
|
|
78
|
+
expect(final!.content).toBe("v1 (writer A)");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("creating a note at an already-taken path throws PathConflictError — the prior note is untouched", async () => {
|
|
82
|
+
const original = await store.createNote("original owner", { path: "shared/path" });
|
|
83
|
+
|
|
84
|
+
let err: any;
|
|
85
|
+
try {
|
|
86
|
+
await store.createNote("racing creator", { path: "shared/path" });
|
|
87
|
+
} catch (e) {
|
|
88
|
+
err = e;
|
|
89
|
+
}
|
|
90
|
+
expect(err).toBeInstanceOf(PathConflictError);
|
|
91
|
+
expect(err.path).toBe("shared/path");
|
|
92
|
+
|
|
93
|
+
const survivor = await store.getNote(original.id);
|
|
94
|
+
expect(survivor!.content).toBe("original owner");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("a metadata update merges rather than replaces — untouched top-level keys survive a partial patch", async () => {
|
|
98
|
+
const note = await store.createNote("has metadata", {
|
|
99
|
+
metadata: { keep: "me", also_keep: 42, drop_me: "bye" },
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// mergeMetadata is the pure merge primitive the MCP/REST write paths
|
|
103
|
+
// call before handing the merged object to store.updateNote (the
|
|
104
|
+
// Store/engine-level updateNote itself replaces `metadata` wholesale —
|
|
105
|
+
// merge is a caller responsibility, exercised here directly).
|
|
106
|
+
const { mergeMetadata } = await import("./notes.js");
|
|
107
|
+
const merged = mergeMetadata(note.metadata as Record<string, unknown>, {
|
|
108
|
+
also_keep: 43,
|
|
109
|
+
drop_me: null,
|
|
110
|
+
new_key: "hello",
|
|
111
|
+
});
|
|
112
|
+
await store.updateNote(note.id, { metadata: merged, if_updated_at: note.updatedAt });
|
|
113
|
+
|
|
114
|
+
const after = await store.getNote(note.id);
|
|
115
|
+
expect(after!.metadata).toEqual({ keep: "me", also_keep: 43, new_key: "hello" });
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contract suite — taxonomy integrity (Wave 1 of the Reliability & Usability
|
|
3
|
+
* Program, umbrella #556). Encodes the 2026-07-09 nine-persona deep test's
|
|
4
|
+
* WS3 findings (#552) as executable tests: PASSING tests lock in behavior
|
|
5
|
+
* that is correct today (the tag-expand axis, `delete-tag`'s "untag, don't
|
|
6
|
+
* delete notes" contract, and `renameTag`'s full cascade — note_tags,
|
|
7
|
+
* `parent_names` references in OTHER tags, and note body content); `test.todo`
|
|
8
|
+
* entries describe the target behavior for confirmed-broken cases, to be
|
|
9
|
+
* flipped to real assertions in a later wave. See #552 for the full write-up.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, it, test, expect, beforeEach } from "bun:test";
|
|
13
|
+
import { Database } from "bun:sqlite";
|
|
14
|
+
import { SqliteStore } from "./store.js";
|
|
15
|
+
|
|
16
|
+
let store: SqliteStore;
|
|
17
|
+
let db: Database;
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
db = new Database(":memory:");
|
|
21
|
+
store = new SqliteStore(db);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const idsOf = (notes: { id: string }[]) => new Set(notes.map((n) => n.id));
|
|
25
|
+
|
|
26
|
+
describe("contract: taxonomy — passing (lock in current behavior)", () => {
|
|
27
|
+
it("expand axis 4-way discrimination: subtypes/namespace/both/exact each answer a distinct question", async () => {
|
|
28
|
+
// Two-axis corpus (mirrors tag-expand-axis.test.ts): `person` is a
|
|
29
|
+
// declared subtype of `entity` (parent_names) but not name-prefixed;
|
|
30
|
+
// `entity/archived` is name-prefixed under `entity` but NOT a declared
|
|
31
|
+
// subtype; `entity/person` is both.
|
|
32
|
+
await store.upsertTagRecord("entity", { description: "an entity" });
|
|
33
|
+
await store.upsertTagRecord("person", { parent_names: ["entity"] });
|
|
34
|
+
await store.upsertTagRecord("entity/archived", {});
|
|
35
|
+
await store.upsertTagRecord("entity/person", { parent_names: ["entity"] });
|
|
36
|
+
|
|
37
|
+
const nEntity = await store.createNote("literal entity", { tags: ["entity"] });
|
|
38
|
+
const nPerson = await store.createNote("a person (subtype)", { tags: ["person"] });
|
|
39
|
+
const nArchived = await store.createNote("filed under entity/", { tags: ["entity/archived"] });
|
|
40
|
+
const nBoth = await store.createNote("subtype AND filed", { tags: ["entity/person"] });
|
|
41
|
+
|
|
42
|
+
const subtypes = await store.queryNotes({ tags: ["entity"], expand: "subtypes" });
|
|
43
|
+
expect(idsOf(subtypes)).toEqual(new Set([nEntity.id, nPerson.id, nBoth.id]));
|
|
44
|
+
|
|
45
|
+
const namespace = await store.queryNotes({ tags: ["entity"], expand: "namespace" });
|
|
46
|
+
expect(idsOf(namespace)).toEqual(new Set([nEntity.id, nArchived.id, nBoth.id]));
|
|
47
|
+
|
|
48
|
+
const both = await store.queryNotes({ tags: ["entity"], expand: "both" });
|
|
49
|
+
expect(idsOf(both)).toEqual(new Set([nEntity.id, nPerson.id, nArchived.id, nBoth.id]));
|
|
50
|
+
|
|
51
|
+
const exact = await store.queryNotes({ tags: ["entity"], expand: "exact" });
|
|
52
|
+
expect(idsOf(exact)).toEqual(new Set([nEntity.id]));
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("delete-tag untags notes without deleting the notes themselves", async () => {
|
|
56
|
+
const note = await store.createNote("keep me", { tags: ["temp", "keeper"] });
|
|
57
|
+
const result = await store.deleteTag("temp");
|
|
58
|
+
expect(result).toEqual({ deleted: true, notes_untagged: 1 });
|
|
59
|
+
|
|
60
|
+
const survivor = await store.getNote(note.id);
|
|
61
|
+
expect(survivor).not.toBeNull();
|
|
62
|
+
expect(survivor!.content).toBe("keep me");
|
|
63
|
+
expect(survivor!.tags).toEqual(["keeper"]);
|
|
64
|
+
expect(survivor!.tags).not.toContain("temp");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("renameTag cascades note_tags, other tags' parent_names, AND note body content in one atomic call", async () => {
|
|
68
|
+
// note_tags surface: a note directly tagged `proj`.
|
|
69
|
+
const tagged = await store.createNote("owns the proj tag", { tags: ["proj"] });
|
|
70
|
+
// parent_names surface: another tag declares `proj` as a parent.
|
|
71
|
+
await store.upsertTagRecord("proj", { description: "root project tag" });
|
|
72
|
+
await store.upsertTagRecord("special", { parent_names: ["proj"] });
|
|
73
|
+
// note-body surface: a note mentions `#proj` inline (not as a real tag).
|
|
74
|
+
const mentioning = await store.createNote("kickoff notes, see #proj for context", {});
|
|
75
|
+
|
|
76
|
+
const result = await store.renameTag("proj", "initiative");
|
|
77
|
+
expect("error" in result).toBe(false);
|
|
78
|
+
if ("error" in result) throw new Error("unreachable");
|
|
79
|
+
expect(result.renamed).toBeGreaterThanOrEqual(1);
|
|
80
|
+
expect(result.parent_refs_updated).toBeGreaterThanOrEqual(1);
|
|
81
|
+
expect(result.notes_rewritten).toBeGreaterThanOrEqual(1);
|
|
82
|
+
|
|
83
|
+
// 1. note_tags cascade.
|
|
84
|
+
const taggedAfter = await store.getNote(tagged.id);
|
|
85
|
+
expect(taggedAfter!.tags).toContain("initiative");
|
|
86
|
+
expect(taggedAfter!.tags).not.toContain("proj");
|
|
87
|
+
|
|
88
|
+
// 2. OTHER tags' parent_names cascade.
|
|
89
|
+
const specialAfter = await store.getTagRecord("special");
|
|
90
|
+
expect(specialAfter?.parent_names).toEqual(["initiative"]);
|
|
91
|
+
|
|
92
|
+
// 3. Note body content cascade.
|
|
93
|
+
const mentioningAfter = await store.getNote(mentioning.id);
|
|
94
|
+
expect(mentioningAfter!.content).toContain("#initiative");
|
|
95
|
+
expect(mentioningAfter!.content).not.toContain("#proj");
|
|
96
|
+
|
|
97
|
+
// The old tag name is fully retired — no longer a live tag row.
|
|
98
|
+
const oldRecord = await store.getTagRecord("proj");
|
|
99
|
+
expect(oldRecord).toBeNull();
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("contract: taxonomy — todo (#552)", () => {
|
|
104
|
+
test.todo(
|
|
105
|
+
"#552: deleting a tag still referenced in another tag's parent_names errors unless cascade/detach is passed (today: store.deleteTag succeeds unconditionally and leaves the referencing tag's parent_names pointing at a now-nonexistent tag name — verified with a fresh parent/child fixture)",
|
|
106
|
+
);
|
|
107
|
+
test.todo(
|
|
108
|
+
"#552: a parent_names cycle (A declares parent B, then B declares parent A) is rejected at write time (today: store.upsertTagRecord accepts both writes with no cycle check — traversal elsewhere is cycle-safe, but the write itself is not honest about creating one)",
|
|
109
|
+
);
|
|
110
|
+
test.todo(
|
|
111
|
+
"#552: rename-tag is exposed as an MCP tool, not just a REST endpoint (today: POST /api/tags/:name/rename exists and store.renameTag is wired, but generateMcpTools has no rename-tag tool — an MCP-only agent cannot rename a tag)",
|
|
112
|
+
);
|
|
113
|
+
test.todo(
|
|
114
|
+
"#552: merge-tags is exposed as an MCP tool, not just a REST endpoint (today: POST /api/tags/merge exists and store.mergeTags is wired, but generateMcpTools has no merge-tags tool)",
|
|
115
|
+
);
|
|
116
|
+
test.todo(
|
|
117
|
+
"#552: a `vault doctor` integrity scan reports dangling parent_names references (today: no doctor/scan surface inspects parent_names for referential integrity — the delete-tag gap above goes undetected)",
|
|
118
|
+
);
|
|
119
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contract suite — typed indexes (Wave 1 of the Reliability & Usability
|
|
3
|
+
* Program, umbrella #556). Encodes the 2026-07-09 nine-persona deep test's
|
|
4
|
+
* WS4 findings (#553) as executable tests: PASSING tests lock in behavior
|
|
5
|
+
* that is correct today (well-typed integer-indexed range queries, the
|
|
6
|
+
* merge-patch metadata contract, and the state_transition compare-and-set
|
|
7
|
+
* conflict); `test.todo` entries describe the target behavior for
|
|
8
|
+
* confirmed-broken cases, to be flipped to real assertions in a later wave.
|
|
9
|
+
* See #553 for the full write-up.
|
|
10
|
+
*
|
|
11
|
+
* The TEXT-poisoning todo below was reproduced live before writing: declaring
|
|
12
|
+
* an indexed integer field and writing `metadata.n = "four"` succeeds with
|
|
13
|
+
* only an advisory `type_mismatch` warning, and the resulting row's generated
|
|
14
|
+
* column carries the raw TEXT value under SQLite's INTEGER-affinity
|
|
15
|
+
* coercion — which then sorts ABOVE every real integer (SQLite type
|
|
16
|
+
* ordering: NULL < INTEGER/REAL < TEXT < BLOB), so `{n: {gt: 100}}`
|
|
17
|
+
* incorrectly matches the poisoned row alongside genuine matches.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { describe, it, test, expect, beforeEach } from "bun:test";
|
|
21
|
+
import { Database } from "bun:sqlite";
|
|
22
|
+
import { SqliteStore } from "./store.js";
|
|
23
|
+
import { generateMcpTools } from "./mcp.js";
|
|
24
|
+
import { TransitionConflictError } from "./notes.js";
|
|
25
|
+
|
|
26
|
+
let store: SqliteStore;
|
|
27
|
+
let db: Database;
|
|
28
|
+
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
db = new Database(":memory:");
|
|
31
|
+
store = new SqliteStore(db);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("contract: typed indexes — passing (lock in current behavior)", () => {
|
|
35
|
+
it("an integer-indexed field with well-typed integer values answers gte/lt correctly", async () => {
|
|
36
|
+
await store.upsertTagRecord("metric", { fields: { n: { type: "integer", indexed: true } } });
|
|
37
|
+
const low = await store.createNote("n=5", { tags: ["metric"], metadata: { n: 5 } });
|
|
38
|
+
const mid = await store.createNote("n=50", { tags: ["metric"], metadata: { n: 50 } });
|
|
39
|
+
const high = await store.createNote("n=200", { tags: ["metric"], metadata: { n: 200 } });
|
|
40
|
+
|
|
41
|
+
const gte50 = await store.queryNotes({ tags: ["metric"], metadata: { n: { gte: 50 } } });
|
|
42
|
+
expect(new Set(gte50.map((n) => n.id))).toEqual(new Set([mid.id, high.id]));
|
|
43
|
+
|
|
44
|
+
const lt50 = await store.queryNotes({ tags: ["metric"], metadata: { n: { lt: 50 } } });
|
|
45
|
+
expect(new Set(lt50.map((n) => n.id))).toEqual(new Set([low.id]));
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("merge-patch metadata preserves untouched fields, updates named fields, and RFC-7386-deletes null fields", async () => {
|
|
49
|
+
const note = await store.createNote("has metadata", {
|
|
50
|
+
metadata: { a: 1, b: 2, c: 3 },
|
|
51
|
+
});
|
|
52
|
+
const tools = generateMcpTools(store);
|
|
53
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
54
|
+
|
|
55
|
+
const result: any = await updateNote.execute({
|
|
56
|
+
id: note.id,
|
|
57
|
+
metadata: { b: 20, c: null },
|
|
58
|
+
force: true,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
expect(result.metadata).toEqual({ a: 1, b: 20 });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("a state_transition compare-and-set conflicts when the note has already transitioned away from `from`", async () => {
|
|
65
|
+
const note = await store.createNote("workflow item", { metadata: { status: "draft" } });
|
|
66
|
+
|
|
67
|
+
const first = await store.updateNote(note.id, {
|
|
68
|
+
state_transition: { field: "status", from: "draft", to: "published" },
|
|
69
|
+
});
|
|
70
|
+
expect((first.metadata as any).status).toBe("published");
|
|
71
|
+
|
|
72
|
+
let err: any;
|
|
73
|
+
try {
|
|
74
|
+
// Same `from: "draft"` precondition — but the note is now "published",
|
|
75
|
+
// so this must conflict rather than silently no-op or overwrite.
|
|
76
|
+
await store.updateNote(note.id, {
|
|
77
|
+
state_transition: { field: "status", from: "draft", to: "archived" },
|
|
78
|
+
});
|
|
79
|
+
} catch (e) {
|
|
80
|
+
err = e;
|
|
81
|
+
}
|
|
82
|
+
expect(err).toBeInstanceOf(TransitionConflictError);
|
|
83
|
+
expect(err.field).toBe("status");
|
|
84
|
+
expect(err.expected_from).toBe("draft");
|
|
85
|
+
expect(err.current).toBe("published");
|
|
86
|
+
|
|
87
|
+
// The note's status is untouched by the rejected transition.
|
|
88
|
+
const after = await store.getNote(note.id);
|
|
89
|
+
expect((after!.metadata as any).status).toBe("published");
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("contract: typed indexes — todo (#553)", () => {
|
|
94
|
+
test.todo(
|
|
95
|
+
`#553: a write of a TEXT value to an indexed integer field is REJECTED (today: it is accepted with only an advisory type_mismatch warning, and the poisoned row then silently matches {gt: 100}-style range queries via SQLite's TEXT-sorts-above-INTEGER type-affinity ordering — reproduced live: metadata.n = "four" on an indexed integer field succeeds, and query {n: {gt: 100}} incorrectly returns it)`,
|
|
96
|
+
);
|
|
97
|
+
test.todo(
|
|
98
|
+
`#553: an unset enum field stays ABSENT — no first-value backfill — so exists:false correctly matches a note that never set the field (today: applySchemaDefaults in core/src/mcp.ts backfills the schema's first enum value onto every note that gains the tag, so "never set" is indistinguishable from "explicitly set to the default" and exists:false never matches)`,
|
|
99
|
+
);
|
|
100
|
+
test.todo(
|
|
101
|
+
`#553: update-tag reports ALL invalid fields in one call AND states explicitly that no changes were applied (today: the cross-tag type/indexed-flag validation loop in core/src/mcp.ts throws on the FIRST offending field and never reports the rest, and the thrown Error's message doesn't say whether the tag's other, valid fields were partially applied — reproduced live: declaring two invalid fields in one update-tag call surfaces only the first field's error)`,
|
|
102
|
+
);
|
|
103
|
+
test.todo(
|
|
104
|
+
`#553: the indexed-field type list is honest about what's actually indexable (core/src/mcp.ts's update-tag field-type description advertises "string, boolean, integer, number, array, object" but indexed-fields.ts's TYPE_MAP only supports string/integer/boolean — "number" and the container types are accepted as declared but silently un-indexable)`,
|
|
105
|
+
);
|
|
106
|
+
});
|
package/core/src/core.test.ts
CHANGED
|
@@ -4046,7 +4046,7 @@ describe("query-notes link expansion", async () => {
|
|
|
4046
4046
|
});
|
|
4047
4047
|
|
|
4048
4048
|
// ---------------------------------------------------------------------------
|
|
4049
|
-
// Tag hierarchy via tags.parent_names (post-v14,
|
|
4049
|
+
// Tag hierarchy via tags.parent_names (post-v14, docs/contracts/tag-data-model.md)
|
|
4050
4050
|
// ---------------------------------------------------------------------------
|
|
4051
4051
|
|
|
4052
4052
|
describe("tag hierarchy (tags.parent_names)", async () => {
|
|
@@ -5107,7 +5107,7 @@ describe("schema inheritance via parent_names (vault#270)", async () => {
|
|
|
5107
5107
|
});
|
|
5108
5108
|
});
|
|
5109
5109
|
|
|
5110
|
-
describe("expandTagsWithDescendants (tag-scoped tokens —
|
|
5110
|
+
describe("expandTagsWithDescendants (tag-scoped tokens — docs/contracts/tag-scoped-tokens.md)", async () => {
|
|
5111
5111
|
it("returns the union of root + every descendant per tags.parent_names", async () => {
|
|
5112
5112
|
await store.upsertTagRecord("health/food", { parent_names: ["health"] });
|
|
5113
5113
|
await store.upsertTagRecord("health/food/breakfast", { parent_names: ["health/food"] });
|
|
@@ -5143,10 +5143,10 @@ describe("expandTagsWithDescendants (tag-scoped tokens — patterns/tag-scoped-t
|
|
|
5143
5143
|
});
|
|
5144
5144
|
|
|
5145
5145
|
// ---------------------------------------------------------------------------
|
|
5146
|
-
// Tag record API —
|
|
5146
|
+
// Tag record API — docs/contracts/tag-data-model.md
|
|
5147
5147
|
// ---------------------------------------------------------------------------
|
|
5148
5148
|
|
|
5149
|
-
describe("tag record API (
|
|
5149
|
+
describe("tag record API (docs/contracts/tag-data-model.md)", async () => {
|
|
5150
5150
|
it("upsertTagRecord persists description + fields + relationships + parent_names", async () => {
|
|
5151
5151
|
await store.upsertTagRecord("project", {
|
|
5152
5152
|
description: "long-running deliverable",
|
|
@@ -5430,7 +5430,7 @@ describe("tag record API (patterns/tag-data-model.md)", async () => {
|
|
|
5430
5430
|
});
|
|
5431
5431
|
|
|
5432
5432
|
// ---------------------------------------------------------------------------
|
|
5433
|
-
// Schema migration v13 → v14 —
|
|
5433
|
+
// Schema migration v13 → v14 — docs/contracts/tag-data-model.md
|
|
5434
5434
|
// ---------------------------------------------------------------------------
|
|
5435
5435
|
|
|
5436
5436
|
describe("schema migration v13 → v14", async () => {
|
|
@@ -5809,7 +5809,7 @@ describe("schema migration v15 → v16", async () => {
|
|
|
5809
5809
|
});
|
|
5810
5810
|
|
|
5811
5811
|
// ---------------------------------------------------------------------------
|
|
5812
|
-
// Tag-scope auth post-v14 —
|
|
5812
|
+
// Tag-scope auth post-v14 — docs/contracts/tag-scoped-tokens.md
|
|
5813
5813
|
// ---------------------------------------------------------------------------
|
|
5814
5814
|
|
|
5815
5815
|
describe("tag-scope auth (post-v14 hierarchy)", async () => {
|
|
@@ -5824,7 +5824,7 @@ describe("tag-scope auth (post-v14 hierarchy)", async () => {
|
|
|
5824
5824
|
});
|
|
5825
5825
|
|
|
5826
5826
|
it("orphan sub-tag fallback: token for `health` still sees `#health/food` even with no declared hierarchy", async () => {
|
|
5827
|
-
// Per
|
|
5827
|
+
// Per docs/contracts/tag-scoped-tokens.md §Storage details, the auth check
|
|
5828
5828
|
// also splits on '/' and matches the root verbatim against the raw
|
|
5829
5829
|
// allowlist. This survives the v14 source-of-truth swap because the
|
|
5830
5830
|
// fallback lives in src/tag-scope.ts, not in the resolver.
|
package/core/src/mcp.ts
CHANGED
|
@@ -1408,7 +1408,7 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1408
1408
|
{
|
|
1409
1409
|
name: "update-tag",
|
|
1410
1410
|
requiredVerb: "write",
|
|
1411
|
-
description: "Create or update a tag's identity row: description, indexed-field schemas, relationship-vocabulary map, and hierarchy parents. If the tag doesn't exist, it's created. Fields are merged (new keys added, existing keys replaced); relationships and parent_names are replaced wholesale when provided. Pass null for fields/relationships/parent_names to clear that column. See parachute-
|
|
1411
|
+
description: "Create or update a tag's identity row: description, indexed-field schemas, relationship-vocabulary map, and hierarchy parents. If the tag doesn't exist, it's created. Fields are merged (new keys added, existing keys replaced); relationships and parent_names are replaced wholesale when provided. Pass null for fields/relationships/parent_names to clear that column. See parachute-vault/docs/contracts/tag-data-model.md.",
|
|
1412
1412
|
inputSchema: {
|
|
1413
1413
|
type: "object",
|
|
1414
1414
|
properties: {
|
package/core/src/schema.ts
CHANGED
|
@@ -41,7 +41,7 @@ CREATE TABLE IF NOT EXISTS notes (
|
|
|
41
41
|
|
|
42
42
|
-- Tags: first-class identity carrying schema, hierarchy, and typed-link
|
|
43
43
|
-- declarations. One row per tag; no notes-as-config sidecars for these
|
|
44
|
-
-- concerns. See
|
|
44
|
+
-- concerns. See docs/contracts/tag-data-model.md.
|
|
45
45
|
--
|
|
46
46
|
-- description — human-readable blurb (markdown).
|
|
47
47
|
-- fields — JSON: indexed metadata field declarations per
|
|
@@ -136,7 +136,7 @@ CREATE TABLE IF NOT EXISTS indexed_fields (
|
|
|
136
136
|
-- scoped_tags is a JSON-encoded array of root tag names that constrain the
|
|
137
137
|
-- token's effective access (intersection with the scopes column). NULL
|
|
138
138
|
-- means unscoped — full vault access per scopes. Introduced in v13 per
|
|
139
|
-
--
|
|
139
|
+
-- docs/contracts/tag-scoped-tokens.md. Hierarchy expansion is applied at auth
|
|
140
140
|
-- time via getTagDescendants; the column stores root names only.
|
|
141
141
|
--
|
|
142
142
|
-- vault_name (v16) binds the token to a single vault. NULL means the
|
|
@@ -443,7 +443,7 @@ export function initSchema(db: Database): void {
|
|
|
443
443
|
// Migrate v13 → v14: tag-data-model reshape. Augment `tags` row with
|
|
444
444
|
// description/fields/relationships/parent_names/timestamps; copy data
|
|
445
445
|
// from the v6-era tag_schemas sidecar and from `_tags/<name>` config
|
|
446
|
-
// notes; drop tag_schemas after copy. See
|
|
446
|
+
// notes; drop tag_schemas after copy. See docs/contracts/tag-data-model.md.
|
|
447
447
|
migrateToV14(db);
|
|
448
448
|
|
|
449
449
|
// Migrate v14 → v15: retire the `_schemas/<name>` and `_schema_defaults`
|
|
@@ -650,7 +650,7 @@ function migrateToV12(db: Database): void {
|
|
|
650
650
|
* (current full-vault behavior); a JSON array of root tag names narrows the
|
|
651
651
|
* token's access to notes carrying one of those tags or a sub-tag thereof
|
|
652
652
|
* (hierarchy expansion via getTagDescendants at auth time). See
|
|
653
|
-
*
|
|
653
|
+
* docs/contracts/tag-scoped-tokens.md.
|
|
654
654
|
*/
|
|
655
655
|
function migrateToV13(db: Database): void {
|
|
656
656
|
if (hasTable(db, "tokens") && !hasColumn(db, "tokens", "scoped_tags")) {
|
|
@@ -659,7 +659,7 @@ function migrateToV13(db: Database): void {
|
|
|
659
659
|
}
|
|
660
660
|
|
|
661
661
|
/**
|
|
662
|
-
* Migrate v13 → v14: tag-data-model reshape (
|
|
662
|
+
* Migrate v13 → v14: tag-data-model reshape (docs/contracts/tag-data-model.md).
|
|
663
663
|
*
|
|
664
664
|
* Augments the `tags` table with five new columns and one timestamp pair,
|
|
665
665
|
* then copies pre-existing data from two notes-as-config sidecars:
|