@openparachute/vault 0.7.9-rc.4 → 0.7.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.
Files changed (40) hide show
  1. package/README.md +4 -0
  2. package/core/src/compact.test.ts +248 -0
  3. package/core/src/delta.test.ts +49 -0
  4. package/core/src/delta.ts +21 -0
  5. package/core/src/doctor.ts +41 -1
  6. package/core/src/history-capture.test.ts +244 -0
  7. package/core/src/history-import.test.ts +82 -0
  8. package/core/src/history-import.ts +157 -0
  9. package/core/src/history.test.ts +229 -0
  10. package/core/src/history.ts +681 -0
  11. package/core/src/mcp-manifest.ts +17 -0
  12. package/core/src/mcp.ts +28 -3
  13. package/core/src/notes.ts +6 -1
  14. package/core/src/portable-md.ts +1 -1
  15. package/core/src/schema-v29-note-versions.test.ts +63 -0
  16. package/core/src/schema-v30-note-deltas.test.ts +51 -0
  17. package/core/src/schema-v31-history-import.test.ts +25 -0
  18. package/core/src/schema.ts +234 -1
  19. package/core/src/store.ts +103 -14
  20. package/core/src/types.ts +35 -2
  21. package/core/src/vendor/fossil-delta.ts +472 -0
  22. package/package.json +1 -1
  23. package/src/cli.ts +10 -1
  24. package/src/config.ts +42 -0
  25. package/src/history-import-mcp.test.ts +94 -0
  26. package/src/history-import-routes.test.ts +91 -0
  27. package/src/history-import.test.ts +318 -0
  28. package/src/history-import.ts +482 -0
  29. package/src/mcp-http.ts +13 -0
  30. package/src/mcp-tools.ts +18 -2
  31. package/src/mirror-config.ts +38 -0
  32. package/src/mirror-manager.ts +6 -0
  33. package/src/mirror-retirement.test.ts +79 -0
  34. package/src/mirror-routes.ts +12 -2
  35. package/src/routes.ts +226 -86
  36. package/src/routing.ts +8 -3
  37. package/src/server.ts +33 -14
  38. package/src/vault-compact-routes.test.ts +257 -0
  39. package/src/vault-history-routes.test.ts +562 -0
  40. package/src/vault-store.ts +13 -0
@@ -0,0 +1,79 @@
1
+ import { test, expect } from "bun:test";
2
+ import { mkdirSync, writeFileSync, rmSync, existsSync } from "node:fs";
3
+ import { dirname } from "node:path";
4
+ import { historyMirrorStatePath, assertVaultNotPaused, assertMirrorActive, VaultImportPausedError, MirrorRetiredError } from "./mirror-config.ts";
5
+ import { getVaultStore } from "./vault-store.ts";
6
+ import { vaultDbPath } from "./config.ts";
7
+
8
+ test("paused and malformed markers refuse before a DB is created; retired serves but cannot export", () => {
9
+ const name = `retirement-${crypto.randomUUID()}`;
10
+ const marker = historyMirrorStatePath(name);
11
+ mkdirSync(dirname(marker), { recursive: true });
12
+ try {
13
+ for (const raw of ['{"phase":"paused"}', '{', '{"phase":"other"}']) {
14
+ writeFileSync(marker, raw);
15
+ expect(() => getVaultStore(name)).toThrow(VaultImportPausedError);
16
+ expect(existsSync(vaultDbPath(name))).toBe(false);
17
+ }
18
+ writeFileSync(marker, '{"phase":"retired"}');
19
+ expect(() => assertVaultNotPaused(name)).not.toThrow();
20
+ expect(() => assertMirrorActive(name)).toThrow(MirrorRetiredError);
21
+ writeFileSync(marker, '{"phase":"active"}');
22
+ expect(() => assertMirrorActive(name)).not.toThrow();
23
+ } finally { rmSync(dirname(marker), { recursive: true, force: true }); }
24
+ });
25
+
26
+ import { mkdtempSync } from "node:fs";
27
+ import { join, resolve } from "node:path";
28
+ import { tmpdir } from "node:os";
29
+ import { writeVaultConfig, writeGlobalConfig } from "./config.ts";
30
+ import { clearVaultStoreCache } from "./vault-store.ts";
31
+ import { waitForHealthy } from "./health.ts";
32
+
33
+ test("real four-vault boot isolates paused vault HTTP/MCP/subscribe before DB open", async () => {
34
+ const saved = process.env.PARACHUTE_HOME, home = mkdtempSync(join(tmpdir(), "import-paused-host-"));
35
+ process.env.PARACHUTE_HOME = home;
36
+ let child: ReturnType<typeof Bun.spawn> | undefined;
37
+ try {
38
+ const reserve = Bun.serve({ port: 0, fetch: () => new Response() }); const port = reserve.port!; reserve.stop(true);
39
+ writeGlobalConfig({ port, default_mirror: "off" });
40
+ for (const name of ["paused", "normal1", "normal2", "normal3"]) writeVaultConfig({ name, api_keys: [], created_at: new Date().toISOString() });
41
+ writeFileSync(historyMirrorStatePath("paused"), JSON.stringify({ phase: "paused" }));
42
+ child = Bun.spawn({ cmd: [process.execPath, resolve(import.meta.dir, "server.ts")], env: { ...process.env, PARACHUTE_HOME: home, PORT: String(port), PARACHUTE_VAULT_NAME: "", SCRIBE_URL: "" }, stdout: "ignore", stderr: "ignore" });
43
+ expect((await waitForHealthy(port, { totalMs: 15_000 })).status).toBe("healthy");
44
+ for (const path of ["api/notes", "mcp", "subscribe"]) {
45
+ const response = await fetch(`http://127.0.0.1:${port}/vault/paused/${path}`, { headers: path === "subscribe" ? { Upgrade: "websocket", Connection: "Upgrade" } : {} });
46
+ expect(response.status).toBe(503);
47
+ expect(await response.json()).toEqual({ error_type: "history_import_paused", message: "Vault history import is paused" });
48
+ }
49
+ expect(existsSync(vaultDbPath("paused"))).toBe(false);
50
+ for (const name of ["normal1", "normal2", "normal3"]) {
51
+ expect(existsSync(vaultDbPath(name))).toBe(true);
52
+ expect((await fetch(`http://127.0.0.1:${port}/vault/${name}/api/notes`)).status).not.toBe(503);
53
+ }
54
+ } finally {
55
+ if (child) { child.kill(); await child.exited; }
56
+ clearVaultStoreCache(); if (saved === undefined) delete process.env.PARACHUTE_HOME; else process.env.PARACHUTE_HOME = saved;
57
+ rmSync(home, { recursive: true, force: true });
58
+ }
59
+ }, 25_000);
60
+
61
+ import { MirrorManager } from "./mirror-manager.ts";
62
+ import { defaultMirrorConfig } from "./mirror-config.ts";
63
+ import { handleMirrorPut, enableSyncToImportedRepo } from "./mirror-routes.ts";
64
+ test("retired manager refuses every export boundary and old-client enable without config writes", async () => {
65
+ const name = `retired-${crypto.randomUUID()}`, marker = historyMirrorStatePath(name);
66
+ mkdirSync(dirname(marker), { recursive: true }); writeFileSync(marker, '{"phase":"retired"}');
67
+ let writes = 0, exports = 0;
68
+ const config = { ...defaultMirrorConfig(), enabled: true };
69
+ const manager = new MirrorManager({ vaultName: name, runExport: async () => { exports++; return { notes: 0 }; }, firstChangedNoteTitle: async () => "", readMirrorConfig: () => config, writeMirrorConfig: () => { writes++; } });
70
+ try {
71
+ for (const call of [() => manager.start(), () => manager.reload(config), () => manager.runNow(), () => manager.pushNow()]) await expect(call()).rejects.toThrow(MirrorRetiredError);
72
+ const response = await handleMirrorPut(new Request("http://localhost/mirror", { method: "PUT", body: JSON.stringify(config) }), manager);
73
+ expect(response.status).toBe(409);
74
+ expect((await response.json() as any).error_type).toBe("mirror_retired");
75
+ expect(writes).toBe(0); expect(exports).toBe(0);
76
+ const sync = await enableSyncToImportedRepo({ vaultName: name, remoteUrl: "https://example.invalid/repo.git", auth: { kind: "none" }, manager });
77
+ expect(sync.sync_enabled).toBe(false);
78
+ } finally { manager.stop(); rmSync(dirname(marker), { recursive: true, force: true }); }
79
+ });
@@ -1,3 +1,4 @@
1
+ import { readHistoryMirrorPhase, assertMirrorActive, MirrorRetiredError, VaultImportPausedError } from "./mirror-config.ts";
1
2
  /**
2
3
  * HTTP surface for the mirror lifecycle.
3
4
  *
@@ -158,6 +159,10 @@ export async function handleMirrorPut(
158
159
  // to exercise the git_not_installed 503 path without uninstalling git.
159
160
  whichOverride?: (cmd: string) => string | null,
160
161
  ): Promise<Response> {
162
+ try { assertMirrorActive(manager.getVaultName()); } catch (error) {
163
+ if (error instanceof MirrorRetiredError || error instanceof VaultImportPausedError) return Response.json({ error_type: error.error_type, message: error.message }, { status: error.status });
164
+ throw error;
165
+ }
161
166
  let body: unknown;
162
167
  try {
163
168
  body = await req.json();
@@ -1067,7 +1072,7 @@ export async function handleAuthDelete(manager: MirrorManager): Promise<Response
1067
1072
  deleteCredentials(manager.getVaultName());
1068
1073
  // Strip origin from the mirror dir if one is set.
1069
1074
  const status = manager.getStatus();
1070
- if (status.mirror_path) {
1075
+ if (status.mirror_path && readHistoryMirrorPhase(manager.getVaultName()) === "active") {
1071
1076
  try {
1072
1077
  await unsetGitRemote(status.mirror_path);
1073
1078
  } catch (err) {
@@ -1441,7 +1446,7 @@ export async function handleAuthGithubSelectRepo(
1441
1446
  // stored, and the URL will get applied next time the mirror starts.
1442
1447
  const status = manager.getStatus();
1443
1448
  let applied = false;
1444
- if (status.mirror_path) {
1449
+ if (status.mirror_path && readHistoryMirrorPhase(manager.getVaultName()) === "active") {
1445
1450
  const res = await applyToGitRemote(status.mirror_path, authedUrl);
1446
1451
  if (!res.ok) {
1447
1452
  return Response.json(
@@ -1520,6 +1525,7 @@ async function maybeEnableHistoryOnLink(
1520
1525
  manager: MirrorManager,
1521
1526
  ): Promise<HistoryOnLink> {
1522
1527
  const vaultName = manager.getVaultName();
1528
+ if (readHistoryMirrorPhase(vaultName) !== "active") return false;
1523
1529
  if (!existsSync(mirrorConfigPath(vaultName))) {
1524
1530
  try {
1525
1531
  const status = await manager.reload({
@@ -1567,6 +1573,7 @@ async function maybeEnableHistoryOnLink(
1567
1573
  async function maybeEnableAutoPush(
1568
1574
  manager: MirrorManager,
1569
1575
  ): Promise<{ was_already_enabled: boolean; auto_push_now_enabled: boolean }> {
1576
+ if (readHistoryMirrorPhase(manager.getVaultName()) !== "active") return { was_already_enabled: false, auto_push_now_enabled: false };
1570
1577
  const config = manager.getConfig();
1571
1578
  // Don't muck with auto_push when the mirror is disabled — the operator
1572
1579
  // is configuring credentials before turning the mirror on, which is a
@@ -1607,6 +1614,7 @@ async function maybeFireInitialPush(
1607
1614
  | { fired: false; reason: "auto_push_disabled" | "no_mirror_path" | "manager_skipped" | "not_enabled" }
1608
1615
  | { fired: true; pushed: boolean; error?: string; sha?: string }
1609
1616
  > {
1617
+ if (readHistoryMirrorPhase(manager.getVaultName()) !== "active") return { fired: false, reason: "not_enabled" };
1610
1618
  if (!autoPushEnabled) return { fired: false, reason: "auto_push_disabled" };
1611
1619
  const status = manager.getStatus();
1612
1620
  if (!status.mirror_path) return { fired: false, reason: "no_mirror_path" };
@@ -1638,6 +1646,7 @@ async function maybeFireInitialPush(
1638
1646
  export async function applyCredentialsToMirror(
1639
1647
  manager: MirrorManager,
1640
1648
  ): Promise<void> {
1649
+ if (readHistoryMirrorPhase(manager.getVaultName()) !== "active") return;
1641
1650
  const status = manager.getStatus();
1642
1651
  if (!status.mirror_path) return;
1643
1652
  const creds = readCredentials(manager.getVaultName());
@@ -2211,6 +2220,7 @@ export async function enableSyncToImportedRepo(opts: {
2211
2220
  ) => Promise<{ ok: boolean; error?: string; heads?: string[] }>;
2212
2221
  }): Promise<{ sync_enabled: boolean; warning?: string }> {
2213
2222
  const { vaultName, remoteUrl, auth, manager, override = false, probeOverride } = opts;
2223
+ if (readHistoryMirrorPhase(vaultName) !== "active") return { sync_enabled: false, warning: "History mirror is paused or retired; automatic sync cannot be enabled" };
2214
2224
  // Set when the unrelated-history probe couldn't reach the remote. Rides along
2215
2225
  // on a SUCCESSFUL arm — the operator gets Sync and the caveat, not neither.
2216
2226
  let historyUnverified = false;
package/src/routes.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { getImportedVersion, importStorageIndex, parseHistorySelector, projectHistoryRow, ImportedHistoryUnrecoverableError } from "../core/src/history-import.js";
1
2
  /**
2
3
  * REST API route handlers for the multi-vault server.
3
4
  *
@@ -11,6 +12,8 @@
11
12
  * and the Request, and returns a Response.
12
13
  */
13
14
 
15
+ import { HistoryNotFoundError, latestTombstone } from "../core/src/history.js";
16
+ import { ULID_REGEX } from "../core/src/ulid.js";
14
17
  import type { Database } from "bun:sqlite";
15
18
  import type { Store, Note, QueryOpts, AggregateSpec } from "../core/src/types.ts";
16
19
  import { TAG_EXPAND_MODES, stripTagHash, suggestSimilarTag, type TagExpandMode } from "../core/src/tag-hierarchy.ts";
@@ -180,6 +183,103 @@ export { assetsDir };
180
183
  // Helpers
181
184
  // ---------------------------------------------------------------------------
182
185
 
186
+ function conflictResponse(e: any): Response | null {
187
+ if (e instanceof NotFoundError) return json({ error: e.message, error_type: "not_found" }, 404);
188
+ // Duck-type on `code` rather than `instanceof ConflictError`: this
189
+ // error originates in the core package and survives any future
190
+ // bundling / module-boundary split more robustly than a prototype check.
191
+ if (e && e.code === "CONFLICT") {
192
+ return json(
193
+ {
194
+ // New structured shape — what agents should key on.
195
+ error_type: "conflict",
196
+ current_updated_at: e.current_updated_at ?? null,
197
+ your_updated_at: e.expected_updated_at,
198
+ path: e.note_path ?? null,
199
+ note_id: e.note_id,
200
+ message: e.message,
201
+ hint: "re-read the note (GET) and re-apply your change against its current `updated_at`, or pass `force: true` to overwrite",
202
+ // Legacy fields — kept for the lens VaultConflictError shim and
203
+ // any other pre-launch callers. Safe to drop post-launch.
204
+ error: "conflict",
205
+ expected_updated_at: e.expected_updated_at,
206
+ },
207
+ 409,
208
+ );
209
+ }
210
+ // State-transition compare-and-set conflict (vault#299 Part B). A
211
+ // DISTINCT error vocabulary from `conflict` (settled lead #3): the
212
+ // field VALUE didn't match `from`, not the updated_at token. 409.
213
+ if (e && e.code === "TRANSITION_CONFLICT") {
214
+ return json(
215
+ {
216
+ error_type: "transition_conflict",
217
+ error: "transition_conflict",
218
+ note_id: e.note_id,
219
+ path: e.note_path ?? null,
220
+ field: e.field,
221
+ expected_from: e.expected_from,
222
+ to: e.to,
223
+ current: e.current ?? null,
224
+ message: e.message,
225
+ hint: "re-read the note's current value for this field and retry the transition from its actual current state",
226
+ },
227
+ 409,
228
+ );
229
+ }
230
+ // Strict-schema rejection (vault#299 Part A). One error carrying ALL
231
+ // per-field violations (settled lead #1). 422 Unprocessable Entity —
232
+ // the note exists / request is well-formed but violates the contract.
233
+ if (e && e.code === "SCHEMA_VALIDATION") {
234
+ return json(
235
+ {
236
+ error_type: "schema_validation",
237
+ error: "schema_validation",
238
+ violations: e.violations ?? [],
239
+ message: e.message,
240
+ hint: "fix every field listed in `violations` and retry — none of this write was applied",
241
+ },
242
+ 422,
243
+ );
244
+ }
245
+ // Path-rename collision — schema's UNIQUE(path) tripped. Issue #126.
246
+ if (e && e.code === "PATH_CONFLICT") {
247
+ return json(
248
+ {
249
+ error_type: "path_conflict",
250
+ error: "path_conflict",
251
+ path: e.path,
252
+ message: e.message,
253
+ hint: "pass a different `path`, or omit it to leave the existing path unchanged",
254
+ },
255
+ 409,
256
+ );
257
+ }
258
+ if (e && e.code === "INVALID_EXTENSION") {
259
+ return json(
260
+ { error_type: "invalid_extension", error: "invalid_extension", extension: e.extension, reason: e.reason, message: e.message },
261
+ 400,
262
+ );
263
+ }
264
+ // Bad `path` value (vault#589 / FIX 2) — NUL byte or `..` segment.
265
+ if (e && e.code === "INVALID_PATH") {
266
+ return json(
267
+ { error_type: "invalid_path", error: "invalid_path", path: e.path, reason: e.reason, message: e.message },
268
+ 400,
269
+ );
270
+ }
271
+ if (e && e.code === "HISTORY_NOT_FOUND") return json({ error: e.message, error_type: "not_found" }, 404);
272
+ if (e && e.code === "HISTORY_OVERFLOW") return json({
273
+ error_type: "history_overflow", note_id: e.note_id, byte_size: e.byte_size, limit: e.limit, message: e.message,
274
+ hint: "this note exceeds the 2 MB version-history ceiling, so it cannot be updated while history is enabled; delete it, or shrink it out-of-band, or disable history for this vault",
275
+ }, 413);
276
+ if (e && e.code === "HISTORY_UNRECOVERABLE") return json({
277
+ error_type: "history_unrecoverable", note_id: e.note_id, ...(e.origin === "git-import" ? { origin: e.origin, import_ix: e.import_ix } : { version_ix: e.version_ix }), message: e.message,
278
+ hint: e.reason === "delta_orphan" ? "this version's stored delta cannot be rebuilt — its base blob is missing or corrupt; run GET /api/doctor for the `history_delta_orphan` finding" : "this version is an overflow tombstone — the note's size and deletion were recorded but its content never was",
279
+ }, 409);
280
+ return null;
281
+ }
282
+
183
283
  function json(data: unknown, status = 200): Response {
184
284
  return Response.json(data, { status });
185
285
  }
@@ -2823,6 +2923,93 @@ async function handleNotesInner(
2823
2923
  return handleRetryTranscription(store, note, vault);
2824
2924
  }
2825
2925
 
2926
+ const verMatch = sub.match(/^\/versions\/([^/]+)$/);
2927
+ const importMatch = sub.match(/^\/imports\/([^/]+)$/);
2928
+ if (sub === "/versions" || verMatch || importMatch || sub === "/restore") {
2929
+ const note = await resolveNote(store, idOrPath);
2930
+ if (note && !noteWithinTagScope(note, tagScope.allowed, tagScope.raw)) {
2931
+ return json({ error: "Not found", error_type: "not_found" }, 404);
2932
+ }
2933
+ if (!note && (tagScope.raw !== null || !ULID_REGEX.test(idOrPath) ||
2934
+ !(await store.listNoteVersions(idOrPath, { limit: 1 })).length)) {
2935
+ return json({ error: "Not found", error_type: "not_found" }, 404);
2936
+ }
2937
+ const id = note?.id ?? idOrPath;
2938
+ if (sub === "/versions") {
2939
+ if (method === "GET") {
2940
+ const parsedLimit = Number.parseInt(url.searchParams.get("limit") ?? "50", 10);
2941
+ const limit = Number.isNaN(parsedLimit) ? 50 : Math.min(200, Math.max(0, parsedLimit));
2942
+ const offset = Math.max(0, Number.parseInt(url.searchParams.get("offset") ?? "0", 10) || 0);
2943
+ const versions = await store.listNoteVersions(id, { limit, offset });
2944
+ const total = await store.countNoteVersions(id);
2945
+ return json({ versions: versions.map(projectHistoryRow), total });
2946
+ }
2947
+ if (method === "DELETE") {
2948
+ const result = await store.eraseNoteHistory(id);
2949
+ return json({ erased: true, id, versions_deleted: result.versionsDeleted, blobs_deleted: result.blobsDeleted });
2950
+ }
2951
+ } else if (importMatch && method === "GET") {
2952
+ const index = Number(importMatch[1]);
2953
+ if (!/^\d+$/.test(importMatch[1]!) || !Number.isSafeInteger(index) || index < 0) return json({ error: "Invalid import_ix", error_type: "invalid_request" }, 400);
2954
+ try {
2955
+ const version = getImportedVersion(db, id, index);
2956
+ return version ? json(projectHistoryRow(version)) : json({ error: "Not found", error_type: "not_found", origin: "git-import", import_ix: index }, 404);
2957
+ } catch (e) {
2958
+ const response = conflictResponse(e);
2959
+ if (response) return response;
2960
+ throw e;
2961
+ }
2962
+ } else if (verMatch && method === "GET") {
2963
+ const ix = Number(verMatch[1]);
2964
+ if (!/^\d+$/.test(verMatch[1]!) || !Number.isInteger(ix) || ix < 0) return json({ error: "Invalid version_ix", error_type: "invalid_request" }, 400);
2965
+ try {
2966
+ const version = await store.getNoteVersion(id, ix);
2967
+ return version ? json(version) : json({ error: "Not found", error_type: "not_found" }, 404);
2968
+ } catch (e) {
2969
+ const response = conflictResponse(e);
2970
+ if (response) return response;
2971
+ throw e;
2972
+ }
2973
+ } else if (sub === "/restore" && method === "POST") {
2974
+ const parsed = await parseJsonBody(req);
2975
+ if (!parsed.ok) return parsed.response;
2976
+ const body = parsed.body;
2977
+ let selector: ReturnType<typeof parseHistorySelector>;
2978
+ try { selector = parseHistorySelector(body); } catch {
2979
+ return json({ error: "Invalid history selector", error_type: "invalid_request" }, 400);
2980
+ }
2981
+ if (!selector) return json({ error: "History selector is required", error_type: "missing_required_field" }, 400);
2982
+ const imported = "import_ix" in selector;
2983
+ const storageIndex = "import_ix" in selector ? importStorageIndex(selector.import_ix) : selector.version_ix;
2984
+ try {
2985
+ const version = "import_ix" in selector ? getImportedVersion(db, id, selector.import_ix) : await store.getNoteVersion(id, storageIndex);
2986
+ if (!version) return json({ error: "Not found", error_type: "not_found", ...(imported ? selector : {}) }, 404);
2987
+ gateStrictWrite(store, writeCtx, {
2988
+ path: note ? note.path : latestTombstone(db, id)?.path,
2989
+ tags: note?.tags ?? [], metadata: version.metadata,
2990
+ });
2991
+ const restored = await store.restoreNoteVersion(id, storageIndex, {
2992
+ actor: writeCtx.actor, via: writeCtx.via,
2993
+ ...(body.if_updated_at !== undefined ? { if_updated_at: body.if_updated_at as string } : {}),
2994
+ });
2995
+ let validated: any = attachValidationStatus(store, db, restored);
2996
+ validated = scrubNoteTagsByScope(validated, tagScope.allowed, tagScope.raw);
2997
+ if (validated.validation_status) {
2998
+ const vs = scrubValidationStatusByScope(validated.validation_status, tagScope.allowed, tagScope.raw);
2999
+ if (vs === undefined) { const { validation_status: _d, ...rest } = validated; validated = rest; }
3000
+ else validated = { ...validated, validation_status: vs };
3001
+ }
3002
+ return json({ ...validated, restored_from: "import_ix" in selector ? selector : selector.version_ix, recreated: !note });
3003
+ } catch (e: any) {
3004
+ if ("import_ix" in selector && e?.code === "HISTORY_UNRECOVERABLE" && e.origin !== "git-import") e = new ImportedHistoryUnrecoverableError(id, selector.import_ix, e.reason);
3005
+ const r = conflictResponse(e);
3006
+ if (r) return r;
3007
+ throw e;
3008
+ }
3009
+ }
3010
+ return json({ error: "Method not allowed", error_type: "method_not_allowed" }, 405);
3011
+ }
3012
+
2826
3013
  if (sub !== "") return json({ error: "Not found", error_type: "not_found" }, 404);
2827
3014
 
2828
3015
  // GET /notes/:idOrPath — single note
@@ -3357,90 +3544,8 @@ async function handleNotesInner(
3357
3544
  lean.created = false;
3358
3545
  return json(lean);
3359
3546
  } catch (e: any) {
3360
- if (e instanceof NotFoundError) return json({ error: e.message, error_type: "not_found" }, 404);
3361
- // Duck-type on `code` rather than `instanceof ConflictError`: this
3362
- // error originates in the core package and survives any future
3363
- // bundling / module-boundary split more robustly than a prototype check.
3364
- if (e && e.code === "CONFLICT") {
3365
- return json(
3366
- {
3367
- // New structured shape — what agents should key on.
3368
- error_type: "conflict",
3369
- current_updated_at: e.current_updated_at ?? null,
3370
- your_updated_at: e.expected_updated_at,
3371
- path: e.note_path ?? null,
3372
- note_id: e.note_id,
3373
- message: e.message,
3374
- hint: "re-read the note (GET) and re-apply your change against its current `updated_at`, or pass `force: true` to overwrite",
3375
- // Legacy fields — kept for the lens VaultConflictError shim and
3376
- // any other pre-launch callers. Safe to drop post-launch.
3377
- error: "conflict",
3378
- expected_updated_at: e.expected_updated_at,
3379
- },
3380
- 409,
3381
- );
3382
- }
3383
- // State-transition compare-and-set conflict (vault#299 Part B). A
3384
- // DISTINCT error vocabulary from `conflict` (settled lead #3): the
3385
- // field VALUE didn't match `from`, not the updated_at token. 409.
3386
- if (e && e.code === "TRANSITION_CONFLICT") {
3387
- return json(
3388
- {
3389
- error_type: "transition_conflict",
3390
- error: "transition_conflict",
3391
- note_id: e.note_id,
3392
- path: e.note_path ?? null,
3393
- field: e.field,
3394
- expected_from: e.expected_from,
3395
- to: e.to,
3396
- current: e.current ?? null,
3397
- message: e.message,
3398
- hint: "re-read the note's current value for this field and retry the transition from its actual current state",
3399
- },
3400
- 409,
3401
- );
3402
- }
3403
- // Strict-schema rejection (vault#299 Part A). One error carrying ALL
3404
- // per-field violations (settled lead #1). 422 Unprocessable Entity —
3405
- // the note exists / request is well-formed but violates the contract.
3406
- if (e && e.code === "SCHEMA_VALIDATION") {
3407
- return json(
3408
- {
3409
- error_type: "schema_validation",
3410
- error: "schema_validation",
3411
- violations: e.violations ?? [],
3412
- message: e.message,
3413
- hint: "fix every field listed in `violations` and retry — none of this write was applied",
3414
- },
3415
- 422,
3416
- );
3417
- }
3418
- // Path-rename collision — schema's UNIQUE(path) tripped. Issue #126.
3419
- if (e && e.code === "PATH_CONFLICT") {
3420
- return json(
3421
- {
3422
- error_type: "path_conflict",
3423
- error: "path_conflict",
3424
- path: e.path,
3425
- message: e.message,
3426
- hint: "pass a different `path`, or omit it to leave the existing path unchanged",
3427
- },
3428
- 409,
3429
- );
3430
- }
3431
- if (e && e.code === "INVALID_EXTENSION") {
3432
- return json(
3433
- { error_type: "invalid_extension", error: "invalid_extension", extension: e.extension, reason: e.reason, message: e.message },
3434
- 400,
3435
- );
3436
- }
3437
- // Bad `path` value (vault#589 / FIX 2) — NUL byte or `..` segment.
3438
- if (e && e.code === "INVALID_PATH") {
3439
- return json(
3440
- { error_type: "invalid_path", error: "invalid_path", path: e.path, reason: e.reason, message: e.message },
3441
- 400,
3442
- );
3443
- }
3547
+ const r = conflictResponse(e);
3548
+ if (r) return r;
3444
3549
  throw e;
3445
3550
  }
3446
3551
  }
@@ -3454,7 +3559,7 @@ async function handleNotesInner(
3454
3559
  if (!noteWithinTagScope(note, tagScope.allowed, tagScope.raw)) {
3455
3560
  return json({ error: "Not found", error_type: "not_found" }, 404);
3456
3561
  }
3457
- await store.deleteNote(note.id);
3562
+ await store.deleteNote(note.id, { actor: writeCtx.actor, via: writeCtx.via });
3458
3563
  return json({ deleted: true, id: note.id });
3459
3564
  }
3460
3565
 
@@ -3471,6 +3576,7 @@ export async function handleTags(
3471
3576
  store: Store,
3472
3577
  subpath = "",
3473
3578
  tagScope: TagScopeCtx = NO_TAG_SCOPE,
3579
+ writeCtx: WriteCtx = NO_WRITE_CTX,
3474
3580
  ): Promise<Response> {
3475
3581
  const url = new URL(req.url);
3476
3582
 
@@ -3619,7 +3725,7 @@ export async function handleTags(
3619
3725
  // other surface). The old fail-closed token-reference check has been
3620
3726
  // removed; the cascade rewrites the JSON allowlist atomically. See
3621
3727
  // notes.ts:renameTag for the surfaces touched.
3622
- const result = await store.renameTag(oldName, newName);
3728
+ const result = await store.renameTag(oldName, newName, { actor: writeCtx.actor ?? undefined, via: writeCtx.via ?? undefined });
3623
3729
  if ("error" in result) {
3624
3730
  if (result.error === "not_found") {
3625
3731
  return json({ error: "not_found", error_type: "tag_not_found", tag: oldName }, 404);
@@ -5142,3 +5248,37 @@ function removeWikilinkBrackets(content: string, targetPath: string): string {
5142
5248
  content = content.replace(new RegExp(`\\[\\[${escaped}(#[^\\]]+)?\\]\\]`, "gi"), `${targetPath}$1`);
5143
5249
  return content;
5144
5250
  }
5251
+
5252
+ /** POST /api/history/compact — unscoped administrators; absent bounds are unbounded. */
5253
+ export async function handleHistoryCompact(req: Request, store: Store, tagScope: TagScopeCtx = NO_TAG_SCOPE): Promise<Response> {
5254
+ if (tagScope.raw !== null)
5255
+ return json({ error: "Not found", error_type: "not_found" }, 404);
5256
+ const declared = Number(req.headers.get("content-length"));
5257
+ if (declared > MAX_JSON_BODY_BYTES)
5258
+ return payloadTooLargeResponse(declared);
5259
+ let body: Record<string, unknown>;
5260
+ try {
5261
+ const raw = await req.text();
5262
+ if (new TextEncoder().encode(raw).length > MAX_JSON_BODY_BYTES)
5263
+ return payloadTooLargeResponse(new TextEncoder().encode(raw).length);
5264
+ const parsed = raw.trim() ? JSON.parse(raw) : {};
5265
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
5266
+ throw new Error("object required");
5267
+ body = parsed;
5268
+ }
5269
+ catch {
5270
+ return json({ error: "Invalid compaction request", error_type: "invalid_request" }, 400);
5271
+ }
5272
+ if ((body.note_id !== undefined && (typeof body.note_id !== "string" || body.note_id.length === 0)) ||
5273
+ [body.budget_ms, body.max_notes].some(v => v !== undefined && (typeof v !== "number" || !Number.isInteger(v) || v < 1)))
5274
+ return json({ error: "Invalid compaction request", error_type: "invalid_request" }, 400);
5275
+ try {
5276
+ return json(store.compactHistory({ noteId: body.note_id as string | undefined, budgetMs: body.budget_ms as number | undefined ?? null, maxNotes: body.max_notes as number | undefined ?? null }));
5277
+ }
5278
+ catch (err) {
5279
+ const conflict = conflictResponse(err);
5280
+ if (conflict)
5281
+ return conflict;
5282
+ return json({ error: "Compaction failed", error_type: "compaction_failed", message: err instanceof Error ? err.message : String(err) }, 500);
5283
+ }
5284
+ }
package/src/routing.ts CHANGED
@@ -85,6 +85,7 @@ import {
85
85
  handleStorage,
86
86
  handleViewNote,
87
87
  handleDoctor,
88
+ handleHistoryCompact,
88
89
  type TagScopeCtx,
89
90
  type WriteCtx,
90
91
  } from "./routes.ts";
@@ -1101,11 +1102,14 @@ export async function route(
1101
1102
  ((req.method === "PUT" || req.method === "DELETE") && /^\/tags\/[^/]+$/.test(apiSubpath)) ||
1102
1103
  (req.method === "POST" &&
1103
1104
  (apiSubpath === "/tags/merge" || /^\/tags\/[^/]+\/rename$/.test(apiSubpath)));
1104
- const requiredVerb = isReadOnlyPost ? "read" : isTagSchemaMutation ? "admin" : verbForMethod(req.method);
1105
+ // Erasing note history is irreversible and requires admin (vault#524).
1106
+ const isNoteHistoryErase = req.method === "DELETE" && /^\/notes\/[^/]+\/versions$/.test(apiSubpath);
1107
+ const isHistoryCompact = req.method === "POST" && apiSubpath === "/history/compact";
1108
+ const requiredVerb = isReadOnlyPost ? "read" : (isTagSchemaMutation || isNoteHistoryErase || isHistoryCompact) ? "admin" : verbForMethod(req.method);
1105
1109
  if (!hasScopeForVault(auth.scopes, vaultName, requiredVerb)) {
1106
1110
  const requiredApiScope = isReadOnlyPost
1107
1111
  ? SCOPE_READ
1108
- : isTagSchemaMutation
1112
+ : (isTagSchemaMutation || isNoteHistoryErase || isHistoryCompact)
1109
1113
  ? SCOPE_ADMIN
1110
1114
  : scopeForMethod(req.method);
1111
1115
  return Response.json(
@@ -1173,7 +1177,8 @@ export async function route(
1173
1177
  { status: 410 },
1174
1178
  );
1175
1179
  }
1176
- if (apiPath.startsWith("/tags")) return handleTags(req, store, apiPath.slice(5), tagScope);
1180
+ if (apiPath.startsWith("/tags")) return handleTags(req, store, apiPath.slice(5), tagScope, writeCtx);
1181
+ if (isHistoryCompact) return handleHistoryCompact(req, store, tagScope);
1177
1182
  if (apiPath === "/find-path") return handleFindPath(req, store, tagScope);
1178
1183
  if (apiPath === "/vault") {
1179
1184
  return handleVault(
package/src/server.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env bun
2
+ import { assertVaultNotPaused, VaultImportPausedError, MirrorRetiredError } from "./mirror-config.ts";
2
3
  /**
3
4
  * Multi-vault HTTP server using Bun.serve().
4
5
  *
@@ -430,22 +431,27 @@ for (const warning of reservedNameSquatWarnings(listVaults())) {
430
431
  // Migrate tag schemas from vault.yaml → DB for each vault.
431
432
  // Only inserts schemas that don't already exist in the DB (safe across restarts).
432
433
  for (const vaultName of listVaults()) {
433
- const vaultConfig = readVaultConfig(vaultName);
434
- if (vaultConfig?.tag_schemas && Object.keys(vaultConfig.tag_schemas).length > 0) {
435
- const store = getVaultStore(vaultName);
436
- const existingTags = new Set((await store.listTagSchemas()).map((s) => s.tag));
437
- let migrated = 0;
438
- for (const [tag, schema] of Object.entries(vaultConfig.tag_schemas)) {
439
- if (!existingTags.has(tag)) {
440
- await store.upsertTagSchema(tag, schema);
441
- migrated++;
434
+ try {
435
+ const vaultConfig = readVaultConfig(vaultName);
436
+ if (vaultConfig?.tag_schemas && Object.keys(vaultConfig.tag_schemas).length > 0) {
437
+ const store = getVaultStore(vaultName);
438
+ const existingTags = new Set((await store.listTagSchemas()).map((s) => s.tag));
439
+ let migrated = 0;
440
+ for (const [tag, schema] of Object.entries(vaultConfig.tag_schemas)) {
441
+ if (!existingTags.has(tag)) {
442
+ await store.upsertTagSchema(tag, schema);
443
+ migrated++;
444
+ }
445
+ }
446
+ if (migrated > 0) {
447
+ console.log(`[migration] migrated ${migrated} tag schema(s) from vault.yaml to DB for vault "${vaultName}"`);
448
+ } else {
449
+ console.log(`[migration] vault "${vaultName}" has tag_schemas in vault.yaml (already in DB — vault.yaml section can be removed)`);
442
450
  }
443
451
  }
444
- if (migrated > 0) {
445
- console.log(`[migration] migrated ${migrated} tag schema(s) from vault.yaml to DB for vault "${vaultName}"`);
446
- } else {
447
- console.log(`[migration] vault "${vaultName}" has tag_schemas in vault.yaml (already in DB — vault.yaml section can be removed)`);
448
- }
452
+ } catch (err) {
453
+ if (!(err instanceof VaultImportPausedError)) throw err;
454
+ console.warn(`[history-import] vault "${vaultName}" is paused; skipping tag-schema migration`);
449
455
  }
450
456
  }
451
457
 
@@ -633,6 +639,16 @@ const server = Bun.serve({
633
639
  return new Response(null, { status: 204, headers: corsHeaders });
634
640
  }
635
641
 
642
+ // Refuse before WS upgrade and before any route can open/maintain a paused DB.
643
+ const vaultSegment = /^\/vault\/([^/]+)/.exec(path)?.[1];
644
+ if (vaultSegment && listVaults().includes(vaultSegment)) {
645
+ try { assertVaultNotPaused(vaultSegment); }
646
+ catch (err) {
647
+ if (!(err instanceof VaultImportPausedError)) throw err;
648
+ return Response.json({ error_type: err.error_type, message: err.message }, { status: 503, headers: corsHeaders });
649
+ }
650
+ }
651
+
636
652
  // Live-query WebSocket upgrade — detected BEFORE the fetch pipeline because
637
653
  // WS upgrades don't traverse `route()`. Non-upgrade requests (incl. a
638
654
  // straggler non-WS GET /subscribe, which now gets a 410 Gone in routing.ts —
@@ -659,6 +675,9 @@ const server = Bun.serve({
659
675
  }
660
676
  return response;
661
677
  } catch (err) {
678
+ if (err instanceof VaultImportPausedError || err instanceof MirrorRetiredError) {
679
+ return Response.json({ error_type: err.error_type, message: err.message }, { status: err.status, headers: corsHeaders });
680
+ }
662
681
  console.error(`[${req.method} ${path}]`, err);
663
682
  return Response.json(
664
683
  { error: "Internal server error" },