@openparachute/vault 0.6.4 → 0.6.5-rc.1

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/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
- if (batched) db.exec("BEGIN");
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
- // we're inside the BEGIN block on a batch rolls back the
719
- // transaction in the outer catch — the same behavior as a
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 via the outer BEGIN/ROLLBACK.
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
- if (batched) db.exec("COMMIT");
756
- } catch (e) {
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
- if (batched) db.exec("BEGIN");
967
- try {
968
- for (const item of items) {
969
- // Try ID-then-path resolve. If not found AND
970
- // `if_missing: "create"` is set, fall through to the create
971
- // branch using this same item's payload. Otherwise mirror the
972
- // existing `requireNote` behavior (throw "Note not found").
973
- // vault#309.
974
- const resolved = resolveNote(db, item.id as string);
975
- if (!resolved) {
976
- if (item.if_missing === "create") {
977
- // Treat the update payload as a create payload. Minimum:
978
- // content OR a path/id (something the createNote-empty-row
979
- // invariant accepts). createNote enforces its own
980
- // not-both-empty check we leave that to the Store and
981
- // surface any error to the caller verbatim.
982
- //
983
- // Field mapping (mirrors the create-note tool surface):
984
- // - `item.id` both the note's `id` AND a fallback
985
- // `path` when `item.path` isn't set. Treating `id` as
986
- // the path-or-id lookup key matches Gitcoin's nightly
987
- // sync shape where the canonical key is a path string
988
- // like "Inbox/2026-05-13-meeting". If the caller
989
- // supplied an opaque ULID as `id` and no `path`, we
990
- // still create with that as `id` (path stays null).
991
- // - `item.content` / `item.path` / `item.tags` /
992
- // `item.metadata` / `item.created_at` forwarded.
993
- // - `if_updated_at` / `force` / `content_edit` /
994
- // `append` / `prepend` are update-only silently
995
- // ignored on the create branch. (Content-edit on a
996
- // non-existent note is a nonsense combination; the
997
- // caller's intent on missing-note is "create the
998
- // row", not "patch in this section".)
999
- // - `links.remove` is also ignored on create (nothing
1000
- // to remove on a fresh note).
1001
- // - `links.add` IS applied below the drift sync can
1002
- // declare typed links at upsert time and have them
1003
- // materialize alongside the create. See vault#320
1004
- // reviewer F1 the prior comment claimed all
1005
- // `links` were ignored, but `links.add` was already
1006
- // processed and used by Gitcoin's sync; the
1007
- // misleading wording is fixed here so a future
1008
- // reader doesn't trust it and break the workflow.
1009
- const idOrPath = item.id as string;
1010
- // Heuristic: if `path` isn't set AND the `id` looks like a
1011
- // path (contains "/" or doesn't match a typical opaque-id
1012
- // shape), use it as the path too. Otherwise treat it as a
1013
- // pure id. The shared `id` field for update is ID-or-path
1014
- // already (see `resolveNote`), so this preserves the
1015
- // caller's intent.
1016
- const idLooksLikePath = idOrPath.includes("/") || !/^[A-Za-z0-9_-]+$/.test(idOrPath);
1017
- const explicitPath = typeof item.path === "string" ? item.path as string : undefined;
1018
- // Validate extension before reaching the Store — same
1019
- // contract as the create-note tool.
1020
- const createExt = item.extension !== undefined
1021
- ? validateExtension(item.extension)
1022
- : undefined;
1023
- const createOpts: Parameters<Store["createNote"]>[1] = {
1024
- ...(idLooksLikePath ? { path: explicitPath ?? idOrPath } : { id: idOrPath, ...(explicitPath !== undefined ? { path: explicitPath } : {}) }),
1025
- ...(item.tags && Array.isArray((item.tags as any).add)
1026
- ? { tags: (item.tags as any).add as string[] }
1027
- : Array.isArray(item.tags)
1028
- ? { tags: item.tags as string[] }
1029
- : {}),
1030
- ...(item.metadata !== undefined ? { metadata: item.metadata as Record<string, unknown> } : {}),
1031
- ...(item.created_at !== undefined ? { created_at: item.created_at as string } : {}),
1032
- ...(createExt !== undefined ? { extension: createExt } : {}),
1033
- // Write-attribution (vault#298) the if_missing:"create" upsert
1034
- // branch is still a CREATE, so it must stamp the same actor/via
1035
- // as the create-note tool + the REST upsert-create path. Without
1036
- // this an MCP-driven upsert-create wrote NULL attribution.
1037
- actor: writeActor,
1038
- via: writeVia,
1039
- };
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
- });
1049
- const created = await store.createNote(content, createOpts);
1050
- await applySchemaDefaults(store, db, [created.id], created.tags ?? []);
1051
- // Apply links.add if the caller declared any.
1052
- const linksAdd = (item.links as any)?.add as { target: string; relationship: string; metadata?: Record<string, unknown> }[] | undefined;
1053
- if (linksAdd) {
1054
- for (const link of linksAdd) {
1055
- const target = resolveNote(db, link.target);
1056
- if (target) await store.createLink(created.id, target.id, link.relationship, link.metadata);
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
- const fresh = noteOps.getNote(db, created.id) ?? created;
1060
- updated.push(fresh);
1061
- createdIds.add(fresh.id);
1062
- // Echo links if this create-on-missing declared `links.add`
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
- // Fallthrough: not-found + no if_missing → existing error
1070
- // contract. Match `requireNote`'s message shape so existing
1071
- // callers see no behavior change.
1072
- throw new Error(`Note not found: "${item.id}"`);
1073
- }
1074
- const note = resolved;
1075
-
1076
- // --- Validate mutual exclusion of content modes ---
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
- "update-note: `content_edit` requires { old_text: string, new_text: string }.",
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
- const idx = note.content.indexOf(ce.old_text);
1139
- if (idx < 0) {
1140
- throw new Error(
1141
- `update-note content_edit: \`old_text\` not found in note "${note.id}". The note may have been edited — re-read and retry.`,
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
- const second = note.content.indexOf(ce.old_text, idx + 1);
1145
- if (second >= 0) {
1146
- throw new Error(
1147
- `update-note content_edit: \`old_text\` matches multiple times in note "${note.id}" must match exactly once. Add surrounding context to disambiguate.`,
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
- // --- Plan bracket cleanup for wikilink removals (no DB writes yet) ---
1154
- // We compute the cleaned content so we can do the core UPDATE first
1155
- // (with if_updated_at atomically) before any link deletions. If the
1156
- // UPDATE fails on a conflict, nothing has been mutated.
1157
- const linksRemove = (item.links as any)?.remove as { target: string; relationship: string }[] | undefined;
1158
- const resolvedLinksToRemove: { targetId: string; relationship: string }[] = [];
1159
- if (linksRemove) {
1160
- for (const link of linksRemove) {
1161
- const target = resolveNote(db, link.target);
1162
- if (!target) continue;
1163
- resolvedLinksToRemove.push({ targetId: target.id, relationship: link.relationship });
1164
- if (link.relationship === "wikilink" && target.path) {
1165
- // Wikilink-removal bracket cleanup operates on the prospective
1166
- // *full* content. Coexists with content_edit; would fight
1167
- // append/prepend (which leave existing content untouched at
1168
- // the JS layer), so we pre-materialize the would-be content
1169
- // for those callers and switch to a `content`-style update.
1170
- const currentContent = contentOverride
1171
- ?? (hasAppendPrepend
1172
- ? (item.prepend as string ?? "") + note.content + (item.append as string ?? "")
1173
- : note.content);
1174
- const cleaned = removeWikilinkBrackets(currentContent, target.path);
1175
- if (cleaned !== currentContent) {
1176
- contentOverride = cleaned;
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
- // --- Core update (content, path, metadata, created_at + concurrency check) ---
1183
- const updates: any = {};
1184
- if (contentOverride !== undefined) {
1185
- updates.content = contentOverride;
1186
- } else if (hasAppendPrepend) {
1187
- // No content_edit and no wikilink-removal pre-materialization —
1188
- // route the append/prepend down to the SQL-atomic path.
1189
- if (item.append !== undefined) updates.append = item.append;
1190
- if (item.prepend !== undefined) updates.prepend = item.prepend;
1191
- }
1192
- if (item.path !== undefined) updates.path = item.path;
1193
- if (item.extension !== undefined) {
1194
- updates.extension = validateExtension(item.extension);
1195
- }
1196
- if (item.metadata !== undefined) {
1197
- // Merge metadata (RFC 7386: keys are merged, incoming `null`
1198
- // removes the key rather than persisting a literal null —
1199
- // vault#478/#479). Mirrors the REST PATCH path.
1200
- updates.metadata = noteOps.mergeMetadata(
1201
- note.metadata as Record<string, unknown> | null | undefined,
1202
- item.metadata as Record<string, unknown>,
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
- updates.state_transition = { field: stItem.field, from: stItem.from, to: stItem.to };
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
- // --- Strict-schema gate (vault#299 Part A) ---
1220
- // Validate the PROSPECTIVE shape (final tags + merged metadata,
1221
- // including a state_transition's `to`) before the write so a
1222
- // rejection leaves the note untouched.
1223
- {
1224
- const removeSet = new Set<string>((item.tags as any)?.remove ?? []);
1225
- const projectedTags = new Set<string>((note.tags ?? []).filter((t) => !removeSet.has(t)));
1226
- for (const t of ((item.tags as any)?.add as string[] | undefined) ?? []) projectedTags.add(t);
1227
- const baseMeta = updates.metadata ?? ((note.metadata as Record<string, unknown>) ?? {});
1228
- const projectedMeta = stItem !== undefined
1229
- ? { ...baseMeta, [stItem.field as string]: stItem.to }
1230
- : baseMeta;
1231
- enforceStrict({ path: note.path, tags: [...projectedTags], metadata: projectedMeta });
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
- let result: Note;
1235
- if (Object.keys(updates).length > 0) {
1236
- // Write-attribution (vault#298): stamp the most-recent-write
1237
- // columns on the same UPDATE that bumps `updated_at`. Only set when
1238
- // there's a real change to write (the empty-updates branch below
1239
- // leaves attribution untouched, symmetric with not bumping
1240
- // updated_at on a no-op).
1241
- updates.actor = writeActor;
1242
- updates.via = writeVia;
1243
- // store.updateNote routes through noteOps.updateNote, which runs
1244
- // the UPDATE (with optional `AND updated_at IS ?`) atomically and
1245
- // throws ConflictError on mismatch. No mutations have happened
1246
- // yet, so a throw here leaves the note untouched.
1247
- result = await store.updateNote(note.id, updates);
1248
- } else {
1249
- result = note;
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
- // --- Remove links (after core UPDATE so a conflict leaves them intact) ---
1253
- for (const { targetId, relationship } of resolvedLinksToRemove) {
1254
- await store.deleteLink(note.id, targetId, relationship);
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
- // --- Tags ---
1258
- const tagsOp = item.tags as { add?: string[]; remove?: string[] } | undefined;
1259
- if (tagsOp?.add?.length) {
1260
- await store.tagNote(note.id, tagsOp.add);
1261
- await applySchemaDefaults(store, db, [note.id], tagsOp.add);
1262
- }
1263
- if (tagsOp?.remove?.length) {
1264
- await store.untagNote(note.id, tagsOp.remove);
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
- // --- Add links ---
1268
- const linksAdd = (item.links as any)?.add as { target: string; relationship: string; metadata?: Record<string, unknown> }[] | undefined;
1269
- if (linksAdd) {
1270
- for (const link of linksAdd) {
1271
- const target = resolveNote(db, link.target);
1272
- if (target) {
1273
- await store.createLink(note.id, target.id, link.relationship, link.metadata);
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
- // Echo links if this update mutated them (`links.add`/`links.remove`)
1279
- // or the caller asked explicitly. vault feedback #8.
1280
- const linkMutated = (item.links as any)?.add !== undefined || (item.links as any)?.remove !== undefined;
1281
- if (linkMutated || item.include_links === true) {
1282
- echoLinkIds.add(note.id);
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
- // Re-read for final state
1286
- updated.push(noteOps.getNote(db, note.id) ?? result);
1287
- }
1288
- if (batched) db.exec("COMMIT");
1289
- } catch (e) {
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