@openparachute/vault 0.7.9-rc.2 → 0.7.9-rc.4
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/core.test.ts +546 -0
- package/core/src/cursor.ts +1 -0
- package/core/src/link-count.test.ts +29 -0
- package/core/src/links.ts +43 -0
- package/core/src/mcp-manifest.ts +10 -6
- package/core/src/mcp.ts +129 -44
- package/core/src/notes.ts +51 -2
- package/core/src/schema-v28-unresolved-wikilinks.test.ts +103 -0
- package/core/src/schema.ts +28 -1
- package/core/src/store.ts +180 -57
- package/core/src/txn.test.ts +33 -1
- package/core/src/txn.ts +80 -7
- package/core/src/types.ts +11 -0
- package/core/src/vault-projection.ts +8 -1
- package/core/src/wikilinks.ts +873 -44
- package/package.json +2 -2
- package/src/aggregate-routes.test.ts +76 -0
- package/src/config.ts +8 -2
- package/src/mcp-http.test.ts +51 -1
- package/src/mcp-tools.ts +106 -5
- package/src/mirror-routes.test.ts +47 -0
- package/src/release-plan.test.ts +90 -1
- package/src/routes.ts +280 -66
- package/src/routing.test.ts +67 -0
- package/src/tag-scope-query-tag.test.ts +374 -0
- package/src/tag-scope.ts +116 -0
- 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.test.ts +928 -1
- package/src/write-warnings-scope.test.ts +344 -0
- package/src/ws-server.ts +16 -3
- package/src/ws-subscribe.test.ts +76 -2
package/core/src/core.test.ts
CHANGED
|
@@ -9,6 +9,7 @@ import * as indexedFieldOps from "./indexed-fields.js";
|
|
|
9
9
|
import { resolveLinkTarget } from "./wikilinks.js";
|
|
10
10
|
import { generateUlid, ULID_REGEX } from "./ulid.js";
|
|
11
11
|
import { getVaultMap, extractH1Title, findNotesByTitle, getNoteByTitle, validatePath, PathValidationError } from "./notes.js";
|
|
12
|
+
import { transactionAsync } from "./txn.js";
|
|
12
13
|
|
|
13
14
|
let store: SqliteStore;
|
|
14
15
|
let db: Database;
|
|
@@ -63,6 +64,51 @@ describe("notes", async () => {
|
|
|
63
64
|
expect(updated.updatedAt).toBeTruthy();
|
|
64
65
|
});
|
|
65
66
|
|
|
67
|
+
it("rolls back the note row when wikilink re-indexing fails mid-update", async () => {
|
|
68
|
+
const source = await store.createNote("before", { path: "Source" });
|
|
69
|
+
await store.createNote("target", { path: "Target" });
|
|
70
|
+
db.exec(`
|
|
71
|
+
CREATE TRIGGER fail_link_insert
|
|
72
|
+
BEFORE INSERT ON links
|
|
73
|
+
BEGIN
|
|
74
|
+
SELECT RAISE(ABORT, 'forced mid-update failure');
|
|
75
|
+
END;
|
|
76
|
+
`);
|
|
77
|
+
|
|
78
|
+
await expect(
|
|
79
|
+
store.updateNote(source.id, { content: "after [[Target]]" }),
|
|
80
|
+
).rejects.toThrow("forced mid-update failure");
|
|
81
|
+
|
|
82
|
+
const unchanged = await store.getNote(source.id);
|
|
83
|
+
expect(unchanged?.content).toBe("before");
|
|
84
|
+
expect(unchanged?.updatedAt).toBe(source.updatedAt);
|
|
85
|
+
expect(await store.getLinks(source.id)).toEqual([]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("does not dispatch update hooks for an outer batch that rolls back", async () => {
|
|
89
|
+
const first = await store.createNote("first");
|
|
90
|
+
const second = await store.createNote("second");
|
|
91
|
+
const fired: string[] = [];
|
|
92
|
+
store.hooks.onNote({
|
|
93
|
+
event: "updated",
|
|
94
|
+
handler: (note) => { fired.push(note.id); },
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
await expect(transactionAsync(db, async () => {
|
|
98
|
+
await store.updateNote(first.id, { content: "changed first" });
|
|
99
|
+
await store.updateNote(second.id, { content: "changed second" });
|
|
100
|
+
throw new Error("forced outer rollback");
|
|
101
|
+
})).rejects.toThrow("forced outer rollback");
|
|
102
|
+
// Let any incorrectly queued dispatches reach the registry before drain.
|
|
103
|
+
await Promise.resolve();
|
|
104
|
+
await Promise.resolve();
|
|
105
|
+
await store.hooks.drain();
|
|
106
|
+
|
|
107
|
+
expect(fired).toEqual([]);
|
|
108
|
+
expect((await store.getNote(first.id))?.content).toBe("first");
|
|
109
|
+
expect((await store.getNote(second.id))?.content).toBe("second");
|
|
110
|
+
});
|
|
111
|
+
|
|
66
112
|
it("updates note path", async () => {
|
|
67
113
|
const note = await store.createNote("Test");
|
|
68
114
|
const updated = await store.updateNote(note.id, { path: "Notes/Test" });
|
|
@@ -1049,6 +1095,33 @@ describe("mergeTags", async () => {
|
|
|
1049
1095
|
expect(result.merged).toEqual({ v1: 1 });
|
|
1050
1096
|
expect((await store.getNote(note.id))!.tags).toEqual(["voice"]);
|
|
1051
1097
|
});
|
|
1098
|
+
|
|
1099
|
+
it("bumps updated_at on notes whose tags actually changed (vault#567)", async () => {
|
|
1100
|
+
const affected = await store.createNote("A", { tags: ["v1"] });
|
|
1101
|
+
const both = await store.createNote("B", { tags: ["v1", "voice"] });
|
|
1102
|
+
const untouched = await store.createNote("C", { tags: ["voice"] });
|
|
1103
|
+
const unrelated = await store.createNote("D", { tags: ["other"] });
|
|
1104
|
+
const affectedAt = affected.updatedAt;
|
|
1105
|
+
const bothAt = both.updatedAt;
|
|
1106
|
+
const untouchedAt = untouched.updatedAt;
|
|
1107
|
+
const unrelatedAt = unrelated.updatedAt;
|
|
1108
|
+
|
|
1109
|
+
// Wall-clock ISO timestamps are millisecond-resolution; wait so the
|
|
1110
|
+
// bump cannot collide with the create timestamp.
|
|
1111
|
+
await Bun.sleep(5);
|
|
1112
|
+
await store.mergeTags(["v1"], "voice");
|
|
1113
|
+
|
|
1114
|
+
const affectedAfter = (await store.getNote(affected.id))!;
|
|
1115
|
+
const bothAfter = (await store.getNote(both.id))!;
|
|
1116
|
+
const untouchedAfter = (await store.getNote(untouched.id))!;
|
|
1117
|
+
const unrelatedAfter = (await store.getNote(unrelated.id))!;
|
|
1118
|
+
expect(affectedAfter.updatedAt > affectedAt).toBe(true);
|
|
1119
|
+
expect(bothAfter.updatedAt > bothAt).toBe(true);
|
|
1120
|
+
expect(untouchedAfter.updatedAt).toBe(untouchedAt);
|
|
1121
|
+
expect(unrelatedAfter.updatedAt).toBe(unrelatedAt);
|
|
1122
|
+
expect(affectedAfter.tags).toEqual(["voice"]);
|
|
1123
|
+
expect(bothAfter.tags).toEqual(["voice"]);
|
|
1124
|
+
});
|
|
1052
1125
|
});
|
|
1053
1126
|
|
|
1054
1127
|
// ---- Vault Stats ----
|
|
@@ -4235,6 +4308,461 @@ describe("MCP tools", async () => {
|
|
|
4235
4308
|
expect(byId.get("mq-list-c")).toEqual([]);
|
|
4236
4309
|
});
|
|
4237
4310
|
|
|
4311
|
+
// ---- has_ambiguous_links / include_ambiguous_links (vault#581) ----
|
|
4312
|
+
//
|
|
4313
|
+
// Symmetric with the has_broken_links block above. Before #581 an
|
|
4314
|
+
// ambiguous target was visible ONLY in the transient write-time
|
|
4315
|
+
// `ambiguous_link` warning — nothing was persisted, so `has_broken_links`
|
|
4316
|
+
// did not match the note (it was classified CLEAN by
|
|
4317
|
+
// `has_broken_links: false`) and a later audit could not find it.
|
|
4318
|
+
|
|
4319
|
+
it("query-notes has_ambiguous_links=true surfaces only notes whose wikilink matched two notes", async () => {
|
|
4320
|
+
await store.createNote("first", { id: "mq-amb-c1", path: "one/Dup" });
|
|
4321
|
+
await store.createNote("second", { id: "mq-amb-c2", path: "two/Dup" });
|
|
4322
|
+
await store.createNote("see [[Dup]]", { id: "mq-amb-src", path: "mq-amb-src" });
|
|
4323
|
+
await store.createNote("no links here", { id: "mq-amb-clean", path: "mq-amb-clean" });
|
|
4324
|
+
|
|
4325
|
+
const tools = generateMcpTools(store);
|
|
4326
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
4327
|
+
const result = await query.execute({ has_ambiguous_links: true, include_content: true }) as any[];
|
|
4328
|
+
expect(result.map((n) => n.path)).toEqual(["mq-amb-src"]);
|
|
4329
|
+
});
|
|
4330
|
+
|
|
4331
|
+
it("query-notes has_ambiguous_links=false excludes notes with an ambiguous link", async () => {
|
|
4332
|
+
await store.createNote("first", { path: "a/Twin" });
|
|
4333
|
+
await store.createNote("second", { path: "b/Twin" });
|
|
4334
|
+
await store.createNote("see [[Twin]]", { id: "mq-amb-src2", path: "mq-amb-src2" });
|
|
4335
|
+
await store.createNote("clean", { id: "mq-amb-clean2", path: "mq-amb-clean2" });
|
|
4336
|
+
|
|
4337
|
+
const tools = generateMcpTools(store);
|
|
4338
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
4339
|
+
const result = await query.execute({ has_ambiguous_links: false, include_content: true }) as any[];
|
|
4340
|
+
expect(result.map((n) => n.path).sort()).toEqual(["a/Twin", "b/Twin", "mq-amb-clean2"]);
|
|
4341
|
+
});
|
|
4342
|
+
|
|
4343
|
+
it("an ambiguous link is NOT counted as a broken link (the two filters are disjoint)", async () => {
|
|
4344
|
+
await store.createNote("first", { path: "x/Both" });
|
|
4345
|
+
await store.createNote("second", { path: "y/Both" });
|
|
4346
|
+
await store.createNote("see [[Both]]", { id: "mq-amb-disjoint", path: "mq-amb-disjoint" });
|
|
4347
|
+
|
|
4348
|
+
const tools = generateMcpTools(store);
|
|
4349
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
4350
|
+
expect((await query.execute({ has_broken_links: true }) as any[]).map((n: any) => n.id)).toEqual([]);
|
|
4351
|
+
expect((await query.execute({ has_ambiguous_links: true }) as any[]).map((n: any) => n.id)).toEqual(["mq-amb-disjoint"]);
|
|
4352
|
+
});
|
|
4353
|
+
|
|
4354
|
+
it("query-notes has_ambiguous_links is safe on a vault where no link has ever been ambiguous", async () => {
|
|
4355
|
+
// Fresh store, beforeEach — the ambiguous_wikilinks table has never been
|
|
4356
|
+
// created. true should match nothing (not throw); false should be a
|
|
4357
|
+
// no-op (matches everything). Mirrors the has_broken_links pin above.
|
|
4358
|
+
await store.createNote("plain note", { id: "mq-amb-none", path: "mq-amb-none" });
|
|
4359
|
+
const tools = generateMcpTools(store);
|
|
4360
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
4361
|
+
const truthy = await query.execute({ has_ambiguous_links: true, include_content: true }) as any[];
|
|
4362
|
+
expect(truthy).toEqual([]);
|
|
4363
|
+
const falsy = await query.execute({ has_ambiguous_links: false, include_content: true }) as any[];
|
|
4364
|
+
expect(falsy.map((n) => n.path)).toEqual(["mq-amb-none"]);
|
|
4365
|
+
});
|
|
4366
|
+
|
|
4367
|
+
it("query-notes include_ambiguous_links surfaces {target, relationship, candidate_count} for a single note", async () => {
|
|
4368
|
+
await store.createNote("first", { path: "p/Ghost" });
|
|
4369
|
+
await store.createNote("second", { path: "q/Ghost" });
|
|
4370
|
+
await store.createNote("see [[Ghost]]", { id: "mq-amb-single", path: "mq-amb-single" });
|
|
4371
|
+
|
|
4372
|
+
const tools = generateMcpTools(store);
|
|
4373
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
4374
|
+
const result = await query.execute({ id: "mq-amb-single", include_ambiguous_links: true }) as any;
|
|
4375
|
+
expect(result.ambiguous_links).toEqual([{ target: "Ghost", relationship: "wikilink", candidate_count: 2 }]);
|
|
4376
|
+
});
|
|
4377
|
+
|
|
4378
|
+
it("query-notes include_ambiguous_links surfaces a structured link's ambiguous_link entry too", async () => {
|
|
4379
|
+
await store.createNote("first", { path: "s1/Fork" });
|
|
4380
|
+
await store.createNote("second", { path: "s2/Fork" });
|
|
4381
|
+
const created = await generateMcpTools(store).find((t) => t.name === "create-note")!.execute({
|
|
4382
|
+
content: "body",
|
|
4383
|
+
path: "mq-amb-structured",
|
|
4384
|
+
links: [{ target: "Fork", relationship: "depends-on" }],
|
|
4385
|
+
}) as any;
|
|
4386
|
+
expect(created.warnings?.[0]?.code).toBe("ambiguous_link");
|
|
4387
|
+
|
|
4388
|
+
const tools = generateMcpTools(store);
|
|
4389
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
4390
|
+
const result = await query.execute({ id: created.id, include_ambiguous_links: true }) as any;
|
|
4391
|
+
expect(result.ambiguous_links).toEqual([{ target: "Fork", relationship: "depends-on", candidate_count: 2 }]);
|
|
4392
|
+
expect((await query.execute({ has_ambiguous_links: true }) as any[]).map((n: any) => n.id)).toEqual([created.id]);
|
|
4393
|
+
});
|
|
4394
|
+
|
|
4395
|
+
it("query-notes include_ambiguous_links is [] for a note with no ambiguous links", async () => {
|
|
4396
|
+
await store.createNote("clean", { id: "mq-amb-clean-single", path: "mq-amb-clean-single" });
|
|
4397
|
+
const tools = generateMcpTools(store);
|
|
4398
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
4399
|
+
const result = await query.execute({ id: "mq-amb-clean-single", include_ambiguous_links: true }) as any;
|
|
4400
|
+
expect(result.ambiguous_links).toEqual([]);
|
|
4401
|
+
});
|
|
4402
|
+
|
|
4403
|
+
it("query-notes include_ambiguous_links works in list mode, batched across the page", async () => {
|
|
4404
|
+
await store.createNote("first", { path: "l1/Echo" });
|
|
4405
|
+
await store.createNote("second", { path: "l2/Echo" });
|
|
4406
|
+
await store.createNote("see [[Echo]]", { id: "mq-amb-list-a", path: "mq-amb-list-a" });
|
|
4407
|
+
await store.createNote("also [[Echo]]", { id: "mq-amb-list-b", path: "mq-amb-list-b" });
|
|
4408
|
+
await store.createNote("clean", { id: "mq-amb-list-c", path: "mq-amb-list-c" });
|
|
4409
|
+
|
|
4410
|
+
const tools = generateMcpTools(store);
|
|
4411
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
4412
|
+
const result = await query.execute({ include_ambiguous_links: true, include_content: true }) as any[];
|
|
4413
|
+
const byId = new Map(result.map((n: any) => [n.id, n.ambiguous_links]));
|
|
4414
|
+
expect(byId.get("mq-amb-list-a")).toEqual([{ target: "Echo", relationship: "wikilink", candidate_count: 2 }]);
|
|
4415
|
+
expect(byId.get("mq-amb-list-b")).toEqual([{ target: "Echo", relationship: "wikilink", candidate_count: 2 }]);
|
|
4416
|
+
expect(byId.get("mq-amb-list-c")).toEqual([]);
|
|
4417
|
+
});
|
|
4418
|
+
|
|
4419
|
+
// ---- self-healing: ambiguity must resolve when it stops being ambiguous ----
|
|
4420
|
+
|
|
4421
|
+
it("deleting one colliding candidate resolves the ambiguity — link created, note drops out of has_ambiguous_links", async () => {
|
|
4422
|
+
const keep = await store.createNote("first", { path: "keep/Heal" });
|
|
4423
|
+
const drop = await store.createNote("second", { path: "drop/Heal" });
|
|
4424
|
+
const src = await store.createNote("see [[Heal]]", { id: "mq-amb-heal", path: "mq-amb-heal" });
|
|
4425
|
+
|
|
4426
|
+
const tools = generateMcpTools(store);
|
|
4427
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
4428
|
+
expect((await query.execute({ has_ambiguous_links: true }) as any[]).length).toBe(1);
|
|
4429
|
+
|
|
4430
|
+
await store.deleteNote(drop.id);
|
|
4431
|
+
expect((await query.execute({ has_ambiguous_links: true }) as any[]).length).toBe(0);
|
|
4432
|
+
const links = await query.execute({ id: src.id, include_links: true }) as any;
|
|
4433
|
+
expect(links.links.map((l: any) => l.targetId)).toEqual([keep.id]);
|
|
4434
|
+
});
|
|
4435
|
+
|
|
4436
|
+
it("renaming one colliding candidate out of the way resolves a structured link's ambiguity", async () => {
|
|
4437
|
+
// Structured links (and typed `reference` fields) are the clean rename
|
|
4438
|
+
// case: unlike a content [[wikilink]], nothing rewrites the stored
|
|
4439
|
+
// target string, so the sweep is the only thing that can heal it.
|
|
4440
|
+
const keep = await store.createNote("first", { path: "keep/Fork" });
|
|
4441
|
+
const moved = await store.createNote("second", { path: "move/Fork" });
|
|
4442
|
+
const created = await generateMcpTools(store).find((t) => t.name === "create-note")!.execute({
|
|
4443
|
+
content: "body", path: "mq-amb-rename", links: [{ target: "Fork", relationship: "depends-on" }],
|
|
4444
|
+
}) as any;
|
|
4445
|
+
|
|
4446
|
+
const tools = generateMcpTools(store);
|
|
4447
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
4448
|
+
expect((await query.execute({ has_ambiguous_links: true }) as any[]).map((n: any) => n.id)).toEqual([created.id]);
|
|
4449
|
+
|
|
4450
|
+
await store.updateNote(moved.id, { path: "move/Elsewhere" });
|
|
4451
|
+
expect((await query.execute({ has_ambiguous_links: true }) as any[]).length).toBe(0);
|
|
4452
|
+
const links = await query.execute({ id: created.id, include_links: true }) as any;
|
|
4453
|
+
expect(links.links.map((l: any) => [l.targetId, l.relationship])).toEqual([[keep.id, "depends-on"]]);
|
|
4454
|
+
});
|
|
4455
|
+
|
|
4456
|
+
it("renaming a colliding candidate also clears a content wikilink's ambiguity (via cascadeRename)", async () => {
|
|
4457
|
+
// vault#708 — an AMBIGUOUS `[[Rename]]` never pointed at the renamed
|
|
4458
|
+
// note (it pointed at nothing: two candidates, no `links` row), so the
|
|
4459
|
+
// cascade must leave the text alone and let the vault#581 sweep heal it.
|
|
4460
|
+
// With `move/Rename` moved away, `keep/Rename` is the only candidate
|
|
4461
|
+
// left, so the bracket resolves to KEEP. Before vault#708 the cascade
|
|
4462
|
+
// rewrote the text to `[[Elsewhere]]` on a basename match and the link
|
|
4463
|
+
// followed the note that moved away — the bug this pin used to record.
|
|
4464
|
+
const keep = await store.createNote("first", { path: "keep/Rename" });
|
|
4465
|
+
const moved = await store.createNote("second", { path: "move/Rename" });
|
|
4466
|
+
const src = await store.createNote("see [[Rename]]", { id: "mq-amb-rename2", path: "mq-amb-rename2" });
|
|
4467
|
+
|
|
4468
|
+
const tools = generateMcpTools(store);
|
|
4469
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
4470
|
+
expect((await query.execute({ has_ambiguous_links: true }) as any[]).length).toBe(1);
|
|
4471
|
+
|
|
4472
|
+
await store.updateNote(moved.id, { path: "move/Elsewhere" });
|
|
4473
|
+
expect((await query.execute({ has_ambiguous_links: true }) as any[]).length).toBe(0);
|
|
4474
|
+
expect((await store.getNote(src.id))!.content).toBe("see [[Rename]]");
|
|
4475
|
+
const links = await query.execute({ id: src.id, include_links: true }) as any;
|
|
4476
|
+
expect(links.links.map((l: any) => l.targetId)).toEqual([keep.id]);
|
|
4477
|
+
});
|
|
4478
|
+
|
|
4479
|
+
// ---- vault#239: the visibility model reaches the BROKEN-links surface ----
|
|
4480
|
+
//
|
|
4481
|
+
// vault#581's auth review made the AMBIGUOUS surface answer on a scoped
|
|
4482
|
+
// reader's own sub-vault (>=2 VISIBLE candidates). The same question has a
|
|
4483
|
+
// second answer core must give: 0 visible candidates means the reference is
|
|
4484
|
+
// BROKEN for that reader — whichever table its row currently lives in.
|
|
4485
|
+
//
|
|
4486
|
+
// Without this, `refreshAmbiguousLinks`' delete-time demotion into
|
|
4487
|
+
// `unresolved_wikilinks` is the ONLY thing that makes such a note "broken",
|
|
4488
|
+
// so the answer flips purely because notes the reader can't see were
|
|
4489
|
+
// deleted. Core stays scope-unaware: it just invokes the injected
|
|
4490
|
+
// `ambiguityVisible` closure, exactly as the ambiguity surface does.
|
|
4491
|
+
|
|
4492
|
+
/** `[[Dup]]` with two candidates, neither of which `visibleIds` contains. */
|
|
4493
|
+
async function hiddenCandidates(prefix: string) {
|
|
4494
|
+
const h1 = await store.createNote("hidden one", { path: `${prefix}-p1/Dup` });
|
|
4495
|
+
const h2 = await store.createNote("hidden two", { path: `${prefix}-p2/Dup` });
|
|
4496
|
+
const src = await store.createNote("see [[Dup]]", { id: `${prefix}-src`, path: `${prefix}-src` });
|
|
4497
|
+
return { h1, h2, src, hidden: new Set([h1.id, h2.id]) };
|
|
4498
|
+
}
|
|
4499
|
+
|
|
4500
|
+
it("include_broken_links reports a target whose only candidates fail the visibility predicate", async () => {
|
|
4501
|
+
const { src, hidden } = await hiddenCandidates("mq-vis-brk");
|
|
4502
|
+
const tools = generateMcpTools(store, { ambiguityVisible: (id: string) => !hidden.has(id) });
|
|
4503
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
4504
|
+
|
|
4505
|
+
const result = await query.execute({ id: src.id, include_broken_links: true }) as any;
|
|
4506
|
+
expect(result.broken_links).toEqual([{ target: "Dup", relationship: "wikilink" }]);
|
|
4507
|
+
// ...and it is NOT also reported as ambiguous (the surfaces stay disjoint).
|
|
4508
|
+
expect((await query.execute({ id: src.id, include_ambiguous_links: true }) as any).ambiguous_links)
|
|
4509
|
+
.toEqual([]);
|
|
4510
|
+
});
|
|
4511
|
+
|
|
4512
|
+
it("has_broken_links answers on the visible sub-vault and does not move when a hidden candidate is deleted", async () => {
|
|
4513
|
+
const { src, h1, h2, hidden } = await hiddenCandidates("mq-vis-heal");
|
|
4514
|
+
const tools = generateMcpTools(store, { ambiguityVisible: (id: string) => !hidden.has(id) });
|
|
4515
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
4516
|
+
const snapshot = async () => ({
|
|
4517
|
+
truthy: ((await query.execute({ has_broken_links: true })) as any[]).map((n: any) => n.id),
|
|
4518
|
+
falsy: ((await query.execute({ has_broken_links: false })) as any[]).map((n: any) => n.id),
|
|
4519
|
+
broken: (await query.execute({ id: src.id, include_broken_links: true }) as any).broken_links,
|
|
4520
|
+
});
|
|
4521
|
+
|
|
4522
|
+
const before = await snapshot();
|
|
4523
|
+
expect(before.truthy).toContain(src.id);
|
|
4524
|
+
expect(before.falsy).not.toContain(src.id);
|
|
4525
|
+
|
|
4526
|
+
await store.deleteNote(h1.id);
|
|
4527
|
+
await store.deleteNote(h2.id);
|
|
4528
|
+
|
|
4529
|
+
const after = await snapshot();
|
|
4530
|
+
expect(after.truthy).toContain(src.id);
|
|
4531
|
+
expect(after.falsy).not.toContain(src.id);
|
|
4532
|
+
expect(after.broken).toEqual(before.broken);
|
|
4533
|
+
});
|
|
4534
|
+
|
|
4535
|
+
it("a target with ONE visible candidate is neither broken nor ambiguous (negative control)", async () => {
|
|
4536
|
+
// Guards against a fix that just reports every ambiguous row as broken:
|
|
4537
|
+
// one candidate survives the predicate, so the reference RESOLVES in the
|
|
4538
|
+
// reader's sub-vault.
|
|
4539
|
+
const keep = await store.createNote("visible", { path: "mq-vis-one-a/Dup" });
|
|
4540
|
+
const hide = await store.createNote("hidden", { path: "mq-vis-one-b/Dup" });
|
|
4541
|
+
const src = await store.createNote("see [[Dup]]", { id: "mq-vis-one-src", path: "mq-vis-one-src" });
|
|
4542
|
+
|
|
4543
|
+
const tools = generateMcpTools(store, { ambiguityVisible: (id: string) => id !== hide.id });
|
|
4544
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
4545
|
+
const result = await query.execute({
|
|
4546
|
+
id: src.id, include_broken_links: true, include_ambiguous_links: true,
|
|
4547
|
+
}) as any;
|
|
4548
|
+
expect(result.broken_links).toEqual([]);
|
|
4549
|
+
expect(result.ambiguous_links).toEqual([]);
|
|
4550
|
+
expect(((await query.execute({ has_broken_links: true })) as any[]).map((n: any) => n.id))
|
|
4551
|
+
.not.toContain(src.id);
|
|
4552
|
+
expect(keep.id).toBeTruthy();
|
|
4553
|
+
});
|
|
4554
|
+
|
|
4555
|
+
it("a STALE ambiguous row that resolves cleanly again is not reported broken", async () => {
|
|
4556
|
+
// A `WikilinkResolution` that RESOLVED carries an empty `candidates`
|
|
4557
|
+
// array, which a naive "count the visible candidates" check would read
|
|
4558
|
+
// as 0 == broken. The row goes stale through the documented vault#581
|
|
4559
|
+
// gap: ambiguity via the H1-TITLE fallback, cleared by editing one
|
|
4560
|
+
// title, which does not re-run the sweep (only a path change does).
|
|
4561
|
+
await store.createNote("# Twin\n\nfirst", { id: "mq-vis-stale-a", path: "mq-vis-stale-a" });
|
|
4562
|
+
const b = await store.createNote("# Twin\n\nsecond", { id: "mq-vis-stale-b", path: "mq-vis-stale-b" });
|
|
4563
|
+
const src = await store.createNote("see [[Twin]]", { id: "mq-vis-stale-src", path: "mq-vis-stale-src" });
|
|
4564
|
+
|
|
4565
|
+
const unscoped = generateMcpTools(store).find((t) => t.name === "query-notes")!;
|
|
4566
|
+
expect((await unscoped.execute({ id: src.id, include_ambiguous_links: true }) as any).ambiguous_links)
|
|
4567
|
+
.toEqual([{ target: "Twin", relationship: "wikilink", candidate_count: 2 }]);
|
|
4568
|
+
|
|
4569
|
+
await store.updateNote(b.id, { content: "# Other\n\nsecond" });
|
|
4570
|
+
|
|
4571
|
+
// Row still says "ambiguous"; the target now resolves to the ONE
|
|
4572
|
+
// remaining, visible note — so neither surface may report it.
|
|
4573
|
+
const query = generateMcpTools(store, { ambiguityVisible: () => true })
|
|
4574
|
+
.find((t) => t.name === "query-notes")!;
|
|
4575
|
+
const result = await query.execute({
|
|
4576
|
+
id: src.id, include_broken_links: true, include_ambiguous_links: true,
|
|
4577
|
+
}) as any;
|
|
4578
|
+
expect(result.broken_links).toEqual([]);
|
|
4579
|
+
expect(result.ambiguous_links).toEqual([]);
|
|
4580
|
+
});
|
|
4581
|
+
|
|
4582
|
+
it("NO predicate injected → broken-links answers from the persisted tables only (regression)", async () => {
|
|
4583
|
+
const { src, h1, h2 } = await hiddenCandidates("mq-vis-unscoped");
|
|
4584
|
+
const query = generateMcpTools(store).find((t) => t.name === "query-notes")!;
|
|
4585
|
+
const read = async () => await query.execute({
|
|
4586
|
+
id: src.id, include_broken_links: true, include_ambiguous_links: true,
|
|
4587
|
+
}) as any;
|
|
4588
|
+
|
|
4589
|
+
const before = await read();
|
|
4590
|
+
expect(before.broken_links).toEqual([]);
|
|
4591
|
+
expect(before.ambiguous_links)
|
|
4592
|
+
.toEqual([{ target: "Dup", relationship: "wikilink", candidate_count: 2 }]);
|
|
4593
|
+
expect(((await query.execute({ has_broken_links: true })) as any[]).map((n: any) => n.id))
|
|
4594
|
+
.not.toContain(src.id);
|
|
4595
|
+
|
|
4596
|
+
await store.deleteNote(h1.id);
|
|
4597
|
+
await store.deleteNote(h2.id);
|
|
4598
|
+
|
|
4599
|
+
const after = await read();
|
|
4600
|
+
expect(after.broken_links).toEqual([{ target: "Dup", relationship: "wikilink" }]);
|
|
4601
|
+
expect(after.ambiguous_links).toEqual([]);
|
|
4602
|
+
});
|
|
4603
|
+
|
|
4604
|
+
// ---- vault#708: the rename cascade follows resolution, not basenames ----
|
|
4605
|
+
|
|
4606
|
+
it("cascadeRename leaves an unambiguous full-path bracket aimed at the OTHER same-named note alone", async () => {
|
|
4607
|
+
const keep = await store.createNote("first", { path: "keep/Rename" });
|
|
4608
|
+
const moved = await store.createNote("second", { path: "move/Rename" });
|
|
4609
|
+
const src = await store.createNote("see [[keep/Rename]]", { id: "c708-other", path: "c708-other" });
|
|
4610
|
+
|
|
4611
|
+
await store.updateNote(moved.id, { path: "move/Elsewhere" });
|
|
4612
|
+
|
|
4613
|
+
expect((await store.getNote(src.id))!.content).toBe("see [[keep/Rename]]");
|
|
4614
|
+
const query = generateMcpTools(store).find((t) => t.name === "query-notes")!;
|
|
4615
|
+
const links = await query.execute({ id: src.id, include_links: true }) as any;
|
|
4616
|
+
expect(links.links.map((l: any) => l.targetId)).toEqual([keep.id]);
|
|
4617
|
+
});
|
|
4618
|
+
|
|
4619
|
+
it("cascadeRename follows an unambiguous BASENAME bracket and keeps it a basename", async () => {
|
|
4620
|
+
const moved = await store.createNote("only one", { path: "move/Rename" });
|
|
4621
|
+
const src = await store.createNote("see [[Rename]]", { id: "c708-basename", path: "c708-basename" });
|
|
4622
|
+
|
|
4623
|
+
await store.updateNote(moved.id, { path: "move/Elsewhere" });
|
|
4624
|
+
|
|
4625
|
+
expect((await store.getNote(src.id))!.content).toBe("see [[Elsewhere]]");
|
|
4626
|
+
const query = generateMcpTools(store).find((t) => t.name === "query-notes")!;
|
|
4627
|
+
const links = await query.execute({ id: src.id, include_links: true }) as any;
|
|
4628
|
+
expect(links.links.map((l: any) => l.targetId)).toEqual([moved.id]);
|
|
4629
|
+
});
|
|
4630
|
+
|
|
4631
|
+
it("cascadeRename widens a basename bracket to the full path when the NEW basename is ambiguous", async () => {
|
|
4632
|
+
const moved = await store.createNote("first", { path: "a/Foo" });
|
|
4633
|
+
await store.createNote("collides with the new name", { path: "b/Bar" });
|
|
4634
|
+
const src = await store.createNote("see [[Foo]]", { id: "c708-widen", path: "c708-widen" });
|
|
4635
|
+
|
|
4636
|
+
await store.updateNote(moved.id, { path: "a/Bar" });
|
|
4637
|
+
|
|
4638
|
+
// `[[Bar]]` would now match two notes, so the bracket widens rather than
|
|
4639
|
+
// becoming ambiguous.
|
|
4640
|
+
expect((await store.getNote(src.id))!.content).toBe("see [[a/Bar]]");
|
|
4641
|
+
const query = generateMcpTools(store).find((t) => t.name === "query-notes")!;
|
|
4642
|
+
const links = await query.execute({ id: src.id, include_links: true }) as any;
|
|
4643
|
+
expect(links.links.map((l: any) => l.targetId)).toEqual([moved.id]);
|
|
4644
|
+
expect((await query.execute({ has_ambiguous_links: true }) as any[]).length).toBe(0);
|
|
4645
|
+
});
|
|
4646
|
+
|
|
4647
|
+
it("cascadeRename rewrites alias / anchor / block-ref / embed / full-path / .ext forms, and skips code fences", async () => {
|
|
4648
|
+
const moved = await store.createNote("only one", { path: "move/Rename" });
|
|
4649
|
+
const src = await store.createNote(
|
|
4650
|
+
"a [[Rename|shown]] b [[Rename#Heading]] c [[Rename#^blk]] d [[move/Rename]] e [[move/Rename.md]] f ![[Rename]]\n\n```\ng [[Rename]]\n```\n",
|
|
4651
|
+
{ id: "c708-forms", path: "c708-forms" },
|
|
4652
|
+
);
|
|
4653
|
+
|
|
4654
|
+
await store.updateNote(moved.id, { path: "move/Elsewhere" });
|
|
4655
|
+
|
|
4656
|
+
expect((await store.getNote(src.id))!.content).toBe(
|
|
4657
|
+
"a [[Elsewhere|shown]] b [[Elsewhere#Heading]] c [[Elsewhere#^blk]] d [[move/Elsewhere]] e [[move/Elsewhere.md]] f ![[Elsewhere]]\n\n```\ng [[Rename]]\n```\n",
|
|
4658
|
+
);
|
|
4659
|
+
});
|
|
4660
|
+
|
|
4661
|
+
it("cascadeRename rewrites an explicit-extension bracket on a non-md note", async () => {
|
|
4662
|
+
const moved = await store.createNote("csv", { path: "move/Data", extension: "csv" });
|
|
4663
|
+
const src = await store.createNote("see [[move/Data.csv]]", { id: "c708-ext", path: "c708-ext" });
|
|
4664
|
+
|
|
4665
|
+
await store.updateNote(moved.id, { path: "move/Info" });
|
|
4666
|
+
|
|
4667
|
+
expect((await store.getNote(src.id))!.content).toBe("see [[move/Info.csv]]");
|
|
4668
|
+
const query = generateMcpTools(store).find((t) => t.name === "query-notes")!;
|
|
4669
|
+
const links = await query.execute({ id: src.id, include_links: true }) as any;
|
|
4670
|
+
expect(links.links.map((l: any) => l.targetId)).toEqual([moved.id]);
|
|
4671
|
+
});
|
|
4672
|
+
|
|
4673
|
+
it("cascadeRename rewrites the renamed note's OWN self-referencing bracket", async () => {
|
|
4674
|
+
// Self-links are deliberately never given a `links` row, so the note
|
|
4675
|
+
// itself has to be seeded into the cascade's source set.
|
|
4676
|
+
const moved = await store.createNote("about [[move/Rename]] itself", { path: "move/Rename" });
|
|
4677
|
+
await store.updateNote(moved.id, { path: "move/Elsewhere" });
|
|
4678
|
+
expect((await store.getNote(moved.id))!.content).toBe("about [[move/Elsewhere]] itself");
|
|
4679
|
+
});
|
|
4680
|
+
|
|
4681
|
+
it("cascadeRename leaves a bracket that resolved through the H1-title fallback alone", async () => {
|
|
4682
|
+
// A title-fallback link doesn't depend on the path, so a repath must not
|
|
4683
|
+
// touch it — and it keeps resolving afterwards.
|
|
4684
|
+
const moved = await store.createNote("# Distinct Title\n\nbody", { path: "move/Rename" });
|
|
4685
|
+
const src = await store.createNote("see [[Distinct Title]]", { id: "c708-title", path: "c708-title" });
|
|
4686
|
+
|
|
4687
|
+
await store.updateNote(moved.id, { path: "move/Elsewhere" });
|
|
4688
|
+
|
|
4689
|
+
expect((await store.getNote(src.id))!.content).toBe("see [[Distinct Title]]");
|
|
4690
|
+
const query = generateMcpTools(store).find((t) => t.name === "query-notes")!;
|
|
4691
|
+
const links = await query.execute({ id: src.id, include_links: true }) as any;
|
|
4692
|
+
expect(links.links.map((l: any) => l.targetId)).toEqual([moved.id]);
|
|
4693
|
+
});
|
|
4694
|
+
|
|
4695
|
+
it("deleting BOTH colliding candidates turns the ambiguous link into a broken one", async () => {
|
|
4696
|
+
const a = await store.createNote("first", { path: "d1/Gone" });
|
|
4697
|
+
const b = await store.createNote("second", { path: "d2/Gone" });
|
|
4698
|
+
await store.createNote("see [[Gone]]", { id: "mq-amb-to-broken", path: "mq-amb-to-broken" });
|
|
4699
|
+
|
|
4700
|
+
const tools = generateMcpTools(store);
|
|
4701
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
4702
|
+
await store.deleteNote(a.id);
|
|
4703
|
+
await store.deleteNote(b.id);
|
|
4704
|
+
|
|
4705
|
+
expect((await query.execute({ has_ambiguous_links: true }) as any[]).length).toBe(0);
|
|
4706
|
+
expect((await query.execute({ has_broken_links: true }) as any[]).map((n: any) => n.id)).toEqual(["mq-amb-to-broken"]);
|
|
4707
|
+
const single = await query.execute({ id: "mq-amb-to-broken", include_broken_links: true }) as any;
|
|
4708
|
+
expect(single.broken_links).toEqual([{ target: "Gone", relationship: "wikilink" }]);
|
|
4709
|
+
});
|
|
4710
|
+
|
|
4711
|
+
it("editing the wikilink out of the content clears the persisted ambiguity", async () => {
|
|
4712
|
+
await store.createNote("first", { path: "e1/Vanish" });
|
|
4713
|
+
await store.createNote("second", { path: "e2/Vanish" });
|
|
4714
|
+
const src = await store.createNote("see [[Vanish]]", { id: "mq-amb-edit", path: "mq-amb-edit" });
|
|
4715
|
+
|
|
4716
|
+
const tools = generateMcpTools(store);
|
|
4717
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
4718
|
+
expect((await query.execute({ has_ambiguous_links: true }) as any[]).length).toBe(1);
|
|
4719
|
+
|
|
4720
|
+
await store.updateNote(src.id, { content: "no links now" });
|
|
4721
|
+
expect((await query.execute({ has_ambiguous_links: true }) as any[]).length).toBe(0);
|
|
4722
|
+
});
|
|
4723
|
+
|
|
4724
|
+
it("deleting the SOURCE note cascades its ambiguous rows away", async () => {
|
|
4725
|
+
await store.createNote("first", { path: "c1/Casc" });
|
|
4726
|
+
await store.createNote("second", { path: "c2/Casc" });
|
|
4727
|
+
const src = await store.createNote("see [[Casc]]", { id: "mq-amb-cascade", path: "mq-amb-cascade" });
|
|
4728
|
+
|
|
4729
|
+
const tools = generateMcpTools(store);
|
|
4730
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
4731
|
+
expect((await query.execute({ has_ambiguous_links: true }) as any[]).length).toBe(1);
|
|
4732
|
+
await store.deleteNote(src.id);
|
|
4733
|
+
expect((await query.execute({ has_ambiguous_links: true }) as any[]).length).toBe(0);
|
|
4734
|
+
});
|
|
4735
|
+
|
|
4736
|
+
it("a THIRD colliding note bumps the persisted candidate_count", async () => {
|
|
4737
|
+
await store.createNote("first", { path: "t1/Trio" });
|
|
4738
|
+
await store.createNote("second", { path: "t2/Trio" });
|
|
4739
|
+
await store.createNote("see [[Trio]]", { id: "mq-amb-count", path: "mq-amb-count" });
|
|
4740
|
+
|
|
4741
|
+
const tools = generateMcpTools(store);
|
|
4742
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
4743
|
+
let single = await query.execute({ id: "mq-amb-count", include_ambiguous_links: true }) as any;
|
|
4744
|
+
expect(single.ambiguous_links).toEqual([{ target: "Trio", relationship: "wikilink", candidate_count: 2 }]);
|
|
4745
|
+
|
|
4746
|
+
await store.createNote("third", { path: "t3/Trio" });
|
|
4747
|
+
single = await query.execute({ id: "mq-amb-count", include_ambiguous_links: true }) as any;
|
|
4748
|
+
expect(single.ambiguous_links).toEqual([{ target: "Trio", relationship: "wikilink", candidate_count: 3 }]);
|
|
4749
|
+
});
|
|
4750
|
+
|
|
4751
|
+
it("has_ambiguous_links is bound into the cursor query-hash", async () => {
|
|
4752
|
+
await store.createNote("first", { path: "cur1/Hash" });
|
|
4753
|
+
await store.createNote("second", { path: "cur2/Hash" });
|
|
4754
|
+
await store.createNote("see [[Hash]]", { id: "mq-amb-cursor", path: "mq-amb-cursor" });
|
|
4755
|
+
|
|
4756
|
+
const tools = generateMcpTools(store);
|
|
4757
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
4758
|
+
const page = await query.execute({ cursor: "", limit: 1, has_ambiguous_links: true }) as any;
|
|
4759
|
+
expect(page.notes.map((n: any) => n.id)).toEqual(["mq-amb-cursor"]);
|
|
4760
|
+
// Flipping the filter must invalidate the cursor rather than silently
|
|
4761
|
+
// continuing the prior filter's watermark.
|
|
4762
|
+
expect(query.execute({ cursor: page.next_cursor, limit: 1, has_ambiguous_links: false }))
|
|
4763
|
+
.rejects.toThrow(/cursor was minted for a different query/);
|
|
4764
|
+
});
|
|
4765
|
+
|
|
4238
4766
|
it("query-notes metadata operator query routes through the indexed column", async () => {
|
|
4239
4767
|
const { declareField } = await import("./indexed-fields.js");
|
|
4240
4768
|
declareField(db, "priority", "INTEGER", "project");
|
|
@@ -8817,6 +9345,24 @@ describe("vault projection (vault#271)", async () => {
|
|
|
8817
9345
|
expect(md).toContain("Querying");
|
|
8818
9346
|
});
|
|
8819
9347
|
|
|
9348
|
+
it("markdown brief skips a non-string description instead of throwing (vault#669 recovery)", async () => {
|
|
9349
|
+
const { buildVaultProjection, projectionToMarkdown } = await import(
|
|
9350
|
+
"./vault-projection.ts"
|
|
9351
|
+
);
|
|
9352
|
+
|
|
9353
|
+
const projection = buildVaultProjection(db, { includeStats: true });
|
|
9354
|
+
const md = projectionToMarkdown({
|
|
9355
|
+
vaultName: "poisoned",
|
|
9356
|
+
// A write door that skipped the type guard used to persist a number
|
|
9357
|
+
// and take down initialize via `description.trim is not a function`.
|
|
9358
|
+
description: 123 as unknown as string,
|
|
9359
|
+
projection,
|
|
9360
|
+
});
|
|
9361
|
+
|
|
9362
|
+
expect(md).toContain('Parachute Vault "poisoned"');
|
|
9363
|
+
expect(md).not.toContain("123");
|
|
9364
|
+
});
|
|
9365
|
+
|
|
8820
9366
|
it("markdown brief stays under ~5K tokens for a 50-tags-with-schemas vault", async () => {
|
|
8821
9367
|
const { buildVaultProjection, projectionToMarkdown } = await import(
|
|
8822
9368
|
"./vault-projection.ts"
|
package/core/src/cursor.ts
CHANGED
|
@@ -299,3 +299,32 @@ describe("query-notes MCP surface: include_link_count", () => {
|
|
|
299
299
|
for (const n of ordered) expect(typeof n.linkCount).toBe("number");
|
|
300
300
|
});
|
|
301
301
|
});
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
describe("vault#714 scoped link degree", () => {
|
|
305
|
+
async function seedScope() {
|
|
306
|
+
for (const id of ["a", "b", "hidden"]) await store.createNote(id, { id });
|
|
307
|
+
await store.createLink("a", "b", "visible");
|
|
308
|
+
await store.createLink("a", "hidden", "hidden-out");
|
|
309
|
+
await store.createLink("hidden", "a", "hidden-in");
|
|
310
|
+
}
|
|
311
|
+
for (const [direction, expected] of [["both", 1], ["outbound", 1], ["inbound", 0]] as const) {
|
|
312
|
+
it(`vault#714 scoped ${direction}`, async () => {
|
|
313
|
+
await seedScope();
|
|
314
|
+
expect(getLinkCounts(db, ["a"], direction, (id) => id !== "hidden").get("a")).toBe(expected);
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
it("vault#714 visible self-loop remains degree two", async () => {
|
|
318
|
+
await seedScope(); await store.createLink("b", "b", "self");
|
|
319
|
+
expect(getLinkCounts(db, ["b"], "both", (id) => id === "b").get("b")).toBe(2);
|
|
320
|
+
});
|
|
321
|
+
it("vault#714 absent predicate preserves global degree", async () => {
|
|
322
|
+
await seedScope();
|
|
323
|
+
expect(getLinkCounts(db, ["a"], "both")).toEqual(new Map([["a", 3]]));
|
|
324
|
+
expect(getLinkCounts(db, ["a"], "both", undefined)).toEqual(getLinkCounts(db, ["a"], "both"));
|
|
325
|
+
});
|
|
326
|
+
it("vault#714 repeated ids and typed rows retain row-count semantics", async () => {
|
|
327
|
+
await seedScope(); await store.createLink("a", "b", "second");
|
|
328
|
+
expect(getLinkCounts(db, ["a", "a"], "both", (id) => id !== "hidden").get("a")).toBe(2);
|
|
329
|
+
});
|
|
330
|
+
});
|