@openparachute/vault 0.4.8 → 0.4.9-rc.11

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.
Files changed (58) hide show
  1. package/core/src/core.test.ts +4 -1
  2. package/core/src/hooks.test.ts +320 -1
  3. package/core/src/hooks.ts +243 -38
  4. package/core/src/indexed-fields.test.ts +151 -0
  5. package/core/src/indexed-fields.ts +98 -0
  6. package/core/src/mcp.ts +99 -41
  7. package/core/src/notes.ts +26 -2
  8. package/core/src/portable-md.test.ts +304 -1
  9. package/core/src/portable-md.ts +418 -2
  10. package/core/src/schema.ts +114 -2
  11. package/core/src/store.ts +185 -2
  12. package/core/src/types.ts +28 -0
  13. package/package.json +2 -2
  14. package/src/auth-hub-jwt.test.ts +147 -0
  15. package/src/auth.ts +121 -1
  16. package/src/auto-transcribe.test.ts +7 -2
  17. package/src/auto-transcribe.ts +6 -2
  18. package/src/cli.ts +131 -36
  19. package/src/config.ts +12 -4
  20. package/src/export-watch.test.ts +74 -0
  21. package/src/export-watch.ts +108 -7
  22. package/src/github-device-flow.test.ts +404 -0
  23. package/src/github-device-flow.ts +415 -0
  24. package/src/hub-jwt.test.ts +27 -2
  25. package/src/hub-jwt.ts +10 -0
  26. package/src/mcp-http.ts +48 -39
  27. package/src/mcp-install-interactive.test.ts +10 -21
  28. package/src/mcp-install-interactive.ts +12 -21
  29. package/src/mcp-install.test.ts +141 -30
  30. package/src/mcp-install.ts +109 -3
  31. package/src/mcp-tools.ts +460 -3
  32. package/src/mirror-config.test.ts +277 -14
  33. package/src/mirror-config.ts +482 -31
  34. package/src/mirror-credentials.test.ts +601 -0
  35. package/src/mirror-credentials.ts +700 -0
  36. package/src/mirror-deps.ts +67 -17
  37. package/src/mirror-import.test.ts +550 -0
  38. package/src/mirror-import.ts +487 -0
  39. package/src/mirror-manager.test.ts +423 -12
  40. package/src/mirror-manager.ts +621 -72
  41. package/src/mirror-per-vault.test.ts +519 -0
  42. package/src/mirror-registry.ts +91 -14
  43. package/src/mirror-routes.test.ts +966 -10
  44. package/src/mirror-routes.ts +1111 -7
  45. package/src/module-config.ts +11 -5
  46. package/src/routes.ts +38 -1
  47. package/src/routing.test.ts +92 -1
  48. package/src/routing.ts +193 -20
  49. package/src/server.ts +116 -35
  50. package/src/storage.test.ts +132 -7
  51. package/src/token-store.ts +300 -5
  52. package/src/transcription-worker.ts +9 -4
  53. package/src/triggers.ts +16 -3
  54. package/src/vault.test.ts +681 -2
  55. package/web/ui/dist/assets/index-Cn-PPMRv.js +60 -0
  56. package/web/ui/dist/assets/{index-BOa-JJtV.css → index-DBe8Xiah.css} +1 -1
  57. package/web/ui/dist/index.html +2 -2
  58. package/web/ui/dist/assets/index-BzA5LgE3.js +0 -60
package/core/src/mcp.ts CHANGED
@@ -20,6 +20,18 @@ export interface McpToolDef {
20
20
  description: string;
21
21
  inputSchema: Record<string, unknown>;
22
22
  execute: (params: Record<string, unknown>) => unknown | Promise<unknown>;
23
+ /**
24
+ * Minimum scope verb the caller must hold for THIS vault to see + invoke
25
+ * the tool. `read` for pure queries, `write` for mutations, `admin` for
26
+ * operator-only surfaces (`prune-schema` in core; `manage-token` in the
27
+ * server layer). The MCP HTTP layer filters
28
+ * `tools/list` by this field and verb-gates `tools/call` against it; the
29
+ * filter is the primary defense, the inner gate is defense-in-depth.
30
+ *
31
+ * Pre-v19 unstamped tools default to `write` at the dispatch layer so a
32
+ * future addition that forgets to stamp this gets the safer treatment.
33
+ */
34
+ requiredVerb: "read" | "write" | "admin";
23
35
  }
24
36
 
25
37
  // ---------------------------------------------------------------------------
@@ -88,9 +100,9 @@ function removeWikilinkBrackets(content: string, targetPath: string): string {
88
100
  // ---------------------------------------------------------------------------
89
101
 
90
102
  /**
91
- * Generate the consolidated MCP tools for a vault. Post-v17 surface (9):
103
+ * Generate the consolidated MCP tools for a vault. Surface (10):
92
104
  * query-notes, create-note, update-note, delete-note, list-tags, update-tag,
93
- * delete-tag, find-path, vault-info.
105
+ * delete-tag, find-path, vault-info, prune-schema (admin).
94
106
  */
95
107
  export function generateMcpTools(store: Store): McpToolDef[] {
96
108
  const db: Database = (store as any).db;
@@ -102,6 +114,7 @@ export function generateMcpTools(store: Store): McpToolDef[] {
102
114
  // =====================================================================
103
115
  {
104
116
  name: "query-notes",
117
+ requiredVerb: "read",
105
118
  description: `Query notes. Returns notes matching the given filters.
106
119
 
107
120
  - **Single note**: pass \`id\` (accepts note ID or path, e.g., "Projects/README")
@@ -403,6 +416,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
403
416
  // =====================================================================
404
417
  {
405
418
  name: "create-note",
419
+ requiredVerb: "write",
406
420
  description: `Create one or more notes. Pass a single note's fields directly, or pass a \`notes\` array for batch creation. Each note accepts content, path, metadata, tags, links, and created_at.`,
407
421
  inputSchema: {
408
422
  type: "object",
@@ -518,6 +532,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
518
532
  // =====================================================================
519
533
  {
520
534
  name: "update-note",
535
+ requiredVerb: "write",
521
536
  description: `Update one or more notes. Accepts ID or path. Supports content, path, metadata updates plus tag and link mutations.
522
537
 
523
538
  - Three content-modification modes (mutually exclusive):
@@ -930,6 +945,14 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
930
945
  // =====================================================================
931
946
  {
932
947
  name: "delete-note",
948
+ // `write` — same destructive verb as update-note. Aaron's call
949
+ // 2026-05-27: "delete- in write; right now the only admin gated
950
+ // thing is tokens." Reserving `admin` for "operator-only
951
+ // capabilities" (token mgmt + future config writes). A future
952
+ // finer-grained model might split `vault:write:no-delete` for
953
+ // genuinely append-only callers — gating WITHIN write rather
954
+ // than promoting deletes out of it.
955
+ requiredVerb: "write",
933
956
  description: "Permanently delete a note and all its tags and links. Accepts ID or path.",
934
957
  inputSchema: {
935
958
  type: "object",
@@ -950,6 +973,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
950
973
  // =====================================================================
951
974
  {
952
975
  name: "list-tags",
976
+ requiredVerb: "read",
953
977
  description: `List tags with usage counts. Pass \`tag\` to get a single tag's full record (description, fields, relationships, parent_names, timestamps). Pass \`include_schema: true\` to include the full record for every tag.`,
954
978
  inputSchema: {
955
979
  type: "object",
@@ -1004,6 +1028,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
1004
1028
  // =====================================================================
1005
1029
  {
1006
1030
  name: "update-tag",
1031
+ requiredVerb: "write",
1007
1032
  description: "Create or update a tag's identity row: description, indexed-field schemas, typed-link relationships, and hierarchy parents. If the tag doesn't exist, it's created. Fields are merged (new keys added, existing keys replaced); relationships and parent_names are replaced wholesale when provided. Pass null for fields/relationships/parent_names to clear that column. See parachute-patterns/patterns/tag-data-model.md.",
1008
1033
  inputSchema: {
1009
1034
  type: "object",
@@ -1049,12 +1074,23 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
1049
1074
  const tag = params.tag as string;
1050
1075
  const existing = tagSchemaOps.getTagRecord(db, tag);
1051
1076
 
1052
- // ---- fields: shallow-merge into existing (preserves prior keys).
1053
- const incomingFields = (params.fields as Record<string, TagFieldSchema> | undefined) ?? {};
1054
- const mergedFields: Record<string, TagFieldSchema> = {
1055
- ...(existing?.fields ?? {}),
1056
- ...incomingFields,
1057
- };
1077
+ // ---- fields: three-way semantics, distinguishing `null` from
1078
+ // `undefined` (do NOT collapse with `?? {}` that silently turns an
1079
+ // explicit clear-all into a no-op, the gitcoin orphaned-fields bug).
1080
+ // - undefined → no change. Preserve every existing field; declare
1081
+ // nothing new. mergedFields === existing.fields.
1082
+ // - null → clear ALL of this tag's field schemas.
1083
+ // mergedFields = {} so the diff below releases every
1084
+ // indexed field this tag exclusively declares.
1085
+ // - object → shallow-merge into existing (preserves prior keys).
1086
+ const incomingFields =
1087
+ params.fields === null || params.fields === undefined
1088
+ ? {}
1089
+ : (params.fields as Record<string, TagFieldSchema>);
1090
+ const mergedFields: Record<string, TagFieldSchema> =
1091
+ params.fields === null
1092
+ ? {}
1093
+ : { ...(existing?.fields ?? {}), ...incomingFields };
1058
1094
 
1059
1095
  // Validate cross-tag consistency on fields being (re)declared in this
1060
1096
  // call. `type` and `indexed` are global — all declarers must agree.
@@ -1121,6 +1157,12 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
1121
1157
  : (params.fields !== undefined ? null : undefined);
1122
1158
  const descriptionPatch =
1123
1159
  params.description === undefined ? undefined : (params.description as string);
1160
+ // The indexed-field lifecycle (declareField for added indexed fields,
1161
+ // releaseField for removed ones, with the co-declaration guard) is
1162
+ // reconciled inside store.upsertTagRecord — the single chokepoint all
1163
+ // callers (MCP, REST PUT /tags/:name, import) share — so it can't be
1164
+ // bypassed. The cross-tag validation above stays here to surface a
1165
+ // clean error before persisting. See the gitcoin orphaned-fields bug.
1124
1166
  const result = await store.upsertTagRecord(tag, {
1125
1167
  ...(descriptionPatch !== undefined ? { description: descriptionPatch } : {}),
1126
1168
  ...(fieldsPatch !== undefined ? { fields: fieldsPatch } : {}),
@@ -1128,28 +1170,6 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
1128
1170
  ...(parentNamesPatch !== undefined ? { parent_names: parentNamesPatch } : {}),
1129
1171
  });
1130
1172
 
1131
- // ---- Reconcile indexed-field lifecycle for this tag.
1132
- const priorIndexed = new Set(
1133
- Object.entries(existing?.fields ?? {})
1134
- .filter(([, v]) => v.indexed === true)
1135
- .map(([k]) => k),
1136
- );
1137
- const nextIndexed = new Set(
1138
- Object.entries(mergedFields)
1139
- .filter(([, v]) => v.indexed === true)
1140
- .map(([k]) => k),
1141
- );
1142
- for (const fieldName of nextIndexed) {
1143
- const spec = mergedFields[fieldName]!;
1144
- const mapped = indexedFieldOps.mapFieldType(spec.type)!;
1145
- indexedFieldOps.declareField(db, fieldName, mapped, tag);
1146
- }
1147
- for (const fieldName of priorIndexed) {
1148
- if (!nextIndexed.has(fieldName)) {
1149
- indexedFieldOps.releaseField(db, fieldName, tag);
1150
- }
1151
- }
1152
-
1153
1173
  return result;
1154
1174
  },
1155
1175
  },
@@ -1159,6 +1179,10 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
1159
1179
  // =====================================================================
1160
1180
  {
1161
1181
  name: "delete-tag",
1182
+ // `write` — Aaron's call 2026-05-27: admin reserved for token
1183
+ // mgmt + future config writes; deletes are write-tier mutations.
1184
+ // See delete-note rationale.
1185
+ requiredVerb: "write",
1162
1186
  description: "Delete a tag, remove it from all notes, and delete its schema. Notes themselves are NOT deleted — just untagged.",
1163
1187
  inputSchema: {
1164
1188
  type: "object",
@@ -1169,19 +1193,12 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
1169
1193
  },
1170
1194
  execute: async (params) => {
1171
1195
  const tag = params.tag as string;
1172
- // Release any indexed fields this tag declared before the row
1173
- // drops. releaseField drops the generated column + index when the
1174
- // declarer set empties.
1175
- const schema = tagSchemaOps.getTagSchema(db, tag);
1176
- if (schema?.fields) {
1177
- for (const [fieldName, spec] of Object.entries(schema.fields)) {
1178
- if (spec.indexed === true) {
1179
- indexedFieldOps.releaseField(db, fieldName, tag);
1180
- }
1181
- }
1182
- }
1183
1196
  // Drop the row outright — description/fields/relationships/parents
1184
1197
  // travel with it. (No more sidecar table to clear separately.)
1198
+ // Indexed-field release is handled inside store.deleteTag →
1199
+ // noteOps.deleteTag so every entry point (MCP, REST, import sweep)
1200
+ // releases consistently with the co-declaration guard. See the
1201
+ // gitcoin orphaned-fields bug report.
1185
1202
  return await store.deleteTag(tag);
1186
1203
  },
1187
1204
  },
@@ -1191,6 +1208,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
1191
1208
  // =====================================================================
1192
1209
  {
1193
1210
  name: "find-path",
1211
+ requiredVerb: "read",
1194
1212
  description: "Find the shortest path between two notes in the link graph. Accepts IDs or paths. Returns the chain of note IDs and relationships, or null if no path exists.",
1195
1213
  inputSchema: {
1196
1214
  type: "object",
@@ -1215,6 +1233,11 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
1215
1233
  // =====================================================================
1216
1234
  {
1217
1235
  name: "vault-info",
1236
+ // `read` so vault:read callers can fetch stats. The
1237
+ // description-update branch performs an inner write-check (see
1238
+ // overrideVaultInfo in src/mcp-tools.ts) — do not promote this to
1239
+ // `write` or read-only callers lose the stats projection.
1240
+ requiredVerb: "read",
1218
1241
  description: "Get a comprehensive vault projection: name, description, tags-with-schemas (own + effective parents/fields per #270 inheritance), indexed metadata fields catalog, and query hints. Pass `include_stats: true` to add note/tag/link counts and the monthly distribution. Pass `description` to update the vault description (changes how AI agents behave in future sessions). Call this anytime mid-session to refresh schema context.",
1219
1242
  inputSchema: {
1220
1243
  type: "object",
@@ -1231,6 +1254,41 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
1231
1254
  },
1232
1255
  },
1233
1256
 
1257
+ // =====================================================================
1258
+ // 10. prune-schema — drop orphaned indexed-field columns
1259
+ // =====================================================================
1260
+ {
1261
+ name: "prune-schema",
1262
+ // `admin` — a destructive schema-maintenance op, same tier as
1263
+ // manage-token. Operator-only; hidden from read/write sessions.
1264
+ requiredVerb: "admin",
1265
+ description:
1266
+ "Drop orphaned indexed-field columns + indexes whose declaring tags no longer exist (the result of a deleted tag never releasing its fields). Dry-run by default — returns the drop plan without mutating. Pass `apply: true` to execute. A field co-declared by a still-live tag is never dropped; only the dead declarers are trimmed from its set. Generated columns are derived from notes.metadata JSON, so a drop loses only the index, never source data — declare the field again to rebuild it.",
1267
+ inputSchema: {
1268
+ type: "object",
1269
+ properties: {
1270
+ apply: {
1271
+ type: "boolean",
1272
+ description: "Execute the prune. Default false (dry-run — report what would be dropped without changing anything).",
1273
+ },
1274
+ },
1275
+ },
1276
+ execute: async (params) => {
1277
+ const apply = params.apply === true;
1278
+ const plan = await store.pruneIndexedFields({ dryRun: !apply });
1279
+ const dropped = plan.filter((p) => p.dropped);
1280
+ const trimmed = plan.filter((p) => !p.dropped);
1281
+ return {
1282
+ dry_run: !apply,
1283
+ fields_dropped: dropped.map((p) => ({ field: p.field, dead_declarers: p.deadDeclarers })),
1284
+ fields_trimmed: trimmed.map((p) => ({ field: p.field, dead_declarers: p.deadDeclarers })),
1285
+ summary: apply
1286
+ ? `pruned ${dropped.length} orphaned field(s); trimmed dead declarers on ${trimmed.length} co-declared field(s)`
1287
+ : `would prune ${dropped.length} orphaned field(s); would trim dead declarers on ${trimmed.length} co-declared field(s) — pass apply:true to execute`,
1288
+ };
1289
+ },
1290
+ },
1291
+
1234
1292
  ];
1235
1293
  }
1236
1294
 
package/core/src/notes.ts CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  type CursorPayload,
19
19
  type QueryHashInputs,
20
20
  } from "./cursor.js";
21
+ import { releaseField } from "./indexed-fields.js";
21
22
 
22
23
  let idCounter = 0;
23
24
 
@@ -943,12 +944,35 @@ export function listTags(db: Database): { name: string; count: number }[] {
943
944
  }
944
945
 
945
946
  export function deleteTag(db: Database, name: string): { deleted: boolean; notes_untagged: number } {
946
- const exists = db.prepare("SELECT 1 FROM tags WHERE name = ?").get(name);
947
- if (!exists) return { deleted: false, notes_untagged: 0 };
947
+ const row = db.prepare("SELECT fields FROM tags WHERE name = ?").get(name) as
948
+ | { fields: string | null }
949
+ | undefined;
950
+ if (!row) return { deleted: false, notes_untagged: 0 };
948
951
 
949
952
  const countRow = db.prepare("SELECT COUNT(*) as c FROM note_tags WHERE tag_name = ?").get(name) as { c: number };
950
953
  const notesUntagged = countRow.c;
951
954
 
955
+ // Release any indexed fields this tag declared BEFORE the row drops.
956
+ // `releaseField` only drops the generated column + index when this tag is
957
+ // the last live declarer (co-declaration guard) — a field co-declared by
958
+ // another live tag keeps its column and just loses this tag from the set.
959
+ // This lives in the store-level delete (not the MCP layer) so every caller
960
+ // — MCP delete-tag, the REST DELETE /tags/:name route, the import
961
+ // blow-away sweep — releases consistently. See the gitcoin orphaned-fields
962
+ // bug report.
963
+ if (row.fields) {
964
+ try {
965
+ const fields = JSON.parse(row.fields) as Record<string, { indexed?: boolean }>;
966
+ for (const [fieldName, spec] of Object.entries(fields)) {
967
+ if (spec?.indexed === true) {
968
+ releaseField(db, fieldName, name);
969
+ }
970
+ }
971
+ } catch {
972
+ // Malformed fields JSON — nothing to release; proceed with the delete.
973
+ }
974
+ }
975
+
952
976
  db.prepare("DELETE FROM note_tags WHERE tag_name = ?").run(name);
953
977
  db.prepare("DELETE FROM tags WHERE name = ?").run(name);
954
978
 
@@ -19,11 +19,13 @@
19
19
 
20
20
  import { describe, it, expect, beforeEach } from "bun:test";
21
21
  import { Database } from "bun:sqlite";
22
- import { mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync, existsSync, statSync } from "fs";
22
+ import { mkdirSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync, existsSync, statSync } from "fs";
23
23
  import { join } from "path";
24
24
  import { tmpdir } from "os";
25
25
 
26
26
  import { SqliteStore } from "./store.js";
27
+ import { getIndexedField } from "./indexed-fields.js";
28
+ import { buildVaultProjection } from "./vault-projection.js";
27
29
  import {
28
30
  CaseCollisionError,
29
31
  emitYamlDoc,
@@ -33,6 +35,7 @@ import {
33
35
  parseFrontmatter,
34
36
  portableExportFilePath,
35
37
  probeCaseSensitive,
38
+ pruneOrphans,
36
39
  SIDECAR_DIR,
37
40
  NOTES_META_DIR,
38
41
  supportsInlineFrontmatter,
@@ -724,6 +727,56 @@ describe("importPortableVault", async () => {
724
727
  expect(schema!.description).toBe("A unit of work");
725
728
  });
726
729
 
730
+ // Fix 2 — import must re-declare indexed fields. The import writes
731
+ // tags.fields via upsertTagRecord but historically never materialized the
732
+ // backing generated columns + indexes, so a fresh import advertised
733
+ // `indexed: true` while queries silently full-scanned. Regression: export a
734
+ // vault with an indexed field → fresh import → the generated column + index
735
+ // exist and the field shows in the vault-info indexed_fields catalog.
736
+ it("re-declares indexed fields on import (column + index + vault-info catalog)", async () => {
737
+ await store.upsertTagRecord("project", {
738
+ fields: { status: { type: "string", indexed: true } },
739
+ });
740
+ await store.createNote("p", { id: "p", tags: ["project"], metadata: { status: "active" } });
741
+ const outDir = join(tmpBase, "out");
742
+ await exportVaultToDir(store, { outDir, exportedAt: "2026-05-13T00:00:00.000Z" });
743
+
744
+ const target = new SqliteStore(new Database(":memory:"));
745
+ const targetDb = target.db;
746
+ // Pre-condition: a fresh store has no backing column yet.
747
+ const colsBefore = (targetDb.prepare("PRAGMA table_xinfo(notes)").all() as { name: string }[]).map((r) => r.name);
748
+ expect(colsBefore).not.toContain("meta_status");
749
+
750
+ const stats = await importPortableVault(target, { inDir: outDir });
751
+ expect(stats.schemas_restored).toBe(1);
752
+ expect(stats.indexes_declared).toBe(1);
753
+
754
+ // The generated column + index now exist — same introspection vault-info
755
+ // uses to advertise the queryable-field catalog.
756
+ const colsAfter = (targetDb.prepare("PRAGMA table_xinfo(notes)").all() as { name: string }[]).map((r) => r.name);
757
+ expect(colsAfter).toContain("meta_status");
758
+ const idxs = (targetDb.prepare("SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='notes'").all() as { name: string }[]).map((r) => r.name);
759
+ expect(idxs).toContain("idx_meta_status");
760
+ expect(getIndexedField(targetDb, "status")?.declarerTags).toEqual(["project"]);
761
+ // And vault-info lists it.
762
+ expect(buildVaultProjection(targetDb).indexed_fields.map((f) => f.name)).toContain("status");
763
+ });
764
+
765
+ it("dry-run import counts indexes_declared without materializing columns", async () => {
766
+ await store.upsertTagRecord("project", {
767
+ fields: { status: { type: "string", indexed: true } },
768
+ });
769
+ const outDir = join(tmpBase, "out");
770
+ await exportVaultToDir(store, { outDir, exportedAt: "2026-05-13T00:00:00.000Z" });
771
+
772
+ const target = new SqliteStore(new Database(":memory:"));
773
+ const stats = await importPortableVault(target, { inDir: outDir, dryRun: true });
774
+ expect(stats.indexes_declared).toBe(1);
775
+ // Dry-run touches nothing.
776
+ const cols = (target.db.prepare("PRAGMA table_xinfo(notes)").all() as { name: string }[]).map((r) => r.name);
777
+ expect(cols).not.toContain("meta_status");
778
+ });
779
+
727
780
  it("restores typed links (non-wikilink relationships)", async () => {
728
781
  await store.createNote("src body", { id: "src", path: "src" });
729
782
  await store.createNote("tgt body", { id: "tgt", path: "tgt" });
@@ -1799,3 +1852,253 @@ describe("case-collision detection (vault#327)", async () => {
1799
1852
  expect(stats.disambiguated_paths).toHaveLength(1);
1800
1853
  });
1801
1854
  });
1855
+
1856
+ // ---------------------------------------------------------------------------
1857
+ // pruneOrphans (vault#382 — event-driven mirror delete propagation)
1858
+ // ---------------------------------------------------------------------------
1859
+
1860
+ describe("pruneOrphans", async () => {
1861
+ let tmpBase: string;
1862
+ beforeEach(() => {
1863
+ tmpBase = join(tmpdir(), `parachute-prune-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
1864
+ mkdirSync(tmpBase, { recursive: true });
1865
+ });
1866
+
1867
+ it("no-op on non-existent directory", () => {
1868
+ const stats = pruneOrphans({
1869
+ outDir: join(tmpBase, "doesnt-exist"),
1870
+ validNoteIds: new Set(),
1871
+ validTagNames: new Set(),
1872
+ validAttachmentIds: new Set(),
1873
+ });
1874
+ expect(stats.notes_removed).toBe(0);
1875
+ expect(stats.unparseable_files).toHaveLength(0);
1876
+ });
1877
+
1878
+ it("removes orphaned note .md file", async () => {
1879
+ const outDir = join(tmpBase, "orphan-note");
1880
+ // First do a real export so the structure is realistic.
1881
+ const db = new Database(":memory:");
1882
+ const store = new SqliteStore(db);
1883
+ await store.createNote("alive", { id: "01HFAA", path: "alive" });
1884
+ await store.createNote("doomed", { id: "01HFBB", path: "doomed" });
1885
+ await exportVaultToDir(store, { outDir, vaultName: "t", exportedAt: "2026-01-01T00:00:00.000Z" });
1886
+ expect(existsSync(join(outDir, "alive.md"))).toBe(true);
1887
+ expect(existsSync(join(outDir, "doomed.md"))).toBe(true);
1888
+
1889
+ // Now prune with only "alive" in the valid set.
1890
+ const stats = pruneOrphans({
1891
+ outDir,
1892
+ validNoteIds: new Set(["01HFAA"]),
1893
+ validTagNames: new Set(),
1894
+ validAttachmentIds: new Set(),
1895
+ });
1896
+ expect(stats.notes_removed).toBe(1);
1897
+ expect(existsSync(join(outDir, "alive.md"))).toBe(true);
1898
+ expect(existsSync(join(outDir, "doomed.md"))).toBe(false);
1899
+ });
1900
+
1901
+ it("removes orphaned schema sidecar", async () => {
1902
+ const outDir = join(tmpBase, "orphan-schema");
1903
+ const db = new Database(":memory:");
1904
+ const store = new SqliteStore(db);
1905
+ await store.upsertTagRecord("alive-tag", { description: "stays" });
1906
+ await store.upsertTagRecord("doomed-tag", { description: "goes" });
1907
+ await exportVaultToDir(store, { outDir, vaultName: "t", exportedAt: "2026-01-01T00:00:00.000Z" });
1908
+ const schemasDir = join(outDir, SIDECAR_DIR, "schemas");
1909
+ expect(existsSync(join(schemasDir, "alive-tag.yaml"))).toBe(true);
1910
+ expect(existsSync(join(schemasDir, "doomed-tag.yaml"))).toBe(true);
1911
+
1912
+ const stats = pruneOrphans({
1913
+ outDir,
1914
+ validNoteIds: new Set(),
1915
+ validTagNames: new Set(["alive-tag"]),
1916
+ validAttachmentIds: new Set(),
1917
+ });
1918
+ expect(stats.schemas_removed).toBe(1);
1919
+ expect(existsSync(join(schemasDir, "alive-tag.yaml"))).toBe(true);
1920
+ expect(existsSync(join(schemasDir, "doomed-tag.yaml"))).toBe(false);
1921
+ });
1922
+
1923
+ it("removes orphaned attachment directories", async () => {
1924
+ const outDir = join(tmpBase, "orphan-att");
1925
+ // Build the export structure by hand (attachment binaries need
1926
+ // assetsDir wiring; cheaper to just create the dirs).
1927
+ const attachmentsDir = join(outDir, SIDECAR_DIR, "attachments");
1928
+ mkdirSync(attachmentsDir, { recursive: true });
1929
+ mkdirSync(join(attachmentsDir, "att-alive"), { recursive: true });
1930
+ writeFileSync(join(attachmentsDir, "att-alive", "voice.m4a"), "");
1931
+ mkdirSync(join(attachmentsDir, "att-doomed"), { recursive: true });
1932
+ writeFileSync(join(attachmentsDir, "att-doomed", "voice.m4a"), "");
1933
+ // Need .parachute/vault.yaml so the structure is recognized (cheap to fake)
1934
+ writeFileSync(join(outDir, SIDECAR_DIR, "vault.yaml"), "name: t\n");
1935
+
1936
+ const stats = pruneOrphans({
1937
+ outDir,
1938
+ validNoteIds: new Set(),
1939
+ validTagNames: new Set(),
1940
+ validAttachmentIds: new Set(["att-alive"]),
1941
+ });
1942
+ expect(stats.attachment_dirs_removed).toBe(1);
1943
+ expect(existsSync(join(attachmentsDir, "att-alive"))).toBe(true);
1944
+ expect(existsSync(join(attachmentsDir, "att-doomed"))).toBe(false);
1945
+ });
1946
+
1947
+ it("skips unparseable .md files without crashing", async () => {
1948
+ const outDir = join(tmpBase, "unparseable");
1949
+ mkdirSync(outDir, { recursive: true });
1950
+ writeFileSync(join(outDir, "no-frontmatter.md"), "just content, no frontmatter\n");
1951
+ writeFileSync(join(outDir, "garbage.md"), "---\nnot-real-yaml\n");
1952
+ const stats = pruneOrphans({
1953
+ outDir,
1954
+ validNoteIds: new Set(),
1955
+ validTagNames: new Set(),
1956
+ validAttachmentIds: new Set(),
1957
+ });
1958
+ // Both files lacked an `id`, so we record them but don't remove.
1959
+ expect(stats.notes_removed).toBe(0);
1960
+ expect(stats.unparseable_files.length).toBeGreaterThanOrEqual(2);
1961
+ expect(existsSync(join(outDir, "no-frontmatter.md"))).toBe(true);
1962
+ expect(existsSync(join(outDir, "garbage.md"))).toBe(true);
1963
+ });
1964
+
1965
+ it("preserves all files when everything is in the valid sets", async () => {
1966
+ const outDir = join(tmpBase, "happy-path");
1967
+ const db = new Database(":memory:");
1968
+ const store = new SqliteStore(db);
1969
+ const a = await store.createNote("a", { path: "a" });
1970
+ const b = await store.createNote("b", { path: "b" });
1971
+ await store.upsertTagRecord("tag1", { description: "x" });
1972
+ await exportVaultToDir(store, { outDir, vaultName: "t", exportedAt: "2026-01-01T00:00:00.000Z" });
1973
+ const stats = pruneOrphans({
1974
+ outDir,
1975
+ validNoteIds: new Set([a.id, b.id]),
1976
+ validTagNames: new Set(["tag1"]),
1977
+ validAttachmentIds: new Set(),
1978
+ });
1979
+ expect(stats.notes_removed).toBe(0);
1980
+ expect(stats.schemas_removed).toBe(0);
1981
+ expect(stats.attachment_dirs_removed).toBe(0);
1982
+ expect(existsSync(join(outDir, "a.md"))).toBe(true);
1983
+ expect(existsSync(join(outDir, "b.md"))).toBe(true);
1984
+ });
1985
+
1986
+ it("removes orphan note + corresponding notes-meta sidecar for csv/yaml notes", async () => {
1987
+ // For non-frontmatter extensions, the sidecar lives at
1988
+ // .parachute/notes-meta/<id>.yaml. Pruning the note should remove
1989
+ // both files.
1990
+ const outDir = join(tmpBase, "orphan-csv");
1991
+ const db = new Database(":memory:");
1992
+ const store = new SqliteStore(db);
1993
+ await store.createNote("col1,col2\n1,2\n", {
1994
+ id: "01CSV-DEL",
1995
+ path: "data/table",
1996
+ extension: "csv",
1997
+ });
1998
+ await exportVaultToDir(store, { outDir, vaultName: "t", exportedAt: "2026-01-01T00:00:00.000Z" });
1999
+ const contentFile = join(outDir, "data", "table.csv");
2000
+ const sidecarFile = join(outDir, SIDECAR_DIR, "notes-meta", "01CSV-DEL.yaml");
2001
+ expect(existsSync(contentFile)).toBe(true);
2002
+ expect(existsSync(sidecarFile)).toBe(true);
2003
+
2004
+ const stats = pruneOrphans({
2005
+ outDir,
2006
+ validNoteIds: new Set(), // doom it
2007
+ validTagNames: new Set(),
2008
+ validAttachmentIds: new Set(),
2009
+ });
2010
+ expect(stats.notes_removed).toBe(1);
2011
+ expect(stats.sidecars_removed).toBeGreaterThanOrEqual(1);
2012
+ expect(existsSync(contentFile)).toBe(false);
2013
+ expect(existsSync(sidecarFile)).toBe(false);
2014
+ });
2015
+
2016
+ // Reviewer-flagged regression on vault#382 Critical #2 — pruneOrphans
2017
+ // walks via statSync (follows symlinks); without the safeRm guard a
2018
+ // symlink inside the mirror pointing OUTSIDE outDir would resurface
2019
+ // its target's files as orphans and rmSync would happily delete them
2020
+ // off-tree. The guard resolves each candidate and refuses anything
2021
+ // not under outDir; refusals get recorded in `unparseable_files` so
2022
+ // an operator can see what was skipped.
2023
+ it("refuses to delete files reached via a symlink pointing outside outDir", async () => {
2024
+ const outDir = join(tmpBase, "symlink-attack");
2025
+ const outside = join(tmpBase, "outside");
2026
+ mkdirSync(outside, { recursive: true });
2027
+ mkdirSync(outDir, { recursive: true });
2028
+ // A real, sensitive file in `outside/` we don't want pruneOrphans
2029
+ // to touch under any circumstance.
2030
+ const externalFile = join(outside, "do-not-touch.md");
2031
+ writeFileSync(externalFile, "---\nid: 01EXTERNAL\n---\nimportant\n");
2032
+ // A symlink inside outDir pointing at outside/ — walkContentFiles
2033
+ // would normally surface outside/do-not-touch.md as a candidate.
2034
+ try {
2035
+ symlinkSync(outside, join(outDir, "via-link"));
2036
+ } catch {
2037
+ // Some CI sandboxes refuse symlink creation. Skip the test in
2038
+ // that case rather than fail spuriously.
2039
+ return;
2040
+ }
2041
+
2042
+ const stats = pruneOrphans({
2043
+ outDir,
2044
+ validNoteIds: new Set(), // doom every id we see
2045
+ validTagNames: new Set(),
2046
+ validAttachmentIds: new Set(),
2047
+ });
2048
+
2049
+ // Critical assertion: the external file MUST survive.
2050
+ expect(existsSync(externalFile)).toBe(true);
2051
+ // And the refusal MUST be recorded so the operator sees it.
2052
+ expect(
2053
+ stats.unparseable_files.some(
2054
+ (u) => u.path.includes("via-link") || u.reason.includes("outside"),
2055
+ ),
2056
+ ).toBe(true);
2057
+ });
2058
+
2059
+ // Reviewer-flagged regression on vault#382 Critical #1 — pruneOrphans
2060
+ // builds `validTagNames` from ALL tag-table rows in mirror-deps.ts.
2061
+ // After `deleteTagSchema(t)` the schema fields are cleared but the
2062
+ // tag row persists with the bare name, so the sidecar lingers
2063
+ // forever. The fix routes validTagNames through `hasSchemaContent`
2064
+ // before passing into pruneOrphans, and exports the predicate so
2065
+ // mirror-deps can reuse the single source of truth.
2066
+ it("considers a schema-content-free tag the same as a deleted tag for sidecar pruning", async () => {
2067
+ const outDir = join(tmpBase, "stale-schema");
2068
+ const db = new Database(":memory:");
2069
+ const store = new SqliteStore(db);
2070
+ await store.upsertTagRecord("bare", {}); // bare-name only
2071
+ await store.upsertTagRecord("with-schema", { description: "real" });
2072
+ await exportVaultToDir(store, {
2073
+ outDir,
2074
+ vaultName: "t",
2075
+ exportedAt: "2026-01-01T00:00:00.000Z",
2076
+ });
2077
+ const schemasDir = join(outDir, SIDECAR_DIR, "schemas");
2078
+ // The bare tag SHOULDN'T have a sidecar; the schema-bearing one
2079
+ // SHOULD. This confirms the export-writer's contract before the
2080
+ // prune step.
2081
+ expect(existsSync(join(schemasDir, "bare.yaml"))).toBe(false);
2082
+ expect(existsSync(join(schemasDir, "with-schema.yaml"))).toBe(true);
2083
+
2084
+ // Now seed a stale sidecar for `bare` (simulating "the operator
2085
+ // previously had a schema for `bare`, then cleared it via
2086
+ // `deleteTagSchema`"). pruneOrphans should remove this iff the
2087
+ // caller correctly filtered validTagNames by hasSchemaContent.
2088
+ writeFileSync(join(schemasDir, "bare.yaml"), 'name: "bare"\ndescription: "stale"\n');
2089
+ expect(existsSync(join(schemasDir, "bare.yaml"))).toBe(true);
2090
+
2091
+ // Filtered set — only `with-schema` has hasSchemaContent === true.
2092
+ const validTagNames = new Set(["with-schema"]);
2093
+ const stats = pruneOrphans({
2094
+ outDir,
2095
+ validNoteIds: new Set(),
2096
+ validTagNames,
2097
+ validAttachmentIds: new Set(),
2098
+ });
2099
+
2100
+ expect(stats.schemas_removed).toBe(1);
2101
+ expect(existsSync(join(schemasDir, "bare.yaml"))).toBe(false);
2102
+ expect(existsSync(join(schemasDir, "with-schema.yaml"))).toBe(true);
2103
+ });
2104
+ });