@openparachute/vault 0.7.0-rc.7 → 0.7.0-rc.9

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/notes.ts CHANGED
@@ -851,6 +851,32 @@ export function queryNotes(db: Database, opts: QueryOpts): Note[] {
851
851
  );
852
852
  }
853
853
 
854
+ // Presence: has_broken_links (vault#555) — a dangling outbound wikilink or
855
+ // structured `links` target that never resolved. The `unresolved_wikilinks`
856
+ // table is created lazily (see wikilinks.ts:ensureUnresolvedTable) only when
857
+ // a link actually goes unresolved — a vault where nothing ever has won't
858
+ // have the table at all. Check existence first rather than reference it
859
+ // unconditionally: a read-only query filter shouldn't have the side effect
860
+ // of creating a table, and a bare `EXISTS`/`NOT EXISTS` against a missing
861
+ // table would throw "no such table" instead of the correct empty answer.
862
+ if (opts.hasBrokenLinks !== undefined) {
863
+ const unresolvedTableExists = db.prepare(
864
+ "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'unresolved_wikilinks'",
865
+ ).get() !== null;
866
+ if (!unresolvedTableExists) {
867
+ // No table yet means no note has ever had a broken link:
868
+ // hasBrokenLinks:true matches nothing; hasBrokenLinks:false is a
869
+ // no-op (every note already qualifies) — add no condition at all.
870
+ if (opts.hasBrokenLinks) conditions.push("0 = 1");
871
+ } else {
872
+ conditions.push(
873
+ opts.hasBrokenLinks
874
+ ? `EXISTS (SELECT 1 FROM unresolved_wikilinks ubl WHERE ubl.source_id = n.id)`
875
+ : `NOT EXISTS (SELECT 1 FROM unresolved_wikilinks ubl WHERE ubl.source_id = n.id)`,
876
+ );
877
+ }
878
+ }
879
+
854
880
  // ID set filter — used by `near` to push neighborhood scoping into SQL so
855
881
  // that LIMIT applies to the neighborhood, not the whole notes table.
856
882
  if (opts.ids !== undefined) {
@@ -1238,6 +1264,7 @@ function toQueryHashInputs(opts: QueryOpts): QueryHashInputs {
1238
1264
  excludeTags: opts.excludeTags,
1239
1265
  hasTags: opts.hasTags,
1240
1266
  hasLinks: opts.hasLinks,
1267
+ hasBrokenLinks: opts.hasBrokenLinks,
1241
1268
  path: opts.path,
1242
1269
  pathPrefix: opts.pathPrefix,
1243
1270
  extension: opts.extension,
@@ -1926,11 +1953,17 @@ export function renameTag(db: Database, oldName: string, newName: string): Renam
1926
1953
  const candidates = db
1927
1954
  .prepare(`SELECT id, content FROM notes WHERE content IS NOT NULL AND content != '' AND (${orClauses})`)
1928
1955
  .all(...params) as { id: string; content: string }[];
1929
- const updateStmt = db.prepare("UPDATE notes SET content = ? WHERE id = ?");
1956
+ // vault#555 fix 2 this content rewrite is a real persisted-state
1957
+ // change (the note's `#oldtag` references literally became
1958
+ // `#newtag`) and must bump `updated_at` like any other content
1959
+ // write, or a cursor/sync-poll loop never sees it. Shares the one
1960
+ // `now` timestamp for the whole cascade (same convention as the
1961
+ // tag-row rename pass above).
1962
+ const updateStmt = db.prepare("UPDATE notes SET content = ?, updated_at = ? WHERE id = ?");
1930
1963
  for (const row of candidates) {
1931
1964
  const next = rewriteNoteBody(row.content, renames);
1932
1965
  if (next === row.content) continue;
1933
- updateStmt.run(next, row.id);
1966
+ updateStmt.run(next, now, row.id);
1934
1967
  notesRewritten++;
1935
1968
  }
1936
1969
  }
@@ -1945,11 +1978,13 @@ export function renameTag(db: Database, oldName: string, newName: string): Renam
1945
1978
  const candidates = db
1946
1979
  .prepare(`SELECT id, path FROM notes WHERE path IS NOT NULL AND (${orClauses})`)
1947
1980
  .all(...params) as { id: string; path: string }[];
1948
- const updateStmt = db.prepare("UPDATE notes SET path = ? WHERE id = ?");
1981
+ // vault#555 fix 2 same reasoning as the content rewrite above: a
1982
+ // path rewrite is a real persisted-state change.
1983
+ const updateStmt = db.prepare("UPDATE notes SET path = ?, updated_at = ? WHERE id = ?");
1949
1984
  for (const row of candidates) {
1950
1985
  const next = rewriteTagConfigPath(row.path, renames);
1951
1986
  if (next === row.path) continue;
1952
- updateStmt.run(next, row.id);
1987
+ updateStmt.run(next, now, row.id);
1953
1988
  pathsRenamed++;
1954
1989
  }
1955
1990
  }
@@ -291,6 +291,20 @@ describe("getting-started pack", () => {
291
291
  expect(GETTING_STARTED_CONTENT).toContain("over MCP");
292
292
  });
293
293
 
294
+ test("Getting Started carries the journaling / agent-memory / search conventions (vault#555)", () => {
295
+ expect(GETTING_STARTED_CONTENT).toContain("## A few shapes worth reusing");
296
+ // Journaling: indexed entry_date + mood enum.
297
+ expect(GETTING_STARTED_CONTENT).toContain("entry_date");
298
+ expect(GETTING_STARTED_CONTENT).toContain("mood");
299
+ // Agent-memory pattern: thread + messages + the if_exists:"ignore" crash-replay recipe.
300
+ expect(GETTING_STARTED_CONTENT).toContain("#thread");
301
+ expect(GETTING_STARTED_CONTENT).toContain("#message");
302
+ expect(GETTING_STARTED_CONTENT).toContain('if_exists: "ignore"');
303
+ // Search one-liner: literal by default, search_mode:"advanced" for FTS syntax.
304
+ expect(GETTING_STARTED_CONTENT).toContain("literal by default");
305
+ expect(GETTING_STARTED_CONTENT).toContain('search_mode: "advanced"');
306
+ });
307
+
294
308
  test("default vault-description constants orient + tell the AI to self-replace them", () => {
295
309
  for (const d of [DEFAULT_VAULT_DESCRIPTION, IMPORTED_VAULT_DESCRIPTION]) {
296
310
  expect(d.length).toBeGreaterThan(100);
@@ -481,6 +481,29 @@ support operator queries (\`metadata: { held_on: { gte: "..." } }\`) and
481
481
  \`order_by\`; all tags declaring the same field must agree on its \`type\` and
482
482
  \`indexed\` flag.
483
483
 
484
+ ## A few shapes worth reusing
485
+
486
+ Testers of this vault independently reinvented these — start from them instead:
487
+
488
+ - **Journaling.** One tag (\`#journal\`) with an indexed \`entry_date\`
489
+ (\`{ type: "string", indexed: true }\`, ISO date) and a \`mood\` enum
490
+ (\`{ type: "string", enum: [...] }\`). Indexing \`entry_date\` (see "Only
491
+ \`indexed: true\` fields support operator queries" above) is what makes
492
+ date-range queries (\`metadata: { entry_date: { gte: "2026-01-01" } }\`) and
493
+ \`order_by: "entry_date"\` work.
494
+ - **Agent memory (thread + messages, crash-replay-safe).** Model a
495
+ conversation as one \`#thread\` note (metadata: \`status\`, \`cursor\`) plus many
496
+ \`#message\` notes linked to it (\`relationship: "in-thread"\`), each carrying
497
+ its own \`status\`. If your write loop can crash and replay the same message,
498
+ give it a stable \`path\` (e.g. \`Threads/<thread-id>/msg-<n>\`) and create it
499
+ with \`if_exists: "ignore"\` — a retry after a crash safely returns the
500
+ already-written message instead of creating a duplicate, no
501
+ query-before-write round trip needed.
502
+ - **Search.** \`query-notes { search: "..." }\` is literal by default — your
503
+ text is escaped, not parsed as FTS5 syntax, so punctuation like "didn't" or
504
+ "18.6" just works. Pass \`search_mode: "advanced"\` only when you actually
505
+ want FTS5 boolean/phrase/prefix syntax.
506
+
484
507
  ## Write gotchas
485
508
 
486
509
  A few behaviors worth knowing before you write at scale:
package/core/src/store.ts CHANGED
@@ -741,22 +741,29 @@ export class BunSqliteStore implements Store {
741
741
  // Throws IndexedFieldError on an invalid identifier (e.g. kebab-case).
742
742
  indexedFieldOps.validateFieldName(fieldName);
743
743
  }
744
- // Default-conformance pre-validate (vault#553 Decision B) — mirrors the
745
- // indexed-type/name checks above: fail BEFORE any persistence so a bad
746
- // `default` never gets written. Runs over EVERY field in the full
747
- // (already-merged) `nextFields` map, not just indexed ones a default
748
- // that doesn't match its own field's type/enum is a tag-schema error
749
- // regardless of queryability. This is the REST parity path: MCP's
750
- // `update-tag` tool ALSO calls `collectTagFieldViolations` before
751
- // reaching here (bundling every violation into one `TagFieldConflictError`
752
- // see that function's doc comment), so a conforming MCP call never
753
- // trips this; a non-conforming one throws there first. REST has no such
754
- // pre-check, so this is REST's only gate same asymmetry as the
755
- // indexed-type/name checks it sits beside.
744
+ // Default-conformance + type-vocabulary pre-validate (vault#553
745
+ // Decision B; vault#555 fix 4/5) mirrors the indexed-type/name
746
+ // checks above: fail BEFORE any persistence so a bad `default` or an
747
+ // unrecognized `type` never gets written. Runs over EVERY field in the
748
+ // full (already-merged) `nextFields` map, not just indexed ones both
749
+ // are tag-schema errors regardless of queryability. This is a
750
+ // DEFENSE-IN-DEPTH backstop, not the primary user-facing gate: both
751
+ // REST's `PUT /api/tags/:name` (`collectCrossTagFieldViolations` +
752
+ // `collectOwnFieldDefaultAndTypeViolations`, bundled 422) and MCP's
753
+ // `update-tag` (`collectTagFieldViolations`, same bundle) now
754
+ // pre-validate every field and report ALL violations together BEFORE
755
+ // ever reaching this chokepoint — a conforming call never trips this
756
+ // fail-fast loop. It stays here so any OTHER caller of
757
+ // `store.upsertTagRecord` (imports, migrations, scripts) still fails
758
+ // closed rather than persisting a lying schema.
756
759
  for (const [fieldName, spec] of Object.entries(nextFields ?? {})) {
757
- const violation = tagSchemaOps.validateFieldDefault(fieldName, spec);
758
- if (violation) {
759
- throw new tagSchemaOps.InvalidFieldDefaultError(fieldName, violation.message);
760
+ const typeViolation = tagSchemaOps.validateFieldType(fieldName, spec);
761
+ if (typeViolation) {
762
+ throw new tagSchemaOps.InvalidFieldTypeError(fieldName, spec.type);
763
+ }
764
+ const defaultViolation = tagSchemaOps.validateFieldDefault(fieldName, spec);
765
+ if (defaultViolation) {
766
+ throw new tagSchemaOps.InvalidFieldDefaultError(fieldName, defaultViolation.message);
760
767
  }
761
768
  }
762
769
  }
@@ -190,6 +190,33 @@ export class InvalidFieldDefaultError extends Error {
190
190
  }
191
191
  }
192
192
 
193
+ /**
194
+ * Thrown by `upsertTagRecord` (core/src/store.ts's chokepoint) when a
195
+ * field's declared `type` isn't one of the six recognized values (vault#555
196
+ * — `update-tag{fields:{weird:{type:"frobnicator"}}}` used to be accepted
197
+ * and persisted verbatim, no error, for any NON-indexed field: the only
198
+ * existing type check, `mapFieldType`, ran solely on `indexed: true` fields
199
+ * — see {@link validateFieldType}'s doc comment). Own-field validation leaf,
200
+ * same posture as `InvalidFieldDefaultError`.
201
+ */
202
+ export class InvalidFieldTypeError extends Error {
203
+ code = "INVALID_FIELD_TYPE" as const;
204
+ error_type = "invalid_field_type" as const;
205
+ field: string;
206
+ type: string;
207
+ valid_types: readonly string[];
208
+
209
+ constructor(field: string, type: string) {
210
+ super(
211
+ `field "${field}" declares unknown type "${type}" — must be one of [${VALID_FIELD_TYPES.join(", ")}]`,
212
+ );
213
+ this.name = "InvalidFieldTypeError";
214
+ this.field = field;
215
+ this.type = type;
216
+ this.valid_types = VALID_FIELD_TYPES;
217
+ }
218
+ }
219
+
193
220
  /**
194
221
  * Validate that a field's declared `default` (vault#553 Decision B) conforms
195
222
  * to its own `type` and (when declared) `enum`. Returns `null` when the
@@ -226,6 +253,73 @@ export function validateFieldDefault(field: string, spec: TagFieldSchema): TagFi
226
253
  return null;
227
254
  }
228
255
 
256
+ /**
257
+ * The full recognized vocabulary for `TagFieldSchema.type` — storage/
258
+ * advisory validation accepts all six; only `string`/`integer`/`boolean`
259
+ * are INDEXABLE (that narrower subset is `indexed-fields.ts`'s `TYPE_MAP`,
260
+ * enforced separately via `mapFieldType` for `indexed: true` fields).
261
+ * Matches `defaultMatchesType`'s switch and `schema-defaults.ts`'s
262
+ * `SchemaField.type` union — kept in lockstep by hand across the two
263
+ * deliberately-decoupled modules (see `validateFieldDefault`'s doc comment
264
+ * for why they don't cross-import).
265
+ */
266
+ export const VALID_FIELD_TYPES = ["string", "number", "integer", "boolean", "array", "object"] as const;
267
+
268
+ /**
269
+ * Validate that a field's declared `type` is one of the six recognized
270
+ * values (vault#555). Returns `null` when `type` is unset (own-field checks
271
+ * elsewhere already treat an unset type as "nothing to check against") or
272
+ * recognized; otherwise a {@link TagFieldViolation} with `reason:
273
+ * "invalid_type"`.
274
+ *
275
+ * Before this, a non-indexed field's `type` was NEVER validated anywhere —
276
+ * `mapFieldType` (the only existing type check) runs solely on fields
277
+ * declaring `indexed: true`. `update-tag{fields:{weird:{type:"frobnicator"}}}`
278
+ * on a plain (non-indexed) field silently persisted the bogus type
279
+ * verbatim; every later read of that field just skipped validation
280
+ * entirely (`valueMatchesType` in schema-defaults.ts has no vocabulary
281
+ * entry for it either). This is now the SINGLE gate — indexed fields still
282
+ * get their own, narrower `unsupported_indexed_type` check (which also
283
+ * covers a RECOGNIZED-but-unindexable type like `"array"`, a different
284
+ * case from a genuinely unknown token).
285
+ *
286
+ * Pure — never throws; callers ({@link collectTagFieldViolations}'s bundled
287
+ * MCP/REST report, and the store chokepoint's defense-in-depth pre-validate)
288
+ * each decide how to surface it.
289
+ */
290
+ export function validateFieldType(field: string, spec: TagFieldSchema): TagFieldViolation | null {
291
+ if (!spec.type) return null;
292
+ if ((VALID_FIELD_TYPES as readonly string[]).includes(spec.type)) return null;
293
+ return {
294
+ field,
295
+ reason: "invalid_type",
296
+ message: `field "${field}" declares unknown type "${spec.type}" — must be one of [${VALID_FIELD_TYPES.join(", ")}]`,
297
+ };
298
+ }
299
+
300
+ /**
301
+ * Combined own-field `default` + `type` violation collection — no cross-tag
302
+ * lookups needed, so no `db` parameter (unlike
303
+ * {@link collectCrossTagFieldViolations}). Used by REST's `PUT
304
+ * /api/tags/:name` (vault#555 fix — folds these into the SAME bundled
305
+ * `tag_field_conflict` 422 report as the cross-tag checks, replacing the
306
+ * old fail-fast single-violation `invalid_field_default` 400) and reused by
307
+ * {@link collectTagFieldViolations} (MCP) so the two surfaces can't drift on
308
+ * what counts as an own-field violation.
309
+ */
310
+ export function collectOwnFieldDefaultAndTypeViolations(
311
+ incomingFields: Record<string, TagFieldSchema>,
312
+ ): TagFieldViolation[] {
313
+ const violations: TagFieldViolation[] = [];
314
+ for (const [fieldName, spec] of Object.entries(incomingFields)) {
315
+ const typeViolation = validateFieldType(fieldName, spec);
316
+ if (typeViolation) violations.push(typeViolation);
317
+ const defaultViolation = validateFieldDefault(fieldName, spec);
318
+ if (defaultViolation) violations.push(defaultViolation);
319
+ }
320
+ return violations;
321
+ }
322
+
229
323
  /** Same six-type vocabulary as `TagFieldSchema.type`'s doc comment. Unknown/unset types pass (nothing to check against). */
230
324
  function defaultMatchesType(value: unknown, type: string): boolean {
231
325
  switch (type) {
@@ -507,7 +601,8 @@ export interface TagFieldViolation {
507
601
  | "indexed_flag_conflict"
508
602
  | "unsupported_indexed_type"
509
603
  | "invalid_field_name"
510
- | "invalid_default";
604
+ | "invalid_default"
605
+ | "invalid_type";
511
606
  message: string;
512
607
  /**
513
608
  * The conflicting declarer tag — present on the cross-tag reasons
@@ -625,18 +720,24 @@ export function collectCrossTagFieldViolations(
625
720
 
626
721
  /**
627
722
  * Full field-violation collection: {@link collectCrossTagFieldViolations}
628
- * PLUS the own-field checks (unsupported type for indexing, invalid
629
- * identifier, and vault#553 Decision B a non-conforming `default`).
630
- * Used by the MCP `update-tag` tool, which unlike REST — had no prior
631
- * single-violation status-code contract to preserve for those checks (its
632
- * old inline loop threw an unstructured `Error` for them, same as
633
- * everything else pre-#554); collecting everything here is a strict
634
- * improvement. See {@link collectCrossTagFieldViolations}'s doc comment for
635
- * why REST calls that narrower function directly instead of this one — REST
636
- * gets the SAME `invalid_default` coverage via `store.upsertTagRecord`'s
637
- * fail-fast pre-validate (mirrors its indexed-type/name checks), just as a
638
- * single-violation `InvalidFieldDefaultError` 400 rather than a bundled
639
- * `TagFieldConflictError` 422.
723
+ * PLUS EVERY own-field check {@link collectOwnFieldDefaultAndTypeViolations}
724
+ * (a non-conforming `default`, vault#553 Decision B; an unrecognized `type`,
725
+ * vault#555) AND the indexed-only checks (unsupported type for indexing,
726
+ * invalid identifier). Used by the MCP `update-tag` tool, which — unlike
727
+ * REST's indexed-type/name path had no prior single-violation
728
+ * status-code contract to preserve for those two (its old inline loop threw
729
+ * an unstructured `Error` for them, same as everything else pre-#554);
730
+ * collecting everything here is a strict improvement. See
731
+ * {@link collectCrossTagFieldViolations}'s doc comment for why REST calls
732
+ * that narrower function directly for the CROSS-tag checks, then ALSO calls
733
+ * {@link collectOwnFieldDefaultAndTypeViolations} itself (vault#555 fix 5
734
+ * REST used to get `invalid_default` coverage only via
735
+ * `store.upsertTagRecord`'s fail-fast pre-validate, a single-violation
736
+ * `InvalidFieldDefaultError` → 400 that silently dropped every OTHER bad
737
+ * field in the same call; both surfaces now report every own-field default/
738
+ * type violation together, bundled with the cross-tag ones) — the
739
+ * indexed-type/name pair stays REST's own single-violation `400
740
+ * invalid_indexed_field` path (unchanged wire contract, vault#478).
640
741
  */
641
742
  export function collectTagFieldViolations(
642
743
  db: Database,
@@ -644,14 +745,9 @@ export function collectTagFieldViolations(
644
745
  incomingFields: Record<string, TagFieldSchema>,
645
746
  ): TagFieldViolation[] {
646
747
  const violations = collectCrossTagFieldViolations(db, tag, incomingFields);
748
+ violations.push(...collectOwnFieldDefaultAndTypeViolations(incomingFields));
647
749
 
648
750
  for (const [fieldName, spec] of Object.entries(incomingFields)) {
649
- // Own-field default-conformance check (vault#553 Decision B) — applies
650
- // to EVERY field (not just indexed ones); a bad default is a tag-schema
651
- // error regardless of whether the field is queryable.
652
- const defaultViolation = validateFieldDefault(fieldName, spec);
653
- if (defaultViolation) violations.push(defaultViolation);
654
-
655
751
  if (spec.indexed === true) {
656
752
  const mapped = mapFieldType(spec.type);
657
753
  if (!mapped) {
package/core/src/types.ts CHANGED
@@ -141,6 +141,15 @@ export interface QueryOpts {
141
141
  // `hasLinks` checks both directions — inbound or outbound counts as "has links".
142
142
  hasTags?: boolean;
143
143
  hasLinks?: boolean;
144
+ /**
145
+ * Presence filter on the `unresolved_wikilinks` table (vault#555):
146
+ * `true` → only notes with at least one dangling outbound link (a
147
+ * `[[wikilink]]` or structured `links` target that never resolved to a
148
+ * note); `false` → only notes with none. Safe on a vault where the
149
+ * `unresolved_wikilinks` table has never been created (no note has ever
150
+ * had a broken link) — `true` matches nothing, `false` is a no-op.
151
+ */
152
+ hasBrokenLinks?: boolean;
144
153
  path?: string; // exact path match (case-insensitive)
145
154
  pathPrefix?: string; // e.g., "Projects/Parachute" matches "Projects/Parachute/README"
146
155
  /**
@@ -1,7 +1,7 @@
1
1
  import { describe, it, expect, beforeEach } from "bun:test";
2
2
  import { Database } from "bun:sqlite";
3
3
  import { SqliteStore } from "./store.js";
4
- import { parseWikilinks, syncWikilinks, resolveWikilink, resolveUnresolvedWikilinks } from "./wikilinks.js";
4
+ import { parseWikilinks, syncWikilinks, resolveWikilink, resolveUnresolvedWikilinks, listUnresolvedWikilinks } from "./wikilinks.js";
5
5
 
6
6
  let store: SqliteStore;
7
7
  let db: Database;
@@ -275,3 +275,115 @@ describe("path-based resolution", async () => {
275
275
  expect(links[0].targetId).toBe(target.id);
276
276
  });
277
277
  });
278
+
279
+ // ---------------------------------------------------------------------------
280
+ // unresolved_wikilinks relationship-column migration — atomicity (vault#555
281
+ // wire+generalist must-fix; W7's migrateToV25-interruption lesson applied).
282
+ // ---------------------------------------------------------------------------
283
+
284
+ describe("ensureRelationshipColumn — crash-safe rebuild", () => {
285
+ /**
286
+ * Build a legacy (pre-vault#555) 2-column `unresolved_wikilinks` table with
287
+ * pending rows, on a store whose other tables already exist. Returns the
288
+ * source note's id (a real row so the FK is satisfiable when
289
+ * foreign_keys is on).
290
+ */
291
+ async function seedLegacyTable(): Promise<string> {
292
+ // A plain note (no wikilinks) doesn't create the v555 table.
293
+ const src = await store.createNote("plain body, no wikilinks", { path: "src-note" });
294
+ const tgt = await store.createNote("plain target", { path: "Target A" });
295
+ // Hand-build the pre-v555 shape (2-column PK, no `relationship`).
296
+ db.exec(`
297
+ CREATE TABLE unresolved_wikilinks (
298
+ source_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
299
+ target_path TEXT NOT NULL COLLATE NOCASE,
300
+ PRIMARY KEY (source_id, target_path)
301
+ )
302
+ `);
303
+ db.prepare("INSERT INTO unresolved_wikilinks (source_id, target_path) VALUES (?, ?)")
304
+ .run(src.id, "Target B");
305
+ db.prepare("INSERT INTO unresolved_wikilinks (source_id, target_path) VALUES (?, ?)")
306
+ .run(src.id, "Target A"); // this one resolves to tgt after the migration
307
+ return src.id;
308
+ }
309
+
310
+ function hasRelationshipColumn(): boolean {
311
+ const cols = db.prepare("PRAGMA table_info(unresolved_wikilinks)").all() as { name: string }[];
312
+ return cols.some((c) => c.name === "relationship");
313
+ }
314
+
315
+ function tableExists(name: string): boolean {
316
+ const row = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = ?").get(name);
317
+ return row !== null;
318
+ }
319
+
320
+ it("a crash mid-rebuild (between RENAME and CREATE) rolls back — original table + pending rows intact, no orphan _pre_v555", async () => {
321
+ await seedLegacyTable();
322
+ expect(hasRelationshipColumn()).toBe(false); // legacy shape confirmed
323
+ const rowsBefore = (db.prepare("SELECT COUNT(*) AS c FROM unresolved_wikilinks").get() as { c: number }).c;
324
+ expect(rowsBefore).toBe(2);
325
+
326
+ // Monkey-patch db.exec to throw on the migration's CREATE — i.e. AFTER
327
+ // the RENAME has moved the table to _pre_v555 but BEFORE the new table
328
+ // exists. This is the exact interruption window the transaction wrapper
329
+ // must survive. BEGIN/RENAME/ROLLBACK all pass through untouched.
330
+ const origExec = db.exec.bind(db);
331
+ let crashed = false;
332
+ (db as unknown as { exec: (sql: string) => unknown }).exec = (sql: string) => {
333
+ if (!crashed && /CREATE TABLE unresolved_wikilinks \(/.test(sql)) {
334
+ crashed = true;
335
+ throw new Error("simulated crash between RENAME and CREATE");
336
+ }
337
+ return origExec(sql);
338
+ };
339
+
340
+ let thrown: unknown;
341
+ try {
342
+ // resolveUnresolvedWikilinks calls ensureRelationshipColumn first —
343
+ // the REAL heal path, not a hand-rolled copy.
344
+ resolveUnresolvedWikilinks(db, "Target A", "irrelevant-id");
345
+ } catch (e) {
346
+ thrown = e;
347
+ } finally {
348
+ (db as unknown as { exec: (sql: string) => unknown }).exec = origExec;
349
+ }
350
+
351
+ // The crash propagated (not swallowed).
352
+ expect(crashed).toBe(true);
353
+ expect((thrown as Error)?.message).toContain("simulated crash");
354
+
355
+ // ROLLBACK restored the ORIGINAL table exactly:
356
+ expect(tableExists("unresolved_wikilinks")).toBe(true); // NOT renamed away
357
+ expect(tableExists("unresolved_wikilinks_pre_v555")).toBe(false); // no orphan
358
+ expect(hasRelationshipColumn()).toBe(false); // still the legacy 2-col shape
359
+ const rowsAfter = (db.prepare("SELECT COUNT(*) AS c FROM unresolved_wikilinks").get() as { c: number }).c;
360
+ expect(rowsAfter).toBe(2); // pending rows NOT lost
361
+
362
+ // And a clean retry (no crash) fully recovers: migration runs, column
363
+ // added, rows preserved and backfilled as "wikilink".
364
+ resolveUnresolvedWikilinks(db, "nothing-matches-here", "irrelevant-id-2");
365
+ expect(hasRelationshipColumn()).toBe(true);
366
+ expect(tableExists("unresolved_wikilinks_pre_v555")).toBe(false);
367
+ const migratedRows = db.prepare("SELECT relationship FROM unresolved_wikilinks").all() as { relationship: string }[];
368
+ expect(migratedRows).toHaveLength(2);
369
+ expect(migratedRows.every((r) => r.relationship === "wikilink")).toBe(true);
370
+ });
371
+
372
+ it("a successful (uninterrupted) rebuild migrates + backfills as wikilink, and a pending forward-ref then resolves", async () => {
373
+ const srcId = await seedLegacyTable();
374
+
375
+ // Drive the heal through the real read path.
376
+ const before = listUnresolvedWikilinks(db);
377
+ expect(before.count).toBe(2);
378
+ expect(hasRelationshipColumn()).toBe(true); // listUnresolvedWikilinks healed it
379
+ expect(before.unresolved.every((u) => u.relationship === "wikilink")).toBe(true);
380
+
381
+ // "Target A" already exists (seedLegacyTable created it) — resolving now
382
+ // backfills the edge from the migrated pending row.
383
+ const targetA = await store.getNoteByPath("Target A");
384
+ const resolved = resolveUnresolvedWikilinks(db, "Target A", targetA!.id);
385
+ expect(resolved).toBe(1);
386
+ const links = await store.getLinks(srcId, { direction: "outbound" });
387
+ expect(links.some((l) => l.targetId === targetA!.id && l.relationship === "wikilink")).toBe(true);
388
+ });
389
+ });