@openparachute/vault 0.7.9-rc.3 → 0.7.9-rc.5

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 (38) hide show
  1. package/core/src/compact.test.ts +248 -0
  2. package/core/src/delta.test.ts +49 -0
  3. package/core/src/delta.ts +21 -0
  4. package/core/src/doctor.ts +41 -1
  5. package/core/src/history-capture.test.ts +244 -0
  6. package/core/src/history.test.ts +229 -0
  7. package/core/src/history.ts +679 -0
  8. package/core/src/link-count.test.ts +29 -0
  9. package/core/src/links.ts +43 -0
  10. package/core/src/mcp-manifest.ts +17 -6
  11. package/core/src/mcp.ts +42 -14
  12. package/core/src/notes.ts +6 -1
  13. package/core/src/portable-md.ts +1 -1
  14. package/core/src/schema-v29-note-versions.test.ts +63 -0
  15. package/core/src/schema-v30-note-deltas.test.ts +51 -0
  16. package/core/src/schema.ts +202 -1
  17. package/core/src/store.ts +103 -14
  18. package/core/src/types.ts +35 -2
  19. package/core/src/vendor/fossil-delta.ts +472 -0
  20. package/core/src/wikilinks.ts +68 -1
  21. package/package.json +1 -1
  22. package/src/config.ts +42 -0
  23. package/src/mcp-tools.ts +12 -2
  24. package/src/release-plan.test.ts +90 -1
  25. package/src/routes.ts +276 -95
  26. package/src/routing.ts +8 -3
  27. package/src/test-support/vault-714-find-path.json +58 -0
  28. package/src/test-support/vault-714-graph.json +27 -0
  29. package/src/test-support/vault-714-has_links.json +96 -0
  30. package/src/test-support/vault-714-include_broken_links.json +118 -0
  31. package/src/test-support/vault-714-include_link_count.json +348 -0
  32. package/src/test-support/vault-714-include_links.json +86 -0
  33. package/src/test-support/vault-714-near.json +78 -0
  34. package/src/test-support/vault-714-unresolved-wikilinks.json +6 -0
  35. package/src/vault-compact-routes.test.ts +257 -0
  36. package/src/vault-history-routes.test.ts +562 -0
  37. package/src/vault-store.ts +11 -0
  38. package/src/vault.test.ts +243 -1
@@ -11,8 +11,10 @@
11
11
  import { describe, expect, test } from "bun:test";
12
12
  import {
13
13
  compareVersions,
14
+ coreVersion,
14
15
  decidePublish,
15
16
  distTagFor,
17
+ matchingRcVersions,
16
18
  readRegistry,
17
19
  unpublishedDrift,
18
20
  } from "../scripts/release-plan.ts";
@@ -193,13 +195,81 @@ describe("decidePublish", () => {
193
195
  {
194
196
  versionExists: false,
195
197
  currentDistTagVersion: "0.7.8",
196
- publishedVersions: ["0.7.8"],
198
+ publishedVersions: ["0.7.8", "0.7.9-rc.1"],
197
199
  },
198
200
  { branch: "main" },
199
201
  );
200
202
  expect(d).toMatchObject({ publish: true });
201
203
  });
202
204
 
205
+ test("a stable without a matching rc is refused — vault#697", () => {
206
+ // Hub 0.7.13–0.7.16 shipped @latest with no rc of the same X.Y.Z.
207
+ // Vault's previous plan (and the inline shell it replaced) did the same.
208
+ const d = decidePublish(
209
+ "0.7.9",
210
+ {
211
+ versionExists: false,
212
+ currentDistTagVersion: "0.7.8",
213
+ publishedVersions: ["0.7.8", "0.7.8-rc.2"],
214
+ },
215
+ { branch: "main" },
216
+ );
217
+ expect(d).toMatchObject({ refuse: true });
218
+ expect("refuse" in d && d.reason).toMatch(/0\.7\.9-rc/);
219
+ expect("refuse" in d && d.reason).toMatch(/suffix-drop|Cut an rc first/i);
220
+ });
221
+
222
+ test("a stable whose only published rcs are a different X.Y.Z is still refused", () => {
223
+ const d = decidePublish(
224
+ "0.7.9",
225
+ {
226
+ versionExists: false,
227
+ currentDistTagVersion: "0.7.8",
228
+ publishedVersions: ["0.7.8", "0.7.8-rc.1"],
229
+ },
230
+ { branch: "main" },
231
+ );
232
+ expect(d).toMatchObject({ refuse: true });
233
+ });
234
+
235
+ test("a stable with a matching rc publishes from main", () => {
236
+ const d = decidePublish(
237
+ "0.7.9",
238
+ {
239
+ versionExists: false,
240
+ currentDistTagVersion: "0.7.8",
241
+ publishedVersions: ["0.7.8", "0.7.9-rc.1"],
242
+ },
243
+ { branch: "main" },
244
+ );
245
+ expect(d).toMatchObject({ publish: true });
246
+ });
247
+
248
+ test("omitted publishedVersions still refuses a stable when latest already exists", () => {
249
+ const d = decidePublish(
250
+ "0.7.9",
251
+ {
252
+ versionExists: false,
253
+ currentDistTagVersion: "0.7.8",
254
+ },
255
+ { branch: "main" },
256
+ );
257
+ expect(d).toMatchObject({ refuse: true });
258
+ });
259
+
260
+ test("an rc publishes even with no prior rc of the same core", () => {
261
+ const d = decidePublish(
262
+ "0.7.9-rc.1",
263
+ {
264
+ versionExists: false,
265
+ currentDistTagVersion: "0.7.8-rc.5",
266
+ publishedVersions: ["0.7.8", "0.7.8-rc.5"],
267
+ },
268
+ { branch: "next" },
269
+ );
270
+ expect(d).toMatchObject({ publish: true });
271
+ });
272
+
203
273
  test("a tag push of a stable does NOT override the main-only gate", () => {
204
274
  const d = decidePublish(
205
275
  "0.7.9",
@@ -281,6 +351,25 @@ describe("readRegistry", () => {
281
351
  });
282
352
  });
283
353
 
354
+ describe("matchingRcVersions", () => {
355
+ test("matches only the same X.Y.Z rc chain", () => {
356
+ expect(
357
+ matchingRcVersions("0.7.9", ["0.7.8", "0.7.8-rc.1", "0.7.9-rc.1", "0.7.9-rc.2"]),
358
+ ).toEqual(["0.7.9-rc.1", "0.7.9-rc.2"]);
359
+ });
360
+
361
+ test("a prerelease still matches siblings of its core", () => {
362
+ expect(matchingRcVersions("0.7.9-rc.3", ["0.7.9-rc.1", "0.7.8-rc.1"])).toEqual(["0.7.9-rc.1"]);
363
+ });
364
+ });
365
+
366
+ describe("coreVersion", () => {
367
+ test("strips the rc suffix and leaves a stable alone", () => {
368
+ expect(coreVersion("0.7.9-rc.1")).toBe("0.7.9");
369
+ expect(coreVersion("0.7.9")).toBe("0.7.9");
370
+ });
371
+ });
372
+
284
373
  describe("unpublishedDrift", () => {
285
374
  test("no commits → not drifted", () => {
286
375
  expect(unpublishedDrift([]).drifted).toBe(false);
package/src/routes.ts CHANGED
@@ -11,6 +11,8 @@
11
11
  * and the Request, and returns a Response.
12
12
  */
13
13
 
14
+ import { HistoryNotFoundError, latestTombstone } from "../core/src/history.js";
15
+ import { ULID_REGEX } from "../core/src/ulid.js";
14
16
  import type { Database } from "bun:sqlite";
15
17
  import type { Store, Note, QueryOpts, AggregateSpec } from "../core/src/types.ts";
16
18
  import { TAG_EXPAND_MODES, stripTagHash, suggestSimilarTag, type TagExpandMode } from "../core/src/tag-hierarchy.ts";
@@ -32,6 +34,7 @@ import {
32
34
  getUnresolvedLinksForNote,
33
35
  getUnresolvedLinksForNotes,
34
36
  listZeroVisibleAmbiguousAsUnresolved,
37
+ listResolvedToInvisibleAsUnresolved,
35
38
  narrowByVisibleBrokenness,
36
39
  sqlHasBrokenLinks,
37
40
  getAmbiguousLinksForNote,
@@ -179,6 +182,103 @@ export { assetsDir };
179
182
  // Helpers
180
183
  // ---------------------------------------------------------------------------
181
184
 
185
+ function conflictResponse(e: any): Response | null {
186
+ if (e instanceof NotFoundError) return json({ error: e.message, error_type: "not_found" }, 404);
187
+ // Duck-type on `code` rather than `instanceof ConflictError`: this
188
+ // error originates in the core package and survives any future
189
+ // bundling / module-boundary split more robustly than a prototype check.
190
+ if (e && e.code === "CONFLICT") {
191
+ return json(
192
+ {
193
+ // New structured shape — what agents should key on.
194
+ error_type: "conflict",
195
+ current_updated_at: e.current_updated_at ?? null,
196
+ your_updated_at: e.expected_updated_at,
197
+ path: e.note_path ?? null,
198
+ note_id: e.note_id,
199
+ message: e.message,
200
+ hint: "re-read the note (GET) and re-apply your change against its current `updated_at`, or pass `force: true` to overwrite",
201
+ // Legacy fields — kept for the lens VaultConflictError shim and
202
+ // any other pre-launch callers. Safe to drop post-launch.
203
+ error: "conflict",
204
+ expected_updated_at: e.expected_updated_at,
205
+ },
206
+ 409,
207
+ );
208
+ }
209
+ // State-transition compare-and-set conflict (vault#299 Part B). A
210
+ // DISTINCT error vocabulary from `conflict` (settled lead #3): the
211
+ // field VALUE didn't match `from`, not the updated_at token. 409.
212
+ if (e && e.code === "TRANSITION_CONFLICT") {
213
+ return json(
214
+ {
215
+ error_type: "transition_conflict",
216
+ error: "transition_conflict",
217
+ note_id: e.note_id,
218
+ path: e.note_path ?? null,
219
+ field: e.field,
220
+ expected_from: e.expected_from,
221
+ to: e.to,
222
+ current: e.current ?? null,
223
+ message: e.message,
224
+ hint: "re-read the note's current value for this field and retry the transition from its actual current state",
225
+ },
226
+ 409,
227
+ );
228
+ }
229
+ // Strict-schema rejection (vault#299 Part A). One error carrying ALL
230
+ // per-field violations (settled lead #1). 422 Unprocessable Entity —
231
+ // the note exists / request is well-formed but violates the contract.
232
+ if (e && e.code === "SCHEMA_VALIDATION") {
233
+ return json(
234
+ {
235
+ error_type: "schema_validation",
236
+ error: "schema_validation",
237
+ violations: e.violations ?? [],
238
+ message: e.message,
239
+ hint: "fix every field listed in `violations` and retry — none of this write was applied",
240
+ },
241
+ 422,
242
+ );
243
+ }
244
+ // Path-rename collision — schema's UNIQUE(path) tripped. Issue #126.
245
+ if (e && e.code === "PATH_CONFLICT") {
246
+ return json(
247
+ {
248
+ error_type: "path_conflict",
249
+ error: "path_conflict",
250
+ path: e.path,
251
+ message: e.message,
252
+ hint: "pass a different `path`, or omit it to leave the existing path unchanged",
253
+ },
254
+ 409,
255
+ );
256
+ }
257
+ if (e && e.code === "INVALID_EXTENSION") {
258
+ return json(
259
+ { error_type: "invalid_extension", error: "invalid_extension", extension: e.extension, reason: e.reason, message: e.message },
260
+ 400,
261
+ );
262
+ }
263
+ // Bad `path` value (vault#589 / FIX 2) — NUL byte or `..` segment.
264
+ if (e && e.code === "INVALID_PATH") {
265
+ return json(
266
+ { error_type: "invalid_path", error: "invalid_path", path: e.path, reason: e.reason, message: e.message },
267
+ 400,
268
+ );
269
+ }
270
+ if (e && e.code === "HISTORY_NOT_FOUND") return json({ error: e.message, error_type: "not_found" }, 404);
271
+ if (e && e.code === "HISTORY_OVERFLOW") return json({
272
+ error_type: "history_overflow", note_id: e.note_id, byte_size: e.byte_size, limit: e.limit, message: e.message,
273
+ 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",
274
+ }, 413);
275
+ if (e && e.code === "HISTORY_UNRECOVERABLE") return json({
276
+ error_type: "history_unrecoverable", note_id: e.note_id, version_ix: e.version_ix, message: e.message,
277
+ 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",
278
+ }, 409);
279
+ return null;
280
+ }
281
+
182
282
  function json(data: unknown, status = 200): Response {
183
283
  return Response.json(data, { status });
184
284
  }
@@ -1026,7 +1126,7 @@ export function parseNotesQueryOpts(url: URL, tagScope: TagScopeCtx = NO_TAG_SCO
1026
1126
  expand,
1027
1127
  excludeTags: parseQueryList(url, "exclude_tag"),
1028
1128
  hasTags: parseBoolOrUndef(parseQuery(url, "has_tags")),
1029
- hasLinks: parseBoolOrUndef(parseQuery(url, "has_links")),
1129
+ hasLinks: linkOps.sqlHasLinks(parseBoolOrUndef(parseQuery(url, "has_links")), tagScope.raw !== null),
1030
1130
  // Presence filter on dangling outbound wikilinks/structured links
1031
1131
  // (vault#555) — see core/src/types.ts QueryOpts.hasBrokenLinks.
1032
1132
  // vault#239: for a TAG-SCOPED reader NEITHER polarity is safe to push
@@ -1376,7 +1476,7 @@ async function handleNotesInner(
1376
1476
  // linkCount injected after filterMetadata on purpose — same as
1377
1477
  // links/attachments above; filterMetadata only touches `metadata`.
1378
1478
  if (parseBool(parseQuery(url, "include_link_count"), false)) {
1379
- result.linkCount = linkOps.getLinkCounts(db, [note.id], parseLinkCountDirection(url)).get(note.id) ?? 0;
1479
+ result.linkCount = linkOps.getLinkCounts(db, [note.id], parseLinkCountDirection(url), ambiguityVisibilityFor(db, tagScope)).get(note.id) ?? 0;
1380
1480
  }
1381
1481
  return json(result);
1382
1482
  }
@@ -1451,7 +1551,26 @@ async function handleNotesInner(
1451
1551
  embeddingsPendingWarning(semanticResult.pendingCount, semanticResult.totalCandidates),
1452
1552
  );
1453
1553
  }
1454
- const filtered = filterNotesByTagScope(semanticResult.notes, tagScope.allowed, tagScope.raw);
1554
+ let filtered = filterNotesByTagScope(semanticResult.notes, tagScope.allowed, tagScope.raw);
1555
+ // Apply the same scoped presence filters as the structured list.
1556
+ filtered = narrowByVisibleAmbiguity(
1557
+ db,
1558
+ filtered,
1559
+ parseBoolOrUndef(parseQuery(url, "has_ambiguous_links")),
1560
+ ambiguityVisibilityFor(db, tagScope),
1561
+ );
1562
+ filtered = narrowByVisibleBrokenness(
1563
+ db,
1564
+ filtered,
1565
+ parseBoolOrUndef(parseQuery(url, "has_broken_links")),
1566
+ ambiguityVisibilityFor(db, tagScope),
1567
+ );
1568
+ filtered = linkOps.narrowByVisibleLinks(
1569
+ db,
1570
+ filtered,
1571
+ parseBoolOrUndef(parseQuery(url, "has_links")),
1572
+ ambiguityVisibilityFor(db, tagScope),
1573
+ );
1455
1574
  const includeContent = parseBool(parseQuery(url, "include_content"), false);
1456
1575
  const contentRange = parseContentRangeQuery(url, includeContent);
1457
1576
  if (contentRange.error) return contentRange.error;
@@ -1477,7 +1596,7 @@ async function handleNotesInner(
1477
1596
  output = output.map((n: any) => filterMetadata(n, inclMeta));
1478
1597
  }
1479
1598
  if (parseBool(parseQuery(url, "include_link_count"), false)) {
1480
- const counts = linkOps.getLinkCounts(db, output.map((n: any) => n.id), parseLinkCountDirection(url));
1599
+ const counts = linkOps.getLinkCounts(db, output.map((n: any) => n.id), parseLinkCountDirection(url), ambiguityVisibilityFor(db, tagScope));
1481
1600
  for (const n of output) n.linkCount = counts.get(n.id) ?? 0;
1482
1601
  }
1483
1602
  return jsonWithWarnings(output, semanticWarnings);
@@ -1646,7 +1765,26 @@ async function handleNotesInner(
1646
1765
  // Tag-scope: drop any result the token isn't permitted to see. Filter
1647
1766
  // happens after the store query so an empty post-filter list still
1648
1767
  // returns 200 [] (consistent with "no matches"), not 403.
1649
- const results = filterNotesByTagScope(rawResults, tagScope.allowed, tagScope.raw);
1768
+ let results = filterNotesByTagScope(rawResults, tagScope.allowed, tagScope.raw);
1769
+ // Apply the same scoped presence filters as the structured list.
1770
+ results = narrowByVisibleAmbiguity(
1771
+ db,
1772
+ results,
1773
+ parseBoolOrUndef(parseQuery(url, "has_ambiguous_links")),
1774
+ ambiguityVisibilityFor(db, tagScope),
1775
+ );
1776
+ results = narrowByVisibleBrokenness(
1777
+ db,
1778
+ results,
1779
+ parseBoolOrUndef(parseQuery(url, "has_broken_links")),
1780
+ ambiguityVisibilityFor(db, tagScope),
1781
+ );
1782
+ results = linkOps.narrowByVisibleLinks(
1783
+ db,
1784
+ results,
1785
+ parseBoolOrUndef(parseQuery(url, "has_links")),
1786
+ ambiguityVisibilityFor(db, tagScope),
1787
+ );
1650
1788
  const includeContent = parseBool(parseQuery(url, "include_content"), false);
1651
1789
  const contentRange = parseContentRangeQuery(url, includeContent);
1652
1790
  if (contentRange.error) return contentRange.error;
@@ -1680,6 +1818,7 @@ async function handleNotesInner(
1680
1818
  db,
1681
1819
  output.map((n: any) => n.id),
1682
1820
  parseLinkCountDirection(url),
1821
+ ambiguityVisibilityFor(db, tagScope),
1683
1822
  );
1684
1823
  for (const n of output) n.linkCount = counts.get(n.id) ?? 0;
1685
1824
  }
@@ -1807,6 +1946,12 @@ async function handleNotesInner(
1807
1946
  parseBoolOrUndef(parseQuery(url, "has_broken_links")),
1808
1947
  ambiguityVisibilityFor(db, tagScope),
1809
1948
  );
1949
+ visible = linkOps.narrowByVisibleLinks(
1950
+ db,
1951
+ visible,
1952
+ parseBoolOrUndef(parseQuery(url, "has_links")),
1953
+ ambiguityVisibilityFor(db, tagScope),
1954
+ );
1810
1955
  // Always run the rollup, even on an empty visible set: ungrouped
1811
1956
  // count (vault#626) must return `[{group:null,value:0}]`, not `[]`.
1812
1957
  rows = await store.aggregateNotes({ ids: visible.map((n) => n.id), aggregate: aggregateParsed.aggregate });
@@ -1980,6 +2125,12 @@ async function handleNotesInner(
1980
2125
  parseBoolOrUndef(parseQuery(url, "has_broken_links")),
1981
2126
  ambiguityVisibilityFor(db, tagScope),
1982
2127
  );
2128
+ results = linkOps.narrowByVisibleLinks(
2129
+ db,
2130
+ results,
2131
+ parseBoolOrUndef(parseQuery(url, "has_links")),
2132
+ ambiguityVisibilityFor(db, tagScope),
2133
+ );
1983
2134
 
1984
2135
  const includeContent = parseBool(parseQuery(url, "include_content"), false);
1985
2136
  const contentRange = parseContentRangeQuery(url, includeContent);
@@ -2059,6 +2210,7 @@ async function handleNotesInner(
2059
2210
  db,
2060
2211
  output.map((n: any) => n.id),
2061
2212
  parseLinkCountDirection(url),
2213
+ ambiguityVisibilityFor(db, tagScope),
2062
2214
  );
2063
2215
  for (const n of output) n.linkCount = counts.get(n.id) ?? 0;
2064
2216
  }
@@ -2770,6 +2922,75 @@ async function handleNotesInner(
2770
2922
  return handleRetryTranscription(store, note, vault);
2771
2923
  }
2772
2924
 
2925
+ const verMatch = sub.match(/^\/versions\/([^/]+)$/);
2926
+ if (sub === "/versions" || verMatch || sub === "/restore") {
2927
+ const note = await resolveNote(store, idOrPath);
2928
+ if (note && !noteWithinTagScope(note, tagScope.allowed, tagScope.raw)) {
2929
+ return json({ error: "Not found", error_type: "not_found" }, 404);
2930
+ }
2931
+ if (!note && (tagScope.raw !== null || !ULID_REGEX.test(idOrPath) ||
2932
+ !(await store.listNoteVersions(idOrPath, { limit: 1 })).length)) {
2933
+ return json({ error: "Not found", error_type: "not_found" }, 404);
2934
+ }
2935
+ const id = note?.id ?? idOrPath;
2936
+ if (sub === "/versions") {
2937
+ if (method === "GET") {
2938
+ const parsedLimit = Number.parseInt(url.searchParams.get("limit") ?? "50", 10);
2939
+ const limit = Number.isNaN(parsedLimit) ? 50 : Math.min(200, Math.max(0, parsedLimit));
2940
+ const offset = Math.max(0, Number.parseInt(url.searchParams.get("offset") ?? "0", 10) || 0);
2941
+ const versions = await store.listNoteVersions(id, { limit, offset });
2942
+ const total = await store.countNoteVersions(id);
2943
+ return json({ versions, total });
2944
+ }
2945
+ if (method === "DELETE") {
2946
+ const result = await store.eraseNoteHistory(id);
2947
+ return json({ erased: true, id, versions_deleted: result.versionsDeleted, blobs_deleted: result.blobsDeleted });
2948
+ }
2949
+ } else if (verMatch && method === "GET") {
2950
+ const ix = Number(verMatch[1]);
2951
+ if (!/^\d+$/.test(verMatch[1]!) || !Number.isInteger(ix) || ix < 0) return json({ error: "Invalid version_ix", error_type: "invalid_request" }, 400);
2952
+ try {
2953
+ const version = await store.getNoteVersion(id, ix);
2954
+ return version ? json(version) : json({ error: "Not found", error_type: "not_found" }, 404);
2955
+ } catch (e) {
2956
+ const response = conflictResponse(e);
2957
+ if (response) return response;
2958
+ throw e;
2959
+ }
2960
+ } else if (sub === "/restore" && method === "POST") {
2961
+ const parsed = await parseJsonBody(req);
2962
+ if (!parsed.ok) return parsed.response;
2963
+ const body = parsed.body;
2964
+ if (body.version_ix === undefined) return json({ error: "version_ix is required", error_type: "missing_required_field" }, 400);
2965
+ if (!Number.isInteger(body.version_ix) || (body.version_ix as number) < 0) return json({ error: "Invalid version_ix", error_type: "invalid_request" }, 400);
2966
+ try {
2967
+ const version = await store.getNoteVersion(id, body.version_ix as number);
2968
+ if (!version) throw new HistoryNotFoundError(id, body.version_ix as number);
2969
+ gateStrictWrite(store, writeCtx, {
2970
+ path: note ? note.path : latestTombstone(db, id)?.path,
2971
+ tags: note?.tags ?? [], metadata: version.metadata,
2972
+ });
2973
+ const restored = await store.restoreNoteVersion(id, body.version_ix as number, {
2974
+ actor: writeCtx.actor, via: writeCtx.via,
2975
+ ...(body.if_updated_at !== undefined ? { if_updated_at: body.if_updated_at as string } : {}),
2976
+ });
2977
+ let validated: any = attachValidationStatus(store, db, restored);
2978
+ validated = scrubNoteTagsByScope(validated, tagScope.allowed, tagScope.raw);
2979
+ if (validated.validation_status) {
2980
+ const vs = scrubValidationStatusByScope(validated.validation_status, tagScope.allowed, tagScope.raw);
2981
+ if (vs === undefined) { const { validation_status: _d, ...rest } = validated; validated = rest; }
2982
+ else validated = { ...validated, validation_status: vs };
2983
+ }
2984
+ return json({ ...validated, restored_from: body.version_ix, recreated: !note });
2985
+ } catch (e: any) {
2986
+ const r = conflictResponse(e);
2987
+ if (r) return r;
2988
+ throw e;
2989
+ }
2990
+ }
2991
+ return json({ error: "Method not allowed", error_type: "method_not_allowed" }, 405);
2992
+ }
2993
+
2773
2994
  if (sub !== "") return json({ error: "Not found", error_type: "not_found" }, 404);
2774
2995
 
2775
2996
  // GET /notes/:idOrPath — single note
@@ -2826,7 +3047,7 @@ async function handleNotesInner(
2826
3047
  result.attachments = await store.getAttachments(note.id);
2827
3048
  }
2828
3049
  if (parseBool(parseQuery(url, "include_link_count"), false)) {
2829
- result.linkCount = linkOps.getLinkCounts(db, [note.id], parseLinkCountDirection(url)).get(note.id) ?? 0;
3050
+ result.linkCount = linkOps.getLinkCounts(db, [note.id], parseLinkCountDirection(url), ambiguityVisibilityFor(db, tagScope)).get(note.id) ?? 0;
2830
3051
  }
2831
3052
  return json(result);
2832
3053
  }
@@ -3304,90 +3525,8 @@ async function handleNotesInner(
3304
3525
  lean.created = false;
3305
3526
  return json(lean);
3306
3527
  } catch (e: any) {
3307
- if (e instanceof NotFoundError) return json({ error: e.message, error_type: "not_found" }, 404);
3308
- // Duck-type on `code` rather than `instanceof ConflictError`: this
3309
- // error originates in the core package and survives any future
3310
- // bundling / module-boundary split more robustly than a prototype check.
3311
- if (e && e.code === "CONFLICT") {
3312
- return json(
3313
- {
3314
- // New structured shape — what agents should key on.
3315
- error_type: "conflict",
3316
- current_updated_at: e.current_updated_at ?? null,
3317
- your_updated_at: e.expected_updated_at,
3318
- path: e.note_path ?? null,
3319
- note_id: e.note_id,
3320
- message: e.message,
3321
- hint: "re-read the note (GET) and re-apply your change against its current `updated_at`, or pass `force: true` to overwrite",
3322
- // Legacy fields — kept for the lens VaultConflictError shim and
3323
- // any other pre-launch callers. Safe to drop post-launch.
3324
- error: "conflict",
3325
- expected_updated_at: e.expected_updated_at,
3326
- },
3327
- 409,
3328
- );
3329
- }
3330
- // State-transition compare-and-set conflict (vault#299 Part B). A
3331
- // DISTINCT error vocabulary from `conflict` (settled lead #3): the
3332
- // field VALUE didn't match `from`, not the updated_at token. 409.
3333
- if (e && e.code === "TRANSITION_CONFLICT") {
3334
- return json(
3335
- {
3336
- error_type: "transition_conflict",
3337
- error: "transition_conflict",
3338
- note_id: e.note_id,
3339
- path: e.note_path ?? null,
3340
- field: e.field,
3341
- expected_from: e.expected_from,
3342
- to: e.to,
3343
- current: e.current ?? null,
3344
- message: e.message,
3345
- hint: "re-read the note's current value for this field and retry the transition from its actual current state",
3346
- },
3347
- 409,
3348
- );
3349
- }
3350
- // Strict-schema rejection (vault#299 Part A). One error carrying ALL
3351
- // per-field violations (settled lead #1). 422 Unprocessable Entity —
3352
- // the note exists / request is well-formed but violates the contract.
3353
- if (e && e.code === "SCHEMA_VALIDATION") {
3354
- return json(
3355
- {
3356
- error_type: "schema_validation",
3357
- error: "schema_validation",
3358
- violations: e.violations ?? [],
3359
- message: e.message,
3360
- hint: "fix every field listed in `violations` and retry — none of this write was applied",
3361
- },
3362
- 422,
3363
- );
3364
- }
3365
- // Path-rename collision — schema's UNIQUE(path) tripped. Issue #126.
3366
- if (e && e.code === "PATH_CONFLICT") {
3367
- return json(
3368
- {
3369
- error_type: "path_conflict",
3370
- error: "path_conflict",
3371
- path: e.path,
3372
- message: e.message,
3373
- hint: "pass a different `path`, or omit it to leave the existing path unchanged",
3374
- },
3375
- 409,
3376
- );
3377
- }
3378
- if (e && e.code === "INVALID_EXTENSION") {
3379
- return json(
3380
- { error_type: "invalid_extension", error: "invalid_extension", extension: e.extension, reason: e.reason, message: e.message },
3381
- 400,
3382
- );
3383
- }
3384
- // Bad `path` value (vault#589 / FIX 2) — NUL byte or `..` segment.
3385
- if (e && e.code === "INVALID_PATH") {
3386
- return json(
3387
- { error_type: "invalid_path", error: "invalid_path", path: e.path, reason: e.reason, message: e.message },
3388
- 400,
3389
- );
3390
- }
3528
+ const r = conflictResponse(e);
3529
+ if (r) return r;
3391
3530
  throw e;
3392
3531
  }
3393
3532
  }
@@ -3401,7 +3540,7 @@ async function handleNotesInner(
3401
3540
  if (!noteWithinTagScope(note, tagScope.allowed, tagScope.raw)) {
3402
3541
  return json({ error: "Not found", error_type: "not_found" }, 404);
3403
3542
  }
3404
- await store.deleteNote(note.id);
3543
+ await store.deleteNote(note.id, { actor: writeCtx.actor, via: writeCtx.via });
3405
3544
  return json({ deleted: true, id: note.id });
3406
3545
  }
3407
3546
 
@@ -3418,6 +3557,7 @@ export async function handleTags(
3418
3557
  store: Store,
3419
3558
  subpath = "",
3420
3559
  tagScope: TagScopeCtx = NO_TAG_SCOPE,
3560
+ writeCtx: WriteCtx = NO_WRITE_CTX,
3421
3561
  ): Promise<Response> {
3422
3562
  const url = new URL(req.url);
3423
3563
 
@@ -3566,7 +3706,7 @@ export async function handleTags(
3566
3706
  // other surface). The old fail-closed token-reference check has been
3567
3707
  // removed; the cascade rewrites the JSON allowlist atomically. See
3568
3708
  // notes.ts:renameTag for the surfaces touched.
3569
- const result = await store.renameTag(oldName, newName);
3709
+ const result = await store.renameTag(oldName, newName, { actor: writeCtx.actor ?? undefined, via: writeCtx.via ?? undefined });
3570
3710
  if ("error" in result) {
3571
3711
  if (result.error === "not_found") {
3572
3712
  return json({ error: "not_found", error_type: "tag_not_found", tag: oldName }, 404);
@@ -4264,9 +4404,16 @@ export function handleUnresolvedWikilinks(
4264
4404
  // target); the two tables are disjoint by construction, so this only
4265
4405
  // guards against a stale row.
4266
4406
  const seen = new Set(filtered.map((r) => `${r.source_id}\u0000${r.relationship}\u0000${r.target_path.toLowerCase()}`));
4267
- const collapsed = listZeroVisibleAmbiguousAsUnresolved(db, ambiguityVisibilityFor(db, tagScope)!, limit)
4268
- .filter((row) => inScope(row)
4269
- && !seen.has(`${row.source_id}\u0000${row.relationship}\u0000${row.target_path.toLowerCase()}`));
4407
+ const visible = ambiguityVisibilityFor(db, tagScope)!;
4408
+ const collapsed = [
4409
+ ...listZeroVisibleAmbiguousAsUnresolved(db, visible, limit),
4410
+ ...listResolvedToInvisibleAsUnresolved(db, visible, limit),
4411
+ ].filter((row) => {
4412
+ const key = `${row.source_id}\u0000${row.relationship}\u0000${row.target_path.toLowerCase()}`;
4413
+ if (!inScope(row) || seen.has(key)) return false;
4414
+ seen.add(key);
4415
+ return true;
4416
+ });
4270
4417
 
4271
4418
  const merged = [...filtered, ...collapsed].slice(0, limit);
4272
4419
  return Response.json({ unresolved: merged, count: merged.length });
@@ -5082,3 +5229,37 @@ function removeWikilinkBrackets(content: string, targetPath: string): string {
5082
5229
  content = content.replace(new RegExp(`\\[\\[${escaped}(#[^\\]]+)?\\]\\]`, "gi"), `${targetPath}$1`);
5083
5230
  return content;
5084
5231
  }
5232
+
5233
+ /** POST /api/history/compact — unscoped administrators; absent bounds are unbounded. */
5234
+ export async function handleHistoryCompact(req: Request, store: Store, tagScope: TagScopeCtx = NO_TAG_SCOPE): Promise<Response> {
5235
+ if (tagScope.raw !== null)
5236
+ return json({ error: "Not found", error_type: "not_found" }, 404);
5237
+ const declared = Number(req.headers.get("content-length"));
5238
+ if (declared > MAX_JSON_BODY_BYTES)
5239
+ return payloadTooLargeResponse(declared);
5240
+ let body: Record<string, unknown>;
5241
+ try {
5242
+ const raw = await req.text();
5243
+ if (new TextEncoder().encode(raw).length > MAX_JSON_BODY_BYTES)
5244
+ return payloadTooLargeResponse(new TextEncoder().encode(raw).length);
5245
+ const parsed = raw.trim() ? JSON.parse(raw) : {};
5246
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
5247
+ throw new Error("object required");
5248
+ body = parsed;
5249
+ }
5250
+ catch {
5251
+ return json({ error: "Invalid compaction request", error_type: "invalid_request" }, 400);
5252
+ }
5253
+ if ((body.note_id !== undefined && (typeof body.note_id !== "string" || body.note_id.length === 0)) ||
5254
+ [body.budget_ms, body.max_notes].some(v => v !== undefined && (typeof v !== "number" || !Number.isInteger(v) || v < 1)))
5255
+ return json({ error: "Invalid compaction request", error_type: "invalid_request" }, 400);
5256
+ try {
5257
+ 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 }));
5258
+ }
5259
+ catch (err) {
5260
+ const conflict = conflictResponse(err);
5261
+ if (conflict)
5262
+ return conflict;
5263
+ return json({ error: "Compaction failed", error_type: "compaction_failed", message: err instanceof Error ? err.message : String(err) }, 500);
5264
+ }
5265
+ }
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(