@openparachute/vault 0.4.9-rc.4 → 0.4.9-rc.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/core/src/hooks.test.ts +320 -1
- package/core/src/hooks.ts +243 -38
- package/core/src/portable-md.test.ts +252 -1
- package/core/src/portable-md.ts +370 -2
- package/core/src/store.ts +68 -2
- package/package.json +1 -1
- package/src/github-device-flow.test.ts +404 -0
- package/src/github-device-flow.ts +415 -0
- package/src/mirror-config.test.ts +125 -14
- package/src/mirror-config.ts +168 -21
- package/src/mirror-credentials.test.ts +450 -0
- package/src/mirror-credentials.ts +577 -0
- package/src/mirror-deps.ts +42 -1
- package/src/mirror-manager.test.ts +319 -12
- package/src/mirror-manager.ts +450 -62
- package/src/mirror-routes.test.ts +421 -9
- package/src/mirror-routes.ts +688 -3
- package/src/routing.ts +74 -1
- package/src/server.ts +21 -8
- package/src/transcription-worker.ts +9 -4
- package/src/triggers.ts +16 -3
- package/web/ui/dist/assets/index-BSnGmcVW.js +60 -0
- package/web/ui/dist/assets/{index-KA1P2P3z.css → index-DBe8Xiah.css} +1 -1
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-BJX47k5V.js +0 -60
package/core/src/portable-md.ts
CHANGED
|
@@ -72,7 +72,7 @@
|
|
|
72
72
|
* See vault#308.
|
|
73
73
|
*/
|
|
74
74
|
|
|
75
|
-
import { readdirSync, readFileSync, statSync, mkdirSync, writeFileSync, copyFileSync, existsSync, rmSync } from "fs";
|
|
75
|
+
import { readdirSync, readFileSync, realpathSync, statSync, mkdirSync, writeFileSync, copyFileSync, existsSync, rmSync } from "fs";
|
|
76
76
|
import { basename, join, relative, extname, dirname, resolve as resolvePath, sep as pathSep } from "path";
|
|
77
77
|
import type { Store, Note, Link, Attachment } from "./types.js";
|
|
78
78
|
import type { TagRecord } from "./tag-schemas.js";
|
|
@@ -1001,7 +1001,375 @@ export async function exportVaultToDir(
|
|
|
1001
1001
|
};
|
|
1002
1002
|
}
|
|
1003
1003
|
|
|
1004
|
-
|
|
1004
|
+
// ---------------------------------------------------------------------------
|
|
1005
|
+
// Orphan sweep — for git-mirror's delete propagation
|
|
1006
|
+
// ---------------------------------------------------------------------------
|
|
1007
|
+
|
|
1008
|
+
/**
|
|
1009
|
+
* Result of a `pruneOrphans` pass.
|
|
1010
|
+
*/
|
|
1011
|
+
export interface PruneOrphansStats {
|
|
1012
|
+
/** Note content files removed (frontmatter id not in `validNoteIds`). */
|
|
1013
|
+
notes_removed: number;
|
|
1014
|
+
/** Sidecar metadata files removed (under `.parachute/notes-meta/`). */
|
|
1015
|
+
sidecars_removed: number;
|
|
1016
|
+
/** Schema sidecars removed (under `.parachute/schemas/`). */
|
|
1017
|
+
schemas_removed: number;
|
|
1018
|
+
/** Attachment directories removed (under `.parachute/attachments/`). */
|
|
1019
|
+
attachment_dirs_removed: number;
|
|
1020
|
+
/**
|
|
1021
|
+
* Files we couldn't parse / classify. Surfaced for operator audit;
|
|
1022
|
+
* the sweep doesn't touch them. Empty when everything classified
|
|
1023
|
+
* cleanly.
|
|
1024
|
+
*/
|
|
1025
|
+
unparseable_files: Array<{ path: string; reason: string }>;
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
/**
|
|
1029
|
+
* Options for the orphan-sweep pass. Run periodically (the mirror manager
|
|
1030
|
+
* arms a safety-net poll, default 1h) and after operator-visible deletions
|
|
1031
|
+
* (the mirror manager's targeted-deletion fast path also calls this for the
|
|
1032
|
+
* touched-set).
|
|
1033
|
+
*
|
|
1034
|
+
* The sweep is the bookkeeping cousin of the event-driven fast path: events
|
|
1035
|
+
* cover the common case (a single note deletion fires "deleted" → mirror
|
|
1036
|
+
* removes that file); the sweep covers anything the fast path missed
|
|
1037
|
+
* (direct SQL writes, app crashes between dispatch and handler, restart
|
|
1038
|
+
* gaps).
|
|
1039
|
+
*/
|
|
1040
|
+
export interface PruneOrphansOptions {
|
|
1041
|
+
/** Directory to sweep — same shape as an `exportVaultToDir` outDir. */
|
|
1042
|
+
outDir: string;
|
|
1043
|
+
/** Note IDs that should be kept (everything else under the export gets removed). */
|
|
1044
|
+
validNoteIds: Set<string>;
|
|
1045
|
+
/** Tag names that should be kept (other schema sidecars under `.parachute/schemas/` get removed). */
|
|
1046
|
+
validTagNames: Set<string>;
|
|
1047
|
+
/** Attachment IDs that should be kept (other dirs under `.parachute/attachments/` get removed). */
|
|
1048
|
+
validAttachmentIds: Set<string>;
|
|
1049
|
+
/**
|
|
1050
|
+
* Override `extension`-based content-file extension recognition. The
|
|
1051
|
+
* default treats `.md` and `.mdx` as having inline frontmatter (with
|
|
1052
|
+
* `id:` reachable via the file head); everything else needs a sidecar
|
|
1053
|
+
* lookup to know what id owns it.
|
|
1054
|
+
*/
|
|
1055
|
+
supportsInlineFrontmatter?: (ext: string) => boolean;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
/**
|
|
1059
|
+
* Sweep the export directory for files belonging to notes / tags /
|
|
1060
|
+
* attachments that no longer exist in the vault. Removes them so the
|
|
1061
|
+
* mirror's `git diff` reflects what vault actually has.
|
|
1062
|
+
*
|
|
1063
|
+
* Strategy:
|
|
1064
|
+
* 1. Walk content files. For each `.md` / `.mdx`, parse the frontmatter
|
|
1065
|
+
* `id` and compare against `validNoteIds`. Mismatch → remove the
|
|
1066
|
+
* file. Files we can't parse are recorded in `unparseable_files`
|
|
1067
|
+
* and left alone (best-effort, no destructive guesses).
|
|
1068
|
+
* 2. For non-inline-frontmatter extensions (`.csv`, `.yaml`, etc.),
|
|
1069
|
+
* check the matching `.parachute/notes-meta/<id>.yaml` sidecar — the
|
|
1070
|
+
* sidecar carries the canonical `id` + `path` + `extension` triple.
|
|
1071
|
+
* An orphaned sidecar (no matching content file) is also removed,
|
|
1072
|
+
* and an orphaned content file (no matching sidecar) without a
|
|
1073
|
+
* parseable frontmatter is left as unparseable.
|
|
1074
|
+
* 3. Walk `.parachute/schemas/`. Each file is `<tag>.yaml` (after
|
|
1075
|
+
* filename sanitization). Parse the `name:` field; compare against
|
|
1076
|
+
* `validTagNames`. Mismatch → remove.
|
|
1077
|
+
* 4. Walk `.parachute/attachments/`. Each subdir name IS the
|
|
1078
|
+
* attachment id (per `exportVaultToDir`'s layout). Compare against
|
|
1079
|
+
* `validAttachmentIds`. Mismatch → recursive remove.
|
|
1080
|
+
*
|
|
1081
|
+
* Returns counts so callers can log + decide whether to commit.
|
|
1082
|
+
*
|
|
1083
|
+
* Safe to call on a directory that's never been exported to (returns
|
|
1084
|
+
* zero counts; doesn't create anything).
|
|
1085
|
+
*/
|
|
1086
|
+
export function pruneOrphans(opts: PruneOrphansOptions): PruneOrphansStats {
|
|
1087
|
+
const stats: PruneOrphansStats = {
|
|
1088
|
+
notes_removed: 0,
|
|
1089
|
+
sidecars_removed: 0,
|
|
1090
|
+
schemas_removed: 0,
|
|
1091
|
+
attachment_dirs_removed: 0,
|
|
1092
|
+
unparseable_files: [],
|
|
1093
|
+
};
|
|
1094
|
+
|
|
1095
|
+
const outDir = opts.outDir;
|
|
1096
|
+
if (!existsSync(outDir)) return stats;
|
|
1097
|
+
|
|
1098
|
+
const supportsInline = opts.supportsInlineFrontmatter ?? supportsInlineFrontmatter;
|
|
1099
|
+
const sidecarRoot = join(outDir, SIDECAR_DIR);
|
|
1100
|
+
const notesMetaRoot = join(sidecarRoot, NOTES_META_DIR);
|
|
1101
|
+
const schemasRoot = join(sidecarRoot, "schemas");
|
|
1102
|
+
const attachmentsRoot = join(sidecarRoot, "attachments");
|
|
1103
|
+
|
|
1104
|
+
// Path-traversal guard. `walkContentFiles` uses `statSync` which
|
|
1105
|
+
// follows symlinks — a symlink inside the mirror pointing OUTSIDE
|
|
1106
|
+
// `outDir` would resurface its target's files in the prune sweep,
|
|
1107
|
+
// and a bare `rmSync(filepath)` would delete them off-tree. Every
|
|
1108
|
+
// deletion in this function routes through `safeRm`, which calls
|
|
1109
|
+
// `realpathSync` (resolves through symlinks, unlike syntactic-only
|
|
1110
|
+
// `path.resolve`) and refuses to delete anything that isn't `outDir`
|
|
1111
|
+
// or beneath it after symlink resolution. Refusals get recorded in
|
|
1112
|
+
// `unparseable_files` so an operator can see what was skipped.
|
|
1113
|
+
// Reviewer-flagged on vault#382 (Critical #2).
|
|
1114
|
+
const outDirReal = realpathSync(outDir);
|
|
1115
|
+
const safeRm = (
|
|
1116
|
+
candidate: string,
|
|
1117
|
+
onSuccess: () => void,
|
|
1118
|
+
options: { recursive?: boolean } = {},
|
|
1119
|
+
): void => {
|
|
1120
|
+
let real: string;
|
|
1121
|
+
try {
|
|
1122
|
+
real = realpathSync(candidate);
|
|
1123
|
+
} catch (err) {
|
|
1124
|
+
// realpathSync throws if the path doesn't exist or is unreadable.
|
|
1125
|
+
// Don't delete what we can't fully resolve.
|
|
1126
|
+
stats.unparseable_files.push({
|
|
1127
|
+
path: candidate,
|
|
1128
|
+
reason: `realpath failed: ${(err as Error).message ?? err}`,
|
|
1129
|
+
});
|
|
1130
|
+
return;
|
|
1131
|
+
}
|
|
1132
|
+
if (!isWithinDir(real, outDirReal)) {
|
|
1133
|
+
stats.unparseable_files.push({
|
|
1134
|
+
path: candidate,
|
|
1135
|
+
reason: "real path resolved outside mirror outDir — refusing to delete (symlink?)",
|
|
1136
|
+
});
|
|
1137
|
+
return;
|
|
1138
|
+
}
|
|
1139
|
+
try {
|
|
1140
|
+
// Delete via the resolved real path; never via the unresolved
|
|
1141
|
+
// candidate (which could route through a symlink we already
|
|
1142
|
+
// determined would escape).
|
|
1143
|
+
rmSync(real, { force: true, recursive: options.recursive ?? false });
|
|
1144
|
+
onSuccess();
|
|
1145
|
+
} catch (err) {
|
|
1146
|
+
stats.unparseable_files.push({
|
|
1147
|
+
path: candidate,
|
|
1148
|
+
reason: `unlink failed: ${(err as Error).message ?? err}`,
|
|
1149
|
+
});
|
|
1150
|
+
}
|
|
1151
|
+
};
|
|
1152
|
+
|
|
1153
|
+
// ---- 1 + 2. Notes + sidecars ----
|
|
1154
|
+
//
|
|
1155
|
+
// First pass: build the sidecar id → { path, extension } map so we can
|
|
1156
|
+
// resolve non-inline-frontmatter content files via their sidecar.
|
|
1157
|
+
// Sidecars whose claimed (path, extension) doesn't map to an existing
|
|
1158
|
+
// content file are tracked as "orphaned sidecar" candidates.
|
|
1159
|
+
const sidecarById = new Map<string, { path: string | null; extension: string | null }>();
|
|
1160
|
+
const sidecarFilesById = new Map<string, string>(); // id → absolute filepath
|
|
1161
|
+
if (existsSync(notesMetaRoot)) {
|
|
1162
|
+
try {
|
|
1163
|
+
for (const entry of readdirSync(notesMetaRoot)) {
|
|
1164
|
+
if (!entry.endsWith(".yaml")) continue;
|
|
1165
|
+
const id = entry.slice(0, -5);
|
|
1166
|
+
const full = join(notesMetaRoot, entry);
|
|
1167
|
+
sidecarFilesById.set(id, full);
|
|
1168
|
+
try {
|
|
1169
|
+
const text = readFileSync(full, "utf-8");
|
|
1170
|
+
const { frontmatter } = parseFrontmatter(`---\n${text}---\n`);
|
|
1171
|
+
sidecarById.set(id, {
|
|
1172
|
+
path: typeof frontmatter.path === "string" ? (frontmatter.path as string) : null,
|
|
1173
|
+
extension: typeof frontmatter.extension === "string" ? (frontmatter.extension as string) : null,
|
|
1174
|
+
});
|
|
1175
|
+
} catch (err) {
|
|
1176
|
+
stats.unparseable_files.push({
|
|
1177
|
+
path: full,
|
|
1178
|
+
reason: `failed to parse sidecar: ${(err as Error).message ?? err}`,
|
|
1179
|
+
});
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
} catch (err) {
|
|
1183
|
+
// Notes-meta dir read-error is non-fatal — record + carry on.
|
|
1184
|
+
stats.unparseable_files.push({
|
|
1185
|
+
path: notesMetaRoot,
|
|
1186
|
+
reason: `notes-meta walk failed: ${(err as Error).message ?? err}`,
|
|
1187
|
+
});
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
// Now walk content files (everything outside the .parachute sidecar).
|
|
1192
|
+
const contentFiles = walkContentFiles(outDir);
|
|
1193
|
+
// Track which sidecars matched a content file so we can also remove
|
|
1194
|
+
// orphaned sidecars (sidecar present but content file gone).
|
|
1195
|
+
const pairedSidecarIds = new Set<string>();
|
|
1196
|
+
for (const filepath of contentFiles) {
|
|
1197
|
+
const ext = extname(filepath).slice(1).toLowerCase();
|
|
1198
|
+
if (supportsInline(ext)) {
|
|
1199
|
+
// Parse frontmatter, read id.
|
|
1200
|
+
try {
|
|
1201
|
+
const raw = readFileSync(filepath, "utf-8");
|
|
1202
|
+
const { frontmatter } = parseFrontmatter(raw);
|
|
1203
|
+
const id = typeof frontmatter.id === "string" ? (frontmatter.id as string) : null;
|
|
1204
|
+
if (!id) {
|
|
1205
|
+
stats.unparseable_files.push({
|
|
1206
|
+
path: filepath,
|
|
1207
|
+
reason: "no `id` in frontmatter",
|
|
1208
|
+
});
|
|
1209
|
+
continue;
|
|
1210
|
+
}
|
|
1211
|
+
if (!opts.validNoteIds.has(id)) {
|
|
1212
|
+
safeRm(filepath, () => {
|
|
1213
|
+
stats.notes_removed++;
|
|
1214
|
+
});
|
|
1215
|
+
}
|
|
1216
|
+
} catch (err) {
|
|
1217
|
+
stats.unparseable_files.push({
|
|
1218
|
+
path: filepath,
|
|
1219
|
+
reason: `read failed: ${(err as Error).message ?? err}`,
|
|
1220
|
+
});
|
|
1221
|
+
}
|
|
1222
|
+
} else {
|
|
1223
|
+
// Sidecar-required extension. Find the matching sidecar by
|
|
1224
|
+
// (path, extension) — sidecars are keyed by id, so we sweep the
|
|
1225
|
+
// sidecarById map.
|
|
1226
|
+
const relPath = relative(outDir, filepath).replace(/\\/g, "/");
|
|
1227
|
+
// Strip extension to get the canonical path stored in the sidecar
|
|
1228
|
+
// (vault paths don't carry extensions).
|
|
1229
|
+
const pathNoExt = relPath.slice(0, -(ext.length + 1));
|
|
1230
|
+
let foundId: string | null = null;
|
|
1231
|
+
for (const [id, info] of sidecarById.entries()) {
|
|
1232
|
+
if (
|
|
1233
|
+
info.path === pathNoExt &&
|
|
1234
|
+
(info.extension ?? "md").toLowerCase() === ext
|
|
1235
|
+
) {
|
|
1236
|
+
foundId = id;
|
|
1237
|
+
break;
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
if (!foundId) {
|
|
1241
|
+
// Content file with no sidecar — can't tell which note owns it.
|
|
1242
|
+
// Conservative: leave alone, record as unparseable.
|
|
1243
|
+
stats.unparseable_files.push({
|
|
1244
|
+
path: filepath,
|
|
1245
|
+
reason: "no sidecar metadata could be matched by (path, extension)",
|
|
1246
|
+
});
|
|
1247
|
+
continue;
|
|
1248
|
+
}
|
|
1249
|
+
pairedSidecarIds.add(foundId);
|
|
1250
|
+
if (!opts.validNoteIds.has(foundId)) {
|
|
1251
|
+
// Note is orphaned — remove both content and sidecar.
|
|
1252
|
+
safeRm(filepath, () => {
|
|
1253
|
+
stats.notes_removed++;
|
|
1254
|
+
});
|
|
1255
|
+
const sidecarPath = sidecarFilesById.get(foundId);
|
|
1256
|
+
if (sidecarPath) {
|
|
1257
|
+
safeRm(sidecarPath, () => {
|
|
1258
|
+
stats.sidecars_removed++;
|
|
1259
|
+
});
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
// Sweep up orphaned sidecars (sidecar exists but no content file
|
|
1266
|
+
// matched OR sidecar's id isn't in validNoteIds).
|
|
1267
|
+
for (const [id, sidecarPath] of sidecarFilesById.entries()) {
|
|
1268
|
+
if (opts.validNoteIds.has(id) && pairedSidecarIds.has(id)) continue;
|
|
1269
|
+
if (opts.validNoteIds.has(id) && !pairedSidecarIds.has(id)) {
|
|
1270
|
+
// Sidecar refers to a valid note but the content file is gone —
|
|
1271
|
+
// that's an inconsistency, not an orphan. Leave the sidecar so
|
|
1272
|
+
// the next export can rewrite the content file alongside it.
|
|
1273
|
+
continue;
|
|
1274
|
+
}
|
|
1275
|
+
safeRm(sidecarPath, () => {
|
|
1276
|
+
stats.sidecars_removed++;
|
|
1277
|
+
});
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
// ---- 3. Schema sidecars ----
|
|
1281
|
+
if (existsSync(schemasRoot)) {
|
|
1282
|
+
try {
|
|
1283
|
+
for (const entry of readdirSync(schemasRoot)) {
|
|
1284
|
+
if (!entry.endsWith(".yaml")) continue;
|
|
1285
|
+
const full = join(schemasRoot, entry);
|
|
1286
|
+
try {
|
|
1287
|
+
const text = readFileSync(full, "utf-8");
|
|
1288
|
+
const { frontmatter } = parseFrontmatter(`---\n${text}---\n`);
|
|
1289
|
+
const name = typeof frontmatter.name === "string" ? (frontmatter.name as string) : null;
|
|
1290
|
+
if (!name) {
|
|
1291
|
+
// Fall back to filename — sanitizeTagFilename replaces `/`
|
|
1292
|
+
// with `__`, so reverse for the lookup.
|
|
1293
|
+
const fromFilename = entry.slice(0, -5).replace(/__/g, "/");
|
|
1294
|
+
if (!opts.validTagNames.has(fromFilename)) {
|
|
1295
|
+
safeRm(full, () => {
|
|
1296
|
+
stats.schemas_removed++;
|
|
1297
|
+
});
|
|
1298
|
+
}
|
|
1299
|
+
continue;
|
|
1300
|
+
}
|
|
1301
|
+
if (!opts.validTagNames.has(name)) {
|
|
1302
|
+
safeRm(full, () => {
|
|
1303
|
+
stats.schemas_removed++;
|
|
1304
|
+
});
|
|
1305
|
+
}
|
|
1306
|
+
} catch (err) {
|
|
1307
|
+
stats.unparseable_files.push({
|
|
1308
|
+
path: full,
|
|
1309
|
+
reason: `schema sweep failed: ${(err as Error).message ?? err}`,
|
|
1310
|
+
});
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
} catch (err) {
|
|
1314
|
+
stats.unparseable_files.push({
|
|
1315
|
+
path: schemasRoot,
|
|
1316
|
+
reason: `schemas walk failed: ${(err as Error).message ?? err}`,
|
|
1317
|
+
});
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
// ---- 4. Attachment directories ----
|
|
1322
|
+
if (existsSync(attachmentsRoot)) {
|
|
1323
|
+
try {
|
|
1324
|
+
for (const entry of readdirSync(attachmentsRoot)) {
|
|
1325
|
+
const full = join(attachmentsRoot, entry);
|
|
1326
|
+
let stat;
|
|
1327
|
+
try {
|
|
1328
|
+
stat = statSync(full);
|
|
1329
|
+
} catch (err) {
|
|
1330
|
+
stats.unparseable_files.push({
|
|
1331
|
+
path: full,
|
|
1332
|
+
reason: `stat failed: ${(err as Error).message ?? err}`,
|
|
1333
|
+
});
|
|
1334
|
+
continue;
|
|
1335
|
+
}
|
|
1336
|
+
if (!stat.isDirectory()) continue;
|
|
1337
|
+
// The directory name IS the attachment id (per the export layout).
|
|
1338
|
+
if (!opts.validAttachmentIds.has(entry)) {
|
|
1339
|
+
safeRm(
|
|
1340
|
+
full,
|
|
1341
|
+
() => {
|
|
1342
|
+
stats.attachment_dirs_removed++;
|
|
1343
|
+
},
|
|
1344
|
+
{ recursive: true },
|
|
1345
|
+
);
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
} catch (err) {
|
|
1349
|
+
stats.unparseable_files.push({
|
|
1350
|
+
path: attachmentsRoot,
|
|
1351
|
+
reason: `attachments walk failed: ${(err as Error).message ?? err}`,
|
|
1352
|
+
});
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
return stats;
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
/**
|
|
1360
|
+
* True iff the tag carries content the export writer will emit as a
|
|
1361
|
+
* schema sidecar (`.parachute/schemas/<tag>.yaml`). Bare-name tags
|
|
1362
|
+
* (the `tags` table has a row but description/fields/relationships/
|
|
1363
|
+
* parents are all empty) get no sidecar — and crucially, after
|
|
1364
|
+
* `deleteTagSchema` clears those fields the row persists with the
|
|
1365
|
+
* bare name. Callers building the `validTagNames` set for
|
|
1366
|
+
* `pruneOrphans` MUST filter through this predicate, otherwise the
|
|
1367
|
+
* stale sidecar lingers indefinitely.
|
|
1368
|
+
*
|
|
1369
|
+
* Reviewer-flagged on vault#382: without this filter, a cleared
|
|
1370
|
+
* schema's sidecar never gets pruned.
|
|
1371
|
+
*/
|
|
1372
|
+
export function hasSchemaContent(tag: TagRecord): boolean {
|
|
1005
1373
|
if (tag.description !== undefined && tag.description.length > 0) return true;
|
|
1006
1374
|
if (tag.fields && Object.keys(tag.fields).length > 0) return true;
|
|
1007
1375
|
if (tag.relationships && Object.keys(tag.relationships).length > 0) return true;
|
package/core/src/store.ts
CHANGED
|
@@ -217,10 +217,23 @@ export class BunSqliteStore implements Store {
|
|
|
217
217
|
}
|
|
218
218
|
|
|
219
219
|
async deleteNote(id: string): Promise<void> {
|
|
220
|
-
// Read before delete so we can invalidate config caches on the way out
|
|
220
|
+
// Read before delete so we can invalidate config caches on the way out
|
|
221
|
+
// AND so the post-delete hook dispatch carries the minimum payload
|
|
222
|
+
// ({ id, path }). The full note can't be reconstructed post-delete —
|
|
223
|
+
// by design, hooks subscribing to "deleted" receive a DeletedNoteRef,
|
|
224
|
+
// not a Note.
|
|
221
225
|
const existing = noteOps.getNote(this.db, id);
|
|
222
226
|
noteOps.deleteNote(this.db, id);
|
|
223
227
|
if (existing?.path) this.invalidateConfigCachesForPath(existing.path);
|
|
228
|
+
// Dispatch even when `existing` was null — the caller asked for a
|
|
229
|
+
// deletion, and downstream consumers (e.g. the mirror) reconcile via
|
|
230
|
+
// id. Path is undefined in that case; the mirror sweep will catch
|
|
231
|
+
// any orphans missed by the targeted-removal fast path.
|
|
232
|
+
this.hooks.dispatch(
|
|
233
|
+
"deleted",
|
|
234
|
+
{ id, ...(existing?.path ? { path: existing.path } : {}) },
|
|
235
|
+
this,
|
|
236
|
+
);
|
|
224
237
|
}
|
|
225
238
|
|
|
226
239
|
async queryNotes(opts: QueryOpts): Promise<Note[]> {
|
|
@@ -340,6 +353,10 @@ export class BunSqliteStore implements Store {
|
|
|
340
353
|
// and may have declared `fields` powering schema validation.
|
|
341
354
|
this._tagHierarchy = null;
|
|
342
355
|
this._schemaConfig = null;
|
|
356
|
+
// Fire "deleted" only when SOMETHING happened (the underlying
|
|
357
|
+
// deleteTag returns `deleted: false` when the tag didn't exist).
|
|
358
|
+
// The git-mirror reacts to this by sweeping the schema sidecar.
|
|
359
|
+
if (result.deleted) this.hooks.dispatchTag("deleted", name, this);
|
|
343
360
|
return result;
|
|
344
361
|
}
|
|
345
362
|
|
|
@@ -352,6 +369,16 @@ export class BunSqliteStore implements Store {
|
|
|
352
369
|
// the schema-config by parent_names + fields content.
|
|
353
370
|
this._tagHierarchy = null;
|
|
354
371
|
this._schemaConfig = null;
|
|
372
|
+
// Rename = delete-then-upsert from the perspective of any consumer
|
|
373
|
+
// that keys schema artifacts on the tag name (e.g. the git-mirror's
|
|
374
|
+
// `.parachute/schemas/<tag>.yaml` sidecar file). Fire both events
|
|
375
|
+
// so the consumer drops the old artifact and writes the new one.
|
|
376
|
+
// Only dispatch when the rename actually happened — error returns
|
|
377
|
+
// ({ error: ... }) shouldn't notify subscribers about phantom moves.
|
|
378
|
+
if ("renamed" in result) {
|
|
379
|
+
this.hooks.dispatchTag("deleted", oldName, this);
|
|
380
|
+
this.hooks.dispatchTag("upserted", newName, this);
|
|
381
|
+
}
|
|
355
382
|
return result;
|
|
356
383
|
}
|
|
357
384
|
|
|
@@ -365,6 +392,15 @@ export class BunSqliteStore implements Store {
|
|
|
365
392
|
// bust the schema cache — `fields` declarations follow tag identity.
|
|
366
393
|
this._tagHierarchy = null;
|
|
367
394
|
this._schemaConfig = null;
|
|
395
|
+
// Each merged source vanishes from the tag set; the target's
|
|
396
|
+
// schema may have absorbed fields/relationships from the sources.
|
|
397
|
+
// Fire "deleted" for each source and "upserted" for the target so
|
|
398
|
+
// the mirror sweeps the source sidecars and rewrites the target.
|
|
399
|
+
for (const source of sources) {
|
|
400
|
+
if (source === target) continue;
|
|
401
|
+
this.hooks.dispatchTag("deleted", source, this);
|
|
402
|
+
}
|
|
403
|
+
this.hooks.dispatchTag("upserted", target, this);
|
|
368
404
|
return result;
|
|
369
405
|
}
|
|
370
406
|
|
|
@@ -440,12 +476,26 @@ export class BunSqliteStore implements Store {
|
|
|
440
476
|
// `fields` drives validation — bust the schema cache so the next
|
|
441
477
|
// create/update sees the new declarations.
|
|
442
478
|
this._schemaConfig = null;
|
|
479
|
+
// The tag schema sidecar in the mirror needs to track this. Fire
|
|
480
|
+
// "upserted" regardless of whether the row was created or modified
|
|
481
|
+
// — the mirror writes the sidecar fresh either way.
|
|
482
|
+
this.hooks.dispatchTag("upserted", tag, this);
|
|
443
483
|
return result;
|
|
444
484
|
}
|
|
445
485
|
|
|
446
486
|
async deleteTagSchema(tag: string) {
|
|
447
487
|
const result = tagSchemaOps.deleteTagSchema(this.db, tag);
|
|
448
|
-
if (result)
|
|
488
|
+
if (result) {
|
|
489
|
+
this._schemaConfig = null;
|
|
490
|
+
// Schema-only delete: the tag may still exist as a name in the
|
|
491
|
+
// hierarchy, but the sidecar lost its content. Mirror reacts by
|
|
492
|
+
// sweeping the sidecar file. (If the underlying row was reduced
|
|
493
|
+
// to a bare name with no schema content, hasSchemaContent() in
|
|
494
|
+
// exportVaultToDir already wouldn't have written it on the next
|
|
495
|
+
// export pass — the targeted delete is the fast path; the sweep
|
|
496
|
+
// is the safety net.)
|
|
497
|
+
this.hooks.dispatchTag("deleted", tag, this);
|
|
498
|
+
}
|
|
449
499
|
return result;
|
|
450
500
|
}
|
|
451
501
|
|
|
@@ -494,6 +544,11 @@ export class BunSqliteStore implements Store {
|
|
|
494
544
|
this._tagHierarchy = null;
|
|
495
545
|
this._schemaConfig = null;
|
|
496
546
|
}
|
|
547
|
+
// Tag-mutation event for the git-mirror and any other downstream
|
|
548
|
+
// consumer. Fire "upserted" on every successful tag-record write —
|
|
549
|
+
// schema/relationship/parent-name mutations all alter the sidecar
|
|
550
|
+
// contents the mirror persists.
|
|
551
|
+
this.hooks.dispatchTag("upserted", tag, this);
|
|
497
552
|
return result;
|
|
498
553
|
}
|
|
499
554
|
|
|
@@ -599,6 +654,17 @@ export class BunSqliteStore implements Store {
|
|
|
599
654
|
const other = this.db.prepare(
|
|
600
655
|
"SELECT 1 FROM attachments WHERE path = ? LIMIT 1",
|
|
601
656
|
).get(row.path);
|
|
657
|
+
|
|
658
|
+
// Post-delete event for downstream consumers (e.g. the git-mirror's
|
|
659
|
+
// sweep of `.parachute/attachments/<id>/...`). Payload is the
|
|
660
|
+
// DeletedAttachmentRef — the row is gone, so we pass only id /
|
|
661
|
+
// note_id / path.
|
|
662
|
+
this.hooks.dispatchAttachment(
|
|
663
|
+
"deleted",
|
|
664
|
+
{ id: attachmentId, noteId, path: row.path },
|
|
665
|
+
this,
|
|
666
|
+
);
|
|
667
|
+
|
|
602
668
|
return { deleted: true, path: row.path, orphaned: !other };
|
|
603
669
|
}
|
|
604
670
|
|