@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.
- package/core/src/core.test.ts +4 -1
- package/core/src/hooks.test.ts +320 -1
- package/core/src/hooks.ts +243 -38
- package/core/src/indexed-fields.test.ts +151 -0
- package/core/src/indexed-fields.ts +98 -0
- package/core/src/mcp.ts +99 -41
- package/core/src/notes.ts +26 -2
- package/core/src/portable-md.test.ts +304 -1
- package/core/src/portable-md.ts +418 -2
- package/core/src/schema.ts +114 -2
- package/core/src/store.ts +185 -2
- package/core/src/types.ts +28 -0
- package/package.json +2 -2
- package/src/auth-hub-jwt.test.ts +147 -0
- package/src/auth.ts +121 -1
- package/src/auto-transcribe.test.ts +7 -2
- package/src/auto-transcribe.ts +6 -2
- package/src/cli.ts +131 -36
- package/src/config.ts +12 -4
- package/src/export-watch.test.ts +74 -0
- package/src/export-watch.ts +108 -7
- package/src/github-device-flow.test.ts +404 -0
- package/src/github-device-flow.ts +415 -0
- package/src/hub-jwt.test.ts +27 -2
- package/src/hub-jwt.ts +10 -0
- package/src/mcp-http.ts +48 -39
- package/src/mcp-install-interactive.test.ts +10 -21
- package/src/mcp-install-interactive.ts +12 -21
- package/src/mcp-install.test.ts +141 -30
- package/src/mcp-install.ts +109 -3
- package/src/mcp-tools.ts +460 -3
- package/src/mirror-config.test.ts +277 -14
- package/src/mirror-config.ts +482 -31
- package/src/mirror-credentials.test.ts +601 -0
- package/src/mirror-credentials.ts +700 -0
- package/src/mirror-deps.ts +67 -17
- package/src/mirror-import.test.ts +550 -0
- package/src/mirror-import.ts +487 -0
- package/src/mirror-manager.test.ts +423 -12
- package/src/mirror-manager.ts +621 -72
- package/src/mirror-per-vault.test.ts +519 -0
- package/src/mirror-registry.ts +91 -14
- package/src/mirror-routes.test.ts +966 -10
- package/src/mirror-routes.ts +1111 -7
- package/src/module-config.ts +11 -5
- package/src/routes.ts +38 -1
- package/src/routing.test.ts +92 -1
- package/src/routing.ts +193 -20
- package/src/server.ts +116 -35
- package/src/storage.test.ts +132 -7
- package/src/token-store.ts +300 -5
- package/src/transcription-worker.ts +9 -4
- package/src/triggers.ts +16 -3
- package/src/vault.test.ts +681 -2
- package/web/ui/dist/assets/index-Cn-PPMRv.js +60 -0
- package/web/ui/dist/assets/{index-BOa-JJtV.css → index-DBe8Xiah.css} +1 -1
- package/web/ui/dist/index.html +2 -2
- 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.
|
|
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:
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
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
|
|
947
|
-
|
|
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
|
+
});
|