@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/core/src/mcp.ts
CHANGED
|
@@ -8,6 +8,11 @@ import * as linkOps from "./links.js";
|
|
|
8
8
|
import * as tagSchemaOps from "./tag-schemas.js";
|
|
9
9
|
import type { TagFieldSchema } from "./tag-schemas.js";
|
|
10
10
|
import * as indexedFieldOps from "./indexed-fields.js";
|
|
11
|
+
import {
|
|
12
|
+
SchemaValidationError,
|
|
13
|
+
strictViolations,
|
|
14
|
+
type ValidationWarning,
|
|
15
|
+
} from "./schema-defaults.js";
|
|
11
16
|
import {
|
|
12
17
|
expandContent,
|
|
13
18
|
DEFAULT_EXPAND_DEPTH,
|
|
@@ -130,6 +135,28 @@ export interface GenerateMcpToolsOpts {
|
|
|
130
135
|
* Omitted (internal / unattributed callers) → writes leave attribution NULL.
|
|
131
136
|
*/
|
|
132
137
|
writeContext?: { actor?: string | null; via?: string | null };
|
|
138
|
+
/**
|
|
139
|
+
* Strict-schema enforcement controls (vault#299 Part A). By default every
|
|
140
|
+
* write through these tools enforces `strict:true` field constraints — a
|
|
141
|
+
* violation throws `SchemaValidationError` and the note is NOT written.
|
|
142
|
+
*
|
|
143
|
+
* `strictBypass: true` — the caller holds the migration-bypass scope
|
|
144
|
+
* (`vault:migrate`); skip enforcement so non-conforming notes can be
|
|
145
|
+
* migrated/backfilled. Every bypassed write that WOULD have been
|
|
146
|
+
* rejected calls `onStrictBypass` for logging (the audit-log table,
|
|
147
|
+
* #300, is deferred — we log to the daemon's structured log for now).
|
|
148
|
+
* `onStrictBypass` — invoked once per bypassed write with the would-be
|
|
149
|
+
* violations plus the actor/via from `writeContext`. Server-layer
|
|
150
|
+
* supplies a structured logger; core stays log-sink-agnostic.
|
|
151
|
+
*/
|
|
152
|
+
strictBypass?: boolean;
|
|
153
|
+
onStrictBypass?: (info: {
|
|
154
|
+
actor: string | null;
|
|
155
|
+
via: string | null;
|
|
156
|
+
path?: string | null;
|
|
157
|
+
tags?: string[];
|
|
158
|
+
violations: ValidationWarning[];
|
|
159
|
+
}) => void;
|
|
133
160
|
expandVisibility?: (note: Note) => boolean;
|
|
134
161
|
/**
|
|
135
162
|
* `nearTraversable` (vault#439) is an OPTIONAL per-note predicate threaded
|
|
@@ -156,6 +183,39 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
|
|
|
156
183
|
// and folded into every create/update the tools perform.
|
|
157
184
|
const writeActor = opts?.writeContext?.actor ?? null;
|
|
158
185
|
const writeVia = opts?.writeContext?.via ?? null;
|
|
186
|
+
const strictBypass = opts?.strictBypass === true;
|
|
187
|
+
const onStrictBypass = opts?.onStrictBypass;
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Pre-write strict-schema gate (vault#299 Part A). Validate the PROSPECTIVE
|
|
191
|
+
* note shape (final tags + merged metadata) against the resolved schemas.
|
|
192
|
+
* - No strict violations → no-op (the write proceeds; advisory warnings
|
|
193
|
+
* still surface later via `attachValidationStatus`).
|
|
194
|
+
* - Strict violations + no bypass → throw `SchemaValidationError` (single
|
|
195
|
+
* error, all per-field violations) so nothing is written.
|
|
196
|
+
* - Strict violations + bypass → log via `onStrictBypass` and proceed.
|
|
197
|
+
* Called immediately before `store.createNote` / `store.updateNote` so a
|
|
198
|
+
* rejection leaves the note untouched.
|
|
199
|
+
*/
|
|
200
|
+
const enforceStrict = (shape: {
|
|
201
|
+
path?: string | null;
|
|
202
|
+
tags?: string[];
|
|
203
|
+
metadata?: Record<string, unknown>;
|
|
204
|
+
}): void => {
|
|
205
|
+
enforceStrictWrite(store, shape, {
|
|
206
|
+
bypass: strictBypass,
|
|
207
|
+
onBypass: onStrictBypass
|
|
208
|
+
? (violations) =>
|
|
209
|
+
onStrictBypass({
|
|
210
|
+
actor: writeActor,
|
|
211
|
+
via: writeVia,
|
|
212
|
+
path: shape.path ?? null,
|
|
213
|
+
tags: shape.tags,
|
|
214
|
+
violations,
|
|
215
|
+
})
|
|
216
|
+
: undefined,
|
|
217
|
+
});
|
|
218
|
+
};
|
|
159
219
|
|
|
160
220
|
return [
|
|
161
221
|
|
|
@@ -661,6 +721,13 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
661
721
|
const extension = item.extension !== undefined
|
|
662
722
|
? validateExtension(item.extension)
|
|
663
723
|
: undefined;
|
|
724
|
+
// Strict-schema gate (vault#299) — reject before any write so a
|
|
725
|
+
// mid-batch violation rolls back via the outer BEGIN/ROLLBACK.
|
|
726
|
+
enforceStrict({
|
|
727
|
+
path: item.path as string | undefined,
|
|
728
|
+
tags: item.tags as string[] | undefined,
|
|
729
|
+
metadata: item.metadata as Record<string, unknown> | undefined,
|
|
730
|
+
});
|
|
664
731
|
const note = await store.createNote(item.content as string ?? "", {
|
|
665
732
|
path: item.path as string | undefined,
|
|
666
733
|
tags: item.tags as string[] | undefined,
|
|
@@ -768,6 +835,16 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
768
835
|
if_updated_at: { type: "string", description: "Optimistic concurrency check: the updated_at value you last read. Rejects with a conflict error if the note has been modified since. Required unless `force: true` is set or the call is `append`/`prepend`-only." },
|
|
769
836
|
force: { type: "boolean", description: "Waive the *requirement to supply* `if_updated_at` and run the update unconditionally. Use only for bulk migrations or scripted writes where concurrency is known-safe. Note: this does not override an `if_updated_at` you actually pass — if you supply both, the precondition still applies and a mismatch returns a conflict error." },
|
|
770
837
|
if_missing: { type: "string", enum: ["fail", "create"], description: "What to do when the note (by `id`/path) doesn't exist. `\"fail\"` (default) — error, current behavior. `\"create\"` — create the note from this same payload (content/path/tags/metadata become the create fields; the response carries `created: true`). Skips the `if_updated_at` precondition on the create branch (nothing to conflict with). Idempotent for sync loops that don't know ahead of time whether the note exists. See vault#309." },
|
|
838
|
+
state_transition: {
|
|
839
|
+
type: "object",
|
|
840
|
+
properties: {
|
|
841
|
+
field: { type: "string", description: "Metadata field to transition." },
|
|
842
|
+
from: { description: "Required current value. The transition only commits if the field currently equals this. A missing field is a conflict; pass `null` to match a field that is absent or explicitly null." },
|
|
843
|
+
to: { description: "New value to set when the `from` precondition holds." },
|
|
844
|
+
},
|
|
845
|
+
required: ["field", "from", "to"],
|
|
846
|
+
description: "Atomic compare-and-set state transition (vault#299). If the metadata `field` currently equals `from`, set it to `to` and commit; otherwise the write is rejected with a `transition_conflict` error (a missing field counts as a conflict; `from: null` matches absent-or-null). A transition-ONLY update needs no `if_updated_at`/`force` — the compare-and-set is the precondition. Combinable with other field updates (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. Use this to advance a state machine race-safely in one round trip instead of read → check → conditional update.",
|
|
847
|
+
},
|
|
771
848
|
tags: {
|
|
772
849
|
type: "object",
|
|
773
850
|
properties: {
|
|
@@ -837,6 +914,16 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
837
914
|
if_updated_at: { type: "string", description: "Optimistic concurrency check for this item; rejects with a conflict error if the note has been modified since. Required unless `force: true` is set on this item or the item is `append`/`prepend`-only." },
|
|
838
915
|
force: { type: "boolean", description: "Waive the *requirement to supply* `if_updated_at` for this item. Does not override an `if_updated_at` you actually pass — a supplied precondition still applies and a mismatch conflicts." },
|
|
839
916
|
if_missing: { type: "string", enum: ["fail", "create"], description: "Per-item: see top-level `if_missing` docs. Each batch item carries its own setting." },
|
|
917
|
+
state_transition: {
|
|
918
|
+
type: "object",
|
|
919
|
+
properties: {
|
|
920
|
+
field: { type: "string" },
|
|
921
|
+
from: {},
|
|
922
|
+
to: {},
|
|
923
|
+
},
|
|
924
|
+
required: ["field", "from", "to"],
|
|
925
|
+
description: "Per-item compare-and-set state transition (vault#299). See top-level `state_transition` docs.",
|
|
926
|
+
},
|
|
840
927
|
tags: { type: "object" },
|
|
841
928
|
links: { type: "object" },
|
|
842
929
|
include_links: { type: "boolean", description: "Per-item: echo hydrated links on this item's response (vault feedback #8). Also implied when this item mutates links." },
|
|
@@ -951,6 +1038,14 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
951
1038
|
via: writeVia,
|
|
952
1039
|
};
|
|
953
1040
|
const content = (item.content as string | undefined) ?? "";
|
|
1041
|
+
// Strict-schema gate (vault#299) — the if_missing:"create"
|
|
1042
|
+
// branch is still a create, so it enforces too. Tags come from
|
|
1043
|
+
// createOpts (already normalized from the {add} dict / array).
|
|
1044
|
+
enforceStrict({
|
|
1045
|
+
path: createOpts.path ?? undefined,
|
|
1046
|
+
tags: createOpts.tags,
|
|
1047
|
+
metadata: createOpts.metadata,
|
|
1048
|
+
});
|
|
954
1049
|
const created = await store.createNote(content, createOpts);
|
|
955
1050
|
await applySchemaDefaults(store, db, [created.id], created.tags ?? []);
|
|
956
1051
|
// Apply links.add if the caller declared any.
|
|
@@ -1009,7 +1104,19 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1009
1104
|
&& item.created_at === undefined
|
|
1010
1105
|
&& item.tags === undefined
|
|
1011
1106
|
&& item.links === undefined;
|
|
1012
|
-
|
|
1107
|
+
// A state_transition is itself a compare-and-set precondition
|
|
1108
|
+
// (vault#299 Part B) — a transition-only update doesn't need
|
|
1109
|
+
// `if_updated_at`/`force`, the CAS guards the lost-write window.
|
|
1110
|
+
const isTransitionOnly = item.state_transition !== undefined
|
|
1111
|
+
&& !hasContent
|
|
1112
|
+
&& !hasAppendPrepend
|
|
1113
|
+
&& !hasContentEdit
|
|
1114
|
+
&& item.path === undefined
|
|
1115
|
+
&& item.metadata === undefined
|
|
1116
|
+
&& item.created_at === undefined
|
|
1117
|
+
&& item.tags === undefined
|
|
1118
|
+
&& item.links === undefined;
|
|
1119
|
+
if (!isAppendOnly && !isTransitionOnly && item.if_updated_at === undefined && item.force !== true) {
|
|
1013
1120
|
throw new PreconditionRequiredError(note.id, note.path ?? null);
|
|
1014
1121
|
}
|
|
1015
1122
|
|
|
@@ -1093,6 +1200,32 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1093
1200
|
}
|
|
1094
1201
|
if (item.created_at !== undefined) updates.created_at = item.created_at;
|
|
1095
1202
|
if (item.if_updated_at !== undefined) updates.if_updated_at = item.if_updated_at as string;
|
|
1203
|
+
// Compare-and-set state transition (vault#299 Part B). Combinable
|
|
1204
|
+
// with other field updates — it folds into the same atomic UPDATE.
|
|
1205
|
+
const stItem = item.state_transition as { field?: unknown; from?: unknown; to?: unknown } | undefined;
|
|
1206
|
+
if (stItem !== undefined) {
|
|
1207
|
+
if (typeof stItem.field !== "string" || stItem.field.length === 0) {
|
|
1208
|
+
throw new Error(
|
|
1209
|
+
`update-note: \`state_transition.field\` must be a non-empty string (note "${note.id}").`,
|
|
1210
|
+
);
|
|
1211
|
+
}
|
|
1212
|
+
updates.state_transition = { field: stItem.field, from: stItem.from, to: stItem.to };
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
// --- Strict-schema gate (vault#299 Part A) ---
|
|
1216
|
+
// Validate the PROSPECTIVE shape (final tags + merged metadata,
|
|
1217
|
+
// including a state_transition's `to`) before the write so a
|
|
1218
|
+
// rejection leaves the note untouched.
|
|
1219
|
+
{
|
|
1220
|
+
const removeSet = new Set<string>((item.tags as any)?.remove ?? []);
|
|
1221
|
+
const projectedTags = new Set<string>((note.tags ?? []).filter((t) => !removeSet.has(t)));
|
|
1222
|
+
for (const t of ((item.tags as any)?.add as string[] | undefined) ?? []) projectedTags.add(t);
|
|
1223
|
+
const baseMeta = updates.metadata ?? ((note.metadata as Record<string, unknown>) ?? {});
|
|
1224
|
+
const projectedMeta = stItem !== undefined
|
|
1225
|
+
? { ...baseMeta, [stItem.field as string]: stItem.to }
|
|
1226
|
+
: baseMeta;
|
|
1227
|
+
enforceStrict({ path: note.path, tags: [...projectedTags], metadata: projectedMeta });
|
|
1228
|
+
}
|
|
1096
1229
|
|
|
1097
1230
|
let result: Note;
|
|
1098
1231
|
if (Object.keys(updates).length > 0) {
|
|
@@ -1287,14 +1420,17 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1287
1420
|
description: { type: "string", description: "Human-readable description of what this tag means" },
|
|
1288
1421
|
fields: {
|
|
1289
1422
|
type: "object",
|
|
1290
|
-
description: 'Metadata fields notes with this tag should have. E.g., { "status": { "type": "string", "enum": ["active", "archived"] } }',
|
|
1423
|
+
description: 'Metadata fields notes with this tag should have. E.g., { "status": { "type": "string", "enum": ["active", "archived"], "strict": true } }. Constraints are ADVISORY by default (violations surface as validation_status warnings; the write still succeeds). Mark a field `strict: true` to ENFORCE all its constraints — type + enum + required + cardinality flip to hard write rejections (vault#299).',
|
|
1291
1424
|
additionalProperties: {
|
|
1292
1425
|
type: "object",
|
|
1293
1426
|
properties: {
|
|
1294
|
-
type: { type: "string", description: "Field type: string, boolean, integer" },
|
|
1427
|
+
type: { type: "string", description: "Field type: string, boolean, integer, number, array, object" },
|
|
1295
1428
|
description: { type: "string" },
|
|
1296
1429
|
enum: { type: "array", items: { type: "string" }, description: "Allowed values (first is default)" },
|
|
1297
1430
|
indexed: { type: "boolean", description: "When true, a generated column + index are maintained on notes.metadata.<field>, making it queryable via metadata operator objects and order_by. Global: all tags declaring the field must agree on both type and indexed." },
|
|
1431
|
+
strict: { type: "boolean", description: "vault#299. Default false (advisory). When true, ALL of this field's declared constraints (type + enum + required + cardinality) are ENFORCED — a violating write is rejected with a schema_validation error, not just warned. All-or-nothing per field; free-form fields on a strict tag simply leave strict off." },
|
|
1432
|
+
required: { type: "boolean", description: "vault#299. The field must be present + non-null on a note with this tag. Advisory unless `strict: true`." },
|
|
1433
|
+
cardinality: { type: "string", enum: ["one", "many"], description: "vault#299. 'one' (scalar, default) or 'many' (array). Advisory unless `strict: true`." },
|
|
1298
1434
|
},
|
|
1299
1435
|
required: ["type"],
|
|
1300
1436
|
},
|
|
@@ -1545,6 +1681,15 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1545
1681
|
* actually written — callers use this to re-read ONLY the mutated notes (and
|
|
1546
1682
|
* to skip the re-read entirely when nothing changed). The common no-schema /
|
|
1547
1683
|
* no-defaults path returns an empty array.
|
|
1684
|
+
*
|
|
1685
|
+
* vault#299: this runs AFTER the create write (so AFTER the strict gate) and
|
|
1686
|
+
* intentionally does NOT re-run `enforceStrict`. Defaults are always
|
|
1687
|
+
* conforming by construction — `defaultForField` returns the first enum value
|
|
1688
|
+
* / the type's zero-value, so a default can never violate type/enum. And a
|
|
1689
|
+
* `required` strict field is already caught at the pre-write gate, so a note
|
|
1690
|
+
* that would need a default to satisfy `required` never reaches this filler
|
|
1691
|
+
* (the create was rejected first). Don't add a defaults path that could
|
|
1692
|
+
* inject a violating value without re-gating.
|
|
1548
1693
|
*/
|
|
1549
1694
|
async function applySchemaDefaults(store: Store, db: Database, noteIds: string[], tags: string[]): Promise<string[]> {
|
|
1550
1695
|
const schemas = tagSchemaOps.getTagSchemaMap(db);
|
|
@@ -1610,6 +1755,36 @@ function defaultForField(field: { type: string; enum?: string[] }): unknown {
|
|
|
1610
1755
|
* the same recipe — see vault#287 for the asymmetry that motivated
|
|
1611
1756
|
* exposing it.
|
|
1612
1757
|
*/
|
|
1758
|
+
/**
|
|
1759
|
+
* Pre-write strict-schema gate (vault#299 Part A). Shared by both write
|
|
1760
|
+
* transports (MCP tools here, REST PATCH/POST in `src/routes.ts`) so the
|
|
1761
|
+
* enforcement contract can't drift between them — the same recipe the
|
|
1762
|
+
* `validation_status` attachment shares via `attachValidationStatus`.
|
|
1763
|
+
*
|
|
1764
|
+
* Validates the PROSPECTIVE note shape (final tags + merged metadata) against
|
|
1765
|
+
* the resolved schemas and:
|
|
1766
|
+
* - no strict violations → no-op, the write proceeds.
|
|
1767
|
+
* - violations + `bypass:false` → throw `SchemaValidationError` (one error,
|
|
1768
|
+
* all per-field violations — settled lead #1). Caller writes nothing.
|
|
1769
|
+
* - violations + `bypass:true` → invoke `onBypass(violations)` (migration
|
|
1770
|
+
* scope) and return; the caller proceeds with the non-conforming write.
|
|
1771
|
+
*
|
|
1772
|
+
* Returns the would-be violations (empty when none) so a caller can inspect
|
|
1773
|
+
* them; the throw / bypass decision is already made internally.
|
|
1774
|
+
*/
|
|
1775
|
+
export function enforceStrictWrite(
|
|
1776
|
+
store: Store,
|
|
1777
|
+
shape: { path?: string | null; tags?: string[]; metadata?: Record<string, unknown> },
|
|
1778
|
+
opts?: { bypass?: boolean; onBypass?: (violations: ValidationWarning[]) => void },
|
|
1779
|
+
): ValidationWarning[] {
|
|
1780
|
+
const status = store.validateNoteAgainstSchemas(shape);
|
|
1781
|
+
const violations = strictViolations(status);
|
|
1782
|
+
if (violations.length === 0) return [];
|
|
1783
|
+
if (opts?.bypass !== true) throw new SchemaValidationError(violations);
|
|
1784
|
+
opts.onBypass?.(violations);
|
|
1785
|
+
return violations;
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1613
1788
|
export function attachValidationStatus(store: Store, _db: Database, note: Note): Note {
|
|
1614
1789
|
// Short-circuit cheaply: when no tag declares fields, the resolver
|
|
1615
1790
|
// returns null without us paying a re-read of the note.
|
|
@@ -1649,7 +1824,11 @@ function normalizeLinkCountDirection(v: unknown): "both" | "outbound" | "inbound
|
|
|
1649
1824
|
// conditional-UPDATE implementation that raises it. AmbiguousPathError
|
|
1650
1825
|
// joins the set (vault#331 N2) so external callers can `instanceof`
|
|
1651
1826
|
// it without crossing module boundaries.
|
|
1652
|
-
export { ConflictError, PathConflictError, AmbiguousPathError, MAX_BATCH_SIZE } from "./notes.js";
|
|
1827
|
+
export { ConflictError, PathConflictError, AmbiguousPathError, TransitionConflictError, MAX_BATCH_SIZE } from "./notes.js";
|
|
1828
|
+
// vault#299: strict-schema enforcement error, re-exported alongside the other
|
|
1829
|
+
// write-path domain errors so external callers can `instanceof` it without
|
|
1830
|
+
// crossing module boundaries.
|
|
1831
|
+
export { SchemaValidationError } from "./schema-defaults.js";
|
|
1653
1832
|
|
|
1654
1833
|
/**
|
|
1655
1834
|
* Thrown by the `update-note` MCP tool (and the REST PATCH handler) when a
|
package/core/src/notes.ts
CHANGED
|
@@ -208,6 +208,50 @@ export class ConflictError extends Error {
|
|
|
208
208
|
}
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
+
/**
|
|
212
|
+
* Thrown by `updateNote` when a `state_transition` precondition fails: the
|
|
213
|
+
* named metadata field's current value does not equal `from` (a missing field
|
|
214
|
+
* counts as a mismatch). Distinct from `ConflictError` (vault#299 settled lead
|
|
215
|
+
* #3) — a state-transition conflict is about a VALUE not matching, not a
|
|
216
|
+
* stale `updated_at` token, so it carries its own `transition_conflict` code
|
|
217
|
+
* and the from/to/current triple rather than the if_updated_at vocabulary.
|
|
218
|
+
*
|
|
219
|
+
* The check + set is one atomic conditional UPDATE (`... WHERE
|
|
220
|
+
* json_extract(metadata,'$.field') IS ?`), so two racing transitioners can't
|
|
221
|
+
* both observe `from` and both commit — exactly one wins, the other gets this.
|
|
222
|
+
*/
|
|
223
|
+
export class TransitionConflictError extends Error {
|
|
224
|
+
code = "TRANSITION_CONFLICT" as const;
|
|
225
|
+
note_id: string;
|
|
226
|
+
note_path: string | null;
|
|
227
|
+
field: string;
|
|
228
|
+
expected_from: unknown;
|
|
229
|
+
to: unknown;
|
|
230
|
+
current: unknown;
|
|
231
|
+
|
|
232
|
+
constructor(
|
|
233
|
+
noteId: string,
|
|
234
|
+
notePath: string | null,
|
|
235
|
+
field: string,
|
|
236
|
+
from: unknown,
|
|
237
|
+
to: unknown,
|
|
238
|
+
current: unknown,
|
|
239
|
+
) {
|
|
240
|
+
super(
|
|
241
|
+
`transition_conflict: note "${noteId}" field "${field}" is ${JSON.stringify(
|
|
242
|
+
current ?? null,
|
|
243
|
+
)}, expected ${JSON.stringify(from)} to transition to ${JSON.stringify(to)}`,
|
|
244
|
+
);
|
|
245
|
+
this.name = "TransitionConflictError";
|
|
246
|
+
this.note_id = noteId;
|
|
247
|
+
this.note_path = notePath;
|
|
248
|
+
this.field = field;
|
|
249
|
+
this.expected_from = from;
|
|
250
|
+
this.to = to;
|
|
251
|
+
this.current = current;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
211
255
|
/**
|
|
212
256
|
* Thrown by `createNote` / `updateNote` when the requested path is already
|
|
213
257
|
* taken by another note. Surfaces as 409 at the HTTP layer so clients can
|
|
@@ -375,6 +419,18 @@ export function updateNote(
|
|
|
375
419
|
* note still exists, a `ConflictError` is thrown.
|
|
376
420
|
*/
|
|
377
421
|
if_updated_at?: string;
|
|
422
|
+
/**
|
|
423
|
+
* Compare-and-set state transition (vault#299 Part B). Atomically: if the
|
|
424
|
+
* named metadata field currently equals `from`, set it to `to` and commit;
|
|
425
|
+
* otherwise throw `TransitionConflictError` (a missing field is a
|
|
426
|
+
* conflict). The check rides the same conditional UPDATE — an additional
|
|
427
|
+
* `AND json_extract(metadata,'$.<field>') IS ?` clause — so two racing
|
|
428
|
+
* transitioners can't both pass. Combinable with `metadata` (other field
|
|
429
|
+
* updates merge alongside the transition) and `if_updated_at` (both
|
|
430
|
+
* preconditions must hold). `from`/`to` are JSON scalars (string / number /
|
|
431
|
+
* boolean / null).
|
|
432
|
+
*/
|
|
433
|
+
state_transition?: { field: string; from: unknown; to: unknown };
|
|
378
434
|
},
|
|
379
435
|
): Note {
|
|
380
436
|
if (updates.content !== undefined && (updates.append !== undefined || updates.prepend !== undefined)) {
|
|
@@ -463,16 +519,37 @@ export function updateNote(
|
|
|
463
519
|
sets.push("extension = ?");
|
|
464
520
|
values.push(updates.extension);
|
|
465
521
|
}
|
|
522
|
+
// State-transition (vault#299 Part B). The `to` value must land in the
|
|
523
|
+
// metadata field. Two sub-cases keep the metadata write consistent:
|
|
524
|
+
// - caller ALSO passed `metadata` (the MCP/REST layer merges before
|
|
525
|
+
// calling): fold `to` into that object so the single `metadata = ?`
|
|
526
|
+
// SET carries it. The transition value wins over a merged value for
|
|
527
|
+
// the same field — the transition is the authoritative state write.
|
|
528
|
+
// - caller passed NO `metadata`: use SQL `json_set` so the field is
|
|
529
|
+
// updated in place without a read-merge-write (keeps it atomic).
|
|
530
|
+
// The atomic GUARD (current value == from) is appended to the WHERE below.
|
|
531
|
+
const st = updates.state_transition;
|
|
532
|
+
if (st !== undefined && updates.metadata !== undefined) {
|
|
533
|
+
(updates.metadata as Record<string, unknown>)[st.field] = st.to;
|
|
534
|
+
}
|
|
466
535
|
if (updates.metadata !== undefined) {
|
|
467
536
|
sets.push("metadata = ?");
|
|
468
537
|
values.push(JSON.stringify(updates.metadata));
|
|
538
|
+
} else if (st !== undefined) {
|
|
539
|
+
// No metadata payload — set the single field via json_set. The path is
|
|
540
|
+
// bound as a parameter (mirrors the json_extract pattern elsewhere); the
|
|
541
|
+
// value is bound as a JSON-typed argument via `json(?)` so booleans /
|
|
542
|
+
// numbers / null land as their JSON types, not stringified text.
|
|
543
|
+
sets.push(`metadata = json_set(COALESCE(metadata, '{}'), ?, json(?))`);
|
|
544
|
+
values.push(`$.${jsonPathKey(st.field)}`, JSON.stringify(st.to));
|
|
469
545
|
}
|
|
470
546
|
if (updates.created_at !== undefined) {
|
|
471
547
|
sets.push("created_at = ?");
|
|
472
548
|
values.push(updates.created_at);
|
|
473
549
|
}
|
|
474
550
|
|
|
475
|
-
// No-op: no SET fields. If a caller still passed `if_updated_at
|
|
551
|
+
// No-op: no SET fields. If a caller still passed `if_updated_at` (and no
|
|
552
|
+
// state_transition — that always pushes a metadata SET above), we
|
|
476
553
|
// need to validate the precondition; a conditional UPDATE that sets
|
|
477
554
|
// updated_at to itself does exactly that atomically — even a no-net-
|
|
478
555
|
// change UPDATE takes the write lock in WAL mode, so it still serializes
|
|
@@ -497,10 +574,23 @@ export function updateNote(
|
|
|
497
574
|
sql += " AND updated_at IS ?";
|
|
498
575
|
values.push(updates.if_updated_at);
|
|
499
576
|
}
|
|
577
|
+
// State-transition guard (vault#299 Part B): the atomic compare. The UPDATE
|
|
578
|
+
// only fires when the stored field currently equals `from`; otherwise zero
|
|
579
|
+
// rows match and we raise TransitionConflictError below. `IS` (not `=`) so
|
|
580
|
+
// a `from: null` correctly matches a stored JSON null / missing field, and
|
|
581
|
+
// a non-null `from` never matches a NULL/missing field (= conflict).
|
|
582
|
+
if (st !== undefined) {
|
|
583
|
+
sql += ` AND json_extract(metadata, ?) IS json_extract(json(?), '$')`;
|
|
584
|
+
values.push(`$.${jsonPathKey(st.field)}`, JSON.stringify(st.from));
|
|
585
|
+
}
|
|
500
586
|
|
|
587
|
+
// A value-conditional WHERE (if_updated_at OR state_transition) needs
|
|
588
|
+
// RETURNING to distinguish "matched + updated" from "no match" — `.changes`
|
|
589
|
+
// is unreliable inside transactions (vault#261).
|
|
590
|
+
const conditional = updates.if_updated_at !== undefined || st !== undefined;
|
|
501
591
|
let matched: { id: string } | null = null;
|
|
502
592
|
try {
|
|
503
|
-
if (
|
|
593
|
+
if (conditional) {
|
|
504
594
|
matched = db.prepare(`${sql} RETURNING id`).get(...values) as
|
|
505
595
|
| { id: string }
|
|
506
596
|
| null;
|
|
@@ -520,13 +610,47 @@ export function updateNote(
|
|
|
520
610
|
throw err;
|
|
521
611
|
}
|
|
522
612
|
|
|
523
|
-
if (
|
|
524
|
-
|
|
613
|
+
if (conditional && matched === null) {
|
|
614
|
+
// No row matched. Disambiguate the cause, checking the if_updated_at
|
|
615
|
+
// precondition first (it's the cheaper, pre-existing contract), then the
|
|
616
|
+
// state-transition value, then not-found.
|
|
617
|
+
const row = db.prepare("SELECT updated_at, path, metadata FROM notes WHERE id = ?").get(id) as
|
|
618
|
+
| { updated_at: string | null; path: string | null; metadata: string | null }
|
|
619
|
+
| null;
|
|
620
|
+
if (!row) throw new Error(`Note not found: "${id}"`);
|
|
621
|
+
if (updates.if_updated_at !== undefined && row.updated_at !== updates.if_updated_at) {
|
|
622
|
+
throw new ConflictError(id, row.path, row.updated_at, updates.if_updated_at);
|
|
623
|
+
}
|
|
624
|
+
if (st !== undefined) {
|
|
625
|
+
// if_updated_at (if any) matched, so the mismatch is the transition.
|
|
626
|
+
let current: unknown;
|
|
627
|
+
try {
|
|
628
|
+
const meta = row.metadata ? JSON.parse(row.metadata) : {};
|
|
629
|
+
current = (meta as Record<string, unknown>)[st.field];
|
|
630
|
+
} catch {
|
|
631
|
+
current = undefined;
|
|
632
|
+
}
|
|
633
|
+
throw new TransitionConflictError(id, row.path, st.field, st.from, st.to, current);
|
|
634
|
+
}
|
|
635
|
+
// if_updated_at-only path that fell through (timestamp matched but row
|
|
636
|
+
// vanished mid-flight): preserve the prior contract.
|
|
637
|
+
throwConflictOrMissing(db, id, updates.if_updated_at!);
|
|
525
638
|
}
|
|
526
639
|
|
|
527
640
|
return getNote(db, id)!;
|
|
528
641
|
}
|
|
529
642
|
|
|
643
|
+
/**
|
|
644
|
+
* Build the dotted JSON-path key fragment for a metadata field name. Field
|
|
645
|
+
* names that contain characters JSON-path treats specially (`.`, `[`, `"`,
|
|
646
|
+
* etc.) are double-quoted; simple identifiers pass through bare. Mirrors the
|
|
647
|
+
* SQLite JSON1 path grammar.
|
|
648
|
+
*/
|
|
649
|
+
function jsonPathKey(field: string): string {
|
|
650
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(field)) return field;
|
|
651
|
+
return `"${field.replace(/"/g, '\\"')}"`;
|
|
652
|
+
}
|
|
653
|
+
|
|
530
654
|
function throwConflictOrMissing(db: Database, id: string, expected: string): never {
|
|
531
655
|
const row = db.prepare("SELECT updated_at, path FROM notes WHERE id = ?").get(id) as
|
|
532
656
|
| { updated_at: string | null; path: string | null }
|
|
@@ -188,3 +188,120 @@ export function buildOperatorClause(
|
|
|
188
188
|
params,
|
|
189
189
|
};
|
|
190
190
|
}
|
|
191
|
+
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
// In-memory operator evaluation (vault#299 — value-matched triggers)
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Evaluate an operator object against a single in-memory value — the
|
|
198
|
+
* non-SQL twin of {@link buildOperatorClause}. The trigger engine fires on a
|
|
199
|
+
* live `note.metadata` object (not a DB row), so it can't route through the
|
|
200
|
+
* indexed generated columns; this gives it the SAME operator vocabulary +
|
|
201
|
+
* semantics without re-implementing them. An object with multiple operators
|
|
202
|
+
* is AND-composed (`{ gt: 5, lt: 10 }`), matching the SQL clause builder.
|
|
203
|
+
*
|
|
204
|
+
* `value` is the field's current value (`undefined` when the field is
|
|
205
|
+
* absent). Semantics mirror the SQL builder, including the NULL/missing
|
|
206
|
+
* handling: `ne`/`not_in` treat an absent/null value as "matches" (the SQL
|
|
207
|
+
* `(col IS NULL OR ...)` shape); `eq: null` matches an absent/null value.
|
|
208
|
+
*
|
|
209
|
+
* Throws `QueryError` on an unknown operator or a malformed operand — the
|
|
210
|
+
* trigger validator surfaces it as a 400 at registration time.
|
|
211
|
+
*/
|
|
212
|
+
export function matchesOperator(
|
|
213
|
+
field: string,
|
|
214
|
+
value: unknown,
|
|
215
|
+
opObj: Record<string, unknown>,
|
|
216
|
+
): boolean {
|
|
217
|
+
validateOperatorObject(field, opObj);
|
|
218
|
+
const absent = value === undefined || value === null;
|
|
219
|
+
|
|
220
|
+
for (const [op, operand] of Object.entries(opObj)) {
|
|
221
|
+
switch (op as QueryOp) {
|
|
222
|
+
case "eq":
|
|
223
|
+
if (operand === null) {
|
|
224
|
+
if (!absent) return false;
|
|
225
|
+
} else if (absent || !looseEquals(value, operand)) {
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
break;
|
|
229
|
+
case "ne":
|
|
230
|
+
if (operand === null) {
|
|
231
|
+
if (absent) return false;
|
|
232
|
+
} else if (!absent && looseEquals(value, operand)) {
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
break;
|
|
236
|
+
case "gt":
|
|
237
|
+
case "gte":
|
|
238
|
+
case "lt":
|
|
239
|
+
case "lte": {
|
|
240
|
+
if (absent) return false;
|
|
241
|
+
const cmp = compareScalar(value, operand);
|
|
242
|
+
if (cmp === null) return false;
|
|
243
|
+
if (op === "gt" && !(cmp > 0)) return false;
|
|
244
|
+
if (op === "gte" && !(cmp >= 0)) return false;
|
|
245
|
+
if (op === "lt" && !(cmp < 0)) return false;
|
|
246
|
+
if (op === "lte" && !(cmp <= 0)) return false;
|
|
247
|
+
break;
|
|
248
|
+
}
|
|
249
|
+
case "in":
|
|
250
|
+
case "not_in": {
|
|
251
|
+
if (!Array.isArray(operand)) {
|
|
252
|
+
throw new QueryError(
|
|
253
|
+
`operator "${op}" on metadata field "${field}" expects an array`,
|
|
254
|
+
"INVALID_OPERATOR_VALUE",
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
const present = !absent && operand.some((o) => looseEquals(value, o));
|
|
258
|
+
if (op === "in" && !present) return false;
|
|
259
|
+
// not_in: absent/null matches (mirrors SQL `col IS NULL OR ...`).
|
|
260
|
+
if (op === "not_in" && present) return false;
|
|
261
|
+
break;
|
|
262
|
+
}
|
|
263
|
+
case "exists":
|
|
264
|
+
if (typeof operand !== "boolean") {
|
|
265
|
+
throw new QueryError(
|
|
266
|
+
`operator "exists" on metadata field "${field}" expects a boolean`,
|
|
267
|
+
"INVALID_OPERATOR_VALUE",
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
if (operand === !absent) break;
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return true;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Loose scalar equality used by in-memory `eq`/`ne`/`in`. Matches SQLite's
|
|
279
|
+
* affinity-tolerant comparison closely enough for trigger value-matching:
|
|
280
|
+
* exact for same-type scalars; cross-type number/string compares by string
|
|
281
|
+
* form (`5` matches `"5"`), since metadata round-trips through JSON and a
|
|
282
|
+
* note may carry either shape.
|
|
283
|
+
*/
|
|
284
|
+
function looseEquals(a: unknown, b: unknown): boolean {
|
|
285
|
+
if (a === b) return true;
|
|
286
|
+
if (
|
|
287
|
+
(typeof a === "number" || typeof a === "string") &&
|
|
288
|
+
(typeof b === "number" || typeof b === "string")
|
|
289
|
+
) {
|
|
290
|
+
return String(a) === String(b);
|
|
291
|
+
}
|
|
292
|
+
return false;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Three-way comparison for ordered operators (gt/gte/lt/lte). Returns a
|
|
297
|
+
* negative/zero/positive number, or `null` when the operands aren't
|
|
298
|
+
* order-comparable (so the caller treats it as "no match"). Numbers compare
|
|
299
|
+
* numerically; strings lexicographically; mixed types don't compare.
|
|
300
|
+
*/
|
|
301
|
+
function compareScalar(a: unknown, b: unknown): number | null {
|
|
302
|
+
if (typeof a === "number" && typeof b === "number") return a - b;
|
|
303
|
+
if (typeof a === "string" && typeof b === "string") {
|
|
304
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
305
|
+
}
|
|
306
|
+
return null;
|
|
307
|
+
}
|