@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/aggregate.test.ts +23 -0
- package/core/src/contract-taxonomy.test.ts +22 -0
- package/core/src/core.test.ts +384 -0
- package/core/src/query-operators.ts +56 -0
- package/core/src/schema-defaults.ts +17 -2
- package/core/src/store.ts +353 -56
- package/core/src/tag-hierarchy.ts +13 -0
- package/core/src/wikilinks.test.ts +175 -0
- package/core/src/wikilinks.ts +180 -10
- package/package.json +1 -1
- package/src/routes.ts +143 -5
- package/src/vault.test.ts +139 -1
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
|
|
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
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
* unrelated write (e.g. a
|
|
139
|
-
* the value DID change (set,
|
|
140
|
-
*
|
|
141
|
-
* first, then re-established
|
|
142
|
-
*
|
|
143
|
-
* without needing to track the
|
|
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
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
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
|
|
906
|
-
//
|
|
907
|
-
//
|
|
908
|
-
//
|
|
909
|
-
//
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
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
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
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
|