@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.
- package/README.md +4 -0
- package/core/src/compact.test.ts +248 -0
- package/core/src/delta.test.ts +49 -0
- package/core/src/delta.ts +21 -0
- package/core/src/doctor.ts +41 -1
- package/core/src/history-capture.test.ts +244 -0
- package/core/src/history-import.test.ts +82 -0
- package/core/src/history-import.ts +157 -0
- package/core/src/history.test.ts +229 -0
- package/core/src/history.ts +681 -0
- package/core/src/mcp-manifest.ts +17 -0
- package/core/src/mcp.ts +28 -3
- package/core/src/notes.ts +6 -1
- package/core/src/portable-md.ts +1 -1
- package/core/src/schema-v29-note-versions.test.ts +63 -0
- package/core/src/schema-v30-note-deltas.test.ts +51 -0
- package/core/src/schema-v31-history-import.test.ts +25 -0
- package/core/src/schema.ts +234 -1
- package/core/src/store.ts +103 -14
- package/core/src/types.ts +35 -2
- package/core/src/vendor/fossil-delta.ts +472 -0
- package/package.json +1 -1
- package/src/cli.ts +10 -1
- package/src/config.ts +42 -0
- package/src/history-import-mcp.test.ts +94 -0
- package/src/history-import-routes.test.ts +91 -0
- package/src/history-import.test.ts +318 -0
- package/src/history-import.ts +482 -0
- package/src/mcp-http.ts +13 -0
- package/src/mcp-tools.ts +18 -2
- package/src/mirror-config.ts +38 -0
- package/src/mirror-manager.ts +6 -0
- package/src/mirror-retirement.test.ts +79 -0
- package/src/mirror-routes.ts +12 -2
- package/src/routes.ts +226 -86
- package/src/routing.ts +8 -3
- package/src/server.ts +33 -14
- package/src/vault-compact-routes.test.ts +257 -0
- package/src/vault-history-routes.test.ts +562 -0
- 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
|
+
});
|
package/src/mirror-routes.ts
CHANGED
|
@@ -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
|
-
|
|
3361
|
-
|
|
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
|
-
|
|
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
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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
|
-
|
|
445
|
-
|
|
446
|
-
}
|
|
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" },
|