@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.
- 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.test.ts +229 -0
- package/core/src/history.ts +679 -0
- package/core/src/link-count.test.ts +29 -0
- package/core/src/links.ts +43 -0
- package/core/src/mcp-manifest.ts +17 -6
- package/core/src/mcp.ts +42 -14
- 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.ts +202 -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/core/src/wikilinks.ts +68 -1
- package/package.json +1 -1
- package/src/config.ts +42 -0
- package/src/mcp-tools.ts +12 -2
- package/src/release-plan.test.ts +90 -1
- package/src/routes.ts +276 -95
- package/src/routing.ts +8 -3
- package/src/test-support/vault-714-find-path.json +58 -0
- package/src/test-support/vault-714-graph.json +27 -0
- package/src/test-support/vault-714-has_links.json +96 -0
- package/src/test-support/vault-714-include_broken_links.json +118 -0
- package/src/test-support/vault-714-include_link_count.json +348 -0
- package/src/test-support/vault-714-include_links.json +86 -0
- package/src/test-support/vault-714-near.json +78 -0
- package/src/test-support/vault-714-unresolved-wikilinks.json +6 -0
- package/src/vault-compact-routes.test.ts +257 -0
- package/src/vault-history-routes.test.ts +562 -0
- package/src/vault-store.ts +11 -0
- package/src/vault.test.ts +243 -1
package/src/release-plan.test.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3308
|
-
|
|
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
|
|
4268
|
-
|
|
4269
|
-
|
|
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
|
-
|
|
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(
|