@openparachute/vault 0.7.2-rc.3 → 0.7.2-rc.5

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/store.ts CHANGED
@@ -15,7 +15,10 @@ import {
15
15
  resolveUnresolvedWikilinks,
16
16
  resolveOrQueueLink,
17
17
  clearQueuedLink,
18
+ clearQueuedLinkTarget,
19
+ requeueInboundWikilinksForDelete,
18
20
  } from "./wikilinks.js";
21
+ import { chunkForInClause } from "./sql-in.js";
19
22
  import { pathTitle } from "./paths.js";
20
23
  import { timestampToMs } from "./cursor.js";
21
24
  import { transaction } from "./txn.js";
@@ -23,6 +26,7 @@ import { HookRegistry } from "./hooks.js";
23
26
  import {
24
27
  loadTagHierarchy,
25
28
  getTagExpansion,
29
+ getTagDescendants,
26
30
  stripTagHash,
27
31
  TAG_CONFIG_PREFIX,
28
32
  DEFAULT_TAG_NAME,
@@ -44,6 +48,36 @@ import {
44
48
  import type { SearchMode } from "./search-query.js";
45
49
  import { runDoctorScan, type DoctorReport, type DoctorScanOpts } from "./doctor.js";
46
50
 
51
+ /**
52
+ * Normalize a `type: "reference"` field value (scalar OR array — see
53
+ * `BunSqliteStore.syncReferenceFieldArrayLinks`) into a Set of non-empty,
54
+ * trimmed string elements. A bare scalar string becomes a one-element set
55
+ * (so a cardinality transition between scalar and array diffs correctly
56
+ * against the other side); a non-array/non-string value (undefined, null,
57
+ * number, object, …) becomes the empty set; array elements that aren't
58
+ * non-empty strings are dropped (they can never resolve to a link target).
59
+ * Set semantics absorb both element ORDER and DUPLICATE entries, matching
60
+ * how the underlying `links` table's UNIQUE(source_id, target_id,
61
+ * relationship) would collapse duplicate-target elements anyway.
62
+ */
63
+ function referenceValueToSet(value: unknown): Set<string> {
64
+ const out = new Set<string>();
65
+ if (Array.isArray(value)) {
66
+ for (const item of value) {
67
+ if (typeof item === "string" && item.trim() !== "") out.add(item);
68
+ }
69
+ } else if (typeof value === "string" && value.trim() !== "") {
70
+ out.add(value);
71
+ }
72
+ return out;
73
+ }
74
+
75
+ function setsEqual(a: Set<string>, b: Set<string>): boolean {
76
+ if (a.size !== b.size) return false;
77
+ for (const v of a) if (!b.has(v)) return false;
78
+ return true;
79
+ }
80
+
47
81
  /**
48
82
  * bun:sqlite-backed Store implementation. Internally everything is
49
83
  * synchronous; the public Store API is async so the same interface
@@ -114,7 +148,8 @@ export class BunSqliteStore implements Store {
114
148
  /**
115
149
  * Auto-link sync for `type: "reference"` schema fields
116
150
  * (vault#typed-reference-field — see `docs/design/typed-reference-field.md`
117
- * for the full design + known gaps).
151
+ * for the full design; gaps #2/#3 closed below, see the doc for what
152
+ * remains open).
118
153
  *
119
154
  * A `reference`-typed field is BOTH an indexed value (handled by the
120
155
  * ordinary metadata write — no special casing needed there, see
@@ -129,27 +164,35 @@ export class BunSqliteStore implements Store {
129
164
  *
130
165
  * Called from `createNote`/`updateNote`/`createNotes` — the single
131
166
  * chokepoint both MCP and REST funnel through — AFTER the note row itself
132
- * is written, so `note.tags`/`note.metadata` reflect the final state.
167
+ * is written, so `note.tags`/`note.metadata` reflect the final state. This
168
+ * is the WRITE-PATH (reconciling) sync; the gap #3 schema-declaration
169
+ * backfill is a separate, purely-additive path
170
+ * ({@link backfillReferenceFieldLinks}), so a `update-tag` re-declare never
171
+ * churns already-correct edges.
133
172
  *
134
173
  * `priorMetadata` is the note's metadata BEFORE this write (`undefined` on
135
- * create). For each reference field, if its value is unchanged from
136
- * `priorMetadata`, nothing is touched the existing link (if any) already
137
- * reflects it, and re-running the resolve/queue machinery on every
138
- * unrelated write (e.g. a content-only edit) would be wasted work. When
139
- * the value DID change (set, changed, or removed), every existing link +
140
- * queued forward-ref under that field's relationship name is cleared
141
- * first, then re-established from the new value — this makes the field's
142
- * current value the single source of truth for "this field's link"
143
- * without needing to track the specific prior target.
174
+ * create). For each SCALAR reference
175
+ * field, if its value is unchanged from `priorMetadata`, nothing is
176
+ * touched the existing link (if any) already reflects it, and
177
+ * re-running the resolve/queue machinery on every unrelated write (e.g. a
178
+ * content-only edit) would be wasted work. When the value DID change (set,
179
+ * changed, or removed), every existing link + queued forward-ref under
180
+ * that field's relationship name is cleared first, then re-established
181
+ * from the new value this makes the field's current value the single
182
+ * source of truth for "this field's link" without needing to track the
183
+ * specific prior target.
144
184
  *
145
- * Known gaps (see the design doc): only scalar (`cardinality: "one"`,
146
- * the default) reference values are linked an array value is left as a
147
- * validated-like-string-per-item... actually an array value simply isn't
148
- * a string, so no link is created for it (the write still succeeds; a
149
- * `type_mismatch`/`cardinality_mismatch` warning surfaces via the normal
150
- * `validation_status` path). A tag gaining a `reference` field declaration
151
- * does NOT retroactively link notes that already carry a matching value —
152
- * only writes that actually touch the field (going forward) sync.
185
+ * A `cardinality: "many"` (array) value takes a different path
186
+ * {@link syncReferenceFieldArrayLinks}since a relationship name can now
187
+ * back MULTIPLE edges (one per element) rather than at most one, so
188
+ * "clear everything under this relationship, recreate" would be wrong: it
189
+ * would drop+recreate edges for elements that didn't even change. That
190
+ * method diffs old-vs-new array membership (a Set, so element order and
191
+ * duplicate entries don't matter) and only touches what actually changed.
192
+ * Dispatched whenever EITHER side of the comparison is an array — this
193
+ * also correctly handles a field transitioning between scalar and array
194
+ * shape (e.g. a schema's `cardinality` changes), by treating a scalar side
195
+ * as a one-element set.
153
196
  */
154
197
  private syncReferenceFieldLinks(
155
198
  note: Note,
@@ -166,6 +209,12 @@ export class BunSqliteStore implements Store {
166
209
 
167
210
  const nextValue = metadata[fieldName];
168
211
  const priorValue = prior[fieldName];
212
+
213
+ if (Array.isArray(nextValue) || Array.isArray(priorValue)) {
214
+ this.syncReferenceFieldArrayLinks(note.id, fieldName, nextValue, priorValue);
215
+ continue;
216
+ }
217
+
169
218
  if (nextValue === priorValue) continue; // unchanged — link (if any) already reflects it
170
219
 
171
220
  // Re-establish this field's link from scratch: drop whatever it
@@ -199,6 +248,205 @@ export class BunSqliteStore implements Store {
199
248
  }
200
249
  }
201
250
 
251
+ /**
252
+ * The `cardinality: "many"` counterpart of the scalar sync above (vault
253
+ * typed-reference-field gap #2, docs/design/typed-reference-field.md).
254
+ * ONE link per array element, all sharing `relationship = fieldName` — the
255
+ * `links` table's `UNIQUE(source_id, target_id, relationship)` naturally
256
+ * dedupes distinct elements resolving to the same target, and distinct
257
+ * elements resolving to distinct targets coexist as separate rows under
258
+ * the same relationship name.
259
+ *
260
+ * `nextValue`/`priorValue` are read as SETS of non-empty, trimmed string
261
+ * elements (via {@link referenceValueToSet}) — a non-array scalar
262
+ * contributes a one-element set (so a cardinality transition diffs
263
+ * correctly), non-string/empty elements are dropped (they can never
264
+ * resolve to a link target; `valueMatchesType` already flags them via the
265
+ * normal `type_mismatch` path if the schema disagrees), and DUPLICATE
266
+ * elements collapse to one (a `["carol","carol"]` array creates exactly
267
+ * one edge to carol, matching how `createLink`'s own UNIQUE constraint
268
+ * would collapse it anyway).
269
+ *
270
+ * Reconciles against the RESOLVED next-set, NOT a raw-value diff (round-4
271
+ * review NIT 3). Resolving every element of the new array yields the exact
272
+ * set of `target_id`s the field's edges under this relationship SHOULD
273
+ * point at; existing edges are then reconciled TO that set:
274
+ * - Delete every existing edge under `(noteId, relationship)` whose
275
+ * `target_id` is NOT in the resolved next-set. Reconciling on resolved
276
+ * TARGETS (not by re-resolving each removed raw string) is what makes
277
+ * the three corners the raw-value diff got wrong come out right:
278
+ * (a) a removed element whose target was renamed/deleted since — its
279
+ * edge's `target_id` is simply absent from the next-set, so it's
280
+ * dropped (the raw-value approach re-resolved the stale string, missed,
281
+ * and left the edge forever); (b) a removed element now ambiguous —
282
+ * same, dropped by target; (c) two elements ALIASING the same target
283
+ * (a path and its H1 title both resolving to one note) — removing one
284
+ * alias keeps the shared edge, because the surviving alias still
285
+ * resolves that `target_id` INTO the next-set (the raw-value approach
286
+ * deleted the shared edge when it processed the removed alias, then
287
+ * never recreated it because the surviving alias looked "unchanged").
288
+ * - Create every resolved next-set edge via `createLink`'s `INSERT OR
289
+ * IGNORE` — an already-present edge (an element unchanged across the
290
+ * update) is untouched, so it KEEPS its original `created_at`.
291
+ * - A next-set element that doesn't resolve is queued exactly like a
292
+ * scalar reference (self-links and ambiguous matches follow the same
293
+ * contract: a self element creates a self-loop; an ambiguous one is
294
+ * neither linked nor queued, vault#570). Queued forward-refs for
295
+ * elements DROPPED from the array (in prior, absent from next) are
296
+ * cleared per-element via {@link clearQueuedLinkTarget} — NOT the
297
+ * blanket {@link clearQueuedLink}, which would also drop OTHER elements'
298
+ * still-pending queue rows under the same relationship.
299
+ *
300
+ * Two sets that are IDENTICAL (regardless of original order/duplicates in
301
+ * either array) short-circuit to a no-op — mirrors the scalar path's
302
+ * unchanged-value fast path.
303
+ */
304
+ private syncReferenceFieldArrayLinks(
305
+ noteId: string,
306
+ fieldName: string,
307
+ nextValue: unknown,
308
+ priorValue: unknown,
309
+ ): void {
310
+ const nextSet = referenceValueToSet(nextValue);
311
+ const priorSet = referenceValueToSet(priorValue);
312
+
313
+ if (setsEqual(nextSet, priorSet)) return; // same membership — nothing to do
314
+
315
+ // Resolve the ENTIRE next-set (queuing misses) → the target ids the
316
+ // field's edges under this relationship should point at right now.
317
+ const desiredTargetIds = new Set<string>();
318
+ for (const value of nextSet) {
319
+ const outcome = resolveOrQueueLink(this.db, noteId, value, fieldName);
320
+ if (outcome.status === "resolved") desiredTargetIds.add(outcome.note_id);
321
+ }
322
+
323
+ // Reconcile existing edges to the desired target set (see the doc comment
324
+ // for why deleting by resolved TARGET, not by re-resolved raw value, is
325
+ // what fixes the rename / ambiguous / aliasing corners).
326
+ const existing = linkOps.getLinks(this.db, noteId, { direction: "outbound" });
327
+ for (const link of existing) {
328
+ if (link.relationship !== fieldName) continue;
329
+ if (!desiredTargetIds.has(link.targetId)) {
330
+ linkOps.deleteLink(this.db, noteId, link.targetId, fieldName);
331
+ }
332
+ }
333
+
334
+ // Create every desired edge — INSERT OR IGNORE keeps an unchanged
335
+ // element's row (and its `created_at`) intact.
336
+ for (const targetId of desiredTargetIds) {
337
+ linkOps.createLink(this.db, noteId, targetId, fieldName);
338
+ }
339
+
340
+ // Drop queued forward-refs for elements removed from the array. A
341
+ // still-present-but-unresolved element stays queued (re-queued
342
+ // idempotently above).
343
+ for (const value of priorSet) {
344
+ if (!nextSet.has(value)) clearQueuedLinkTarget(this.db, noteId, fieldName, value);
345
+ }
346
+ }
347
+
348
+ /**
349
+ * Gap #3 backfill (vault typed-reference-field,
350
+ * docs/design/typed-reference-field.md): when `update-tag`'s persisted
351
+ * schema for `tag` includes one or more `type: "reference"` fields,
352
+ * existing notes that already carry a value for such a field may predate
353
+ * the declaration (or predate the gap #2 array-link fix) and sit unlinked
354
+ * — only a future write that actually touches the field would otherwise
355
+ * sync it. This walks every note carrying `tag` (or a descendant, via
356
+ * schema inheritance — same scope `countConformanceViolations` uses for
357
+ * its own schema-change impact walk) and materializes the missing links
358
+ * for each note's CURRENT value of the declared reference field(s).
359
+ *
360
+ * `referenceFieldNames` is the set of fields THIS `update-tag` persisted
361
+ * as `type: "reference"` (round-4 review NIT 5) — the walk syncs ONLY
362
+ * those, never every reference field the note happens to carry from OTHER
363
+ * co-tags/ancestors, so declaring a reference field on tag A can't churn
364
+ * an unrelated reference field contributed by tag B on a note carrying
365
+ * both. Fired for ANY reference field in the declared schema, not just a
366
+ * type-transition (round-4 review BLOCKER 2), so re-declaring an
367
+ * already-reference field HEALS notes whose links were never built —
368
+ * exactly what UPGRADING tells operators to do. Safe to re-run: the
369
+ * backfill is purely ADDITIVE + idempotent (see
370
+ * {@link backfillOneReferenceField}).
371
+ *
372
+ * Walks the FULL matching note set — the id sweep is unbounded (round-4
373
+ * review BLOCKER 1: `queryNotes` silently caps at LIMIT 100, which on a
374
+ * >100-note tag left the majority of notes unlinked — the exact
375
+ * silent-half-graph this feature exists to prevent). Ids are collected by
376
+ * a direct `note_tags` scan chunked under the IN-param cap, then hydrated
377
+ * in batches.
378
+ *
379
+ * Runs INSIDE the caller's tag-write transaction (round-4 review NIT 4 —
380
+ * NOT its own separate transaction), so a walk failure rolls the schema
381
+ * write back and the retry re-fires rather than leaving a persisted
382
+ * `reference` schema whose links never got built. `update-tag` is
383
+ * `vault:admin`-tier, so a bounded per-tag(+descendants) walk is an
384
+ * accepted cost.
385
+ */
386
+ private backfillReferenceFieldLinks(tag: string, referenceFieldNames: Set<string>): void {
387
+ if (referenceFieldNames.size === 0) return;
388
+ const hierarchy = this.getTagHierarchy();
389
+ const tagSet = Array.from(getTagDescendants(hierarchy, tag));
390
+ if (tagSet.length === 0) return;
391
+
392
+ // Unbounded id sweep: every note carrying `tag` or a descendant. Direct
393
+ // note_tags scan (deduped across tags), chunked under the IN-param cap —
394
+ // NO default LIMIT, unlike `queryNotes`.
395
+ const idSet = new Set<string>();
396
+ for (const chunk of chunkForInClause(tagSet)) {
397
+ const placeholders = chunk.map(() => "?").join(", ");
398
+ const rows = this.db.prepare(
399
+ `SELECT DISTINCT note_id FROM note_tags WHERE tag_name IN (${placeholders})`,
400
+ ).all(...chunk) as { note_id: string }[];
401
+ for (const r of rows) idSet.add(r.note_id);
402
+ }
403
+ if (idSet.size === 0) return;
404
+
405
+ const ids = Array.from(idSet);
406
+ const schemaConfig = this.getSchemaConfig();
407
+ const tagsById = noteOps.getNoteTagsForNotes(this.db, ids);
408
+ const notes = noteOps.getNotes(this.db, ids);
409
+
410
+ for (const note of notes) {
411
+ const tags = tagsById.get(note.id) ?? note.tags ?? [];
412
+ const resolution = resolveNoteSchemas(schemaConfig, { tags });
413
+ const metadata = note.metadata ?? {};
414
+ for (const fieldName of referenceFieldNames) {
415
+ const merged = resolution.mergedFields.get(fieldName);
416
+ // The field must actually be `reference` in THIS note's effective
417
+ // schema — a descendant tag can override it to a non-reference type
418
+ // (first-in-walk wins), in which case this note doesn't get a link.
419
+ if (!merged || merged.spec.type !== "reference") continue;
420
+ this.backfillOneReferenceField(note.id, fieldName, metadata[fieldName]);
421
+ }
422
+ }
423
+ }
424
+
425
+ /**
426
+ * Materialize the missing graph link(s) for ONE reference field's current
427
+ * value on one note (gap #3 backfill helper). Purely ADDITIVE and
428
+ * idempotent: for each element of the value (a scalar string is a
429
+ * one-element set; an array is deduped via {@link referenceValueToSet}),
430
+ * resolve-or-queue and, on a resolve, `createLink`. It NEVER deletes an
431
+ * edge — a backfill materializes links that were never built; reconciling
432
+ * divergent state (removing a stale edge when a value CHANGES) stays the
433
+ * write path's job (`syncReferenceFieldLinks` /
434
+ * {@link syncReferenceFieldArrayLinks}). Because `createLink` and the
435
+ * forward-ref queue are both `INSERT OR IGNORE`, a re-run — or an edge
436
+ * that already exists — is a no-op that preserves the existing row's
437
+ * `created_at` (so re-declaring a schema doesn't churn already-correct
438
+ * edges, and a scalar value that has since gone ambiguous doesn't silently
439
+ * lose its already-built edge — round-4 review NIT 5).
440
+ */
441
+ private backfillOneReferenceField(noteId: string, fieldName: string, value: unknown): void {
442
+ for (const element of referenceValueToSet(value)) {
443
+ const outcome = resolveOrQueueLink(this.db, noteId, element, fieldName);
444
+ if (outcome.status === "resolved") {
445
+ linkOps.createLink(this.db, noteId, outcome.note_id, fieldName);
446
+ }
447
+ }
448
+ }
449
+
202
450
  /**
203
451
  * Drop the tag-hierarchy cache if the mutated path is in the `_tags/*`
204
452
  * namespace. Called from create/update/delete — old path is passed
@@ -382,6 +630,13 @@ export class BunSqliteStore implements Store {
382
630
  // by design, hooks subscribing to "deleted" receive a DeletedNoteRef,
383
631
  // not a Note.
384
632
  const existing = noteOps.getNote(this.db, id);
633
+ // LB6: re-queue inbound wikilink edges BEFORE the FK cascade drops the
634
+ // `links` rows, so recreating a note at this path/title auto-heals the
635
+ // edge instead of leaving every referencing note's `[[link]]` dead until
636
+ // it's individually re-saved. See requeueInboundWikilinksForDelete's doc
637
+ // comment for why this must run pre-delete and what it deliberately
638
+ // excludes (typed `links`, not just wikilinks).
639
+ requeueInboundWikilinksForDelete(this.db, id);
385
640
  noteOps.deleteNote(this.db, id);
386
641
  if (existing?.path) this.invalidateConfigCachesForPath(existing.path);
387
642
  // Dispatch even when `existing` was null — the caller asked for a
@@ -855,6 +1110,25 @@ export class BunSqliteStore implements Store {
855
1110
  const priorIndexed = indexedSet(priorRecord?.fields);
856
1111
  const nextIndexed = indexedSet(nextFields);
857
1112
 
1113
+ // Gap #3 backfill target set (vault typed-reference-field,
1114
+ // docs/design/typed-reference-field.md) — the field names THIS call
1115
+ // persists as `type: "reference"`. Fired for ANY reference field in the
1116
+ // declared schema, NOT just a type-transition (round-4 review BLOCKER
1117
+ // 2): keying on "transition to reference" made the documented
1118
+ // "re-declare the field to heal existing notes" path a silent no-op (an
1119
+ // already-reference field stays reference, so the transition never
1120
+ // fires), stranding pre-fix vaults whose reference-many links were never
1121
+ // built. The backfill itself is additive + idempotent, so re-firing on
1122
+ // every declare is safe; `update-tag` is a rare admin op, so the bounded
1123
+ // walk is an accepted cost. Empty when `fields` is untouched or cleared.
1124
+ const referenceFieldNames = new Set<string>(
1125
+ patch.fields !== undefined
1126
+ ? Object.entries(nextFields ?? {})
1127
+ .filter(([, spec]) => spec.type === "reference")
1128
+ .map(([name]) => name)
1129
+ : [],
1130
+ );
1131
+
858
1132
  // PRE-VALIDATE every newly-indexed field BEFORE any persistence. A bad
859
1133
  // field name (or unmappable type) must fail closed — the schema record
860
1134
  // must NOT be written when the backing index can't be created. Pre-checking
@@ -902,50 +1176,73 @@ export class BunSqliteStore implements Store {
902
1176
  }
903
1177
  }
904
1178
 
905
- // Persist the record + reconcile the indexed-field lifecycle atomically.
906
- // If declareField throws inside the transaction (e.g. a cross-tag type
907
- // mismatch only detectable once the existing declarer set is consulted),
908
- // the whole write rolls back — the schema never ends up claiming an index
909
- // that doesn't exist. vault#478 transactional fix.
910
- const result = this.transaction(() => {
911
- const record = tagSchemaOps.upsertTagRecord(this.db, tag, patch);
912
-
913
- if (patch.fields !== undefined) {
914
- for (const fieldName of nextIndexed) {
915
- const spec = nextFields![fieldName]!;
916
- // Type already validated above; non-null assertion is safe here.
917
- const mapped = indexedFieldOps.mapFieldType(spec.type)!;
918
- indexedFieldOps.declareField(this.db, fieldName, mapped, tag);
919
- }
920
- for (const fieldName of priorIndexed) {
921
- if (!nextIndexed.has(fieldName)) {
922
- indexedFieldOps.releaseField(this.db, fieldName, tag);
1179
+ // Persist the record + reconcile the indexed-field lifecycle AND the
1180
+ // gap #3 reference-link backfill atomically, in ONE transaction (round-4
1181
+ // review NIT 4). If declareField throws (e.g. a cross-tag type mismatch
1182
+ // only detectable once the existing declarer set is consulted) OR the
1183
+ // backfill walk throws, the whole write rolls back — the schema never
1184
+ // ends up claiming an index that doesn't exist (vault#478), and never
1185
+ // persists a `reference` declaration whose links silently failed to
1186
+ // build (a partial-state 500 whose retry, post-BLOCKER-2, would re-fire
1187
+ // and heal — but atomic avoids the partial state in the first place).
1188
+ let result: tagSchemaOps.TagRecord;
1189
+ try {
1190
+ result = this.transaction(() => {
1191
+ const record = tagSchemaOps.upsertTagRecord(this.db, tag, patch);
1192
+
1193
+ if (patch.fields !== undefined) {
1194
+ for (const fieldName of nextIndexed) {
1195
+ const spec = nextFields![fieldName]!;
1196
+ // Type already validated above; non-null assertion is safe here.
1197
+ const mapped = indexedFieldOps.mapFieldType(spec.type)!;
1198
+ indexedFieldOps.declareField(this.db, fieldName, mapped, tag);
1199
+ }
1200
+ for (const fieldName of priorIndexed) {
1201
+ if (!nextIndexed.has(fieldName)) {
1202
+ indexedFieldOps.releaseField(this.db, fieldName, tag);
1203
+ }
923
1204
  }
924
1205
  }
925
- }
926
- return record;
927
- });
928
1206
 
929
- if (patch.parent_names !== undefined) {
930
- // parent_names drives both query expansion (tag hierarchy) AND, post
931
- // vault#270, schema inheritance bust both caches.
932
- this._tagHierarchy = null;
933
- this._schemaConfig = null;
934
- }
935
- if (patch.fields !== undefined) {
936
- this._schemaConfig = null;
937
- }
938
- // First-time creation of a tag row (e.g. an empty `_default` placeholder)
939
- // changes the `_default` universal-parent gate even when no fields or
940
- // parent_names are touched. Cheap to bust: caches rebuild on next read.
941
- if (tag === "_default") {
942
- this._tagHierarchy = null;
943
- this._schemaConfig = null;
1207
+ // Gap #3 backfill — INSIDE the transaction. Bust the in-memory
1208
+ // caches FIRST (round-4 review NIT 6): the walk's
1209
+ // `resolveNoteSchemas` must see the just-persisted reference
1210
+ // declaration, and `getTagDescendants` must see the post-write
1211
+ // hierarchy — including the `_default` universal-parent gate a
1212
+ // first-time `_default` row flips (which used to be busted only
1213
+ // AFTER the backfill call). Nulling a cache inside the txn is safe:
1214
+ // it only forces a rebuild from the current in-txn DB state; the
1215
+ // `finally` below re-nulls so a rollback can't strand the rebuild.
1216
+ if (referenceFieldNames.size > 0) {
1217
+ this._tagHierarchy = null;
1218
+ this._schemaConfig = null;
1219
+ this.backfillReferenceFieldLinks(tag, referenceFieldNames);
1220
+ }
1221
+ return record;
1222
+ });
1223
+ } finally {
1224
+ // Invalidate whatever this write (or the backfill's mid-txn cache
1225
+ // rebuild) may have touched — on BOTH the commit and the rollback
1226
+ // path. `parent_names` drives query expansion + (vault#270) schema
1227
+ // inheritance; `fields` drives schema validation; a first-time
1228
+ // `_default` row flips the universal-parent gate; and the backfill
1229
+ // above re-caches mid-transaction state a rollback must not keep.
1230
+ if (
1231
+ patch.parent_names !== undefined ||
1232
+ patch.fields !== undefined ||
1233
+ tag === "_default" ||
1234
+ referenceFieldNames.size > 0
1235
+ ) {
1236
+ this._tagHierarchy = null;
1237
+ this._schemaConfig = null;
1238
+ }
944
1239
  }
1240
+
945
1241
  // Tag-mutation event for the git-mirror and any other downstream
946
1242
  // consumer. Fire "upserted" on every successful tag-record write —
947
1243
  // schema/relationship/parent-name mutations all alter the sidecar
948
- // contents the mirror persists.
1244
+ // contents the mirror persists. (Success path only — a thrown/rolled-back
1245
+ // write never reaches here.)
949
1246
  this.hooks.dispatchTag("upserted", tag, this);
950
1247
  return result;
951
1248
  }
@@ -372,6 +372,19 @@ export function computeExpandedTagCounts(
372
372
  export function findHierarchyCycles(h: TagHierarchy): string[] {
373
373
  const cycles: string[] = [];
374
374
  for (const tag of h.childrenOf.keys()) {
375
+ // Direct self-edge (`parent_names` lists the tag itself) — checked
376
+ // separately from the descendant-set gate below because
377
+ // `getTagDescendants` can't see it: its traversal seeds `result` with
378
+ // `{tag}` and then skips re-expanding any child already present in
379
+ // `result` (the visited-set that makes runtime traversal cycle-safe) —
380
+ // so a bare `X -> X` edge never grows `descendants` past size 1, and
381
+ // `descendants.size > 1` alone silently reports it clean. Only
382
+ // reachable via guard-bypassing data (a direct DB write, or an import
383
+ // that skips `upsertTagRecord`'s write-time cycle guard).
384
+ if (h.childrenOf.get(tag)?.has(tag)) {
385
+ cycles.push(tag);
386
+ continue;
387
+ }
375
388
  const descendants = getTagDescendants(h, tag);
376
389
  if (descendants.has(tag) && descendants.size > 1) {
377
390
  // tag reaches itself through a non-trivial path