@openparachute/vault 0.6.4 → 0.6.5-rc.2
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/core/src/__fixtures__/golden-vault.ts +125 -0
- package/core/src/__fixtures__/portable-export-golden.json +10 -0
- package/core/src/mcp.ts +306 -314
- package/core/src/notes.ts +21 -47
- package/core/src/portable-md-batching.test.ts +67 -0
- package/core/src/portable-md-golden.test.ts +55 -0
- package/core/src/portable-md.ts +579 -435
- package/core/src/schema.ts +21 -43
- package/core/src/store.ts +14 -9
- package/core/src/txn.test.ts +229 -0
- package/core/src/txn.ts +105 -0
- package/core/src/types.ts +9 -0
- package/package.json +1 -1
- package/src/routes.ts +8 -7
package/core/src/mcp.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
2
|
import type { Store, Note } from "./types.js";
|
|
3
|
+
import { transactionAsync } from "./txn.js";
|
|
3
4
|
import * as noteOps from "./notes.js";
|
|
4
5
|
import { filterMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError } from "./notes.js";
|
|
5
6
|
import { QueryError } from "./query-operators.js";
|
|
@@ -711,18 +712,16 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
711
712
|
// colliding with concurrent callers on the shared bun:sqlite
|
|
712
713
|
// connection.
|
|
713
714
|
const batched = items.length > 1;
|
|
714
|
-
|
|
715
|
-
try {
|
|
715
|
+
const runBatch = async (): Promise<void> => {
|
|
716
716
|
for (const item of items) {
|
|
717
717
|
// Validate extension up front (vault#328). Throwing here while
|
|
718
|
-
//
|
|
719
|
-
//
|
|
720
|
-
// path conflict mid-batch.
|
|
718
|
+
// inside the batch transaction rolls it back — the same behavior
|
|
719
|
+
// as a path conflict mid-batch.
|
|
721
720
|
const extension = item.extension !== undefined
|
|
722
721
|
? validateExtension(item.extension)
|
|
723
722
|
: undefined;
|
|
724
723
|
// Strict-schema gate (vault#299) — reject before any write so a
|
|
725
|
-
// mid-batch violation rolls back
|
|
724
|
+
// mid-batch violation rolls back the batch transaction.
|
|
726
725
|
enforceStrict({
|
|
727
726
|
path: item.path as string | undefined,
|
|
728
727
|
tags: item.tags as string[] | undefined,
|
|
@@ -752,11 +751,8 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
752
751
|
|
|
753
752
|
created.push(noteOps.getNote(db, note.id) ?? note);
|
|
754
753
|
}
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
if (batched) db.exec("ROLLBACK");
|
|
758
|
-
throw e;
|
|
759
|
-
}
|
|
754
|
+
};
|
|
755
|
+
await (batched ? transactionAsync(db, runBatch) : runBatch());
|
|
760
756
|
|
|
761
757
|
// Apply tag schema effects, then re-read the notes whose metadata was
|
|
762
758
|
// actually default-filled so the response reflects the final on-disk
|
|
@@ -963,333 +959,329 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
963
959
|
// Single-item calls skip the wrap so concurrent callers don't
|
|
964
960
|
// collide on the shared bun:sqlite connection.
|
|
965
961
|
const batched = items.length > 1;
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
:
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
962
|
+
const runBatch = async (): Promise<void> => {
|
|
963
|
+
for (const item of items) {
|
|
964
|
+
// Try ID-then-path resolve. If not found AND
|
|
965
|
+
// `if_missing: "create"` is set, fall through to the create
|
|
966
|
+
// branch using this same item's payload. Otherwise mirror the
|
|
967
|
+
// existing `requireNote` behavior (throw "Note not found").
|
|
968
|
+
// vault#309.
|
|
969
|
+
const resolved = resolveNote(db, item.id as string);
|
|
970
|
+
if (!resolved) {
|
|
971
|
+
if (item.if_missing === "create") {
|
|
972
|
+
// Treat the update payload as a create payload. Minimum:
|
|
973
|
+
// content OR a path/id (something the createNote-empty-row
|
|
974
|
+
// invariant accepts). createNote enforces its own
|
|
975
|
+
// not-both-empty check — we leave that to the Store and
|
|
976
|
+
// surface any error to the caller verbatim.
|
|
977
|
+
//
|
|
978
|
+
// Field mapping (mirrors the create-note tool surface):
|
|
979
|
+
// - `item.id` → both the note's `id` AND a fallback
|
|
980
|
+
// `path` when `item.path` isn't set. Treating `id` as
|
|
981
|
+
// the path-or-id lookup key matches Gitcoin's nightly
|
|
982
|
+
// sync shape where the canonical key is a path string
|
|
983
|
+
// like "Inbox/2026-05-13-meeting". If the caller
|
|
984
|
+
// supplied an opaque ULID as `id` and no `path`, we
|
|
985
|
+
// still create with that as `id` (path stays null).
|
|
986
|
+
// - `item.content` / `item.path` / `item.tags` /
|
|
987
|
+
// `item.metadata` / `item.created_at` → forwarded.
|
|
988
|
+
// - `if_updated_at` / `force` / `content_edit` /
|
|
989
|
+
// `append` / `prepend` are update-only — silently
|
|
990
|
+
// ignored on the create branch. (Content-edit on a
|
|
991
|
+
// non-existent note is a nonsense combination; the
|
|
992
|
+
// caller's intent on missing-note is "create the
|
|
993
|
+
// row", not "patch in this section".)
|
|
994
|
+
// - `links.remove` is also ignored on create (nothing
|
|
995
|
+
// to remove on a fresh note).
|
|
996
|
+
// - `links.add` IS applied below — the drift sync can
|
|
997
|
+
// declare typed links at upsert time and have them
|
|
998
|
+
// materialize alongside the create. See vault#320
|
|
999
|
+
// reviewer F1 — the prior comment claimed all
|
|
1000
|
+
// `links` were ignored, but `links.add` was already
|
|
1001
|
+
// processed and used by Gitcoin's sync; the
|
|
1002
|
+
// misleading wording is fixed here so a future
|
|
1003
|
+
// reader doesn't trust it and break the workflow.
|
|
1004
|
+
const idOrPath = item.id as string;
|
|
1005
|
+
// Heuristic: if `path` isn't set AND the `id` looks like a
|
|
1006
|
+
// path (contains "/" or doesn't match a typical opaque-id
|
|
1007
|
+
// shape), use it as the path too. Otherwise treat it as a
|
|
1008
|
+
// pure id. The shared `id` field for update is ID-or-path
|
|
1009
|
+
// already (see `resolveNote`), so this preserves the
|
|
1010
|
+
// caller's intent.
|
|
1011
|
+
const idLooksLikePath = idOrPath.includes("/") || !/^[A-Za-z0-9_-]+$/.test(idOrPath);
|
|
1012
|
+
const explicitPath = typeof item.path === "string" ? item.path as string : undefined;
|
|
1013
|
+
// Validate extension before reaching the Store — same
|
|
1014
|
+
// contract as the create-note tool.
|
|
1015
|
+
const createExt = item.extension !== undefined
|
|
1016
|
+
? validateExtension(item.extension)
|
|
1017
|
+
: undefined;
|
|
1018
|
+
const createOpts: Parameters<Store["createNote"]>[1] = {
|
|
1019
|
+
...(idLooksLikePath ? { path: explicitPath ?? idOrPath } : { id: idOrPath, ...(explicitPath !== undefined ? { path: explicitPath } : {}) }),
|
|
1020
|
+
...(item.tags && Array.isArray((item.tags as any).add)
|
|
1021
|
+
? { tags: (item.tags as any).add as string[] }
|
|
1022
|
+
: Array.isArray(item.tags)
|
|
1023
|
+
? { tags: item.tags as string[] }
|
|
1024
|
+
: {}),
|
|
1025
|
+
...(item.metadata !== undefined ? { metadata: item.metadata as Record<string, unknown> } : {}),
|
|
1026
|
+
...(item.created_at !== undefined ? { created_at: item.created_at as string } : {}),
|
|
1027
|
+
...(createExt !== undefined ? { extension: createExt } : {}),
|
|
1028
|
+
// Write-attribution (vault#298) — the if_missing:"create" upsert
|
|
1029
|
+
// branch is still a CREATE, so it must stamp the same actor/via
|
|
1030
|
+
// as the create-note tool + the REST upsert-create path. Without
|
|
1031
|
+
// this an MCP-driven upsert-create wrote NULL attribution.
|
|
1032
|
+
actor: writeActor,
|
|
1033
|
+
via: writeVia,
|
|
1034
|
+
};
|
|
1035
|
+
const content = (item.content as string | undefined) ?? "";
|
|
1036
|
+
// Strict-schema gate (vault#299) — the if_missing:"create"
|
|
1037
|
+
// branch is still a create, so it enforces too. Tags come from
|
|
1038
|
+
// createOpts (already normalized from the {add} dict / array).
|
|
1039
|
+
enforceStrict({
|
|
1040
|
+
path: createOpts.path ?? undefined,
|
|
1041
|
+
tags: createOpts.tags,
|
|
1042
|
+
metadata: createOpts.metadata,
|
|
1043
|
+
});
|
|
1044
|
+
const created = await store.createNote(content, createOpts);
|
|
1045
|
+
await applySchemaDefaults(store, db, [created.id], created.tags ?? []);
|
|
1046
|
+
// Apply links.add if the caller declared any.
|
|
1047
|
+
const linksAdd = (item.links as any)?.add as { target: string; relationship: string; metadata?: Record<string, unknown> }[] | undefined;
|
|
1048
|
+
if (linksAdd) {
|
|
1049
|
+
for (const link of linksAdd) {
|
|
1050
|
+
const target = resolveNote(db, link.target);
|
|
1051
|
+
if (target) await store.createLink(created.id, target.id, link.relationship, link.metadata);
|
|
1052
|
+
}
|
|
1057
1053
|
}
|
|
1054
|
+
const fresh = noteOps.getNote(db, created.id) ?? created;
|
|
1055
|
+
updated.push(fresh);
|
|
1056
|
+
createdIds.add(fresh.id);
|
|
1057
|
+
// Echo links if this create-on-missing declared `links.add`
|
|
1058
|
+
// (the only link op honored on create) or asked explicitly.
|
|
1059
|
+
if (linksAdd !== undefined || item.include_links === true) {
|
|
1060
|
+
echoLinkIds.add(fresh.id);
|
|
1061
|
+
}
|
|
1062
|
+
continue;
|
|
1058
1063
|
}
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
// (the only link op honored on create) or asked explicitly.
|
|
1064
|
-
if (linksAdd !== undefined || item.include_links === true) {
|
|
1065
|
-
echoLinkIds.add(fresh.id);
|
|
1066
|
-
}
|
|
1067
|
-
continue;
|
|
1064
|
+
// Fallthrough: not-found + no if_missing → existing error
|
|
1065
|
+
// contract. Match `requireNote`'s message shape so existing
|
|
1066
|
+
// callers see no behavior change.
|
|
1067
|
+
throw new Error(`Note not found: "${item.id}"`);
|
|
1068
1068
|
}
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
//
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
const hasContent = item.content !== undefined;
|
|
1078
|
-
const hasAppendPrepend = item.append !== undefined || item.prepend !== undefined;
|
|
1079
|
-
const hasContentEdit = item.content_edit !== undefined;
|
|
1080
|
-
const contentModes = (hasContent ? 1 : 0) + (hasAppendPrepend ? 1 : 0) + (hasContentEdit ? 1 : 0);
|
|
1081
|
-
if (contentModes > 1) {
|
|
1082
|
-
throw new Error(
|
|
1083
|
-
`update-note: \`content\`, \`append\`/\`prepend\`, and \`content_edit\` are mutually exclusive — pick one mode of content update for note "${note.id}".`,
|
|
1084
|
-
);
|
|
1085
|
-
}
|
|
1086
|
-
|
|
1087
|
-
// --- Safety-by-default: refuse mutations without a precondition ---
|
|
1088
|
-
// The caller must either echo the note's last-seen `updated_at`
|
|
1089
|
-
// (`if_updated_at`) so the conditional UPDATE can catch lost
|
|
1090
|
-
// writes, or explicitly opt out with `force: true`. This runs
|
|
1091
|
-
// *before* any DB writes so a rejection leaves the note untouched.
|
|
1092
|
-
//
|
|
1093
|
-
// Append/prepend-only updates are exempt: they're SQL-atomic
|
|
1094
|
-
// concatenations that can't lose data on a stale read, so the
|
|
1095
|
-
// precondition would be ceremony for no benefit. Tag and link
|
|
1096
|
-
// mutations are *not* exempt — they're idempotent set-ops at
|
|
1097
|
-
// the SQL layer but still represent a non-content change the
|
|
1098
|
-
// caller should have observed before re-asserting (#201).
|
|
1099
|
-
const isAppendOnly = hasAppendPrepend
|
|
1100
|
-
&& !hasContent
|
|
1101
|
-
&& !hasContentEdit
|
|
1102
|
-
&& item.path === undefined
|
|
1103
|
-
&& item.metadata === undefined
|
|
1104
|
-
&& item.created_at === undefined
|
|
1105
|
-
&& item.tags === undefined
|
|
1106
|
-
&& item.links === undefined;
|
|
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) {
|
|
1120
|
-
throw new PreconditionRequiredError(note.id, note.path ?? null);
|
|
1121
|
-
}
|
|
1122
|
-
|
|
1123
|
-
// --- Resolve content_edit into a full content string ---
|
|
1124
|
-
// We do the find-and-replace at the JS level (read note.content,
|
|
1125
|
-
// validate occurrence count, replace). The race window between
|
|
1126
|
-
// this read and the UPDATE is closed by `if_updated_at` for
|
|
1127
|
-
// strict callers; without it, content_edit is fail-closed —
|
|
1128
|
-
// a stale read where someone else removed `old_text` produces
|
|
1129
|
-
// a "not found" error instead of silently overwriting.
|
|
1130
|
-
let contentOverride = item.content as string | undefined;
|
|
1131
|
-
if (hasContentEdit) {
|
|
1132
|
-
const ce = item.content_edit as { old_text: string; new_text: string };
|
|
1133
|
-
if (typeof ce?.old_text !== "string" || typeof ce?.new_text !== "string") {
|
|
1069
|
+
const note = resolved;
|
|
1070
|
+
|
|
1071
|
+
// --- Validate mutual exclusion of content modes ---
|
|
1072
|
+
const hasContent = item.content !== undefined;
|
|
1073
|
+
const hasAppendPrepend = item.append !== undefined || item.prepend !== undefined;
|
|
1074
|
+
const hasContentEdit = item.content_edit !== undefined;
|
|
1075
|
+
const contentModes = (hasContent ? 1 : 0) + (hasAppendPrepend ? 1 : 0) + (hasContentEdit ? 1 : 0);
|
|
1076
|
+
if (contentModes > 1) {
|
|
1134
1077
|
throw new Error(
|
|
1135
|
-
|
|
1078
|
+
`update-note: \`content\`, \`append\`/\`prepend\`, and \`content_edit\` are mutually exclusive — pick one mode of content update for note "${note.id}".`,
|
|
1136
1079
|
);
|
|
1137
1080
|
}
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1081
|
+
|
|
1082
|
+
// --- Safety-by-default: refuse mutations without a precondition ---
|
|
1083
|
+
// The caller must either echo the note's last-seen `updated_at`
|
|
1084
|
+
// (`if_updated_at`) so the conditional UPDATE can catch lost
|
|
1085
|
+
// writes, or explicitly opt out with `force: true`. This runs
|
|
1086
|
+
// *before* any DB writes so a rejection leaves the note untouched.
|
|
1087
|
+
//
|
|
1088
|
+
// Append/prepend-only updates are exempt: they're SQL-atomic
|
|
1089
|
+
// concatenations that can't lose data on a stale read, so the
|
|
1090
|
+
// precondition would be ceremony for no benefit. Tag and link
|
|
1091
|
+
// mutations are *not* exempt — they're idempotent set-ops at
|
|
1092
|
+
// the SQL layer but still represent a non-content change the
|
|
1093
|
+
// caller should have observed before re-asserting (#201).
|
|
1094
|
+
const isAppendOnly = hasAppendPrepend
|
|
1095
|
+
&& !hasContent
|
|
1096
|
+
&& !hasContentEdit
|
|
1097
|
+
&& item.path === undefined
|
|
1098
|
+
&& item.metadata === undefined
|
|
1099
|
+
&& item.created_at === undefined
|
|
1100
|
+
&& item.tags === undefined
|
|
1101
|
+
&& item.links === undefined;
|
|
1102
|
+
// A state_transition is itself a compare-and-set precondition
|
|
1103
|
+
// (vault#299 Part B) — a transition-only update doesn't need
|
|
1104
|
+
// `if_updated_at`/`force`, the CAS guards the lost-write window.
|
|
1105
|
+
const isTransitionOnly = item.state_transition !== undefined
|
|
1106
|
+
&& !hasContent
|
|
1107
|
+
&& !hasAppendPrepend
|
|
1108
|
+
&& !hasContentEdit
|
|
1109
|
+
&& item.path === undefined
|
|
1110
|
+
&& item.metadata === undefined
|
|
1111
|
+
&& item.created_at === undefined
|
|
1112
|
+
&& item.tags === undefined
|
|
1113
|
+
&& item.links === undefined;
|
|
1114
|
+
if (!isAppendOnly && !isTransitionOnly && item.if_updated_at === undefined && item.force !== true) {
|
|
1115
|
+
throw new PreconditionRequiredError(note.id, note.path ?? null);
|
|
1143
1116
|
}
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1117
|
+
|
|
1118
|
+
// --- Resolve content_edit into a full content string ---
|
|
1119
|
+
// We do the find-and-replace at the JS level (read note.content,
|
|
1120
|
+
// validate occurrence count, replace). The race window between
|
|
1121
|
+
// this read and the UPDATE is closed by `if_updated_at` for
|
|
1122
|
+
// strict callers; without it, content_edit is fail-closed —
|
|
1123
|
+
// a stale read where someone else removed `old_text` produces
|
|
1124
|
+
// a "not found" error instead of silently overwriting.
|
|
1125
|
+
let contentOverride = item.content as string | undefined;
|
|
1126
|
+
if (hasContentEdit) {
|
|
1127
|
+
const ce = item.content_edit as { old_text: string; new_text: string };
|
|
1128
|
+
if (typeof ce?.old_text !== "string" || typeof ce?.new_text !== "string") {
|
|
1129
|
+
throw new Error(
|
|
1130
|
+
"update-note: `content_edit` requires { old_text: string, new_text: string }.",
|
|
1131
|
+
);
|
|
1132
|
+
}
|
|
1133
|
+
const idx = note.content.indexOf(ce.old_text);
|
|
1134
|
+
if (idx < 0) {
|
|
1135
|
+
throw new Error(
|
|
1136
|
+
`update-note content_edit: \`old_text\` not found in note "${note.id}". The note may have been edited — re-read and retry.`,
|
|
1137
|
+
);
|
|
1138
|
+
}
|
|
1139
|
+
const second = note.content.indexOf(ce.old_text, idx + 1);
|
|
1140
|
+
if (second >= 0) {
|
|
1141
|
+
throw new Error(
|
|
1142
|
+
`update-note content_edit: \`old_text\` matches multiple times in note "${note.id}" — must match exactly once. Add surrounding context to disambiguate.`,
|
|
1143
|
+
);
|
|
1144
|
+
}
|
|
1145
|
+
contentOverride = note.content.slice(0, idx) + ce.new_text + note.content.slice(idx + ce.old_text.length);
|
|
1149
1146
|
}
|
|
1150
|
-
contentOverride = note.content.slice(0, idx) + ce.new_text + note.content.slice(idx + ce.old_text.length);
|
|
1151
|
-
}
|
|
1152
1147
|
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1148
|
+
// --- Plan bracket cleanup for wikilink removals (no DB writes yet) ---
|
|
1149
|
+
// We compute the cleaned content so we can do the core UPDATE first
|
|
1150
|
+
// (with if_updated_at atomically) before any link deletions. If the
|
|
1151
|
+
// UPDATE fails on a conflict, nothing has been mutated.
|
|
1152
|
+
const linksRemove = (item.links as any)?.remove as { target: string; relationship: string }[] | undefined;
|
|
1153
|
+
const resolvedLinksToRemove: { targetId: string; relationship: string }[] = [];
|
|
1154
|
+
if (linksRemove) {
|
|
1155
|
+
for (const link of linksRemove) {
|
|
1156
|
+
const target = resolveNote(db, link.target);
|
|
1157
|
+
if (!target) continue;
|
|
1158
|
+
resolvedLinksToRemove.push({ targetId: target.id, relationship: link.relationship });
|
|
1159
|
+
if (link.relationship === "wikilink" && target.path) {
|
|
1160
|
+
// Wikilink-removal bracket cleanup operates on the prospective
|
|
1161
|
+
// *full* content. Coexists with content_edit; would fight
|
|
1162
|
+
// append/prepend (which leave existing content untouched at
|
|
1163
|
+
// the JS layer), so we pre-materialize the would-be content
|
|
1164
|
+
// for those callers and switch to a `content`-style update.
|
|
1165
|
+
const currentContent = contentOverride
|
|
1166
|
+
?? (hasAppendPrepend
|
|
1167
|
+
? (item.prepend as string ?? "") + note.content + (item.append as string ?? "")
|
|
1168
|
+
: note.content);
|
|
1169
|
+
const cleaned = removeWikilinkBrackets(currentContent, target.path);
|
|
1170
|
+
if (cleaned !== currentContent) {
|
|
1171
|
+
contentOverride = cleaned;
|
|
1172
|
+
}
|
|
1177
1173
|
}
|
|
1178
1174
|
}
|
|
1179
1175
|
}
|
|
1180
|
-
}
|
|
1181
1176
|
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
);
|
|
1204
|
-
}
|
|
1205
|
-
if (item.created_at !== undefined) updates.created_at = item.created_at;
|
|
1206
|
-
if (item.if_updated_at !== undefined) updates.if_updated_at = item.if_updated_at as string;
|
|
1207
|
-
// Compare-and-set state transition (vault#299 Part B). Combinable
|
|
1208
|
-
// with other field updates — it folds into the same atomic UPDATE.
|
|
1209
|
-
const stItem = item.state_transition as { field?: unknown; from?: unknown; to?: unknown } | undefined;
|
|
1210
|
-
if (stItem !== undefined) {
|
|
1211
|
-
if (typeof stItem.field !== "string" || stItem.field.length === 0) {
|
|
1212
|
-
throw new Error(
|
|
1213
|
-
`update-note: \`state_transition.field\` must be a non-empty string (note "${note.id}").`,
|
|
1177
|
+
// --- Core update (content, path, metadata, created_at + concurrency check) ---
|
|
1178
|
+
const updates: any = {};
|
|
1179
|
+
if (contentOverride !== undefined) {
|
|
1180
|
+
updates.content = contentOverride;
|
|
1181
|
+
} else if (hasAppendPrepend) {
|
|
1182
|
+
// No content_edit and no wikilink-removal pre-materialization —
|
|
1183
|
+
// route the append/prepend down to the SQL-atomic path.
|
|
1184
|
+
if (item.append !== undefined) updates.append = item.append;
|
|
1185
|
+
if (item.prepend !== undefined) updates.prepend = item.prepend;
|
|
1186
|
+
}
|
|
1187
|
+
if (item.path !== undefined) updates.path = item.path;
|
|
1188
|
+
if (item.extension !== undefined) {
|
|
1189
|
+
updates.extension = validateExtension(item.extension);
|
|
1190
|
+
}
|
|
1191
|
+
if (item.metadata !== undefined) {
|
|
1192
|
+
// Merge metadata (RFC 7386: keys are merged, incoming `null`
|
|
1193
|
+
// removes the key rather than persisting a literal null —
|
|
1194
|
+
// vault#478/#479). Mirrors the REST PATCH path.
|
|
1195
|
+
updates.metadata = noteOps.mergeMetadata(
|
|
1196
|
+
note.metadata as Record<string, unknown> | null | undefined,
|
|
1197
|
+
item.metadata as Record<string, unknown>,
|
|
1214
1198
|
);
|
|
1215
1199
|
}
|
|
1216
|
-
|
|
1217
|
-
|
|
1200
|
+
if (item.created_at !== undefined) updates.created_at = item.created_at;
|
|
1201
|
+
if (item.if_updated_at !== undefined) updates.if_updated_at = item.if_updated_at as string;
|
|
1202
|
+
// Compare-and-set state transition (vault#299 Part B). Combinable
|
|
1203
|
+
// with other field updates — it folds into the same atomic UPDATE.
|
|
1204
|
+
const stItem = item.state_transition as { field?: unknown; from?: unknown; to?: unknown } | undefined;
|
|
1205
|
+
if (stItem !== undefined) {
|
|
1206
|
+
if (typeof stItem.field !== "string" || stItem.field.length === 0) {
|
|
1207
|
+
throw new Error(
|
|
1208
|
+
`update-note: \`state_transition.field\` must be a non-empty string (note "${note.id}").`,
|
|
1209
|
+
);
|
|
1210
|
+
}
|
|
1211
|
+
updates.state_transition = { field: stItem.field, from: stItem.from, to: stItem.to };
|
|
1212
|
+
}
|
|
1218
1213
|
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1214
|
+
// --- Strict-schema gate (vault#299 Part A) ---
|
|
1215
|
+
// Validate the PROSPECTIVE shape (final tags + merged metadata,
|
|
1216
|
+
// including a state_transition's `to`) before the write so a
|
|
1217
|
+
// rejection leaves the note untouched.
|
|
1218
|
+
{
|
|
1219
|
+
const removeSet = new Set<string>((item.tags as any)?.remove ?? []);
|
|
1220
|
+
const projectedTags = new Set<string>((note.tags ?? []).filter((t) => !removeSet.has(t)));
|
|
1221
|
+
for (const t of ((item.tags as any)?.add as string[] | undefined) ?? []) projectedTags.add(t);
|
|
1222
|
+
const baseMeta = updates.metadata ?? ((note.metadata as Record<string, unknown>) ?? {});
|
|
1223
|
+
const projectedMeta = stItem !== undefined
|
|
1224
|
+
? { ...baseMeta, [stItem.field as string]: stItem.to }
|
|
1225
|
+
: baseMeta;
|
|
1226
|
+
enforceStrict({ path: note.path, tags: [...projectedTags], metadata: projectedMeta });
|
|
1227
|
+
}
|
|
1233
1228
|
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1229
|
+
let result: Note;
|
|
1230
|
+
if (Object.keys(updates).length > 0) {
|
|
1231
|
+
// Write-attribution (vault#298): stamp the most-recent-write
|
|
1232
|
+
// columns on the same UPDATE that bumps `updated_at`. Only set when
|
|
1233
|
+
// there's a real change to write (the empty-updates branch below
|
|
1234
|
+
// leaves attribution untouched, symmetric with not bumping
|
|
1235
|
+
// updated_at on a no-op).
|
|
1236
|
+
updates.actor = writeActor;
|
|
1237
|
+
updates.via = writeVia;
|
|
1238
|
+
// store.updateNote routes through noteOps.updateNote, which runs
|
|
1239
|
+
// the UPDATE (with optional `AND updated_at IS ?`) atomically and
|
|
1240
|
+
// throws ConflictError on mismatch. No mutations have happened
|
|
1241
|
+
// yet, so a throw here leaves the note untouched.
|
|
1242
|
+
result = await store.updateNote(note.id, updates);
|
|
1243
|
+
} else {
|
|
1244
|
+
result = note;
|
|
1245
|
+
}
|
|
1251
1246
|
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1247
|
+
// --- Remove links (after core UPDATE so a conflict leaves them intact) ---
|
|
1248
|
+
for (const { targetId, relationship } of resolvedLinksToRemove) {
|
|
1249
|
+
await store.deleteLink(note.id, targetId, relationship);
|
|
1250
|
+
}
|
|
1256
1251
|
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1252
|
+
// --- Tags ---
|
|
1253
|
+
const tagsOp = item.tags as { add?: string[]; remove?: string[] } | undefined;
|
|
1254
|
+
if (tagsOp?.add?.length) {
|
|
1255
|
+
await store.tagNote(note.id, tagsOp.add);
|
|
1256
|
+
await applySchemaDefaults(store, db, [note.id], tagsOp.add);
|
|
1257
|
+
}
|
|
1258
|
+
if (tagsOp?.remove?.length) {
|
|
1259
|
+
await store.untagNote(note.id, tagsOp.remove);
|
|
1260
|
+
}
|
|
1266
1261
|
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1262
|
+
// --- Add links ---
|
|
1263
|
+
const linksAdd = (item.links as any)?.add as { target: string; relationship: string; metadata?: Record<string, unknown> }[] | undefined;
|
|
1264
|
+
if (linksAdd) {
|
|
1265
|
+
for (const link of linksAdd) {
|
|
1266
|
+
const target = resolveNote(db, link.target);
|
|
1267
|
+
if (target) {
|
|
1268
|
+
await store.createLink(note.id, target.id, link.relationship, link.metadata);
|
|
1269
|
+
}
|
|
1274
1270
|
}
|
|
1275
1271
|
}
|
|
1276
|
-
}
|
|
1277
1272
|
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1273
|
+
// Echo links if this update mutated them (`links.add`/`links.remove`)
|
|
1274
|
+
// or the caller asked explicitly. vault feedback #8.
|
|
1275
|
+
const linkMutated = (item.links as any)?.add !== undefined || (item.links as any)?.remove !== undefined;
|
|
1276
|
+
if (linkMutated || item.include_links === true) {
|
|
1277
|
+
echoLinkIds.add(note.id);
|
|
1278
|
+
}
|
|
1284
1279
|
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
if (batched) db.exec("ROLLBACK");
|
|
1291
|
-
throw e;
|
|
1292
|
-
}
|
|
1280
|
+
// Re-read for final state
|
|
1281
|
+
updated.push(noteOps.getNote(db, note.id) ?? result);
|
|
1282
|
+
}
|
|
1283
|
+
};
|
|
1284
|
+
await (batched ? transactionAsync(db, runBatch) : runBatch());
|
|
1293
1285
|
|
|
1294
1286
|
// Response shape: full Note (back-compat default) or lean NoteIndex
|
|
1295
1287
|
// (#285 friction point 2.response — opt-out for callers making
|