@openparachute/vault 0.7.0-rc.8 → 0.7.0-rc.9
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 +482 -0
- package/core/src/cursor.ts +1 -0
- package/core/src/mcp.ts +278 -29
- package/core/src/notes.ts +27 -0
- package/core/src/seed-packs.test.ts +14 -0
- package/core/src/seed-packs.ts +23 -0
- package/core/src/types.ts +9 -0
- package/core/src/wikilinks.ts +44 -0
- package/package.json +1 -1
- package/src/mcp-tools.ts +25 -3
- package/src/routes.ts +198 -17
- package/src/vault.test.ts +454 -0
package/core/src/core.test.ts
CHANGED
|
@@ -3376,6 +3376,93 @@ describe("MCP tools", async () => {
|
|
|
3376
3376
|
expect(result.map((n) => n.content)).toEqual(["Orphan"]);
|
|
3377
3377
|
});
|
|
3378
3378
|
|
|
3379
|
+
// ---- has_broken_links / include_broken_links (vault#555) ----
|
|
3380
|
+
|
|
3381
|
+
it("query-notes has_broken_links=true surfaces only notes with a dangling wikilink", async () => {
|
|
3382
|
+
await store.createNote("[[Nonexistent Target]]", { id: "mq-broken", path: "mq-broken" });
|
|
3383
|
+
await store.createNote("no links here", { id: "mq-clean", path: "mq-clean" });
|
|
3384
|
+
|
|
3385
|
+
const tools = generateMcpTools(store);
|
|
3386
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
3387
|
+
const result = await query.execute({ has_broken_links: true, include_content: true }) as any[];
|
|
3388
|
+
expect(result.map((n) => n.path)).toEqual(["mq-broken"]);
|
|
3389
|
+
});
|
|
3390
|
+
|
|
3391
|
+
it("query-notes has_broken_links=false excludes notes with a dangling link", async () => {
|
|
3392
|
+
await store.createNote("[[Nonexistent Target]]", { id: "mq-broken2", path: "mq-broken2" });
|
|
3393
|
+
await store.createNote("no links here", { id: "mq-clean2", path: "mq-clean2" });
|
|
3394
|
+
|
|
3395
|
+
const tools = generateMcpTools(store);
|
|
3396
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
3397
|
+
const result = await query.execute({ has_broken_links: false, include_content: true }) as any[];
|
|
3398
|
+
expect(result.map((n) => n.path).sort()).toEqual(["mq-clean2"]);
|
|
3399
|
+
});
|
|
3400
|
+
|
|
3401
|
+
it("query-notes has_broken_links is safe on a vault where no link has ever gone unresolved", async () => {
|
|
3402
|
+
// Fresh store, beforeEach — the unresolved_wikilinks table has never
|
|
3403
|
+
// been created. true should match nothing (not throw); false should
|
|
3404
|
+
// be a no-op (matches everything).
|
|
3405
|
+
await store.createNote("plain note", { id: "mq-nolinks", path: "mq-nolinks" });
|
|
3406
|
+
const tools = generateMcpTools(store);
|
|
3407
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
3408
|
+
const truthy = await query.execute({ has_broken_links: true, include_content: true }) as any[];
|
|
3409
|
+
expect(truthy).toEqual([]);
|
|
3410
|
+
const falsy = await query.execute({ has_broken_links: false, include_content: true }) as any[];
|
|
3411
|
+
expect(falsy.map((n) => n.path)).toEqual(["mq-nolinks"]);
|
|
3412
|
+
});
|
|
3413
|
+
|
|
3414
|
+
it("a target created later resolves the wikilink and the note drops out of has_broken_links:true", async () => {
|
|
3415
|
+
await store.createNote("[[Later Target]]", { id: "mq-fixable", path: "mq-fixable" });
|
|
3416
|
+
const tools = generateMcpTools(store);
|
|
3417
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
3418
|
+
expect((await query.execute({ has_broken_links: true }) as any[]).length).toBe(1);
|
|
3419
|
+
|
|
3420
|
+
await store.createNote("here now", { path: "Later Target" });
|
|
3421
|
+
expect((await query.execute({ has_broken_links: true }) as any[]).length).toBe(0);
|
|
3422
|
+
});
|
|
3423
|
+
|
|
3424
|
+
it("query-notes include_broken_links surfaces {target, relationship} for a single note", async () => {
|
|
3425
|
+
await store.createNote("[[Missing Note]]", { id: "mq-single-broken", path: "mq-single-broken" });
|
|
3426
|
+
const tools = generateMcpTools(store);
|
|
3427
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
3428
|
+
const result = await query.execute({ id: "mq-single-broken", include_broken_links: true }) as any;
|
|
3429
|
+
expect(result.broken_links).toEqual([{ target: "Missing Note", relationship: "wikilink" }]);
|
|
3430
|
+
});
|
|
3431
|
+
|
|
3432
|
+
it("query-notes include_broken_links surfaces a structured link's unresolved_link entry too", async () => {
|
|
3433
|
+
const created = await generateMcpTools(store).find((t) => t.name === "create-note")!.execute({
|
|
3434
|
+
content: "body",
|
|
3435
|
+
path: "mq-structured-broken",
|
|
3436
|
+
links: [{ target: "Does Not Exist", relationship: "depends-on" }],
|
|
3437
|
+
}) as any;
|
|
3438
|
+
const tools = generateMcpTools(store);
|
|
3439
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
3440
|
+
const result = await query.execute({ id: created.id, include_broken_links: true }) as any;
|
|
3441
|
+
expect(result.broken_links).toEqual([{ target: "Does Not Exist", relationship: "depends-on" }]);
|
|
3442
|
+
});
|
|
3443
|
+
|
|
3444
|
+
it("query-notes include_broken_links is [] for a note with no dangling links", async () => {
|
|
3445
|
+
await store.createNote("clean", { id: "mq-clean-single", path: "mq-clean-single" });
|
|
3446
|
+
const tools = generateMcpTools(store);
|
|
3447
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
3448
|
+
const result = await query.execute({ id: "mq-clean-single", include_broken_links: true }) as any;
|
|
3449
|
+
expect(result.broken_links).toEqual([]);
|
|
3450
|
+
});
|
|
3451
|
+
|
|
3452
|
+
it("query-notes include_broken_links works in list mode, batched across the page", async () => {
|
|
3453
|
+
await store.createNote("[[Ghost A]]", { id: "mq-list-a", path: "mq-list-a" });
|
|
3454
|
+
await store.createNote("[[Ghost B]]", { id: "mq-list-b", path: "mq-list-b" });
|
|
3455
|
+
await store.createNote("clean", { id: "mq-list-c", path: "mq-list-c" });
|
|
3456
|
+
|
|
3457
|
+
const tools = generateMcpTools(store);
|
|
3458
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
3459
|
+
const result = await query.execute({ include_broken_links: true, include_content: true }) as any[];
|
|
3460
|
+
const byId = new Map(result.map((n: any) => [n.id, n.broken_links]));
|
|
3461
|
+
expect(byId.get("mq-list-a")).toEqual([{ target: "Ghost A", relationship: "wikilink" }]);
|
|
3462
|
+
expect(byId.get("mq-list-b")).toEqual([{ target: "Ghost B", relationship: "wikilink" }]);
|
|
3463
|
+
expect(byId.get("mq-list-c")).toEqual([]);
|
|
3464
|
+
});
|
|
3465
|
+
|
|
3379
3466
|
it("query-notes metadata operator query routes through the indexed column", async () => {
|
|
3380
3467
|
const { declareField } = await import("./indexed-fields.js");
|
|
3381
3468
|
declareField(db, "priority", "INTEGER", "project");
|
|
@@ -5188,6 +5275,401 @@ describe("update-note if_missing=create (vault#309)", async () => {
|
|
|
5188
5275
|
});
|
|
5189
5276
|
});
|
|
5190
5277
|
|
|
5278
|
+
// ---------------------------------------------------------------------------
|
|
5279
|
+
// create-note `if_exists` — idempotent upsert on a path conflict (vault#555)
|
|
5280
|
+
// ---------------------------------------------------------------------------
|
|
5281
|
+
|
|
5282
|
+
describe("create-note if_exists (vault#555)", async () => {
|
|
5283
|
+
let store: SqliteStore;
|
|
5284
|
+
beforeEach(() => {
|
|
5285
|
+
store = new SqliteStore(new Database(":memory:"));
|
|
5286
|
+
});
|
|
5287
|
+
|
|
5288
|
+
it("default (no if_exists) still throws path_conflict — back-compat", async () => {
|
|
5289
|
+
const tools = generateMcpTools(store);
|
|
5290
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
5291
|
+
await create.execute({ content: "first", path: "Inbox/dup" });
|
|
5292
|
+
await expect(create.execute({ content: "second", path: "Inbox/dup" }))
|
|
5293
|
+
.rejects.toThrow(/path_conflict/);
|
|
5294
|
+
});
|
|
5295
|
+
|
|
5296
|
+
it('if_exists:"error" behaves identically to omitting it', async () => {
|
|
5297
|
+
const tools = generateMcpTools(store);
|
|
5298
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
5299
|
+
await create.execute({ content: "first", path: "Inbox/dup-explicit" });
|
|
5300
|
+
await expect(create.execute({ content: "second", path: "Inbox/dup-explicit", if_exists: "error" }))
|
|
5301
|
+
.rejects.toThrow(/path_conflict/);
|
|
5302
|
+
});
|
|
5303
|
+
|
|
5304
|
+
it('if_exists:"ignore" returns the existing note untouched, no mutation', async () => {
|
|
5305
|
+
const tools = generateMcpTools(store);
|
|
5306
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
5307
|
+
const first = await create.execute({
|
|
5308
|
+
content: "original",
|
|
5309
|
+
path: "Inbox/ignore-me",
|
|
5310
|
+
tags: ["daily"],
|
|
5311
|
+
metadata: { v: 1 },
|
|
5312
|
+
}) as any;
|
|
5313
|
+
|
|
5314
|
+
const second = await create.execute({
|
|
5315
|
+
content: "attempted overwrite",
|
|
5316
|
+
path: "Inbox/ignore-me",
|
|
5317
|
+
tags: ["other"],
|
|
5318
|
+
metadata: { v: 2 },
|
|
5319
|
+
if_exists: "ignore",
|
|
5320
|
+
}) as any;
|
|
5321
|
+
|
|
5322
|
+
expect(second.existed).toBe(true);
|
|
5323
|
+
expect(second.id).toBe(first.id);
|
|
5324
|
+
expect(second.content).toBe("original"); // untouched
|
|
5325
|
+
expect(second.metadata?.v).toBe(1); // untouched
|
|
5326
|
+
expect(second.tags).toEqual(["daily"]); // "other" was never applied
|
|
5327
|
+
|
|
5328
|
+
// Confirm on disk too — no side effects at all.
|
|
5329
|
+
const onDisk = await store.getNoteByPath("Inbox/ignore-me");
|
|
5330
|
+
expect(onDisk!.content).toBe("original");
|
|
5331
|
+
expect(onDisk!.tags).toEqual(["daily"]);
|
|
5332
|
+
});
|
|
5333
|
+
|
|
5334
|
+
it('if_exists:"ignore" does not run schema-default backfill (genuinely zero mutation)', async () => {
|
|
5335
|
+
const tools = generateMcpTools(store);
|
|
5336
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
5337
|
+
// Create the note BEFORE the schema declares a default, so the
|
|
5338
|
+
// existing row is missing the field the schema would now backfill.
|
|
5339
|
+
const first = await create.execute({ content: "x", path: "Inbox/predates-schema", tags: ["task"] }) as any;
|
|
5340
|
+
expect(first.metadata?.priority).toBeUndefined();
|
|
5341
|
+
|
|
5342
|
+
await store.upsertTagSchema("task", {
|
|
5343
|
+
fields: { priority: { type: "string", enum: ["high", "low"], default: "high" } },
|
|
5344
|
+
});
|
|
5345
|
+
|
|
5346
|
+
const second = await create.execute({
|
|
5347
|
+
content: "y",
|
|
5348
|
+
path: "Inbox/predates-schema",
|
|
5349
|
+
tags: ["task"],
|
|
5350
|
+
if_exists: "ignore",
|
|
5351
|
+
}) as any;
|
|
5352
|
+
expect(second.existed).toBe(true);
|
|
5353
|
+
expect(second.metadata?.priority).toBeUndefined(); // no backfill — zero mutation promise
|
|
5354
|
+
});
|
|
5355
|
+
|
|
5356
|
+
it('if_exists:"update" full-replaces content, RFC-7386-merges metadata, and unions tags/links', async () => {
|
|
5357
|
+
await store.createNote("target body", { id: "t-upd-target", path: "Targets/upd" });
|
|
5358
|
+
|
|
5359
|
+
const tools = generateMcpTools(store);
|
|
5360
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
5361
|
+
const first = await create.execute({
|
|
5362
|
+
content: "v1",
|
|
5363
|
+
path: "Inbox/update-me",
|
|
5364
|
+
tags: ["daily"],
|
|
5365
|
+
metadata: { a: 1, b: 2 },
|
|
5366
|
+
}) as any;
|
|
5367
|
+
|
|
5368
|
+
const second = await create.execute({
|
|
5369
|
+
content: "v2",
|
|
5370
|
+
path: "Inbox/update-me",
|
|
5371
|
+
tags: ["extra"],
|
|
5372
|
+
metadata: { b: null, c: 3 }, // b deleted (RFC 7386), c added, a untouched
|
|
5373
|
+
links: [{ target: "t-upd-target", relationship: "relates-to" }],
|
|
5374
|
+
if_exists: "update",
|
|
5375
|
+
}) as any;
|
|
5376
|
+
|
|
5377
|
+
expect(second.existed).toBe(true);
|
|
5378
|
+
expect(second.id).toBe(first.id);
|
|
5379
|
+
expect(second.content).toBe("v2"); // full replace
|
|
5380
|
+
expect(second.metadata).toEqual({ a: 1, c: 3 }); // merged, b deleted
|
|
5381
|
+
expect(second.tags?.sort()).toEqual(["daily", "extra"]); // union, nothing removed
|
|
5382
|
+
|
|
5383
|
+
const links = await store.getLinks(second.id, { direction: "outbound" });
|
|
5384
|
+
expect(links.find((l) => l.relationship === "relates-to")?.targetId).toBe("t-upd-target");
|
|
5385
|
+
});
|
|
5386
|
+
|
|
5387
|
+
it('if_exists:"update" with an omitted field leaves it untouched', async () => {
|
|
5388
|
+
const tools = generateMcpTools(store);
|
|
5389
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
5390
|
+
await create.execute({ content: "v1", path: "Inbox/partial-update", metadata: { keep: "me" } });
|
|
5391
|
+
|
|
5392
|
+
const second = await create.execute({
|
|
5393
|
+
path: "Inbox/partial-update", // no `content` — should stay "v1"
|
|
5394
|
+
metadata: { added: true },
|
|
5395
|
+
if_exists: "update",
|
|
5396
|
+
}) as any;
|
|
5397
|
+
expect(second.content).toBe("v1");
|
|
5398
|
+
expect(second.metadata).toEqual({ keep: "me", added: true });
|
|
5399
|
+
});
|
|
5400
|
+
|
|
5401
|
+
it('if_exists:"replace" wholesale-overwrites content + metadata but keeps id/createdAt and unions tags', async () => {
|
|
5402
|
+
const tools = generateMcpTools(store);
|
|
5403
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
5404
|
+
const first = await create.execute({
|
|
5405
|
+
content: "v1",
|
|
5406
|
+
path: "Inbox/replace-me",
|
|
5407
|
+
tags: ["daily"],
|
|
5408
|
+
metadata: { a: 1, b: 2 },
|
|
5409
|
+
}) as any;
|
|
5410
|
+
|
|
5411
|
+
const second = await create.execute({
|
|
5412
|
+
content: "v2",
|
|
5413
|
+
path: "Inbox/replace-me",
|
|
5414
|
+
tags: ["extra"],
|
|
5415
|
+
metadata: { c: 3 }, // a and b are NOT merged in — wholesale replace
|
|
5416
|
+
if_exists: "replace",
|
|
5417
|
+
}) as any;
|
|
5418
|
+
|
|
5419
|
+
expect(second.existed).toBe(true);
|
|
5420
|
+
expect(second.id).toBe(first.id);
|
|
5421
|
+
expect(second.createdAt).toBe(first.createdAt);
|
|
5422
|
+
expect(second.content).toBe("v2");
|
|
5423
|
+
expect(second.metadata).toEqual({ c: 3 }); // a/b dropped, not merged
|
|
5424
|
+
expect(second.tags?.sort()).toEqual(["daily", "extra"]); // tags stay additive
|
|
5425
|
+
});
|
|
5426
|
+
|
|
5427
|
+
it('if_exists:"replace" with omitted content/metadata clears them to empty defaults', async () => {
|
|
5428
|
+
const tools = generateMcpTools(store);
|
|
5429
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
5430
|
+
await create.execute({ content: "v1", path: "Inbox/replace-blank", metadata: { a: 1 } });
|
|
5431
|
+
|
|
5432
|
+
const second = await create.execute({
|
|
5433
|
+
path: "Inbox/replace-blank",
|
|
5434
|
+
if_exists: "replace",
|
|
5435
|
+
}) as any;
|
|
5436
|
+
expect(second.content).toBe("");
|
|
5437
|
+
// An empty `{}` metadata collapses to `undefined` on read — the same
|
|
5438
|
+
// convention every note with no metadata follows (rowToNote), not
|
|
5439
|
+
// something special about `replace`.
|
|
5440
|
+
expect(second.metadata).toBeUndefined();
|
|
5441
|
+
});
|
|
5442
|
+
|
|
5443
|
+
it("if_exists is a no-op without a path — a pathless create can never conflict", async () => {
|
|
5444
|
+
const tools = generateMcpTools(store);
|
|
5445
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
5446
|
+
const a = await create.execute({ content: "one", if_exists: "ignore" }) as any;
|
|
5447
|
+
const b = await create.execute({ content: "two", if_exists: "ignore" }) as any;
|
|
5448
|
+
expect(a.id).not.toBe(b.id);
|
|
5449
|
+
// `if_exists` was still passed, so `existed` is attached (there was
|
|
5450
|
+
// simply nothing to conflict with) — `false`, not absent.
|
|
5451
|
+
expect(a.existed).toBe(false);
|
|
5452
|
+
expect(b.existed).toBe(false);
|
|
5453
|
+
});
|
|
5454
|
+
|
|
5455
|
+
it("plain create-note calls (no if_exists) see zero response-shape change", async () => {
|
|
5456
|
+
const tools = generateMcpTools(store);
|
|
5457
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
5458
|
+
const result = await create.execute({ content: "plain" }) as any;
|
|
5459
|
+
expect("existed" in result).toBe(false);
|
|
5460
|
+
});
|
|
5461
|
+
|
|
5462
|
+
it("existed:false when if_exists is set but no conflict occurs (fresh insert)", async () => {
|
|
5463
|
+
const tools = generateMcpTools(store);
|
|
5464
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
5465
|
+
const result = await create.execute({ content: "fresh", path: "Inbox/fresh-one", if_exists: "update" }) as any;
|
|
5466
|
+
expect(result.existed).toBe(false);
|
|
5467
|
+
});
|
|
5468
|
+
|
|
5469
|
+
it("respects a per-item extension when disambiguating the conflict (vault#328)", async () => {
|
|
5470
|
+
const tools = generateMcpTools(store);
|
|
5471
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
5472
|
+
// Two notes at the same path, different extensions — legal per vault#328.
|
|
5473
|
+
await create.execute({ content: "csv body", path: "Reports/q1", extension: "csv" });
|
|
5474
|
+
await create.execute({ content: "md body", path: "Reports/q1", extension: "md" });
|
|
5475
|
+
|
|
5476
|
+
// ignore against the CSV one specifically.
|
|
5477
|
+
const result = await create.execute({
|
|
5478
|
+
content: "attempted",
|
|
5479
|
+
path: "Reports/q1",
|
|
5480
|
+
extension: "csv",
|
|
5481
|
+
if_exists: "ignore",
|
|
5482
|
+
}) as any;
|
|
5483
|
+
expect(result.existed).toBe(true);
|
|
5484
|
+
expect(result.content).toBe("csv body");
|
|
5485
|
+
expect(result.extension).toBe("csv");
|
|
5486
|
+
});
|
|
5487
|
+
|
|
5488
|
+
it("batch: if_exists is per-item, not inherited from a top-level default", async () => {
|
|
5489
|
+
const tools = generateMcpTools(store);
|
|
5490
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
5491
|
+
await create.execute({ content: "existing", path: "Inbox/batch-conflict" });
|
|
5492
|
+
|
|
5493
|
+
// Top-level if_exists is NOT merged into batch items (matches
|
|
5494
|
+
// if_missing's contract on update-note) — the batch item below has no
|
|
5495
|
+
// if_exists of its own, so it hits the default "error" path and the
|
|
5496
|
+
// WHOLE batch rolls back, even though a top-level if_exists was passed.
|
|
5497
|
+
await expect(create.execute({
|
|
5498
|
+
if_exists: "ignore",
|
|
5499
|
+
notes: [{ content: "conflict", path: "Inbox/batch-conflict" }],
|
|
5500
|
+
})).rejects.toThrow(/path_conflict/);
|
|
5501
|
+
});
|
|
5502
|
+
|
|
5503
|
+
it("batch: mixes fresh creates and if_exists hits in the same call, in order", async () => {
|
|
5504
|
+
const tools = generateMcpTools(store);
|
|
5505
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
5506
|
+
await create.execute({ content: "pre-existing", path: "Inbox/batch-b" });
|
|
5507
|
+
|
|
5508
|
+
const result = await create.execute({
|
|
5509
|
+
notes: [
|
|
5510
|
+
{ content: "a", path: "Inbox/batch-a", if_exists: "ignore" },
|
|
5511
|
+
{ content: "b-new", path: "Inbox/batch-b", if_exists: "ignore" },
|
|
5512
|
+
{ content: "c", path: "Inbox/batch-c", if_exists: "ignore" },
|
|
5513
|
+
],
|
|
5514
|
+
}) as any[];
|
|
5515
|
+
|
|
5516
|
+
expect(result).toHaveLength(3);
|
|
5517
|
+
expect(result[0].existed).toBe(false);
|
|
5518
|
+
expect(result[1].existed).toBe(true);
|
|
5519
|
+
expect(result[1].content).toBe("pre-existing"); // untouched
|
|
5520
|
+
expect(result[2].existed).toBe(false);
|
|
5521
|
+
});
|
|
5522
|
+
|
|
5523
|
+
it("a genuine schema violation on if_exists:update still rejects and mutates nothing", async () => {
|
|
5524
|
+
await store.upsertTagSchema("task", {
|
|
5525
|
+
fields: { status: { type: "string", enum: ["open", "done"], strict: true, required: true } },
|
|
5526
|
+
});
|
|
5527
|
+
const tools = generateMcpTools(store);
|
|
5528
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
5529
|
+
await create.execute({ content: "v1", path: "Inbox/strict-target", tags: ["task"], metadata: { status: "open" } });
|
|
5530
|
+
|
|
5531
|
+
await expect(create.execute({
|
|
5532
|
+
content: "v2",
|
|
5533
|
+
path: "Inbox/strict-target",
|
|
5534
|
+
metadata: { status: "NOT-A-VALID-ENUM" },
|
|
5535
|
+
if_exists: "update",
|
|
5536
|
+
})).rejects.toThrow();
|
|
5537
|
+
|
|
5538
|
+
const onDisk = await store.getNoteByPath("Inbox/strict-target");
|
|
5539
|
+
expect(onDisk!.content).toBe("v1"); // untouched — rejected before any write
|
|
5540
|
+
});
|
|
5541
|
+
|
|
5542
|
+
it("if_exists:update re-validates against the CURRENT schema without re-supplying satisfied required fields", async () => {
|
|
5543
|
+
await store.upsertTagSchema("task", {
|
|
5544
|
+
fields: { status: { type: "string", enum: ["open", "done"], strict: true, required: true } },
|
|
5545
|
+
});
|
|
5546
|
+
const tools = generateMcpTools(store);
|
|
5547
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
5548
|
+
await create.execute({ content: "v1", path: "Inbox/status-set", tags: ["task"], metadata: { status: "open" } });
|
|
5549
|
+
|
|
5550
|
+
// Only touching content — `status` is already satisfied by the
|
|
5551
|
+
// EXISTING note, so re-validating the merged/projected shape (not the
|
|
5552
|
+
// raw incoming item) must NOT demand it again.
|
|
5553
|
+
const result = await create.execute({
|
|
5554
|
+
content: "v2",
|
|
5555
|
+
path: "Inbox/status-set",
|
|
5556
|
+
if_exists: "update",
|
|
5557
|
+
}) as any;
|
|
5558
|
+
expect(result.content).toBe("v2");
|
|
5559
|
+
expect(result.metadata?.status).toBe("open");
|
|
5560
|
+
});
|
|
5561
|
+
|
|
5562
|
+
// vault#555 CRITICAL 2 (W8 fix-2 bug class): a tags-ONLY if_exists:"update"
|
|
5563
|
+
// (no content/metadata) must still bump updated_at — store.tagNote genuinely
|
|
5564
|
+
// mutates the note, and a frozen updated_at breaks cursor polling + sync.
|
|
5565
|
+
it('if_exists:"update" with ONLY tags added still advances updated_at', async () => {
|
|
5566
|
+
const tools = generateMcpTools(store);
|
|
5567
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
5568
|
+
const first = await create.execute({ content: "body", path: "Inbox/tagonly", tags: ["alpha"] }) as any;
|
|
5569
|
+
const before = (await store.getNoteByPath("Inbox/tagonly"))!.updatedAt!;
|
|
5570
|
+
|
|
5571
|
+
// Ensure a strictly-later wall-clock so the bump is observable even if the
|
|
5572
|
+
// two writes land in the same millisecond.
|
|
5573
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
5574
|
+
|
|
5575
|
+
const second = await create.execute({
|
|
5576
|
+
path: "Inbox/tagonly",
|
|
5577
|
+
tags: ["beta"], // ONLY a tag change — no content, no metadata
|
|
5578
|
+
if_exists: "update",
|
|
5579
|
+
}) as any;
|
|
5580
|
+
expect(second.existed).toBe(true);
|
|
5581
|
+
expect(second.tags?.sort()).toEqual(["alpha", "beta"]); // tag actually added
|
|
5582
|
+
const after = (await store.getNoteByPath("Inbox/tagonly"))!.updatedAt!;
|
|
5583
|
+
expect(after > before).toBe(true); // updated_at advanced
|
|
5584
|
+
expect(second.id).toBe(first.id);
|
|
5585
|
+
});
|
|
5586
|
+
|
|
5587
|
+
it('if_exists:"update" with ONLY links added still advances updated_at', async () => {
|
|
5588
|
+
await store.createNote("target", { id: "tgt-linkonly", path: "Targets/linkonly" });
|
|
5589
|
+
const tools = generateMcpTools(store);
|
|
5590
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
5591
|
+
await create.execute({ content: "body", path: "Inbox/linkonly", tags: ["alpha"] });
|
|
5592
|
+
const before = (await store.getNoteByPath("Inbox/linkonly"))!.updatedAt!;
|
|
5593
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
5594
|
+
|
|
5595
|
+
await create.execute({
|
|
5596
|
+
path: "Inbox/linkonly",
|
|
5597
|
+
links: [{ target: "tgt-linkonly", relationship: "relates-to" }],
|
|
5598
|
+
if_exists: "update",
|
|
5599
|
+
});
|
|
5600
|
+
const after = (await store.getNoteByPath("Inbox/linkonly"))!.updatedAt!;
|
|
5601
|
+
expect(after > before).toBe(true);
|
|
5602
|
+
});
|
|
5603
|
+
});
|
|
5604
|
+
|
|
5605
|
+
// ---------------------------------------------------------------------------
|
|
5606
|
+
// create-note `summary` — compact batch response shape (vault#555)
|
|
5607
|
+
// ---------------------------------------------------------------------------
|
|
5608
|
+
|
|
5609
|
+
describe("create-note summary (vault#555)", async () => {
|
|
5610
|
+
let store: SqliteStore;
|
|
5611
|
+
beforeEach(() => {
|
|
5612
|
+
store = new SqliteStore(new Database(":memory:"));
|
|
5613
|
+
});
|
|
5614
|
+
|
|
5615
|
+
it("summary:true returns {created, ids, failed} instead of N full note objects", async () => {
|
|
5616
|
+
const tools = generateMcpTools(store);
|
|
5617
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
5618
|
+
const result = await create.execute({
|
|
5619
|
+
summary: true,
|
|
5620
|
+
notes: [
|
|
5621
|
+
{ content: "a", path: "Inbox/sum-a" },
|
|
5622
|
+
{ content: "b", path: "Inbox/sum-b" },
|
|
5623
|
+
{ content: "c", path: "Inbox/sum-c" },
|
|
5624
|
+
],
|
|
5625
|
+
}) as any;
|
|
5626
|
+
expect(result.created).toBe(3);
|
|
5627
|
+
expect(result.ids).toHaveLength(3);
|
|
5628
|
+
expect(result.failed).toEqual([]);
|
|
5629
|
+
// Not the full-object shape.
|
|
5630
|
+
expect(result.notes).toBeUndefined();
|
|
5631
|
+
expect(Array.isArray(result)).toBe(false);
|
|
5632
|
+
|
|
5633
|
+
// The ids really do resolve to the notes just created.
|
|
5634
|
+
const onDisk = await store.getNoteByPath("Inbox/sum-a");
|
|
5635
|
+
expect(result.ids).toContain(onDisk!.id);
|
|
5636
|
+
});
|
|
5637
|
+
|
|
5638
|
+
it("summary:true `created` excludes if_exists hits — only counts brand-new inserts", async () => {
|
|
5639
|
+
const tools = generateMcpTools(store);
|
|
5640
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
5641
|
+
await create.execute({ content: "pre-existing", path: "Inbox/sum-existing" });
|
|
5642
|
+
|
|
5643
|
+
const result = await create.execute({
|
|
5644
|
+
summary: true,
|
|
5645
|
+
notes: [
|
|
5646
|
+
{ content: "fresh", path: "Inbox/sum-fresh", if_exists: "ignore" },
|
|
5647
|
+
{ content: "ignored", path: "Inbox/sum-existing", if_exists: "ignore" },
|
|
5648
|
+
],
|
|
5649
|
+
}) as any;
|
|
5650
|
+
expect(result.created).toBe(1); // only the fresh one
|
|
5651
|
+
expect(result.ids).toHaveLength(2); // both ids are still reported
|
|
5652
|
+
});
|
|
5653
|
+
|
|
5654
|
+
it("summary is ignored on a single-note (non-batch) call", async () => {
|
|
5655
|
+
const tools = generateMcpTools(store);
|
|
5656
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
5657
|
+
const result = await create.execute({ content: "solo", summary: true }) as any;
|
|
5658
|
+
expect(result.content).toBe("solo"); // full note object, not a summary
|
|
5659
|
+
expect(result.created).toBeUndefined();
|
|
5660
|
+
});
|
|
5661
|
+
|
|
5662
|
+
it("without summary, batch still returns full note objects (back-compat)", async () => {
|
|
5663
|
+
const tools = generateMcpTools(store);
|
|
5664
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
5665
|
+
const result = await create.execute({
|
|
5666
|
+
notes: [{ content: "a", path: "Inbox/sum-nosummary" }],
|
|
5667
|
+
}) as any[];
|
|
5668
|
+
expect(Array.isArray(result)).toBe(true);
|
|
5669
|
+
expect(result[0].content).toBe("a");
|
|
5670
|
+
});
|
|
5671
|
+
});
|
|
5672
|
+
|
|
5191
5673
|
// ---------------------------------------------------------------------------
|
|
5192
5674
|
// Schema inheritance via parent_names + `_default` universal parent — vault#270
|
|
5193
5675
|
// ---------------------------------------------------------------------------
|