@openparachute/vault 0.7.2-rc.4 → 0.7.2-rc.6
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/core.test.ts +497 -10
- package/core/src/mcp.ts +1 -1
- package/core/src/notes.ts +79 -11
- package/core/src/portable-md.test.ts +43 -0
- package/core/src/portable-md.ts +34 -2
- package/core/src/schema-defaults.ts +17 -2
- package/core/src/store.ts +345 -56
- package/core/src/types.ts +8 -0
- package/core/src/wikilinks.ts +29 -0
- package/package.json +1 -1
- package/src/vault.test.ts +12 -8
package/core/src/store.ts
CHANGED
|
@@ -15,8 +15,10 @@ import {
|
|
|
15
15
|
resolveUnresolvedWikilinks,
|
|
16
16
|
resolveOrQueueLink,
|
|
17
17
|
clearQueuedLink,
|
|
18
|
+
clearQueuedLinkTarget,
|
|
18
19
|
requeueInboundWikilinksForDelete,
|
|
19
20
|
} from "./wikilinks.js";
|
|
21
|
+
import { chunkForInClause } from "./sql-in.js";
|
|
20
22
|
import { pathTitle } from "./paths.js";
|
|
21
23
|
import { timestampToMs } from "./cursor.js";
|
|
22
24
|
import { transaction } from "./txn.js";
|
|
@@ -24,6 +26,7 @@ import { HookRegistry } from "./hooks.js";
|
|
|
24
26
|
import {
|
|
25
27
|
loadTagHierarchy,
|
|
26
28
|
getTagExpansion,
|
|
29
|
+
getTagDescendants,
|
|
27
30
|
stripTagHash,
|
|
28
31
|
TAG_CONFIG_PREFIX,
|
|
29
32
|
DEFAULT_TAG_NAME,
|
|
@@ -45,6 +48,36 @@ import {
|
|
|
45
48
|
import type { SearchMode } from "./search-query.js";
|
|
46
49
|
import { runDoctorScan, type DoctorReport, type DoctorScanOpts } from "./doctor.js";
|
|
47
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
|
+
|
|
48
81
|
/**
|
|
49
82
|
* bun:sqlite-backed Store implementation. Internally everything is
|
|
50
83
|
* synchronous; the public Store API is async so the same interface
|
|
@@ -115,7 +148,8 @@ export class BunSqliteStore implements Store {
|
|
|
115
148
|
/**
|
|
116
149
|
* Auto-link sync for `type: "reference"` schema fields
|
|
117
150
|
* (vault#typed-reference-field — see `docs/design/typed-reference-field.md`
|
|
118
|
-
* for the full design
|
|
151
|
+
* for the full design; gaps #2/#3 closed below, see the doc for what
|
|
152
|
+
* remains open).
|
|
119
153
|
*
|
|
120
154
|
* A `reference`-typed field is BOTH an indexed value (handled by the
|
|
121
155
|
* ordinary metadata write — no special casing needed there, see
|
|
@@ -130,27 +164,35 @@ export class BunSqliteStore implements Store {
|
|
|
130
164
|
*
|
|
131
165
|
* Called from `createNote`/`updateNote`/`createNotes` — the single
|
|
132
166
|
* chokepoint both MCP and REST funnel through — AFTER the note row itself
|
|
133
|
-
* 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.
|
|
134
172
|
*
|
|
135
173
|
* `priorMetadata` is the note's metadata BEFORE this write (`undefined` on
|
|
136
|
-
* create). For each reference
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
* unrelated write (e.g. a
|
|
140
|
-
* the value DID change (set,
|
|
141
|
-
*
|
|
142
|
-
* first, then re-established
|
|
143
|
-
*
|
|
144
|
-
* 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.
|
|
145
184
|
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
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.
|
|
154
196
|
*/
|
|
155
197
|
private syncReferenceFieldLinks(
|
|
156
198
|
note: Note,
|
|
@@ -167,6 +209,12 @@ export class BunSqliteStore implements Store {
|
|
|
167
209
|
|
|
168
210
|
const nextValue = metadata[fieldName];
|
|
169
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
|
+
|
|
170
218
|
if (nextValue === priorValue) continue; // unchanged — link (if any) already reflects it
|
|
171
219
|
|
|
172
220
|
// Re-establish this field's link from scratch: drop whatever it
|
|
@@ -200,6 +248,205 @@ export class BunSqliteStore implements Store {
|
|
|
200
248
|
}
|
|
201
249
|
}
|
|
202
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
|
+
|
|
203
450
|
/**
|
|
204
451
|
* Drop the tag-hierarchy cache if the mutated path is in the `_tags/*`
|
|
205
452
|
* namespace. Called from create/update/delete — old path is passed
|
|
@@ -863,6 +1110,25 @@ export class BunSqliteStore implements Store {
|
|
|
863
1110
|
const priorIndexed = indexedSet(priorRecord?.fields);
|
|
864
1111
|
const nextIndexed = indexedSet(nextFields);
|
|
865
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
|
+
|
|
866
1132
|
// PRE-VALIDATE every newly-indexed field BEFORE any persistence. A bad
|
|
867
1133
|
// field name (or unmappable type) must fail closed — the schema record
|
|
868
1134
|
// must NOT be written when the backing index can't be created. Pre-checking
|
|
@@ -910,50 +1176,73 @@ export class BunSqliteStore implements Store {
|
|
|
910
1176
|
}
|
|
911
1177
|
}
|
|
912
1178
|
|
|
913
|
-
// Persist the record + reconcile the indexed-field lifecycle
|
|
914
|
-
//
|
|
915
|
-
//
|
|
916
|
-
//
|
|
917
|
-
//
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
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
|
+
}
|
|
931
1204
|
}
|
|
932
1205
|
}
|
|
933
|
-
}
|
|
934
|
-
return record;
|
|
935
|
-
});
|
|
936
1206
|
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
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
|
+
}
|
|
952
1239
|
}
|
|
1240
|
+
|
|
953
1241
|
// Tag-mutation event for the git-mirror and any other downstream
|
|
954
1242
|
// consumer. Fire "upserted" on every successful tag-record write —
|
|
955
1243
|
// schema/relationship/parent-name mutations all alter the sidecar
|
|
956
|
-
// contents the mirror persists.
|
|
1244
|
+
// contents the mirror persists. (Success path only — a thrown/rolled-back
|
|
1245
|
+
// write never reaches here.)
|
|
957
1246
|
this.hooks.dispatchTag("upserted", tag, this);
|
|
958
1247
|
return result;
|
|
959
1248
|
}
|
package/core/src/types.ts
CHANGED
|
@@ -237,6 +237,14 @@ export interface QueryOpts {
|
|
|
237
237
|
// raw row count — using the same directional-sum definition as the
|
|
238
238
|
// `linkCount` response field, so the sort key equals the field value for
|
|
239
239
|
// every note (self-loops included). See `queryNotes`/`getLinkCounts`.
|
|
240
|
+
//
|
|
241
|
+
// The pseudo-field `updated_at` is also special-cased (vault#585): it
|
|
242
|
+
// orders on the integer `notes.updated_at_ms` mirror column (vault#586),
|
|
243
|
+
// NOT the TEXT `updated_at`, with `id` appended as the tiebreaker instead
|
|
244
|
+
// of `created_at` — a TEXT sort mis-orders non-canonical stored timestamps
|
|
245
|
+
// (space-form / offset / no-`Z`) the same way the pre-v26 cursor keyset
|
|
246
|
+
// did. `created_at` itself has no ms mirror and is not special-cased here
|
|
247
|
+
// (its default/no-`orderBy` sort still reads the TEXT column).
|
|
240
248
|
orderBy?: string;
|
|
241
249
|
limit?: number;
|
|
242
250
|
offset?: number;
|
package/core/src/wikilinks.ts
CHANGED
|
@@ -807,6 +807,35 @@ export function clearQueuedLink(db: Database, sourceId: string, relationship: st
|
|
|
807
807
|
}
|
|
808
808
|
}
|
|
809
809
|
|
|
810
|
+
/**
|
|
811
|
+
* Drop exactly ONE pending forward-ref — scoped by `targetPath` in addition
|
|
812
|
+
* to `sourceId`/`relationship` (the full `unresolved_wikilinks` primary
|
|
813
|
+
* key). Used by the `cardinality:"many"` reference-field array sync
|
|
814
|
+
* (core/src/store.ts's `syncReferenceFieldLinks`) when a SPECIFIC array
|
|
815
|
+
* element is removed from a field's value: unlike the scalar path (which
|
|
816
|
+
* owns the ENTIRE relationship namespace and can safely clear every queued
|
|
817
|
+
* row via {@link clearQueuedLink}), an array field can have MULTIPLE
|
|
818
|
+
* pending forward-refs under the same relationship at once (one per
|
|
819
|
+
* unresolved element) — blanket-clearing all of them on a single element's
|
|
820
|
+
* removal would silently drop the other still-pending elements' queue rows.
|
|
821
|
+
* Safe no-op when the table doesn't exist yet or nothing is queued for this
|
|
822
|
+
* exact (source, target, relationship) triple.
|
|
823
|
+
*/
|
|
824
|
+
export function clearQueuedLinkTarget(
|
|
825
|
+
db: Database,
|
|
826
|
+
sourceId: string,
|
|
827
|
+
relationship: string,
|
|
828
|
+
targetPath: string,
|
|
829
|
+
): void {
|
|
830
|
+
try {
|
|
831
|
+
db.prepare(
|
|
832
|
+
"DELETE FROM unresolved_wikilinks WHERE source_id = ? AND relationship = ? AND target_path = ? COLLATE NOCASE",
|
|
833
|
+
).run(sourceId, relationship, targetPath);
|
|
834
|
+
} catch {
|
|
835
|
+
// Table may not exist yet — nothing to clear.
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
|
|
810
839
|
/**
|
|
811
840
|
* Resolve a structured link NOW, or queue it for lazy resolution when the
|
|
812
841
|
* target doesn't exist yet — mirroring the wikilink forward-ref contract
|
package/package.json
CHANGED
package/src/vault.test.ts
CHANGED
|
@@ -2226,10 +2226,13 @@ describe("HTTP /notes", async () => {
|
|
|
2226
2226
|
const a = await store.createNote("untouched", { id: "ua", path: "ua" });
|
|
2227
2227
|
const b = await store.createNote("modified", { id: "ub", path: "ub" });
|
|
2228
2228
|
// Bump b's updated_at into the test window, leave a's at its createdAt.
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2229
|
+
// Pin BOTH `updated_at` and `updated_at_ms` — mirrors production
|
|
2230
|
+
// (vault#586: every real write keeps them in lockstep) — the vault#585
|
|
2231
|
+
// fix compares the ms mirror, not the TEXT column.
|
|
2232
|
+
db.prepare("UPDATE notes SET updated_at = ?, updated_at_ms = ? WHERE id = ?")
|
|
2233
|
+
.run("2026-01-15T00:00:00.000Z", Date.parse("2026-01-15T00:00:00.000Z"), a.id);
|
|
2234
|
+
db.prepare("UPDATE notes SET updated_at = ?, updated_at_ms = ? WHERE id = ?")
|
|
2235
|
+
.run("2026-04-25T00:00:00.000Z", Date.parse("2026-04-25T00:00:00.000Z"), b.id);
|
|
2233
2236
|
|
|
2234
2237
|
const res = await handleNotes(
|
|
2235
2238
|
mkReq("GET", "/notes?meta[updated_at][gte]=2026-04-01&include_content=true"),
|
|
@@ -3370,10 +3373,11 @@ describe("HTTP /notes", async () => {
|
|
|
3370
3373
|
test("`meta[updated_at][gte]=…` routes to dateFilter on n.updated_at", async () => {
|
|
3371
3374
|
const a = await store.createNote("untouched");
|
|
3372
3375
|
const b = await store.createNote("modified");
|
|
3373
|
-
|
|
3374
|
-
|
|
3375
|
-
|
|
3376
|
-
|
|
3376
|
+
// Pin BOTH columns (vault#585/#586 — see the identical note above).
|
|
3377
|
+
db.prepare("UPDATE notes SET updated_at = ?, updated_at_ms = ? WHERE id = ?")
|
|
3378
|
+
.run("2026-01-15T00:00:00.000Z", Date.parse("2026-01-15T00:00:00.000Z"), a.id);
|
|
3379
|
+
db.prepare("UPDATE notes SET updated_at = ?, updated_at_ms = ? WHERE id = ?")
|
|
3380
|
+
.run("2026-04-25T00:00:00.000Z", Date.parse("2026-04-25T00:00:00.000Z"), b.id);
|
|
3377
3381
|
const res = await handleNotes(
|
|
3378
3382
|
mkReq("GET", "/notes?meta[updated_at][gte]=2026-04-01&include_content=true"),
|
|
3379
3383
|
store,
|