@openparachute/vault 0.6.3 → 0.6.4-rc.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -11
- package/core/src/attribution.test.ts +273 -0
- package/core/src/core.test.ts +126 -0
- package/core/src/cursor.ts +8 -0
- package/core/src/enforced-writes.test.ts +533 -0
- package/core/src/mcp.ts +235 -9
- package/core/src/migrate-tag-field.test.ts +471 -0
- package/core/src/migrate-tag-field.ts +638 -0
- package/core/src/notes.ts +280 -7
- package/core/src/query-operators.ts +117 -0
- package/core/src/schema-defaults.ts +162 -9
- package/core/src/schema.ts +61 -2
- package/core/src/store.ts +51 -19
- package/core/src/tag-schemas.ts +12 -0
- package/core/src/triggers-store.ts +6 -0
- package/core/src/types.ts +35 -14
- package/core/src/vault-projection.ts +19 -0
- package/package.json +1 -1
- package/src/admin-spa.test.ts +18 -5
- package/src/admin-spa.ts +24 -3
- package/src/attribution-threading.test.ts +350 -0
- package/src/auth.ts +82 -4
- package/src/cli.ts +345 -9
- package/src/config.ts +11 -0
- package/src/first-boot-create.test.ts +155 -0
- package/src/import-daemon-busy.test.ts +8 -17
- package/src/mcp-http.ts +27 -0
- package/src/mcp-tools.ts +31 -4
- package/src/mirror-credentials.test.ts +47 -0
- package/src/mirror-credentials.ts +33 -0
- package/src/mirror-history.test.ts +426 -0
- package/src/mirror-manager.ts +202 -22
- package/src/mirror-routes.test.ts +78 -0
- package/src/mirror-routes.ts +195 -1
- package/src/module-config.ts +46 -80
- package/src/routes.ts +209 -41
- package/src/routing.test.ts +115 -25
- package/src/routing.ts +64 -2
- package/src/scale.bench.test.ts +82 -0
- package/src/scopes.test.ts +24 -0
- package/src/scopes.ts +63 -0
- package/src/self-register.test.ts +5 -5
- package/src/self-register.ts +8 -3
- package/src/server.ts +58 -38
- package/src/subscribe.test.ts +23 -2
- package/src/test-support/spawn.ts +85 -0
- package/src/triggers-api.ts +24 -0
- package/src/triggers.test.ts +33 -0
- package/src/triggers.ts +17 -0
- package/src/vault-remove.test.ts +4 -5
- package/src/vault.test.ts +188 -17
package/core/src/notes.ts
CHANGED
|
@@ -22,6 +22,36 @@ import { getIndexedField, releaseField } from "./indexed-fields.js";
|
|
|
22
22
|
|
|
23
23
|
let idCounter = 0;
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Write-attribution context (vault#298) — the two axes of provenance threaded
|
|
27
|
+
* from the authenticated request down into every note write.
|
|
28
|
+
*
|
|
29
|
+
* actor — WHO: the principal the write is attributed to (a JWT `sub`, or an
|
|
30
|
+
* operator / `token:<id>` label for non-JWT auth). Lands in
|
|
31
|
+
* `created_by` on the first write and `last_updated_by` on every
|
|
32
|
+
* write.
|
|
33
|
+
* via — VIA WHAT: the interface/channel the write arrived through
|
|
34
|
+
* (`mcp`, `surface:<name>`, `agent:<id>`, `operator`/`cli`, `api`).
|
|
35
|
+
* Lands in `created_via` / `last_updated_via` symmetrically.
|
|
36
|
+
*
|
|
37
|
+
* Both are independently optional — an internal/import write may carry
|
|
38
|
+
* neither, and a non-JWT operator write carries an `actor`/`via` pair without
|
|
39
|
+
* a `sub`. `undefined` (or a missing context) means "don't write this column,"
|
|
40
|
+
* so legacy callers and importers leave attribution NULL rather than
|
|
41
|
+
* fabricating it.
|
|
42
|
+
*/
|
|
43
|
+
export interface WriteContext {
|
|
44
|
+
actor?: string | null;
|
|
45
|
+
via?: string | null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Normalize an attribution value: empty/whitespace → null (never store ""). */
|
|
49
|
+
function attrValue(v: string | null | undefined): string | null {
|
|
50
|
+
if (typeof v !== "string") return null;
|
|
51
|
+
const t = v.trim();
|
|
52
|
+
return t.length === 0 ? null : t;
|
|
53
|
+
}
|
|
54
|
+
|
|
25
55
|
/** Generate a timestamp-based ID: YYYY-MM-DD-HH-MM-SS-ffffff */
|
|
26
56
|
export function generateId(): string {
|
|
27
57
|
const now = new Date();
|
|
@@ -41,7 +71,7 @@ export function generateId(): string {
|
|
|
41
71
|
export function createNote(
|
|
42
72
|
db: Database,
|
|
43
73
|
content: string,
|
|
44
|
-
opts?: { id?: string; path?: string; tags?: string[]; metadata?: Record<string, unknown>; created_at?: string; extension?: string },
|
|
74
|
+
opts?: { id?: string; path?: string; tags?: string[]; metadata?: Record<string, unknown>; created_at?: string; extension?: string; actor?: string | null; via?: string | null },
|
|
45
75
|
): Note {
|
|
46
76
|
const id = opts?.id ?? generateId();
|
|
47
77
|
const createdAt = opts?.created_at ?? new Date().toISOString();
|
|
@@ -52,6 +82,14 @@ export function createNote(
|
|
|
52
82
|
// whatever the caller passed; importer paths trust the export's shape.
|
|
53
83
|
const extension = opts?.extension ?? "md";
|
|
54
84
|
|
|
85
|
+
// Write-attribution (vault#298). On CREATE both axes land in the
|
|
86
|
+
// `created_*` columns AND mirror into `last_updated_*` — the first write IS
|
|
87
|
+
// the most-recent write, so a never-updated note reports the same author on
|
|
88
|
+
// both. NULL when the caller passed no attribution (internal/import writes),
|
|
89
|
+
// never an empty string.
|
|
90
|
+
const actor = attrValue(opts?.actor);
|
|
91
|
+
const via = attrValue(opts?.via);
|
|
92
|
+
|
|
55
93
|
// Empty content is a valid state (vault#323): skeleton notes, drafts
|
|
56
94
|
// saved before content, organizing-only notes, capture-then-fill flows.
|
|
57
95
|
// The earlier #213 guard rejected `content + path both absent`; we no
|
|
@@ -66,8 +104,8 @@ export function createNote(
|
|
|
66
104
|
// "user-touched since creation."
|
|
67
105
|
try {
|
|
68
106
|
db.prepare(
|
|
69
|
-
`INSERT INTO notes (id, content, path, metadata, created_at, updated_at, extension) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
70
|
-
).run(id, content, path, metadata, createdAt, createdAt, extension);
|
|
107
|
+
`INSERT INTO notes (id, content, path, metadata, created_at, updated_at, extension, created_by, created_via, last_updated_by, last_updated_via) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
108
|
+
).run(id, content, path, metadata, createdAt, createdAt, extension, actor, via, actor, via);
|
|
71
109
|
} catch (err) {
|
|
72
110
|
if (path !== null && isPathUniqueError(err)) {
|
|
73
111
|
throw new PathConflictError(path);
|
|
@@ -170,6 +208,50 @@ export class ConflictError extends Error {
|
|
|
170
208
|
}
|
|
171
209
|
}
|
|
172
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
|
+
|
|
173
255
|
/**
|
|
174
256
|
* Thrown by `createNote` / `updateNote` when the requested path is already
|
|
175
257
|
* taken by another note. Surfaces as 409 at the HTTP layer so clients can
|
|
@@ -321,12 +403,34 @@ export function updateNote(
|
|
|
321
403
|
metadata?: Record<string, unknown>;
|
|
322
404
|
created_at?: string;
|
|
323
405
|
skipUpdatedAt?: boolean;
|
|
406
|
+
/**
|
|
407
|
+
* Write-attribution (vault#298). When set, the most-recent-write columns
|
|
408
|
+
* `last_updated_by` / `last_updated_via` are bumped to this principal /
|
|
409
|
+
* channel as part of the same UPDATE. Gated on the same condition as
|
|
410
|
+
* `updated_at` — a `skipUpdatedAt` machine write doesn't claim authorship,
|
|
411
|
+
* symmetric with not bumping the timestamp. `created_*` is never touched by
|
|
412
|
+
* an update (it's set-once at create). Omitted → attribution left as-is.
|
|
413
|
+
*/
|
|
414
|
+
actor?: string | null;
|
|
415
|
+
via?: string | null;
|
|
324
416
|
/**
|
|
325
417
|
* Optimistic concurrency token. When provided, the UPDATE runs with an
|
|
326
418
|
* additional `AND updated_at IS ?` clause; if no row is affected and the
|
|
327
419
|
* note still exists, a `ConflictError` is thrown.
|
|
328
420
|
*/
|
|
329
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 };
|
|
330
434
|
},
|
|
331
435
|
): Note {
|
|
332
436
|
if (updates.content !== undefined && (updates.append !== undefined || updates.prepend !== undefined)) {
|
|
@@ -358,6 +462,17 @@ export function updateNote(
|
|
|
358
462
|
}
|
|
359
463
|
sets.push("updated_at = ?");
|
|
360
464
|
values.push(now);
|
|
465
|
+
|
|
466
|
+
// Write-attribution (vault#298): the most-recent-write columns ride the
|
|
467
|
+
// SAME gate as `updated_at`. A `skipUpdatedAt` machine write doesn't bump
|
|
468
|
+
// the timestamp, so it doesn't claim authorship either. Set unconditionally
|
|
469
|
+
// within this branch (even to NULL) so a write that arrives without
|
|
470
|
+
// attribution honestly records "unknown author for the latest edit" rather
|
|
471
|
+
// than leaving a stale prior principal — the latest writer wasn't them.
|
|
472
|
+
sets.push("last_updated_by = ?");
|
|
473
|
+
values.push(attrValue(updates.actor));
|
|
474
|
+
sets.push("last_updated_via = ?");
|
|
475
|
+
values.push(attrValue(updates.via));
|
|
361
476
|
}
|
|
362
477
|
|
|
363
478
|
if (updates.content !== undefined) {
|
|
@@ -404,16 +519,37 @@ export function updateNote(
|
|
|
404
519
|
sets.push("extension = ?");
|
|
405
520
|
values.push(updates.extension);
|
|
406
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
|
+
}
|
|
407
535
|
if (updates.metadata !== undefined) {
|
|
408
536
|
sets.push("metadata = ?");
|
|
409
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));
|
|
410
545
|
}
|
|
411
546
|
if (updates.created_at !== undefined) {
|
|
412
547
|
sets.push("created_at = ?");
|
|
413
548
|
values.push(updates.created_at);
|
|
414
549
|
}
|
|
415
550
|
|
|
416
|
-
// 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
|
|
417
553
|
// need to validate the precondition; a conditional UPDATE that sets
|
|
418
554
|
// updated_at to itself does exactly that atomically — even a no-net-
|
|
419
555
|
// change UPDATE takes the write lock in WAL mode, so it still serializes
|
|
@@ -438,10 +574,23 @@ export function updateNote(
|
|
|
438
574
|
sql += " AND updated_at IS ?";
|
|
439
575
|
values.push(updates.if_updated_at);
|
|
440
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
|
+
}
|
|
441
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;
|
|
442
591
|
let matched: { id: string } | null = null;
|
|
443
592
|
try {
|
|
444
|
-
if (
|
|
593
|
+
if (conditional) {
|
|
445
594
|
matched = db.prepare(`${sql} RETURNING id`).get(...values) as
|
|
446
595
|
| { id: string }
|
|
447
596
|
| null;
|
|
@@ -461,13 +610,47 @@ export function updateNote(
|
|
|
461
610
|
throw err;
|
|
462
611
|
}
|
|
463
612
|
|
|
464
|
-
if (
|
|
465
|
-
|
|
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!);
|
|
466
638
|
}
|
|
467
639
|
|
|
468
640
|
return getNote(db, id)!;
|
|
469
641
|
}
|
|
470
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
|
+
|
|
471
654
|
function throwConflictOrMissing(db: Database, id: string, expected: string): never {
|
|
472
655
|
const row = db.prepare("SELECT updated_at, path FROM notes WHERE id = ?").get(id) as
|
|
473
656
|
| { updated_at: string | null; path: string | null }
|
|
@@ -601,6 +784,29 @@ export function queryNotes(db: Database, opts: QueryOpts): Note[] {
|
|
|
601
784
|
}
|
|
602
785
|
}
|
|
603
786
|
|
|
787
|
+
// Write-attribution filters (vault#298). Exact match on the indexed
|
|
788
|
+
// attribution columns. Each rides its own B-tree (idx_notes_<col>), so
|
|
789
|
+
// "everything Mathilda wrote" / "everything via the meeting-ingest surface"
|
|
790
|
+
// is a seek, not a scan. NULL columns never match a non-null filter value
|
|
791
|
+
// (SQL `=` is unknown against NULL), so legacy/unattributed rows are
|
|
792
|
+
// correctly excluded from an attribution query.
|
|
793
|
+
if (opts.createdBy !== undefined) {
|
|
794
|
+
conditions.push("n.created_by = ?");
|
|
795
|
+
params.push(opts.createdBy);
|
|
796
|
+
}
|
|
797
|
+
if (opts.lastUpdatedBy !== undefined) {
|
|
798
|
+
conditions.push("n.last_updated_by = ?");
|
|
799
|
+
params.push(opts.lastUpdatedBy);
|
|
800
|
+
}
|
|
801
|
+
if (opts.createdVia !== undefined) {
|
|
802
|
+
conditions.push("n.created_via = ?");
|
|
803
|
+
params.push(opts.createdVia);
|
|
804
|
+
}
|
|
805
|
+
if (opts.lastUpdatedVia !== undefined) {
|
|
806
|
+
conditions.push("n.last_updated_via = ?");
|
|
807
|
+
params.push(opts.lastUpdatedVia);
|
|
808
|
+
}
|
|
809
|
+
|
|
604
810
|
// Metadata filters — operator objects route through the indexed generated
|
|
605
811
|
// column (fast, loud errors on non-indexed fields); primitives keep the
|
|
606
812
|
// existing JSON-scan exact-match behavior for backcompat.
|
|
@@ -898,6 +1104,10 @@ function toQueryHashInputs(opts: QueryOpts): QueryHashInputs {
|
|
|
898
1104
|
extension: opts.extension,
|
|
899
1105
|
ids: opts.ids,
|
|
900
1106
|
metadata: opts.metadata,
|
|
1107
|
+
createdBy: opts.createdBy,
|
|
1108
|
+
lastUpdatedBy: opts.lastUpdatedBy,
|
|
1109
|
+
createdVia: opts.createdVia,
|
|
1110
|
+
lastUpdatedVia: opts.lastUpdatedVia,
|
|
901
1111
|
dateFrom: opts.dateFrom,
|
|
902
1112
|
dateTo: opts.dateTo,
|
|
903
1113
|
dateFilter: opts.dateFilter,
|
|
@@ -1587,6 +1797,10 @@ export function toNoteIndex(note: Note): NoteIndex {
|
|
|
1587
1797
|
extension: note.extension,
|
|
1588
1798
|
createdAt: note.createdAt,
|
|
1589
1799
|
updatedAt: note.updatedAt,
|
|
1800
|
+
createdBy: note.createdBy ?? null,
|
|
1801
|
+
createdVia: note.createdVia ?? null,
|
|
1802
|
+
lastUpdatedBy: note.lastUpdatedBy ?? null,
|
|
1803
|
+
lastUpdatedVia: note.lastUpdatedVia ?? null,
|
|
1590
1804
|
tags: note.tags,
|
|
1591
1805
|
metadata: note.metadata,
|
|
1592
1806
|
byteSize,
|
|
@@ -1617,6 +1831,43 @@ export function filterMetadata(obj: any, includeMetadata: boolean | string[] | u
|
|
|
1617
1831
|
return { ...obj, metadata: Object.keys(filtered).length > 0 ? filtered : undefined };
|
|
1618
1832
|
}
|
|
1619
1833
|
|
|
1834
|
+
/**
|
|
1835
|
+
* Shallow-merge an incoming metadata patch onto a note's existing metadata,
|
|
1836
|
+
* with RFC 7386 (JSON Merge Patch) null-as-delete semantics — the canonical
|
|
1837
|
+
* merge for every metadata write surface (REST `PATCH /api/notes`, MCP
|
|
1838
|
+
* `update-note`, batch).
|
|
1839
|
+
*
|
|
1840
|
+
* An incoming value of `null` is a DELETE tombstone: the key is removed from
|
|
1841
|
+
* the result rather than persisted as a literal JSON `null`. This is the only
|
|
1842
|
+
* way to remove a metadata key through the API — under a plain
|
|
1843
|
+
* `{ ...existing, ...incoming }` merge, omission can't delete (it preserves the
|
|
1844
|
+
* prior value) and `null` used to persist literally, leaving no removal path.
|
|
1845
|
+
* Now key renames are pure-API: `{ new_key: "v", "old-key": null }` adds the
|
|
1846
|
+
* new key and drops the old one in one PATCH. See vault#478 / #479.
|
|
1847
|
+
*
|
|
1848
|
+
* Shallow by design — top-level keys only, matching the existing wholesale
|
|
1849
|
+
* top-level merge. A nested object value replaces its key wholesale (we do not
|
|
1850
|
+
* recurse), so an incoming `null` removes the whole key regardless of depth.
|
|
1851
|
+
* Compat: storing a literal null is no longer possible via this path; the
|
|
1852
|
+
* boulder migration confirmed zero callers relied on that (vault#478). A caller
|
|
1853
|
+
* that genuinely needs an absent-vs-null distinction should model it with a
|
|
1854
|
+
* sentinel string, not a JSON null.
|
|
1855
|
+
*/
|
|
1856
|
+
export function mergeMetadata(
|
|
1857
|
+
existing: Record<string, unknown> | null | undefined,
|
|
1858
|
+
incoming: Record<string, unknown>,
|
|
1859
|
+
): Record<string, unknown> {
|
|
1860
|
+
const result: Record<string, unknown> = { ...(existing ?? {}) };
|
|
1861
|
+
for (const [key, value] of Object.entries(incoming)) {
|
|
1862
|
+
if (value === null) {
|
|
1863
|
+
delete result[key];
|
|
1864
|
+
} else {
|
|
1865
|
+
result[key] = value;
|
|
1866
|
+
}
|
|
1867
|
+
}
|
|
1868
|
+
return result;
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1620
1871
|
// ---- Vault stats (aggregate situational awareness) ----
|
|
1621
1872
|
|
|
1622
1873
|
/**
|
|
@@ -1703,6 +1954,10 @@ export interface BulkNoteInput {
|
|
|
1703
1954
|
metadata?: Record<string, unknown>;
|
|
1704
1955
|
created_at?: string;
|
|
1705
1956
|
extension?: string;
|
|
1957
|
+
/** Write-attribution (vault#298) — see WriteContext. Per-item so a batch can
|
|
1958
|
+
* carry the same actor/via on every row. */
|
|
1959
|
+
actor?: string | null;
|
|
1960
|
+
via?: string | null;
|
|
1706
1961
|
}
|
|
1707
1962
|
|
|
1708
1963
|
export function createNotes(db: Database, inputs: BulkNoteInput[]): Note[] {
|
|
@@ -1719,6 +1974,8 @@ export function createNotes(db: Database, inputs: BulkNoteInput[]): Note[] {
|
|
|
1719
1974
|
metadata: input.metadata,
|
|
1720
1975
|
created_at: input.created_at,
|
|
1721
1976
|
extension: input.extension,
|
|
1977
|
+
actor: input.actor,
|
|
1978
|
+
via: input.via,
|
|
1722
1979
|
}),
|
|
1723
1980
|
);
|
|
1724
1981
|
}
|
|
@@ -1787,6 +2044,14 @@ interface NoteRow {
|
|
|
1787
2044
|
created_at: string;
|
|
1788
2045
|
updated_at: string | null;
|
|
1789
2046
|
extension: string | null;
|
|
2047
|
+
// Write-attribution (vault#298). All four nullable — NULL on legacy rows and
|
|
2048
|
+
// on writes that carried no attribution context. A v22 vault reading these
|
|
2049
|
+
// before its migration runs would see them absent on the row object
|
|
2050
|
+
// (`SELECT *` simply omits non-existent columns); `?? null` normalizes.
|
|
2051
|
+
created_by?: string | null;
|
|
2052
|
+
created_via?: string | null;
|
|
2053
|
+
last_updated_by?: string | null;
|
|
2054
|
+
last_updated_via?: string | null;
|
|
1790
2055
|
}
|
|
1791
2056
|
|
|
1792
2057
|
function rowToNote(row: NoteRow): Note {
|
|
@@ -1807,5 +2072,13 @@ function rowToNote(row: NoteRow): Note {
|
|
|
1807
2072
|
// Legacy notes (pre-#70) may have NULL updated_at. Fall back to created_at
|
|
1808
2073
|
// so the optimistic-concurrency contract always has a real token to echo.
|
|
1809
2074
|
updatedAt: row.updated_at ?? row.created_at,
|
|
2075
|
+
// Write-attribution (vault#298). NULL passes through verbatim — a missing
|
|
2076
|
+
// author is meaningful ("pre-attribution / unknown"), so we don't coerce
|
|
2077
|
+
// to a placeholder. `?? null` collapses both SQL NULL and an absent column
|
|
2078
|
+
// (pre-migration read) to the same `null`.
|
|
2079
|
+
createdBy: row.created_by ?? null,
|
|
2080
|
+
createdVia: row.created_via ?? null,
|
|
2081
|
+
lastUpdatedBy: row.last_updated_by ?? null,
|
|
2082
|
+
lastUpdatedVia: row.last_updated_via ?? null,
|
|
1810
2083
|
};
|
|
1811
2084
|
}
|
|
@@ -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
|
+
}
|