@openparachute/vault 0.6.4-rc.1 → 0.6.4-rc.3
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 +38 -1
- package/core/src/attribution.test.ts +273 -0
- package/core/src/cursor.ts +8 -0
- package/core/src/enforced-writes.test.ts +533 -0
- package/core/src/mcp.ts +227 -5
- package/core/src/notes.ts +243 -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 +4 -1
- 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/attribution-threading.test.ts +350 -0
- package/src/auth.ts +82 -4
- package/src/config.ts +11 -0
- package/src/mcp-http.ts +27 -0
- package/src/mcp-tools.ts +31 -4
- package/src/routes.ts +162 -3
- package/src/routing.ts +18 -2
- package/src/scopes.test.ts +24 -0
- package/src/scopes.ts +63 -0
- package/src/triggers-api.ts +24 -0
- package/src/triggers.test.ts +33 -0
- package/src/triggers.ts +17 -0
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Enforced writes — vault#299.
|
|
3
|
+
*
|
|
4
|
+
* Part A: opt-in field-level strict schema validation (enum / required /
|
|
5
|
+
* type / cardinality reject under strict; advisory unchanged; migration
|
|
6
|
+
* bypass writes through AND logs).
|
|
7
|
+
*
|
|
8
|
+
* Part B: compare-and-set state-transition (`state_transition`) +
|
|
9
|
+
* `transition_conflict`; value-matched trigger operators (`matchesOperator`).
|
|
10
|
+
*
|
|
11
|
+
* Plus a state-machine cookbook test wiring all three together.
|
|
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 {
|
|
18
|
+
generateMcpTools,
|
|
19
|
+
SchemaValidationError,
|
|
20
|
+
TransitionConflictError,
|
|
21
|
+
enforceStrictWrite,
|
|
22
|
+
} from "./mcp.js";
|
|
23
|
+
import {
|
|
24
|
+
loadSchemaConfig,
|
|
25
|
+
validateNote,
|
|
26
|
+
strictViolations,
|
|
27
|
+
enforceStrictSchema,
|
|
28
|
+
} from "./schema-defaults.js";
|
|
29
|
+
import { matchesOperator } from "./query-operators.js";
|
|
30
|
+
import * as noteOps from "./notes.js";
|
|
31
|
+
|
|
32
|
+
let store: SqliteStore;
|
|
33
|
+
let db: Database;
|
|
34
|
+
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
db = new Database(":memory:");
|
|
37
|
+
store = new SqliteStore(db);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
/** Find an MCP tool's execute by name. */
|
|
41
|
+
function tool(name: string, opts?: Parameters<typeof generateMcpTools>[1]) {
|
|
42
|
+
return generateMcpTools(store, opts).find((t) => t.name === name)!;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// Part A — validateNote: required / cardinality / strict flag
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
describe("validateNote — required + cardinality + strict flag (vault#299)", () => {
|
|
50
|
+
beforeEach(async () => {
|
|
51
|
+
await store.upsertTagRecord("piece", {
|
|
52
|
+
fields: {
|
|
53
|
+
state: { type: "string", enum: ["idea", "published"], strict: true },
|
|
54
|
+
priority: { type: "number" }, // advisory
|
|
55
|
+
owners: { type: "array", cardinality: "many", strict: true },
|
|
56
|
+
title: { type: "string", required: true, strict: true },
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("flags missing_required and marks it strict", () => {
|
|
62
|
+
const resolved = loadSchemaConfig(db);
|
|
63
|
+
const status = validateNote(resolved, { tags: ["piece"], metadata: { state: "idea", owners: ["a"] } });
|
|
64
|
+
const missing = status!.warnings.find((w) => w.reason === "missing_required");
|
|
65
|
+
expect(missing).toBeDefined();
|
|
66
|
+
expect(missing!.field).toBe("title");
|
|
67
|
+
expect(missing!.strict).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("flags cardinality_mismatch when a 'many' field gets a scalar", () => {
|
|
71
|
+
const resolved = loadSchemaConfig(db);
|
|
72
|
+
const status = validateNote(resolved, {
|
|
73
|
+
tags: ["piece"],
|
|
74
|
+
metadata: { state: "idea", title: "t", owners: "not-an-array" },
|
|
75
|
+
});
|
|
76
|
+
const card = status!.warnings.find((w) => w.reason === "cardinality_mismatch");
|
|
77
|
+
expect(card).toBeDefined();
|
|
78
|
+
expect(card!.field).toBe("owners");
|
|
79
|
+
expect(card!.strict).toBe(true);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("advisory (non-strict) fields don't carry strict:true", () => {
|
|
83
|
+
const resolved = loadSchemaConfig(db);
|
|
84
|
+
const status = validateNote(resolved, {
|
|
85
|
+
tags: ["piece"],
|
|
86
|
+
metadata: { state: "idea", title: "t", owners: ["a"], priority: "high" },
|
|
87
|
+
});
|
|
88
|
+
const tm = status!.warnings.find((w) => w.field === "priority");
|
|
89
|
+
expect(tm).toBeDefined();
|
|
90
|
+
expect(tm!.reason).toBe("type_mismatch");
|
|
91
|
+
expect(tm!.strict).toBeUndefined();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("strictViolations excludes advisory + conflict warnings", () => {
|
|
95
|
+
const resolved = loadSchemaConfig(db);
|
|
96
|
+
const status = validateNote(resolved, {
|
|
97
|
+
tags: ["piece"],
|
|
98
|
+
metadata: { state: "bogus", title: "t", owners: ["a"], priority: "high" },
|
|
99
|
+
});
|
|
100
|
+
const strict = strictViolations(status);
|
|
101
|
+
// enum violation on `state` is strict; priority type_mismatch is advisory.
|
|
102
|
+
expect(strict.map((v) => v.field).sort()).toEqual(["state"]);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("enforceStrictSchema throws on strict violation, passes when clean", () => {
|
|
106
|
+
const resolved = loadSchemaConfig(db);
|
|
107
|
+
expect(() =>
|
|
108
|
+
enforceStrictSchema(resolved, { tags: ["piece"], metadata: { state: "idea", owners: ["a"] } }),
|
|
109
|
+
).toThrow(SchemaValidationError);
|
|
110
|
+
// Clean note: no throw.
|
|
111
|
+
const ok = enforceStrictSchema(resolved, {
|
|
112
|
+
tags: ["piece"],
|
|
113
|
+
metadata: { state: "idea", title: "t", owners: ["a"] },
|
|
114
|
+
});
|
|
115
|
+
expect(ok.violations.length).toBe(0);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("enforceStrictSchema bypass:true skips the throw", () => {
|
|
119
|
+
const resolved = loadSchemaConfig(db);
|
|
120
|
+
const res = enforceStrictSchema(
|
|
121
|
+
resolved,
|
|
122
|
+
{ tags: ["piece"], metadata: { state: "idea", owners: ["a"] } },
|
|
123
|
+
{ bypass: true },
|
|
124
|
+
);
|
|
125
|
+
expect(res.violations.length).toBeGreaterThan(0); // would-be violations reported
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
// Part A — write-path enforcement through MCP tools
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
describe("strict enforcement on the MCP write path (vault#299)", () => {
|
|
134
|
+
beforeEach(async () => {
|
|
135
|
+
await store.upsertTagRecord("piece", {
|
|
136
|
+
fields: {
|
|
137
|
+
state: { type: "string", enum: ["idea", "drafted", "published"], strict: true },
|
|
138
|
+
count: { type: "integer", strict: true },
|
|
139
|
+
title: { type: "string", required: true, strict: true },
|
|
140
|
+
owners: { type: "array", cardinality: "many", strict: true },
|
|
141
|
+
notes: { type: "string" }, // advisory free-form on a strict tag
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("rejects an enum violation on create", async () => {
|
|
147
|
+
const create = tool("create-note");
|
|
148
|
+
await expect(
|
|
149
|
+
create.execute({ content: "x", tags: ["piece"], metadata: { state: "bogus", title: "t", owners: [] } }),
|
|
150
|
+
).rejects.toThrow(SchemaValidationError);
|
|
151
|
+
// Nothing written.
|
|
152
|
+
expect((await store.listTags()).find((t) => t.name === "piece")!.count).toBe(0);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("rejects a missing-required violation on create", async () => {
|
|
156
|
+
const create = tool("create-note");
|
|
157
|
+
await expect(
|
|
158
|
+
create.execute({ content: "x", tags: ["piece"], metadata: { state: "idea", owners: [] } }),
|
|
159
|
+
).rejects.toThrow(SchemaValidationError);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("rejects a type-mismatch violation on create", async () => {
|
|
163
|
+
const create = tool("create-note");
|
|
164
|
+
await expect(
|
|
165
|
+
create.execute({
|
|
166
|
+
content: "x",
|
|
167
|
+
tags: ["piece"],
|
|
168
|
+
metadata: { state: "idea", title: "t", count: "five", owners: [] },
|
|
169
|
+
}),
|
|
170
|
+
).rejects.toThrow(SchemaValidationError);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("rejects a cardinality violation on create", async () => {
|
|
174
|
+
const create = tool("create-note");
|
|
175
|
+
await expect(
|
|
176
|
+
create.execute({
|
|
177
|
+
content: "x",
|
|
178
|
+
tags: ["piece"],
|
|
179
|
+
metadata: { state: "idea", title: "t", owners: "solo" },
|
|
180
|
+
}),
|
|
181
|
+
).rejects.toThrow(SchemaValidationError);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("carries ALL per-field violations in one SchemaValidationError", async () => {
|
|
185
|
+
const create = tool("create-note");
|
|
186
|
+
let err: SchemaValidationError | null = null;
|
|
187
|
+
try {
|
|
188
|
+
await create.execute({ content: "x", tags: ["piece"], metadata: { state: "bogus", count: "nope" } });
|
|
189
|
+
} catch (e) {
|
|
190
|
+
err = e as SchemaValidationError;
|
|
191
|
+
}
|
|
192
|
+
expect(err).toBeInstanceOf(SchemaValidationError);
|
|
193
|
+
const fields = err!.violations.map((v) => v.field).sort();
|
|
194
|
+
// state (enum) + count (type) + title (missing required). `owners` is
|
|
195
|
+
// strict+cardinality but NOT required, so an absent owners is no violation.
|
|
196
|
+
expect(fields).toEqual(["count", "state", "title"]);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("accepts a fully-conforming note; advisory free-form field is fine", async () => {
|
|
200
|
+
const create = tool("create-note");
|
|
201
|
+
const note = (await create.execute({
|
|
202
|
+
content: "x",
|
|
203
|
+
tags: ["piece"],
|
|
204
|
+
metadata: { state: "idea", title: "t", count: 3, owners: ["me"], notes: "anything goes here" },
|
|
205
|
+
})) as any;
|
|
206
|
+
expect(note.metadata.state).toBe("idea");
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("rejects a strict violation introduced on update", async () => {
|
|
210
|
+
const create = tool("create-note");
|
|
211
|
+
const update = tool("update-note");
|
|
212
|
+
const note = (await create.execute({
|
|
213
|
+
content: "x",
|
|
214
|
+
tags: ["piece"],
|
|
215
|
+
metadata: { state: "idea", title: "t", owners: ["me"] },
|
|
216
|
+
})) as any;
|
|
217
|
+
await expect(
|
|
218
|
+
update.execute({ id: note.id, metadata: { state: "bogus" }, if_updated_at: note.updatedAt }),
|
|
219
|
+
).rejects.toThrow(SchemaValidationError);
|
|
220
|
+
// Untouched.
|
|
221
|
+
const fresh = await store.getNote(note.id);
|
|
222
|
+
expect((fresh!.metadata as any).state).toBe("idea");
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
describe("advisory (strict:false) behavior is unchanged (vault#299)", () => {
|
|
227
|
+
beforeEach(async () => {
|
|
228
|
+
await store.upsertTagRecord("task", {
|
|
229
|
+
fields: {
|
|
230
|
+
status: { type: "string", enum: ["open", "done"] }, // advisory — no strict
|
|
231
|
+
priority: { type: "number" },
|
|
232
|
+
},
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("writes a non-conforming note through and surfaces validation_status warnings", async () => {
|
|
237
|
+
const create = tool("create-note");
|
|
238
|
+
const note = (await create.execute({
|
|
239
|
+
content: "x",
|
|
240
|
+
tags: ["task"],
|
|
241
|
+
metadata: { status: "bogus", priority: "high" },
|
|
242
|
+
})) as any;
|
|
243
|
+
// Write succeeded.
|
|
244
|
+
expect(note.metadata.status).toBe("bogus");
|
|
245
|
+
// Advisory warnings present.
|
|
246
|
+
expect(note.validation_status).toBeDefined();
|
|
247
|
+
const reasons = note.validation_status.warnings.map((w: any) => w.reason).sort();
|
|
248
|
+
expect(reasons).toContain("enum_mismatch");
|
|
249
|
+
expect(reasons).toContain("type_mismatch");
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// ---------------------------------------------------------------------------
|
|
254
|
+
// Part A — migration bypass + logging
|
|
255
|
+
// ---------------------------------------------------------------------------
|
|
256
|
+
|
|
257
|
+
describe("migration-bypass scope writes through AND logs (vault#299)", () => {
|
|
258
|
+
beforeEach(async () => {
|
|
259
|
+
await store.upsertTagRecord("piece", {
|
|
260
|
+
fields: {
|
|
261
|
+
state: { type: "string", enum: ["idea", "published"], strict: true },
|
|
262
|
+
title: { type: "string", required: true, strict: true },
|
|
263
|
+
},
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("enforceStrictWrite with bypass:false throws and never logs", () => {
|
|
268
|
+
const calls: any[] = [];
|
|
269
|
+
expect(() =>
|
|
270
|
+
enforceStrictWrite(
|
|
271
|
+
store,
|
|
272
|
+
{ tags: ["piece"], metadata: { state: "bogus" } },
|
|
273
|
+
{ bypass: false, onBypass: (v) => calls.push(v) },
|
|
274
|
+
),
|
|
275
|
+
).toThrow(SchemaValidationError);
|
|
276
|
+
expect(calls.length).toBe(0);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("enforceStrictWrite with bypass:true writes through and logs the would-be violations", () => {
|
|
280
|
+
const logged: any[] = [];
|
|
281
|
+
const violations = enforceStrictWrite(
|
|
282
|
+
store,
|
|
283
|
+
{ tags: ["piece"], metadata: { state: "bogus" } },
|
|
284
|
+
{ bypass: true, onBypass: (v) => logged.push(v) },
|
|
285
|
+
);
|
|
286
|
+
expect(violations.length).toBeGreaterThan(0);
|
|
287
|
+
expect(logged.length).toBe(1);
|
|
288
|
+
// The log carries the field+reason of every waived violation.
|
|
289
|
+
const reasons = logged[0].map((v: any) => v.reason).sort();
|
|
290
|
+
expect(reasons).toContain("enum_mismatch");
|
|
291
|
+
expect(reasons).toContain("missing_required");
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("MCP tools with strictBypass:true write non-conforming notes + fire onStrictBypass", async () => {
|
|
295
|
+
const logged: any[] = [];
|
|
296
|
+
const create = generateMcpTools(store, {
|
|
297
|
+
strictBypass: true,
|
|
298
|
+
onStrictBypass: (info) => logged.push(info),
|
|
299
|
+
writeContext: { actor: "migrator", via: "operator" },
|
|
300
|
+
}).find((t) => t.name === "create-note")!;
|
|
301
|
+
const note = (await create.execute({
|
|
302
|
+
content: "legacy row",
|
|
303
|
+
tags: ["piece"],
|
|
304
|
+
metadata: { state: "bogus" },
|
|
305
|
+
})) as any;
|
|
306
|
+
// Written despite violations.
|
|
307
|
+
expect(note.metadata.state).toBe("bogus");
|
|
308
|
+
// Logged with attribution (MW1 actor/via).
|
|
309
|
+
expect(logged.length).toBe(1);
|
|
310
|
+
expect(logged[0].actor).toBe("migrator");
|
|
311
|
+
expect(logged[0].via).toBe("operator");
|
|
312
|
+
expect(logged[0].violations.length).toBeGreaterThan(0);
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
// ---------------------------------------------------------------------------
|
|
317
|
+
// Part B — state_transition compare-and-set
|
|
318
|
+
// ---------------------------------------------------------------------------
|
|
319
|
+
|
|
320
|
+
describe("state_transition compare-and-set (vault#299 Part B)", () => {
|
|
321
|
+
async function seed(state: string) {
|
|
322
|
+
const note = noteOps.createNote(db, "body", { metadata: { state } });
|
|
323
|
+
return note;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
it("transitions when current == from", () => {
|
|
327
|
+
const note = noteOps.createNote(db, "b", { metadata: { state: "draft" } });
|
|
328
|
+
const updated = noteOps.updateNote(db, note.id, {
|
|
329
|
+
state_transition: { field: "state", from: "draft", to: "published" },
|
|
330
|
+
});
|
|
331
|
+
expect((updated.metadata as any).state).toBe("published");
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it("conflicts when current != from", () => {
|
|
335
|
+
const note = noteOps.createNote(db, "b", { metadata: { state: "draft" } });
|
|
336
|
+
expect(() =>
|
|
337
|
+
noteOps.updateNote(db, note.id, {
|
|
338
|
+
state_transition: { field: "state", from: "published", to: "archived" },
|
|
339
|
+
}),
|
|
340
|
+
).toThrow(TransitionConflictError);
|
|
341
|
+
// Unchanged.
|
|
342
|
+
expect((noteOps.getNote(db, note.id)!.metadata as any).state).toBe("draft");
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it("missing field counts as a conflict", () => {
|
|
346
|
+
const note = noteOps.createNote(db, "b", { metadata: {} });
|
|
347
|
+
let err: TransitionConflictError | null = null;
|
|
348
|
+
try {
|
|
349
|
+
noteOps.updateNote(db, note.id, {
|
|
350
|
+
state_transition: { field: "state", from: "draft", to: "published" },
|
|
351
|
+
});
|
|
352
|
+
} catch (e) {
|
|
353
|
+
err = e as TransitionConflictError;
|
|
354
|
+
}
|
|
355
|
+
expect(err).toBeInstanceOf(TransitionConflictError);
|
|
356
|
+
expect(err!.code).toBe("TRANSITION_CONFLICT");
|
|
357
|
+
expect(err!.field).toBe("state");
|
|
358
|
+
expect(err!.current).toBeUndefined();
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it("combines with other field updates in the same write (raw layer)", () => {
|
|
362
|
+
const note = noteOps.createNote(db, "b", { metadata: { state: "draft", views: 1 } });
|
|
363
|
+
const updated = noteOps.updateNote(db, note.id, {
|
|
364
|
+
metadata: { state: "draft", views: 99, extra: "added" },
|
|
365
|
+
state_transition: { field: "state", from: "draft", to: "published" },
|
|
366
|
+
});
|
|
367
|
+
const meta = updated.metadata as any;
|
|
368
|
+
expect(meta.state).toBe("published"); // transition wins for the state field
|
|
369
|
+
expect(meta.views).toBe(99); // other merged field landed
|
|
370
|
+
expect(meta.extra).toBe("added");
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it("combines with other field updates through the MCP layer (needs if_updated_at for the OTHER fields)", async () => {
|
|
374
|
+
const create = tool("create-note");
|
|
375
|
+
const update = tool("update-note");
|
|
376
|
+
const note = (await create.execute({ content: "b", metadata: { state: "draft", views: 1 } })) as any;
|
|
377
|
+
const updated = (await update.execute({
|
|
378
|
+
id: note.id,
|
|
379
|
+
metadata: { views: 99, extra: "added" },
|
|
380
|
+
state_transition: { field: "state", from: "draft", to: "published" },
|
|
381
|
+
if_updated_at: note.updatedAt, // combined write still needs the OC token for the merge
|
|
382
|
+
})) as any;
|
|
383
|
+
expect(updated.metadata.state).toBe("published");
|
|
384
|
+
expect(updated.metadata.views).toBe(99);
|
|
385
|
+
expect(updated.metadata.extra).toBe("added");
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it("from: null matches an absent/null field; non-null from conflicts on it", () => {
|
|
389
|
+
// Absent field + from:null → transition commits.
|
|
390
|
+
const a = noteOps.createNote(db, "b", { metadata: {} });
|
|
391
|
+
const a2 = noteOps.updateNote(db, a.id, { state_transition: { field: "phase", from: null, to: "start" } });
|
|
392
|
+
expect((a2.metadata as any).phase).toBe("start");
|
|
393
|
+
// A non-null field + from:null → conflict (it's neither absent nor null).
|
|
394
|
+
const c = noteOps.createNote(db, "b", { metadata: { phase: "running" } });
|
|
395
|
+
expect(() =>
|
|
396
|
+
noteOps.updateNote(db, c.id, { state_transition: { field: "phase", from: null, to: "start" } }),
|
|
397
|
+
).toThrow(TransitionConflictError);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it("a losing concurrent transitioner sees the conflict (CAS race)", () => {
|
|
401
|
+
const note = noteOps.createNote(db, "b", { metadata: { state: "draft" } });
|
|
402
|
+
// First transition wins.
|
|
403
|
+
noteOps.updateNote(db, note.id, { state_transition: { field: "state", from: "draft", to: "published" } });
|
|
404
|
+
// Second observer still thinks it's "draft" → conflict.
|
|
405
|
+
expect(() =>
|
|
406
|
+
noteOps.updateNote(db, note.id, { state_transition: { field: "state", from: "draft", to: "archived" } }),
|
|
407
|
+
).toThrow(TransitionConflictError);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
it("transition_conflict is a distinct code from CONFLICT (if_updated_at)", () => {
|
|
411
|
+
const note = noteOps.createNote(db, "b", { metadata: { state: "draft" } });
|
|
412
|
+
let code: string | undefined;
|
|
413
|
+
try {
|
|
414
|
+
noteOps.updateNote(db, note.id, { state_transition: { field: "state", from: "X", to: "Y" } });
|
|
415
|
+
} catch (e) {
|
|
416
|
+
code = (e as any).code;
|
|
417
|
+
}
|
|
418
|
+
expect(code).toBe("TRANSITION_CONFLICT");
|
|
419
|
+
expect(code).not.toBe("CONFLICT");
|
|
420
|
+
});
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
// ---------------------------------------------------------------------------
|
|
424
|
+
// Part B — value-matched operator engine
|
|
425
|
+
// ---------------------------------------------------------------------------
|
|
426
|
+
|
|
427
|
+
describe("matchesOperator — in-memory value matching (vault#299)", () => {
|
|
428
|
+
it("eq matches and rejects", () => {
|
|
429
|
+
expect(matchesOperator("state", "published", { eq: "published" })).toBe(true);
|
|
430
|
+
expect(matchesOperator("state", "draft", { eq: "published" })).toBe(false);
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it("in matches membership", () => {
|
|
434
|
+
expect(matchesOperator("state", "published", { in: ["queued", "published"] })).toBe(true);
|
|
435
|
+
expect(matchesOperator("state", "draft", { in: ["queued", "published"] })).toBe(false);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it("absent field never matches eq/in", () => {
|
|
439
|
+
expect(matchesOperator("state", undefined, { eq: "published" })).toBe(false);
|
|
440
|
+
expect(matchesOperator("state", undefined, { in: ["published"] })).toBe(false);
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
it("ne / not_in treat absent as a match (SQL NULL parity)", () => {
|
|
444
|
+
expect(matchesOperator("state", undefined, { ne: "published" })).toBe(true);
|
|
445
|
+
expect(matchesOperator("state", undefined, { not_in: ["published"] })).toBe(true);
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it("numeric comparisons + cross-type loose eq", () => {
|
|
449
|
+
expect(matchesOperator("n", 5, { gt: 3, lt: 10 })).toBe(true);
|
|
450
|
+
expect(matchesOperator("n", 5, { gt: 5 })).toBe(false);
|
|
451
|
+
expect(matchesOperator("n", "5", { eq: 5 })).toBe(true); // round-tripped JSON shape
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it("exists", () => {
|
|
455
|
+
expect(matchesOperator("x", "v", { exists: true })).toBe(true);
|
|
456
|
+
expect(matchesOperator("x", undefined, { exists: false })).toBe(true);
|
|
457
|
+
expect(matchesOperator("x", undefined, { exists: true })).toBe(false);
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it("unknown operator throws", () => {
|
|
461
|
+
expect(() => matchesOperator("x", "v", { equals: "v" })).toThrow();
|
|
462
|
+
});
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
// ---------------------------------------------------------------------------
|
|
466
|
+
// Cookbook — a full state machine (strict enum + state_transition)
|
|
467
|
+
// ---------------------------------------------------------------------------
|
|
468
|
+
|
|
469
|
+
describe("cookbook: content state machine (vault#299)", () => {
|
|
470
|
+
it("strict enum state + advance via state_transition; bad target rejected; bad transition conflicts", async () => {
|
|
471
|
+
// 1. Declare a strict enum state field.
|
|
472
|
+
await store.upsertTagRecord("concept-seed", {
|
|
473
|
+
fields: {
|
|
474
|
+
state: {
|
|
475
|
+
type: "string",
|
|
476
|
+
enum: ["idea", "drafted", "scripted", "produced", "published", "killed"],
|
|
477
|
+
strict: true,
|
|
478
|
+
},
|
|
479
|
+
title: { type: "string", required: true, strict: true },
|
|
480
|
+
},
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
const create = tool("create-note");
|
|
484
|
+
const update = tool("update-note");
|
|
485
|
+
|
|
486
|
+
// 2. Create a conforming seed.
|
|
487
|
+
const seed = (await create.execute({
|
|
488
|
+
content: "a content idea",
|
|
489
|
+
path: "Seeds/my-idea",
|
|
490
|
+
tags: ["concept-seed"],
|
|
491
|
+
metadata: { state: "idea", title: "My idea" },
|
|
492
|
+
})) as any;
|
|
493
|
+
expect(seed.metadata.state).toBe("idea");
|
|
494
|
+
|
|
495
|
+
// 3. Advance it race-safely with a value-matched compare-and-set.
|
|
496
|
+
const advanced = (await update.execute({
|
|
497
|
+
id: seed.id,
|
|
498
|
+
state_transition: { field: "state", from: "idea", to: "drafted" },
|
|
499
|
+
})) as any;
|
|
500
|
+
expect(advanced.metadata.state).toBe("drafted");
|
|
501
|
+
|
|
502
|
+
// 4. A transition to a non-enum value is rejected by strict validation.
|
|
503
|
+
await expect(
|
|
504
|
+
update.execute({ id: seed.id, state_transition: { field: "state", from: "drafted", to: "bogus" } }),
|
|
505
|
+
).rejects.toThrow(SchemaValidationError);
|
|
506
|
+
|
|
507
|
+
// 5. A transition from the wrong current value conflicts.
|
|
508
|
+
await expect(
|
|
509
|
+
update.execute({ id: seed.id, state_transition: { field: "state", from: "idea", to: "scripted" } }),
|
|
510
|
+
).rejects.toThrow(TransitionConflictError);
|
|
511
|
+
|
|
512
|
+
// 6. The note is still "drafted" — neither bad attempt mutated it.
|
|
513
|
+
expect((await store.getNote(seed.id))!.metadata).toMatchObject({ state: "drafted" });
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
it("value-matched trigger predicate fires on eq/in match and NOT otherwise", async () => {
|
|
517
|
+
// The trigger predicate uses matchesOperator with the same engine. Emulate
|
|
518
|
+
// a value-matched `when.metadata` against a note's live metadata.
|
|
519
|
+
const when = { state: { eq: "published" } as Record<string, unknown> };
|
|
520
|
+
const fires = (meta: Record<string, unknown>) =>
|
|
521
|
+
Object.entries(when).every(([f, op]) => matchesOperator(f, meta[f], op));
|
|
522
|
+
|
|
523
|
+
expect(fires({ state: "published" })).toBe(true);
|
|
524
|
+
expect(fires({ state: "drafted" })).toBe(false);
|
|
525
|
+
expect(fires({})).toBe(false); // absent → no fire
|
|
526
|
+
|
|
527
|
+
const whenIn = { state: { in: ["produced", "published"] } as Record<string, unknown> };
|
|
528
|
+
const firesIn = (meta: Record<string, unknown>) =>
|
|
529
|
+
Object.entries(whenIn).every(([f, op]) => matchesOperator(f, meta[f], op));
|
|
530
|
+
expect(firesIn({ state: "produced" })).toBe(true);
|
|
531
|
+
expect(firesIn({ state: "idea" })).toBe(false);
|
|
532
|
+
});
|
|
533
|
+
});
|