@openparachute/vault 0.6.4-rc.2 → 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/enforced-writes.test.ts +533 -0
- package/core/src/mcp.ts +183 -4
- package/core/src/notes.ts +128 -4
- package/core/src/query-operators.ts +117 -0
- package/core/src/schema-defaults.ts +162 -9
- package/core/src/tag-schemas.ts +12 -0
- package/core/src/triggers-store.ts +6 -0
- package/core/src/types.ts +2 -11
- package/core/src/vault-projection.ts +19 -0
- package/package.json +1 -1
- package/src/config.ts +11 -0
- package/src/mcp-http.ts +27 -0
- package/src/mcp-tools.ts +15 -3
- package/src/routes.ts +133 -3
- package/src/routing.ts +10 -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
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
|
|
|
@@ -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
|
+
});
|