@openparachute/vault 0.6.3 → 0.6.4-rc.10
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 +46 -11
- package/core/src/attribution.test.ts +273 -0
- package/core/src/core.test.ts +126 -0
- package/core/src/cursor.ts +8 -0
- package/core/src/enforced-writes.test.ts +533 -0
- package/core/src/mcp.ts +235 -9
- package/core/src/migrate-tag-field.test.ts +471 -0
- package/core/src/migrate-tag-field.ts +638 -0
- package/core/src/notes.ts +280 -7
- package/core/src/query-operators.ts +117 -0
- package/core/src/schema-defaults.ts +162 -9
- package/core/src/schema.ts +61 -2
- package/core/src/store.ts +51 -19
- package/core/src/tag-schemas.ts +12 -0
- package/core/src/triggers-store.ts +6 -0
- package/core/src/types.ts +35 -14
- package/core/src/vault-projection.ts +19 -0
- package/package.json +1 -1
- package/src/admin-spa.test.ts +18 -5
- package/src/admin-spa.ts +24 -3
- package/src/attribution-threading.test.ts +350 -0
- package/src/auth.ts +82 -4
- package/src/cli.ts +345 -9
- package/src/config.ts +11 -0
- package/src/first-boot-create.test.ts +155 -0
- package/src/import-daemon-busy.test.ts +8 -17
- package/src/mcp-http.ts +27 -0
- package/src/mcp-tools.ts +31 -4
- package/src/mirror-credentials.test.ts +47 -0
- package/src/mirror-credentials.ts +33 -0
- package/src/mirror-history.test.ts +426 -0
- package/src/mirror-manager.ts +202 -22
- package/src/mirror-routes.test.ts +78 -0
- package/src/mirror-routes.ts +195 -1
- package/src/module-config.ts +46 -80
- package/src/routes.ts +209 -41
- package/src/routing.test.ts +115 -25
- package/src/routing.ts +64 -2
- package/src/scale.bench.test.ts +82 -0
- package/src/scopes.test.ts +24 -0
- package/src/scopes.ts +63 -0
- package/src/self-register.test.ts +5 -5
- package/src/self-register.ts +8 -3
- package/src/server.ts +58 -38
- package/src/subscribe.test.ts +23 -2
- package/src/test-support/spawn.ts +85 -0
- package/src/triggers-api.ts +24 -0
- package/src/triggers.test.ts +33 -0
- package/src/triggers.ts +17 -0
- package/src/vault-remove.test.ts +4 -5
- package/src/vault.test.ts +188 -17
package/README.md
CHANGED
|
@@ -273,6 +273,31 @@ description: |
|
|
|
273
273
|
|
|
274
274
|
Sent as the MCP server instruction at session start.
|
|
275
275
|
|
|
276
|
+
### Schema enforcement & state machines (vault#299)
|
|
277
|
+
|
|
278
|
+
Tag-field schemas (`update-tag`'s `fields`) are **advisory by default** — a violating write succeeds and the response carries `validation_status` warnings the agent can self-correct on. That's right for organic single-vault use. Once a vault has multiple writers (humans + agents), opt individual fields into **strict** enforcement:
|
|
279
|
+
|
|
280
|
+
```yaml
|
|
281
|
+
tag: concept-seed
|
|
282
|
+
fields:
|
|
283
|
+
state: { type: string, enum: [idea, drafted, scripted, produced, published, killed], strict: true }
|
|
284
|
+
title: { type: string, required: true, strict: true }
|
|
285
|
+
owners: { type: array, cardinality: many, strict: true }
|
|
286
|
+
priority: { type: number } # advisory (default)
|
|
287
|
+
notes: { type: string } # advisory — free-form stays free-form
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
`strict: true` flips **all** of that field's declared constraints together — `type` + `enum` + `required` + `cardinality` — from warnings to hard write rejections (all-or-nothing per field). A violating create/update is rejected with a single `schema_validation` error carrying **every** per-field violation (MCP `data.error_type: "schema_validation"` / HTTP 422), and nothing is written. Strictness is **per-field**, so a free-form `notes` field stays advisory on an otherwise-strict tag. Declared strict fields surface in `vault-info` and the connect-time brief so an agent knows the contract before writing.
|
|
291
|
+
|
|
292
|
+
**Migration bypass.** A token holding the `vault:migrate` scope (broad) or `vault:<name>:migrate` (narrowed) skips strict enforcement — for backfilling/migrating existing non-conforming notes. It's an orthogonal capability (an `admin` token does **not** bypass unless it also holds `migrate`), and every bypassed write is logged to the daemon's structured log (`[schema-bypass] {...}` with the actor/via and waived violations) for later audit.
|
|
293
|
+
|
|
294
|
+
**Compare-and-set state transitions.** Advance a state machine race-safely in one round trip instead of read → check → conditional update. `update-note`'s `state_transition: { field, from, to }` atomically sets `field` to `to` **only if** it currently equals `from`; otherwise it's rejected with a distinct `transition_conflict` error. A **missing field** counts as a conflict; pass `from: null` to match a field that is absent or explicitly null. A **transition-only** update needs no `if_updated_at`/`force` — the compare-and-set IS the precondition. Combinable with other field updates in the same call (they land in the same atomic UPDATE), but a combined call still needs `if_updated_at`/`force` for the *other* fields — the CAS only guards the transitioned field, not the rest of the merge.
|
|
295
|
+
|
|
296
|
+
```jsonc
|
|
297
|
+
// advance a seed from idea → drafted, race-safely
|
|
298
|
+
update-note { "id": "Seeds/my-idea", "state_transition": { "field": "state", "from": "idea", "to": "drafted" } }
|
|
299
|
+
```
|
|
300
|
+
|
|
276
301
|
## Features
|
|
277
302
|
|
|
278
303
|
### Wikilink auto-linking
|
|
@@ -321,7 +346,19 @@ triggers:
|
|
|
321
346
|
send: content
|
|
322
347
|
```
|
|
323
348
|
|
|
324
|
-
**Predicate fields**: `tags` (all must match), `has_content` (true/false), `missing_metadata` (keys that must be absent), `has_metadata` (keys that must be present).
|
|
349
|
+
**Predicate fields**: `tags` (all must match), `has_content` (true/false), `missing_metadata` (keys that must be absent), `has_metadata` (keys that must be present), and `metadata` (value-matched — a field → operator-object map using the same operators as `query-notes`: `eq/ne/gt/gte/lt/lte/in/not_in/exists`). All predicate fields are ANDed. Value-matching lets a trigger fire on a specific transition rather than every edit (vault#299):
|
|
350
|
+
|
|
351
|
+
```yaml
|
|
352
|
+
- name: announce-published
|
|
353
|
+
events: [updated]
|
|
354
|
+
when:
|
|
355
|
+
tags: [piece]
|
|
356
|
+
metadata:
|
|
357
|
+
state: { eq: published } # fire only when state reaches "published"
|
|
358
|
+
missing_metadata: [announced_at]
|
|
359
|
+
action:
|
|
360
|
+
webhook: https://example.com/announce
|
|
361
|
+
```
|
|
325
362
|
|
|
326
363
|
**Two-phase markers**: On match, the trigger sets `<name>_pending_at` metadata before calling the webhook, then replaces it with `<name>_rendered_at` on success. This prevents re-entry and concurrent runs.
|
|
327
364
|
|
|
@@ -549,16 +586,12 @@ Range params require content in the response — with `include_content=false` (o
|
|
|
549
586
|
|
|
550
587
|
### Incremental rebuilds: "what changed since X"
|
|
551
588
|
|
|
552
|
-
The SSG / sync pattern.
|
|
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.)
|
|
553
590
|
|
|
554
591
|
```bash
|
|
555
|
-
# Bracket-style (
|
|
592
|
+
# Bracket-style (the query-string date filter)
|
|
556
593
|
curl -H "Authorization: Bearer $VAULT_TOKEN" \
|
|
557
594
|
"http://localhost:1940/vault/default/api/notes?meta[updated_at][gte]=2026-04-01T00:00:00Z"
|
|
558
|
-
|
|
559
|
-
# Flat form (DEPRECATED in 0.4.3; planned removal in a later 0.x per vault#288)
|
|
560
|
-
curl -H "Authorization: Bearer $VAULT_TOKEN" \
|
|
561
|
-
"http://localhost:1940/vault/default/api/notes?date_field=updated_at&date_from=2026-04-01T00:00:00Z"
|
|
562
595
|
```
|
|
563
596
|
|
|
564
597
|
```jsonc
|
|
@@ -910,7 +943,7 @@ cp .env.example .env # edit with your config
|
|
|
910
943
|
docker compose up -d
|
|
911
944
|
```
|
|
912
945
|
|
|
913
|
-
|
|
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>`.
|
|
914
947
|
|
|
915
948
|
### Cloud platforms
|
|
916
949
|
|
|
@@ -935,9 +968,11 @@ typical v0.6 self-host wants. If you're not sure, use the hub-managed
|
|
|
935
968
|
path above. The standalone Blueprint stays in tree because some
|
|
936
969
|
operators specifically want this shape (vault#341).
|
|
937
970
|
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
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>`.
|
|
941
976
|
|
|
942
977
|
#### Other platforms
|
|
943
978
|
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Write-attribution (vault#298) — per-identity (`*_by`) + per-interface
|
|
3
|
+
* (`*_via`) provenance at the store layer.
|
|
4
|
+
*
|
|
5
|
+
* Covers the core contract:
|
|
6
|
+
* - create stamps BOTH axes onto created_* AND mirrors into last_updated_*
|
|
7
|
+
* - update bumps ONLY last_updated_* (created_* is set-once)
|
|
8
|
+
* - a `skipUpdatedAt` machine write does NOT claim authorship
|
|
9
|
+
* - no-attribution writes leave the columns NULL (never "")
|
|
10
|
+
* - the four query-notes filters (created_by / last_updated_by /
|
|
11
|
+
* created_via / last_updated_via)
|
|
12
|
+
* - legacy rows written before the v23 migration stay NULL and don't crash
|
|
13
|
+
* - the cursor binds the attribution filters (changing them invalidates it)
|
|
14
|
+
*
|
|
15
|
+
* The auth → AuthResult → MCP/REST threading is exercised separately in
|
|
16
|
+
* src/attribution-threading.test.ts (it needs the server-side auth context).
|
|
17
|
+
*/
|
|
18
|
+
import { describe, it, expect, beforeEach } from "bun:test";
|
|
19
|
+
import { Database } from "bun:sqlite";
|
|
20
|
+
import { SqliteStore } from "./store.js";
|
|
21
|
+
import { initSchema, SCHEMA_VERSION } from "./schema.js";
|
|
22
|
+
|
|
23
|
+
let store: SqliteStore;
|
|
24
|
+
let db: Database;
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
db = new Database(":memory:");
|
|
28
|
+
store = new SqliteStore(db);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
function rawRow(id: string) {
|
|
32
|
+
return db
|
|
33
|
+
.prepare(
|
|
34
|
+
"SELECT created_by, created_via, last_updated_by, last_updated_via FROM notes WHERE id = ?",
|
|
35
|
+
)
|
|
36
|
+
.get(id) as {
|
|
37
|
+
created_by: string | null;
|
|
38
|
+
created_via: string | null;
|
|
39
|
+
last_updated_by: string | null;
|
|
40
|
+
last_updated_via: string | null;
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe("write-attribution — schema", () => {
|
|
45
|
+
it("bumped SCHEMA_VERSION to 23", () => {
|
|
46
|
+
expect(SCHEMA_VERSION).toBe(23);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("the four columns exist and are indexed", () => {
|
|
50
|
+
const cols = (db.prepare("PRAGMA table_info(notes)").all() as { name: string }[]).map(
|
|
51
|
+
(r) => r.name,
|
|
52
|
+
);
|
|
53
|
+
expect(cols).toContain("created_by");
|
|
54
|
+
expect(cols).toContain("created_via");
|
|
55
|
+
expect(cols).toContain("last_updated_by");
|
|
56
|
+
expect(cols).toContain("last_updated_via");
|
|
57
|
+
|
|
58
|
+
const idx = (
|
|
59
|
+
db
|
|
60
|
+
.prepare("SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='notes'")
|
|
61
|
+
.all() as { name: string }[]
|
|
62
|
+
).map((r) => r.name);
|
|
63
|
+
expect(idx).toContain("idx_notes_created_by");
|
|
64
|
+
expect(idx).toContain("idx_notes_created_via");
|
|
65
|
+
expect(idx).toContain("idx_notes_last_updated_by");
|
|
66
|
+
expect(idx).toContain("idx_notes_last_updated_via");
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("write-attribution — per-identity (WHO) + per-interface (VIA)", () => {
|
|
71
|
+
it("create captures actor + via on BOTH the created_* and last_updated_* pairs", async () => {
|
|
72
|
+
const note = await store.createNote("hi", { actor: "aaron", via: "mcp" });
|
|
73
|
+
// Read-model surfaces both axes.
|
|
74
|
+
expect(note.createdBy).toBe("aaron");
|
|
75
|
+
expect(note.createdVia).toBe("mcp");
|
|
76
|
+
// First write IS the most-recent write — mirrored.
|
|
77
|
+
expect(note.lastUpdatedBy).toBe("aaron");
|
|
78
|
+
expect(note.lastUpdatedVia).toBe("mcp");
|
|
79
|
+
|
|
80
|
+
const row = rawRow(note.id);
|
|
81
|
+
expect(row.created_by).toBe("aaron");
|
|
82
|
+
expect(row.created_via).toBe("mcp");
|
|
83
|
+
expect(row.last_updated_by).toBe("aaron");
|
|
84
|
+
expect(row.last_updated_via).toBe("mcp");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("update bumps last_updated_* but leaves created_* untouched (set-once)", async () => {
|
|
88
|
+
const note = await store.createNote("v1", { actor: "aaron", via: "mcp" });
|
|
89
|
+
const updated = await store.updateNote(note.id, {
|
|
90
|
+
content: "v2",
|
|
91
|
+
actor: "mathilda",
|
|
92
|
+
via: "surface:meeting-ingest",
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// created_* pins the ORIGINAL author/channel forever.
|
|
96
|
+
expect(updated.createdBy).toBe("aaron");
|
|
97
|
+
expect(updated.createdVia).toBe("mcp");
|
|
98
|
+
// last_updated_* reflects the most recent writer.
|
|
99
|
+
expect(updated.lastUpdatedBy).toBe("mathilda");
|
|
100
|
+
expect(updated.lastUpdatedVia).toBe("surface:meeting-ingest");
|
|
101
|
+
|
|
102
|
+
const row = rawRow(note.id);
|
|
103
|
+
expect(row.created_by).toBe("aaron");
|
|
104
|
+
expect(row.last_updated_by).toBe("mathilda");
|
|
105
|
+
expect(row.last_updated_via).toBe("surface:meeting-ingest");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("append/prepend updates also record the most-recent author", async () => {
|
|
109
|
+
const note = await store.createNote("base", { actor: "aaron", via: "mcp" });
|
|
110
|
+
const after = await store.updateNote(note.id, {
|
|
111
|
+
append: " +more",
|
|
112
|
+
actor: "agent:nightly",
|
|
113
|
+
via: "agent:nightly",
|
|
114
|
+
});
|
|
115
|
+
expect(after.content).toBe("base +more");
|
|
116
|
+
expect(after.createdBy).toBe("aaron");
|
|
117
|
+
expect(after.lastUpdatedBy).toBe("agent:nightly");
|
|
118
|
+
expect(after.lastUpdatedVia).toBe("agent:nightly");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("a skipUpdatedAt machine write does NOT claim authorship", async () => {
|
|
122
|
+
const note = await store.createNote("x", { actor: "aaron", via: "mcp" });
|
|
123
|
+
const before = rawRow(note.id);
|
|
124
|
+
// A hook-style marker write: passes actor/via but skipUpdatedAt — should
|
|
125
|
+
// not bump updated_at NOR rewrite last_updated_*.
|
|
126
|
+
await store.updateNote(note.id, {
|
|
127
|
+
metadata: { marker: true },
|
|
128
|
+
skipUpdatedAt: true,
|
|
129
|
+
actor: "some-hook",
|
|
130
|
+
via: "operator",
|
|
131
|
+
});
|
|
132
|
+
const after = rawRow(note.id);
|
|
133
|
+
expect(after.last_updated_by).toBe(before.last_updated_by); // still "aaron"
|
|
134
|
+
expect(after.last_updated_by).toBe("aaron");
|
|
135
|
+
expect(after.last_updated_via).toBe("mcp");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("an attribution-less update honestly clears the most-recent author (NULL, not stale)", async () => {
|
|
139
|
+
const note = await store.createNote("x", { actor: "aaron", via: "mcp" });
|
|
140
|
+
// A real edit arriving with no attribution context — the latest writer is
|
|
141
|
+
// unknown; we must NOT leave "aaron" as the apparent last editor.
|
|
142
|
+
const after = await store.updateNote(note.id, { content: "y" });
|
|
143
|
+
expect(after.createdBy).toBe("aaron"); // original author preserved
|
|
144
|
+
expect(after.lastUpdatedBy).toBeNull();
|
|
145
|
+
expect(after.lastUpdatedVia).toBeNull();
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe("write-attribution — no context → NULL (never empty string)", () => {
|
|
150
|
+
it("create without actor/via leaves all four columns NULL", async () => {
|
|
151
|
+
const note = await store.createNote("anon");
|
|
152
|
+
const row = rawRow(note.id);
|
|
153
|
+
expect(row.created_by).toBeNull();
|
|
154
|
+
expect(row.created_via).toBeNull();
|
|
155
|
+
expect(row.last_updated_by).toBeNull();
|
|
156
|
+
expect(row.last_updated_via).toBeNull();
|
|
157
|
+
expect(note.createdBy).toBeNull();
|
|
158
|
+
expect(note.createdVia).toBeNull();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("empty / whitespace-only actor/via normalize to NULL", async () => {
|
|
162
|
+
const note = await store.createNote("e", { actor: " ", via: "" });
|
|
163
|
+
const row = rawRow(note.id);
|
|
164
|
+
expect(row.created_by).toBeNull();
|
|
165
|
+
expect(row.created_via).toBeNull();
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe("write-attribution — query filters", () => {
|
|
170
|
+
beforeEach(async () => {
|
|
171
|
+
await store.createNote("a", { actor: "aaron", via: "mcp" });
|
|
172
|
+
await store.createNote("b", { actor: "mathilda", via: "surface:meeting-ingest" });
|
|
173
|
+
const c = await store.createNote("c", { actor: "aaron", via: "api" });
|
|
174
|
+
// c's most-recent write is by the nightly agent via mcp.
|
|
175
|
+
await store.updateNote(c.id, { content: "c2", actor: "agent:nightly", via: "mcp" });
|
|
176
|
+
await store.createNote("legacy"); // no attribution
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("created_by selects only that principal's CREATED notes", async () => {
|
|
180
|
+
const aaron = await store.queryNotes({ createdBy: "aaron" });
|
|
181
|
+
expect(aaron.map((n) => n.content).sort()).toEqual(["a", "c2"]);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("last_updated_by selects by MOST-RECENT author (agent's edit on c)", async () => {
|
|
185
|
+
const byAgent = await store.queryNotes({ lastUpdatedBy: "agent:nightly" });
|
|
186
|
+
expect(byAgent.map((n) => n.content)).toEqual(["c2"]);
|
|
187
|
+
// aaron created c but is no longer its last editor.
|
|
188
|
+
const aaronLatest = await store.queryNotes({ lastUpdatedBy: "aaron" });
|
|
189
|
+
expect(aaronLatest.map((n) => n.content)).toEqual(["a"]);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("created_via / last_updated_via filter on the interface axis", async () => {
|
|
193
|
+
const viaSurface = await store.queryNotes({ createdVia: "surface:meeting-ingest" });
|
|
194
|
+
expect(viaSurface.map((n) => n.content)).toEqual(["b"]);
|
|
195
|
+
const lastViaMcp = await store.queryNotes({ lastUpdatedVia: "mcp" });
|
|
196
|
+
// "a" (created via mcp, never updated) + "c2" (last updated via mcp).
|
|
197
|
+
expect(lastViaMcp.map((n) => n.content).sort()).toEqual(["a", "c2"]);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("an attribution filter never matches legacy/unattributed (NULL) rows", async () => {
|
|
201
|
+
const any = await store.queryNotes({ createdBy: "aaron" });
|
|
202
|
+
expect(any.some((n) => n.content === "legacy")).toBe(false);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
describe("write-attribution — legacy backfill (v23 migration)", () => {
|
|
207
|
+
it("a pre-v23 vault: existing rows stay NULL, new rows attribute, no crash", () => {
|
|
208
|
+
// Build a v22-shaped notes table by hand (no attribution columns) +
|
|
209
|
+
// schema_version row, then run initSchema to drive the v23 migration.
|
|
210
|
+
const legacy = new Database(":memory:");
|
|
211
|
+
legacy.exec(`
|
|
212
|
+
CREATE TABLE notes (
|
|
213
|
+
id TEXT PRIMARY KEY,
|
|
214
|
+
content TEXT DEFAULT '',
|
|
215
|
+
path TEXT,
|
|
216
|
+
metadata TEXT DEFAULT '{}',
|
|
217
|
+
created_at TEXT NOT NULL,
|
|
218
|
+
updated_at TEXT,
|
|
219
|
+
extension TEXT NOT NULL DEFAULT 'md'
|
|
220
|
+
);
|
|
221
|
+
CREATE TABLE schema_version (version INTEGER PRIMARY KEY, applied_at TEXT);
|
|
222
|
+
INSERT INTO schema_version (version, applied_at) VALUES (22, '2026-01-01T00:00:00.000Z');
|
|
223
|
+
INSERT INTO notes (id, content, created_at, updated_at)
|
|
224
|
+
VALUES ('old-1', 'pre-existing', '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z');
|
|
225
|
+
`);
|
|
226
|
+
|
|
227
|
+
// Should migrate without throwing.
|
|
228
|
+
initSchema(legacy);
|
|
229
|
+
|
|
230
|
+
const ver = (legacy.prepare("SELECT MAX(version) AS v FROM schema_version").get() as {
|
|
231
|
+
v: number;
|
|
232
|
+
}).v;
|
|
233
|
+
expect(ver).toBe(23);
|
|
234
|
+
|
|
235
|
+
// Legacy row: attribution columns exist now but are NULL (not fabricated).
|
|
236
|
+
const old = legacy
|
|
237
|
+
.prepare("SELECT created_by, last_updated_by, created_via FROM notes WHERE id = ?")
|
|
238
|
+
.get("old-1") as { created_by: string | null; last_updated_by: string | null; created_via: string | null };
|
|
239
|
+
expect(old.created_by).toBeNull();
|
|
240
|
+
expect(old.last_updated_by).toBeNull();
|
|
241
|
+
expect(old.created_via).toBeNull();
|
|
242
|
+
|
|
243
|
+
// A NEW write on the migrated DB attributes correctly.
|
|
244
|
+
const migratedStore = new SqliteStore(legacy);
|
|
245
|
+
return migratedStore.createNote("fresh", { actor: "aaron", via: "mcp" }).then((n) => {
|
|
246
|
+
expect(n.createdBy).toBe("aaron");
|
|
247
|
+
expect(n.createdVia).toBe("mcp");
|
|
248
|
+
// The legacy row is still NULL and still queryable.
|
|
249
|
+
return migratedStore.queryNotes({ createdBy: "aaron" }).then((res) => {
|
|
250
|
+
expect(res.map((x) => x.content)).toEqual(["fresh"]);
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("re-running the v23 migration is idempotent (no duplicate-column error)", () => {
|
|
256
|
+
const again = new Database(":memory:");
|
|
257
|
+
const s = new SqliteStore(again); // runs migrations once
|
|
258
|
+
initSchema(again); // run again — must not throw
|
|
259
|
+
expect(() => s.createNote("ok", { actor: "x", via: "api" })).not.toThrow();
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
describe("write-attribution — cursor binding", () => {
|
|
264
|
+
it("changing an attribution filter between cursor polls invalidates the cursor", async () => {
|
|
265
|
+
await store.createNote("a", { actor: "aaron", via: "mcp" });
|
|
266
|
+
const page = await store.queryNotesPaged({ createdBy: "aaron" });
|
|
267
|
+
// Reusing the cursor under a DIFFERENT createdBy must be rejected, not
|
|
268
|
+
// silently continue aaron's watermark.
|
|
269
|
+
await expect(
|
|
270
|
+
store.queryNotesPaged({ createdBy: "mathilda", cursor: page.next_cursor }),
|
|
271
|
+
).rejects.toThrow(/different query/i);
|
|
272
|
+
});
|
|
273
|
+
});
|
package/core/src/core.test.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { generateMcpTools } from "./mcp.js";
|
|
|
5
5
|
import { initSchema } from "./schema.js";
|
|
6
6
|
import { decodeCursor } from "./cursor.js";
|
|
7
7
|
import { traverseLinks } from "./links.js";
|
|
8
|
+
import * as indexedFieldOps from "./indexed-fields.js";
|
|
8
9
|
|
|
9
10
|
let store: SqliteStore;
|
|
10
11
|
let db: Database;
|
|
@@ -2169,6 +2170,50 @@ describe("MCP tools", async () => {
|
|
|
2169
2170
|
expect(result.extension).toBe("csv");
|
|
2170
2171
|
});
|
|
2171
2172
|
|
|
2173
|
+
// ---- Metadata key-deletion via null tombstone (vault#478 / #479) ---------
|
|
2174
|
+
|
|
2175
|
+
it("update-note deletes a metadata key when its value is null (RFC 7386)", async () => {
|
|
2176
|
+
const note = await store.createNote("m", {
|
|
2177
|
+
metadata: { keep: "yes", drop: "old", other: 1 },
|
|
2178
|
+
});
|
|
2179
|
+
const tools = generateMcpTools(store);
|
|
2180
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
2181
|
+
await updateNote.execute({ id: note.id, metadata: { drop: null }, force: true });
|
|
2182
|
+
|
|
2183
|
+
const fresh = await store.getNote(note.id);
|
|
2184
|
+
// The deleted key is GONE — not a literal JSON null.
|
|
2185
|
+
expect(fresh!.metadata).not.toHaveProperty("drop");
|
|
2186
|
+
// Other keys are untouched.
|
|
2187
|
+
expect(fresh!.metadata).toMatchObject({ keep: "yes", other: 1 });
|
|
2188
|
+
});
|
|
2189
|
+
|
|
2190
|
+
it("update-note metadata key-rename in one call (new key set, old key deleted)", async () => {
|
|
2191
|
+
const note = await store.createNote("m", {
|
|
2192
|
+
metadata: { "old-key": "v", stable: true },
|
|
2193
|
+
});
|
|
2194
|
+
const tools = generateMcpTools(store);
|
|
2195
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
2196
|
+
await updateNote.execute({
|
|
2197
|
+
id: note.id,
|
|
2198
|
+
metadata: { new_key: "v", "old-key": null },
|
|
2199
|
+
force: true,
|
|
2200
|
+
});
|
|
2201
|
+
|
|
2202
|
+
const fresh = await store.getNote(note.id);
|
|
2203
|
+
expect(fresh!.metadata).not.toHaveProperty("old-key");
|
|
2204
|
+
expect(fresh!.metadata).toMatchObject({ new_key: "v", stable: true });
|
|
2205
|
+
});
|
|
2206
|
+
|
|
2207
|
+
it("deleting an absent metadata key is a no-op (round-trips cleanly)", async () => {
|
|
2208
|
+
const note = await store.createNote("m", { metadata: { a: 1 } });
|
|
2209
|
+
const tools = generateMcpTools(store);
|
|
2210
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
2211
|
+
await updateNote.execute({ id: note.id, metadata: { ghost: null }, force: true });
|
|
2212
|
+
const fresh = await store.getNote(note.id);
|
|
2213
|
+
expect(fresh!.metadata).toEqual({ a: 1 });
|
|
2214
|
+
expect(fresh!.metadata).not.toHaveProperty("ghost");
|
|
2215
|
+
});
|
|
2216
|
+
|
|
2172
2217
|
it("query-notes filters by extension (vault#328)", async () => {
|
|
2173
2218
|
await store.createNote("md note", { path: "a" });
|
|
2174
2219
|
await store.createNote("csv note", { path: "b", extension: "csv" });
|
|
@@ -5301,6 +5346,87 @@ describe("tag record API (patterns/tag-data-model.md)", async () => {
|
|
|
5301
5346
|
expect((await store.queryNotes({ tags: ["manual"] })).length).toBe(0);
|
|
5302
5347
|
expect(await store.getTagRecord("voice")).toBeNull();
|
|
5303
5348
|
});
|
|
5349
|
+
|
|
5350
|
+
// ---- Indexed-field atomicity (vault#478) ----------------------------------
|
|
5351
|
+
|
|
5352
|
+
it("upsertTagRecord with a bad indexed-field name throws IndexedFieldError and leaves the schema unchanged", async () => {
|
|
5353
|
+
// kebab-case field name violates [A-Za-z0-9_] / no-leading-digit. The
|
|
5354
|
+
// declaration must NOT persist, and no orphan/lying index may be created.
|
|
5355
|
+
await expect(
|
|
5356
|
+
store.upsertTagRecord("meeting", {
|
|
5357
|
+
description: "meetings",
|
|
5358
|
+
fields: { "meeting-type": { type: "string", indexed: true } },
|
|
5359
|
+
}),
|
|
5360
|
+
).rejects.toThrow(indexedFieldOps.IndexedFieldError);
|
|
5361
|
+
|
|
5362
|
+
// Schema is untouched — the tag row may not even exist, and definitely
|
|
5363
|
+
// doesn't claim the poisoned field.
|
|
5364
|
+
const record = await store.getTagRecord("meeting");
|
|
5365
|
+
expect(record?.fields?.["meeting-type"]).toBeUndefined();
|
|
5366
|
+
|
|
5367
|
+
// No backing index row was created for the bad field.
|
|
5368
|
+
expect(indexedFieldOps.getIndexedField(db, "meeting-type")).toBeNull();
|
|
5369
|
+
// And the generated column doesn't exist either.
|
|
5370
|
+
const cols = (db.prepare("PRAGMA table_xinfo(notes)").all() as { name: string }[]).map(
|
|
5371
|
+
(c) => c.name,
|
|
5372
|
+
);
|
|
5373
|
+
expect(cols).not.toContain("meta_meeting-type");
|
|
5374
|
+
});
|
|
5375
|
+
|
|
5376
|
+
it("upsertTagRecord with an unindexable type throws and leaves the schema unchanged", async () => {
|
|
5377
|
+
await expect(
|
|
5378
|
+
store.upsertTagRecord("meeting", {
|
|
5379
|
+
fields: { attendees: { type: "json", indexed: true } },
|
|
5380
|
+
}),
|
|
5381
|
+
).rejects.toThrow(indexedFieldOps.IndexedFieldError);
|
|
5382
|
+
const record = await store.getTagRecord("meeting");
|
|
5383
|
+
expect(record?.fields?.attendees).toBeUndefined();
|
|
5384
|
+
expect(indexedFieldOps.getIndexedField(db, "attendees")).toBeNull();
|
|
5385
|
+
});
|
|
5386
|
+
|
|
5387
|
+
it("a bad indexed field does NOT corrupt a prior valid declaration on the same tag", async () => {
|
|
5388
|
+
// First, a clean valid indexed field.
|
|
5389
|
+
await store.upsertTagRecord("meeting", {
|
|
5390
|
+
fields: { held_on: { type: "string", indexed: true } },
|
|
5391
|
+
});
|
|
5392
|
+
expect(indexedFieldOps.getIndexedField(db, "held_on")?.declarerTags).toContain("meeting");
|
|
5393
|
+
|
|
5394
|
+
// Now an update that adds a poisoned field. The whole write must roll
|
|
5395
|
+
// back — the prior valid field survives untouched.
|
|
5396
|
+
await expect(
|
|
5397
|
+
store.upsertTagRecord("meeting", {
|
|
5398
|
+
fields: {
|
|
5399
|
+
held_on: { type: "string", indexed: true },
|
|
5400
|
+
"bad-field": { type: "string", indexed: true },
|
|
5401
|
+
},
|
|
5402
|
+
}),
|
|
5403
|
+
).rejects.toThrow(indexedFieldOps.IndexedFieldError);
|
|
5404
|
+
|
|
5405
|
+
const record = await store.getTagRecord("meeting");
|
|
5406
|
+
expect(record?.fields?.held_on?.indexed).toBe(true);
|
|
5407
|
+
expect(record?.fields?.["bad-field"]).toBeUndefined();
|
|
5408
|
+
expect(indexedFieldOps.getIndexedField(db, "held_on")?.declarerTags).toContain("meeting");
|
|
5409
|
+
expect(indexedFieldOps.getIndexedField(db, "bad-field")).toBeNull();
|
|
5410
|
+
});
|
|
5411
|
+
|
|
5412
|
+
it("upsertTagRecord with fields:null releases indexed fields (transaction commits the release path)", async () => {
|
|
5413
|
+
// Declare an indexed field, then clear all fields — the release path runs
|
|
5414
|
+
// inside the same transaction as the persist. It must commit cleanly:
|
|
5415
|
+
// the row, generated column, and index all drop.
|
|
5416
|
+
await store.upsertTagRecord("meeting", {
|
|
5417
|
+
fields: { held_on: { type: "string", indexed: true } },
|
|
5418
|
+
});
|
|
5419
|
+
expect(indexedFieldOps.getIndexedField(db, "held_on")?.declarerTags).toContain("meeting");
|
|
5420
|
+
|
|
5421
|
+
await store.upsertTagRecord("meeting", { fields: null });
|
|
5422
|
+
const record = await store.getTagRecord("meeting");
|
|
5423
|
+
expect(record?.fields).toBeUndefined();
|
|
5424
|
+
expect(indexedFieldOps.getIndexedField(db, "held_on")).toBeNull();
|
|
5425
|
+
const cols = (db.prepare("PRAGMA table_xinfo(notes)").all() as { name: string }[]).map(
|
|
5426
|
+
(c) => c.name,
|
|
5427
|
+
);
|
|
5428
|
+
expect(cols).not.toContain("meta_held_on");
|
|
5429
|
+
});
|
|
5304
5430
|
});
|
|
5305
5431
|
|
|
5306
5432
|
// ---------------------------------------------------------------------------
|
package/core/src/cursor.ts
CHANGED
|
@@ -160,6 +160,14 @@ export interface QueryHashInputs {
|
|
|
160
160
|
extension?: string | string[];
|
|
161
161
|
ids?: string[];
|
|
162
162
|
metadata?: Record<string, unknown>;
|
|
163
|
+
// Write-attribution filters (vault#298) — part of the result-set-affecting
|
|
164
|
+
// opts, so they're bound into the cursor: changing "who/via" between polls
|
|
165
|
+
// invalidates the cursor with cursor_query_mismatch rather than silently
|
|
166
|
+
// continuing the prior filter's watermark.
|
|
167
|
+
createdBy?: string;
|
|
168
|
+
lastUpdatedBy?: string;
|
|
169
|
+
createdVia?: string;
|
|
170
|
+
lastUpdatedVia?: string;
|
|
163
171
|
dateFrom?: string;
|
|
164
172
|
dateTo?: string;
|
|
165
173
|
dateFilter?: { field?: string; from?: string; to?: string };
|