@openparachute/vault 0.7.3-rc.13 → 0.7.3-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -5
- package/core/src/conformance.ts +1 -2
- package/core/src/content-range.test.ts +0 -127
- package/core/src/content-range.ts +0 -100
- package/core/src/contract-typed-index.test.ts +3 -4
- package/core/src/core.test.ts +4 -521
- package/core/src/expand.ts +3 -11
- package/core/src/indexed-fields.test.ts +3 -9
- package/core/src/indexed-fields.ts +1 -9
- package/core/src/mcp.ts +10 -601
- package/core/src/notes.ts +4 -201
- package/core/src/schema-defaults.ts +1 -85
- package/core/src/search-fts-v25.test.ts +1 -9
- package/core/src/search-query.test.ts +0 -42
- package/core/src/search-query.ts +0 -27
- package/core/src/seed-packs.test.ts +1 -117
- package/core/src/seed-packs.ts +1 -217
- package/core/src/store.ts +3 -59
- package/core/src/tag-schemas.ts +12 -27
- package/core/src/types.ts +1 -7
- package/core/src/vault-projection.ts +0 -55
- package/package.json +1 -1
- package/src/add-pack.test.ts +0 -32
- package/src/auth-hub-jwt.test.ts +1 -118
- package/src/auth.ts +0 -64
- package/src/cli.ts +1 -5
- package/src/contract-errors.test.ts +1 -2
- package/src/mcp-http.ts +4 -33
- package/src/mcp-tools.ts +14 -61
- package/src/oauth-discovery.ts +0 -31
- package/src/onboarding-seed.test.ts +0 -64
- package/src/routes.ts +95 -92
- package/src/routing.test.ts +4 -229
- package/src/routing.ts +23 -152
- package/src/scopes.ts +0 -22
- package/src/server.ts +0 -7
- package/src/storage.test.ts +1 -200
- package/src/transcription-worker.test.ts +0 -151
- package/src/transcription-worker.ts +52 -113
- package/src/vault.test.ts +11 -48
- package/core/src/attachment/bytes-provider.ts +0 -65
- package/core/src/attachment/policy.test.ts +0 -66
- package/core/src/attachment/policy.ts +0 -131
- package/core/src/attachment/tickets.test.ts +0 -45
- package/core/src/attachment/tickets.ts +0 -117
- package/core/src/attachment-tickets-tool.test.ts +0 -286
- package/core/src/display-title.test.ts +0 -190
- package/core/src/lede.test.ts +0 -96
- package/core/src/search-title-boost.test.ts +0 -125
- package/src/attachment-bytes.ts +0 -68
- package/src/attachment-tickets.test.ts +0 -475
- package/src/attachment-tickets.ts +0 -340
- package/src/read-attachment.test.ts +0 -436
package/core/src/core.test.ts
CHANGED
|
@@ -1478,42 +1478,6 @@ describe("queryNotes", async () => {
|
|
|
1478
1478
|
expect(results.map((n) => n.content)).toEqual(["recent email"]);
|
|
1479
1479
|
});
|
|
1480
1480
|
|
|
1481
|
-
// ---- `date` field type (vault#date-field-type) ----
|
|
1482
|
-
//
|
|
1483
|
-
// Same date_filter machinery as `email_date` above, but declared via a
|
|
1484
|
-
// real tag schema (`type: "date", indexed: true`) rather than raw
|
|
1485
|
-
// `declareField`, and mixing both accepted ISO forms (bare date + full
|
|
1486
|
-
// RFC3339 timestamp) in one vault.
|
|
1487
|
-
it("date_filter works on a schema-declared, indexed `date` field, accepting both ISO forms", async () => {
|
|
1488
|
-
const tools = generateMcpTools(store);
|
|
1489
|
-
// Indexed-field reconciliation (declareField → generated column + index)
|
|
1490
|
-
// runs through the `update-tag` tool / `store.upsertTagRecord` — the
|
|
1491
|
-
// legacy `store.upsertTagSchema` facade writes description+fields only
|
|
1492
|
-
// and does NOT create the backing column. See store.ts's upsertTagRecord.
|
|
1493
|
-
const updateTag = tools.find((t) => t.name === "update-tag")!;
|
|
1494
|
-
await updateTag.execute({
|
|
1495
|
-
tag: "meeting",
|
|
1496
|
-
fields: { meeting_date: { type: "date", indexed: true } },
|
|
1497
|
-
});
|
|
1498
|
-
await store.createNote("too early", { tags: ["meeting"], metadata: { meeting_date: "2025-12-01" } });
|
|
1499
|
-
await store.createNote("in window (date-only)", { tags: ["meeting"], metadata: { meeting_date: "2026-04-15" } });
|
|
1500
|
-
await store.createNote("in window (full timestamp)", {
|
|
1501
|
-
tags: ["meeting"],
|
|
1502
|
-
metadata: { meeting_date: "2026-04-25T09:00:00.000Z" },
|
|
1503
|
-
});
|
|
1504
|
-
await store.createNote("too late", { tags: ["meeting"], metadata: { meeting_date: "2026-06-01" } });
|
|
1505
|
-
|
|
1506
|
-
const query = tools.find((t) => t.name === "query-notes")!;
|
|
1507
|
-
const results = await query.execute({
|
|
1508
|
-
date_filter: { field: "meeting_date", from: "2026-04-01", to: "2026-05-01" },
|
|
1509
|
-
include_content: true,
|
|
1510
|
-
}) as any[];
|
|
1511
|
-
expect(results.map((n) => n.content).sort()).toEqual([
|
|
1512
|
-
"in window (date-only)",
|
|
1513
|
-
"in window (full timestamp)",
|
|
1514
|
-
]);
|
|
1515
|
-
});
|
|
1516
|
-
|
|
1517
1481
|
// ---- updated_at filter (vault#285 friction point 1.5) ----
|
|
1518
1482
|
//
|
|
1519
1483
|
// Incremental-rebuild flows ask "what changed since X." Like `created_at`,
|
|
@@ -2266,50 +2230,6 @@ describe("queryNotes", async () => {
|
|
|
2266
2230
|
store.queryNotes({ metadata: { priority: { in: 5 } as any } }),
|
|
2267
2231
|
).rejects.toThrow(/expects an array/);
|
|
2268
2232
|
});
|
|
2269
|
-
|
|
2270
|
-
// ---- `date` field type: gt/gte/lt/lte + order_by (vault#date-field-type) ----
|
|
2271
|
-
//
|
|
2272
|
-
// Indexed `date` fields store TEXT (ISO-8601 strings compare correctly
|
|
2273
|
-
// lexicographically), so this exercises the SAME generic string-indexed-
|
|
2274
|
-
// field machinery `email_date`/`status` above already use — no
|
|
2275
|
-
// date-specific SQL was added. Uses a real tag schema (`type: "date",
|
|
2276
|
-
// indexed: true`), not raw `declareField`, to prove the wiring end to end.
|
|
2277
|
-
it("gt/gte/lt/lte compose into range queries on an indexed `date` field", async () => {
|
|
2278
|
-
// Indexed-field reconciliation (declareField → generated column +
|
|
2279
|
-
// index) runs through the `update-tag` tool / `store.upsertTagRecord`
|
|
2280
|
-
// — `store.upsertTagSchema` is the legacy description+fields-only
|
|
2281
|
-
// facade and does NOT create the backing column.
|
|
2282
|
-
const tools = generateMcpTools(store);
|
|
2283
|
-
const updateTag = tools.find((t) => t.name === "update-tag")!;
|
|
2284
|
-
await updateTag.execute({
|
|
2285
|
-
tag: "meeting",
|
|
2286
|
-
fields: { meeting_date: { type: "date", indexed: true } },
|
|
2287
|
-
});
|
|
2288
|
-
await store.createNote("jan", { tags: ["meeting"], metadata: { meeting_date: "2026-01-15" } });
|
|
2289
|
-
await store.createNote("apr", { tags: ["meeting"], metadata: { meeting_date: "2026-04-15" } });
|
|
2290
|
-
await store.createNote("jul", { tags: ["meeting"], metadata: { meeting_date: "2026-07-09T14:30:00.000Z" } });
|
|
2291
|
-
await store.createNote("oct", { tags: ["meeting"], metadata: { meeting_date: "2026-10-15" } });
|
|
2292
|
-
|
|
2293
|
-
const range = await store.queryNotes({
|
|
2294
|
-
metadata: { meeting_date: { gte: "2026-04-01", lt: "2026-10-01" } },
|
|
2295
|
-
});
|
|
2296
|
-
expect(range.map((n) => n.content).sort()).toEqual(["apr", "jul"]);
|
|
2297
|
-
});
|
|
2298
|
-
|
|
2299
|
-
it("order_by sorts by an indexed `date` field", async () => {
|
|
2300
|
-
const tools = generateMcpTools(store);
|
|
2301
|
-
const updateTag = tools.find((t) => t.name === "update-tag")!;
|
|
2302
|
-
await updateTag.execute({
|
|
2303
|
-
tag: "meeting",
|
|
2304
|
-
fields: { meeting_date: { type: "date", indexed: true } },
|
|
2305
|
-
});
|
|
2306
|
-
await store.createNote("later", { tags: ["meeting"], metadata: { meeting_date: "2026-07-09" } });
|
|
2307
|
-
await store.createNote("earlier", { tags: ["meeting"], metadata: { meeting_date: "2026-01-15" } });
|
|
2308
|
-
await store.createNote("middle", { tags: ["meeting"], metadata: { meeting_date: "2026-04-01" } });
|
|
2309
|
-
|
|
2310
|
-
const asc = await store.queryNotes({ orderBy: "meeting_date" });
|
|
2311
|
-
expect(asc.map((n) => n.content)).toEqual(["earlier", "middle", "later"]);
|
|
2312
|
-
});
|
|
2313
2233
|
});
|
|
2314
2234
|
});
|
|
2315
2235
|
|
|
@@ -4636,54 +4556,11 @@ describe("MCP tools", async () => {
|
|
|
4636
4556
|
e: { type: "array" },
|
|
4637
4557
|
f: { type: "object" },
|
|
4638
4558
|
g: { type: "reference" },
|
|
4639
|
-
h: { type: "date" },
|
|
4640
4559
|
},
|
|
4641
4560
|
}) as any;
|
|
4642
4561
|
expect(result.fields.a.type).toBe("string");
|
|
4643
4562
|
expect(result.fields.f.type).toBe("object");
|
|
4644
4563
|
expect(result.fields.g.type).toBe("reference");
|
|
4645
|
-
expect(result.fields.h.type).toBe("date");
|
|
4646
|
-
});
|
|
4647
|
-
|
|
4648
|
-
// vault#date-field-type: `date` stores/validates like `string` — an
|
|
4649
|
-
// ISO-8601 date or full RFC3339 timestamp — reusing cursor.ts's
|
|
4650
|
-
// `timestampToMs` (the same parser `date_filter`'s `updated_at` bound
|
|
4651
|
-
// uses) rather than a second, independently-drifting date parser.
|
|
4652
|
-
it("update-tag accepts a `date` field's default in both ISO forms", async () => {
|
|
4653
|
-
const tools = generateMcpTools(store);
|
|
4654
|
-
const updateTag = tools.find((t) => t.name === "update-tag")!;
|
|
4655
|
-
const dateOnly = await updateTag.execute({
|
|
4656
|
-
tag: "meeting",
|
|
4657
|
-
fields: { meeting_date: { type: "date", default: "2026-07-09" } },
|
|
4658
|
-
}) as any;
|
|
4659
|
-
expect(dateOnly.fields.meeting_date.default).toBe("2026-07-09");
|
|
4660
|
-
|
|
4661
|
-
const fullTimestamp = await updateTag.execute({
|
|
4662
|
-
tag: "meeting",
|
|
4663
|
-
fields: { meeting_date: { type: "date", default: "2026-07-09T14:30:00.000Z" } },
|
|
4664
|
-
}) as any;
|
|
4665
|
-
expect(fullTimestamp.fields.meeting_date.default).toBe("2026-07-09T14:30:00.000Z");
|
|
4666
|
-
});
|
|
4667
|
-
|
|
4668
|
-
it("update-tag rejects a non-ISO default on a `date` field (invalid_default)", async () => {
|
|
4669
|
-
const tools = generateMcpTools(store);
|
|
4670
|
-
const updateTag = tools.find((t) => t.name === "update-tag")!;
|
|
4671
|
-
let caught: any;
|
|
4672
|
-
try {
|
|
4673
|
-
await updateTag.execute({
|
|
4674
|
-
tag: "meeting",
|
|
4675
|
-
fields: { meeting_date: { type: "date", default: "not-a-date" } },
|
|
4676
|
-
});
|
|
4677
|
-
} catch (e) {
|
|
4678
|
-
caught = e;
|
|
4679
|
-
}
|
|
4680
|
-
expect(caught).toBeDefined();
|
|
4681
|
-
expect(caught.error_type).toBe("tag_field_conflict");
|
|
4682
|
-
expect(caught.violations[0].field).toBe("meeting_date");
|
|
4683
|
-
expect(caught.violations[0].reason).toBe("invalid_default");
|
|
4684
|
-
// Nothing persisted.
|
|
4685
|
-
const record = await store.getTagRecord("meeting");
|
|
4686
|
-
expect(record?.fields ?? null).toBeFalsy();
|
|
4687
4564
|
});
|
|
4688
4565
|
|
|
4689
4566
|
// ---- Typed reference field: indexed value + auto-link (vault#typed-reference-field) ----
|
|
@@ -5893,10 +5770,8 @@ describe("query-notes link expansion", async () => {
|
|
|
5893
5770
|
expect(result[0].preview).toBeTruthy();
|
|
5894
5771
|
});
|
|
5895
5772
|
|
|
5896
|
-
it("expand_mode=summary with no metadata.summary
|
|
5897
|
-
|
|
5898
|
-
// paragraph after it for computeLede to fall back to.
|
|
5899
|
-
await store.createNote("unsummarized title-only body", { path: "Plain" });
|
|
5773
|
+
it("expand_mode=summary with no metadata.summary renders empty body inline", async () => {
|
|
5774
|
+
await store.createNote("unsummarized body", { path: "Plain" });
|
|
5900
5775
|
await store.createNote("see [[Plain]]", { path: "Src" });
|
|
5901
5776
|
const tools = generateMcpTools(store);
|
|
5902
5777
|
const query = tools.find((t) => t.name === "query-notes")!;
|
|
@@ -5907,68 +5782,8 @@ describe("query-notes link expansion", async () => {
|
|
|
5907
5782
|
expand_mode: "summary",
|
|
5908
5783
|
}) as any;
|
|
5909
5784
|
expect(result.content).toContain('mode="summary"');
|
|
5910
|
-
//
|
|
5911
|
-
expect(result.content).not.toContain("unsummarized
|
|
5912
|
-
});
|
|
5913
|
-
|
|
5914
|
-
it("expand_mode=summary with no metadata.summary falls back to the note's lede", async () => {
|
|
5915
|
-
await store.createNote(
|
|
5916
|
-
"# Long canonical statement\n\nUnforced / wu wei, in one paragraph.\n\n(Many paragraphs of detail follow...)",
|
|
5917
|
-
{ path: "Statements/NoSummary" },
|
|
5918
|
-
);
|
|
5919
|
-
await store.createNote("Overview: [[Statements/NoSummary]]", { path: "Index2" });
|
|
5920
|
-
const tools = generateMcpTools(store);
|
|
5921
|
-
const query = tools.find((t) => t.name === "query-notes")!;
|
|
5922
|
-
|
|
5923
|
-
const result = await query.execute({
|
|
5924
|
-
id: "Index2",
|
|
5925
|
-
expand_links: true,
|
|
5926
|
-
expand_mode: "summary",
|
|
5927
|
-
}) as any;
|
|
5928
|
-
|
|
5929
|
-
expect(result.content).toContain('mode="summary"');
|
|
5930
|
-
expect(result.content).toContain("Unforced / wu wei, in one paragraph.");
|
|
5931
|
-
expect(result.content).not.toContain("Many paragraphs of detail");
|
|
5932
|
-
// The heading/title itself isn't repeated as the summary.
|
|
5933
|
-
expect(result.content).not.toContain("Long canonical statement");
|
|
5934
|
-
});
|
|
5935
|
-
|
|
5936
|
-
it("expand_mode=summary: metadata.summary still wins over the lede when both are present", async () => {
|
|
5937
|
-
await store.createNote(
|
|
5938
|
-
"# Title\n\nThis is the lede paragraph, not the summary.",
|
|
5939
|
-
{ path: "Statements/Both", metadata: { summary: "The curated summary." } },
|
|
5940
|
-
);
|
|
5941
|
-
await store.createNote("see [[Statements/Both]]", { path: "Index3" });
|
|
5942
|
-
const tools = generateMcpTools(store);
|
|
5943
|
-
const query = tools.find((t) => t.name === "query-notes")!;
|
|
5944
|
-
|
|
5945
|
-
const result = await query.execute({
|
|
5946
|
-
id: "Index3",
|
|
5947
|
-
expand_links: true,
|
|
5948
|
-
expand_mode: "summary",
|
|
5949
|
-
}) as any;
|
|
5950
|
-
|
|
5951
|
-
expect(result.content).toContain("The curated summary.");
|
|
5952
|
-
expect(result.content).not.toContain("This is the lede paragraph");
|
|
5953
|
-
});
|
|
5954
|
-
|
|
5955
|
-
it("expand_mode=summary lede fallback respects a leading frontmatter block", async () => {
|
|
5956
|
-
await store.createNote(
|
|
5957
|
-
"---\ntitle: X\n---\n# Real Title\n\nThe lede after frontmatter and title.",
|
|
5958
|
-
{ path: "Statements/Frontmatter" },
|
|
5959
|
-
);
|
|
5960
|
-
await store.createNote("see [[Statements/Frontmatter]]", { path: "Index4" });
|
|
5961
|
-
const tools = generateMcpTools(store);
|
|
5962
|
-
const query = tools.find((t) => t.name === "query-notes")!;
|
|
5963
|
-
|
|
5964
|
-
const result = await query.execute({
|
|
5965
|
-
id: "Index4",
|
|
5966
|
-
expand_links: true,
|
|
5967
|
-
expand_mode: "summary",
|
|
5968
|
-
}) as any;
|
|
5969
|
-
|
|
5970
|
-
expect(result.content).toContain("The lede after frontmatter and title.");
|
|
5971
|
-
expect(result.content).not.toContain("Real Title");
|
|
5785
|
+
// Summary is empty — we still get the block but with nothing between delimiters.
|
|
5786
|
+
expect(result.content).not.toContain("unsummarized body");
|
|
5972
5787
|
});
|
|
5973
5788
|
});
|
|
5974
5789
|
|
|
@@ -6235,113 +6050,6 @@ describe("schema validation (tags.fields)", async () => {
|
|
|
6235
6050
|
expect(result.validation_status.warnings[0].reason).toBe("enum_mismatch");
|
|
6236
6051
|
});
|
|
6237
6052
|
|
|
6238
|
-
// ---- `date` field type (vault#date-field-type) ----
|
|
6239
|
-
//
|
|
6240
|
-
// Motivation: today a date-ish field (e.g. a `meeting` tag's
|
|
6241
|
-
// `meeting_date`) can only be declared `type: "string"`, with "ISO date"
|
|
6242
|
-
// explained in prose — nothing can programmatically discover
|
|
6243
|
-
// date-candidate fields for a calendar view. `date` validates like
|
|
6244
|
-
// `string` but requires an ISO-8601 date or full RFC3339 timestamp,
|
|
6245
|
-
// parsed by the SAME `timestampToMs` (cursor.ts) `date_filter`'s
|
|
6246
|
-
// `updated_at` bound uses.
|
|
6247
|
-
|
|
6248
|
-
it("no warning when a `date` field's value is a bare ISO date (YYYY-MM-DD)", async () => {
|
|
6249
|
-
await store.upsertTagSchema("meeting", {
|
|
6250
|
-
fields: { meeting_date: { type: "date" } },
|
|
6251
|
-
});
|
|
6252
|
-
const tools = generateMcpTools(store);
|
|
6253
|
-
const create = tools.find((t) => t.name === "create-note")!;
|
|
6254
|
-
const result = await create.execute({
|
|
6255
|
-
content: "standup",
|
|
6256
|
-
tags: ["meeting"],
|
|
6257
|
-
metadata: { meeting_date: "2026-07-09" },
|
|
6258
|
-
}) as any;
|
|
6259
|
-
expect(result.validation_status.warnings).toEqual([]);
|
|
6260
|
-
});
|
|
6261
|
-
|
|
6262
|
-
it("no warning when a `date` field's value is a full RFC3339 timestamp", async () => {
|
|
6263
|
-
await store.upsertTagSchema("meeting", {
|
|
6264
|
-
fields: { meeting_date: { type: "date" } },
|
|
6265
|
-
});
|
|
6266
|
-
const tools = generateMcpTools(store);
|
|
6267
|
-
const create = tools.find((t) => t.name === "create-note")!;
|
|
6268
|
-
const result = await create.execute({
|
|
6269
|
-
content: "standup",
|
|
6270
|
-
tags: ["meeting"],
|
|
6271
|
-
metadata: { meeting_date: "2026-07-09T14:30:00.000Z" },
|
|
6272
|
-
}) as any;
|
|
6273
|
-
expect(result.validation_status.warnings).toEqual([]);
|
|
6274
|
-
});
|
|
6275
|
-
|
|
6276
|
-
it("type_mismatch warning when a `date` field's value isn't a parseable ISO string", async () => {
|
|
6277
|
-
await store.upsertTagSchema("meeting", {
|
|
6278
|
-
fields: { meeting_date: { type: "date" } },
|
|
6279
|
-
});
|
|
6280
|
-
const tools = generateMcpTools(store);
|
|
6281
|
-
const create = tools.find((t) => t.name === "create-note")!;
|
|
6282
|
-
const result = await create.execute({
|
|
6283
|
-
content: "standup",
|
|
6284
|
-
tags: ["meeting"],
|
|
6285
|
-
metadata: { meeting_date: "not-a-date" },
|
|
6286
|
-
}) as any;
|
|
6287
|
-
expect(result.validation_status.warnings).toHaveLength(1);
|
|
6288
|
-
expect(result.validation_status.warnings[0].reason).toBe("type_mismatch");
|
|
6289
|
-
expect(result.validation_status.warnings[0].field).toBe("meeting_date");
|
|
6290
|
-
});
|
|
6291
|
-
|
|
6292
|
-
it("a `date` field's type_mismatch is a HARD REJECTION under strict:true, same as any other type", async () => {
|
|
6293
|
-
await store.upsertTagSchema("meeting", {
|
|
6294
|
-
fields: { meeting_date: { type: "date", strict: true } },
|
|
6295
|
-
});
|
|
6296
|
-
const tools = generateMcpTools(store);
|
|
6297
|
-
const create = tools.find((t) => t.name === "create-note")!;
|
|
6298
|
-
await expect(create.execute({
|
|
6299
|
-
content: "standup",
|
|
6300
|
-
tags: ["meeting"],
|
|
6301
|
-
metadata: { meeting_date: "not-a-date" },
|
|
6302
|
-
})).rejects.toThrow();
|
|
6303
|
-
});
|
|
6304
|
-
|
|
6305
|
-
it("an indexed `date` field's type_mismatch is ALWAYS a hard rejection, independent of strict (vault#553 Decision A)", async () => {
|
|
6306
|
-
await store.upsertTagSchema("meeting", {
|
|
6307
|
-
fields: { meeting_date: { type: "date", indexed: true } },
|
|
6308
|
-
});
|
|
6309
|
-
const tools = generateMcpTools(store);
|
|
6310
|
-
const create = tools.find((t) => t.name === "create-note")!;
|
|
6311
|
-
await expect(create.execute({
|
|
6312
|
-
content: "standup",
|
|
6313
|
-
tags: ["meeting"],
|
|
6314
|
-
metadata: { meeting_date: "not-a-date" },
|
|
6315
|
-
})).rejects.toThrow();
|
|
6316
|
-
});
|
|
6317
|
-
|
|
6318
|
-
// Sharp edge (docs/HTTP_API.md's "type: date" callout): a schema edit
|
|
6319
|
-
// that tightens an existing field from `string` to `date` does NOT
|
|
6320
|
-
// retroactively revalidate notes already carrying a non-ISO value —
|
|
6321
|
-
// only the field's NEXT write is checked. No migration, no data change.
|
|
6322
|
-
it("tightening an existing field from string to date does not retroactively touch existing notes", async () => {
|
|
6323
|
-
await store.upsertTagSchema("meeting", {
|
|
6324
|
-
fields: { meeting_date: { type: "string" } },
|
|
6325
|
-
});
|
|
6326
|
-
const tools = generateMcpTools(store);
|
|
6327
|
-
const create = tools.find((t) => t.name === "create-note")!;
|
|
6328
|
-
const query = tools.find((t) => t.name === "query-notes")!;
|
|
6329
|
-
const note = await create.execute({
|
|
6330
|
-
content: "old-style note",
|
|
6331
|
-
tags: ["meeting"],
|
|
6332
|
-
metadata: { meeting_date: "sometime next week" }, // not ISO — fine under type:"string"
|
|
6333
|
-
}) as any;
|
|
6334
|
-
expect(note.validation_status.warnings).toEqual([]);
|
|
6335
|
-
|
|
6336
|
-
// Tighten the schema. This must not touch the existing note.
|
|
6337
|
-
await store.upsertTagSchema("meeting", {
|
|
6338
|
-
fields: { meeting_date: { type: "date" } },
|
|
6339
|
-
});
|
|
6340
|
-
|
|
6341
|
-
const fresh = await query.execute({ id: note.id }) as any;
|
|
6342
|
-
expect(fresh.metadata.meeting_date).toBe("sometime next week");
|
|
6343
|
-
});
|
|
6344
|
-
|
|
6345
6053
|
// vault#555 fix 3 — an enum-membership violation on an INDEXED
|
|
6346
6054
|
// (non-strict) field is stored, findable via `eq` (indexed⇒strict is
|
|
6347
6055
|
// TYPE-only, not enum-domain — a ratified policy, not a bug), but the
|
|
@@ -6638,212 +6346,6 @@ describe("schema validation (tags.fields)", async () => {
|
|
|
6638
6346
|
});
|
|
6639
6347
|
});
|
|
6640
6348
|
|
|
6641
|
-
// ---------------------------------------------------------------------------
|
|
6642
|
-
// `date` field type — offset normalization on write (vault#date-field-type
|
|
6643
|
-
// review fix). A raw TEXT compare only sorts ISO-8601 timestamps correctly
|
|
6644
|
-
// when every stored value shares the SAME offset representation.
|
|
6645
|
-
// `timestampToMs` correctly VALIDATES an explicit ±HH:MM offset, but the
|
|
6646
|
-
// value used to persist VERBATIM — a mixed-offset vault silently
|
|
6647
|
-
// mis-ordered/mis-filtered. `normalizeDateFields` (schema-defaults.ts),
|
|
6648
|
-
// wired into every store write path, rewrites a full timestamp to canonical
|
|
6649
|
-
// UTC (`Z`-suffixed) before the row is written.
|
|
6650
|
-
// ---------------------------------------------------------------------------
|
|
6651
|
-
|
|
6652
|
-
describe("date field type — offset normalization (vault#date-field-type review fix)", async () => {
|
|
6653
|
-
async function declareIndexedMeetingDate() {
|
|
6654
|
-
const tools = generateMcpTools(store);
|
|
6655
|
-
const updateTag = tools.find((t) => t.name === "update-tag")!;
|
|
6656
|
-
await updateTag.execute({
|
|
6657
|
-
tag: "meeting",
|
|
6658
|
-
fields: { meeting_date: { type: "date", indexed: true } },
|
|
6659
|
-
});
|
|
6660
|
-
return tools;
|
|
6661
|
-
}
|
|
6662
|
-
|
|
6663
|
-
// The reviewer's exact repro: "2026-07-16T10:00:00+02:00" (= 08:00Z) vs
|
|
6664
|
-
// "2026-07-16T09:00:00Z" (= 09:00Z). Persisted verbatim, a raw string
|
|
6665
|
-
// compare gets these BACKWARDS (comparing "10" vs "09" after the shared
|
|
6666
|
-
// "2026-07-16T" prefix says the +02:00 value sorts LATER, when its actual
|
|
6667
|
-
// instant — 08:00Z — is EARLIER). Post-normalization both values are
|
|
6668
|
-
// Z-form UTC, so the string compare IS the instant compare.
|
|
6669
|
-
it("order_by ASC returns mixed-offset values in true chronological order", async () => {
|
|
6670
|
-
const tools = await declareIndexedMeetingDate();
|
|
6671
|
-
const create = tools.find((t) => t.name === "create-note")!;
|
|
6672
|
-
await create.execute({
|
|
6673
|
-
content: "earlier (offset form)",
|
|
6674
|
-
tags: ["meeting"],
|
|
6675
|
-
metadata: { meeting_date: "2026-07-16T10:00:00+02:00" }, // = 08:00Z
|
|
6676
|
-
});
|
|
6677
|
-
await create.execute({
|
|
6678
|
-
content: "later (Z form)",
|
|
6679
|
-
tags: ["meeting"],
|
|
6680
|
-
metadata: { meeting_date: "2026-07-16T09:00:00Z" }, // = 09:00Z
|
|
6681
|
-
});
|
|
6682
|
-
|
|
6683
|
-
const asc = await store.queryNotes({ orderBy: "meeting_date" });
|
|
6684
|
-
expect(asc.map((n) => n.content)).toEqual(["earlier (offset form)", "later (Z form)"]);
|
|
6685
|
-
});
|
|
6686
|
-
|
|
6687
|
-
it("a gt bound correctly excludes an earlier instant expressed with an offset", async () => {
|
|
6688
|
-
const tools = await declareIndexedMeetingDate();
|
|
6689
|
-
const create = tools.find((t) => t.name === "create-note")!;
|
|
6690
|
-
await create.execute({
|
|
6691
|
-
content: "before threshold (offset form, = 08:00Z)",
|
|
6692
|
-
tags: ["meeting"],
|
|
6693
|
-
metadata: { meeting_date: "2026-07-16T10:00:00+02:00" },
|
|
6694
|
-
});
|
|
6695
|
-
await create.execute({
|
|
6696
|
-
content: "after threshold (Z form, = 09:00Z)",
|
|
6697
|
-
tags: ["meeting"],
|
|
6698
|
-
metadata: { meeting_date: "2026-07-16T09:00:00Z" },
|
|
6699
|
-
});
|
|
6700
|
-
|
|
6701
|
-
// Threshold sits BETWEEN the two instants (08:30Z). Pre-fix, the
|
|
6702
|
-
// unnormalized "+02:00" value's raw string ("...10:00:00+02:00")
|
|
6703
|
-
// compared GREATER than the threshold string despite its instant being
|
|
6704
|
-
// earlier — a false match. Post-fix it correctly falls below.
|
|
6705
|
-
const results = await store.queryNotes({
|
|
6706
|
-
metadata: { meeting_date: { gt: "2026-07-16T08:30:00Z" } },
|
|
6707
|
-
});
|
|
6708
|
-
expect(results.map((n) => n.content)).toEqual(["after threshold (Z form, = 09:00Z)"]);
|
|
6709
|
-
});
|
|
6710
|
-
|
|
6711
|
-
it("create-note normalizes an offset timestamp to canonical Z-form; the stored/returned value is not the verbatim input", async () => {
|
|
6712
|
-
const tools = await declareIndexedMeetingDate();
|
|
6713
|
-
const create = tools.find((t) => t.name === "create-note")!;
|
|
6714
|
-
const query = tools.find((t) => t.name === "query-notes")!;
|
|
6715
|
-
const created = await create.execute({
|
|
6716
|
-
content: "standup",
|
|
6717
|
-
tags: ["meeting"],
|
|
6718
|
-
metadata: { meeting_date: "2026-07-16T10:00:00+02:00" },
|
|
6719
|
-
}) as any;
|
|
6720
|
-
|
|
6721
|
-
// The create response itself already reflects the normalized value...
|
|
6722
|
-
expect(created.metadata.meeting_date).toBe("2026-07-16T08:00:00.000Z");
|
|
6723
|
-
// ...and so does a fresh read-back (round-trips through the DB, not an echo).
|
|
6724
|
-
const fresh = await query.execute({ id: created.id }) as any;
|
|
6725
|
-
expect(fresh.metadata.meeting_date).toBe("2026-07-16T08:00:00.000Z");
|
|
6726
|
-
});
|
|
6727
|
-
|
|
6728
|
-
it("update-note (merge-patch) normalizes an offset timestamp the same way create-note does", async () => {
|
|
6729
|
-
const tools = await declareIndexedMeetingDate();
|
|
6730
|
-
const create = tools.find((t) => t.name === "create-note")!;
|
|
6731
|
-
const update = tools.find((t) => t.name === "update-note")!;
|
|
6732
|
-
const query = tools.find((t) => t.name === "query-notes")!;
|
|
6733
|
-
const created = await create.execute({
|
|
6734
|
-
content: "standup",
|
|
6735
|
-
tags: ["meeting"],
|
|
6736
|
-
}) as any;
|
|
6737
|
-
|
|
6738
|
-
await update.execute({
|
|
6739
|
-
id: created.id,
|
|
6740
|
-
metadata: { meeting_date: "2026-07-16T10:00:00+02:00" },
|
|
6741
|
-
force: true,
|
|
6742
|
-
});
|
|
6743
|
-
|
|
6744
|
-
const fresh = await query.execute({ id: created.id }) as any;
|
|
6745
|
-
expect(fresh.metadata.meeting_date).toBe("2026-07-16T08:00:00.000Z");
|
|
6746
|
-
});
|
|
6747
|
-
|
|
6748
|
-
// ---- round 2: tag-add + date-metadata in the SAME call (vault#date-
|
|
6749
|
-
// field-type review round 2) ----
|
|
6750
|
-
//
|
|
6751
|
-
// `store.tagNote` (the actual tag mutation) runs AFTER the core UPDATE in
|
|
6752
|
-
// both mcp.ts call sites that combine tags + metadata in one item — the
|
|
6753
|
-
// reviewer live-verified that without passing the PROJECTED tag set into
|
|
6754
|
-
// `store.updateNote`, normalization resolved the schema against the
|
|
6755
|
-
// note's STALE pre-write tags and missed a field newly declared by the
|
|
6756
|
-
// tag being added in this same call.
|
|
6757
|
-
|
|
6758
|
-
it("update-note: tags.add + an offset date value in the SAME call still normalizes (bare, untagged note)", async () => {
|
|
6759
|
-
const tools = await declareIndexedMeetingDate();
|
|
6760
|
-
const create = tools.find((t) => t.name === "create-note")!;
|
|
6761
|
-
const update = tools.find((t) => t.name === "update-note")!;
|
|
6762
|
-
const query = tools.find((t) => t.name === "query-notes")!;
|
|
6763
|
-
// Bare, untagged note — "meeting" (and its meeting_date:date field) is
|
|
6764
|
-
// NOT yet on this note; it's added in the SAME update-note call below.
|
|
6765
|
-
const created = await create.execute({ content: "standup" }) as any;
|
|
6766
|
-
|
|
6767
|
-
const result = await update.execute({
|
|
6768
|
-
id: created.id,
|
|
6769
|
-
tags: { add: ["meeting"] },
|
|
6770
|
-
metadata: { meeting_date: "2026-07-16T10:00:00+02:00" },
|
|
6771
|
-
force: true,
|
|
6772
|
-
}) as any;
|
|
6773
|
-
|
|
6774
|
-
expect(result.metadata.meeting_date).toBe("2026-07-16T08:00:00.000Z");
|
|
6775
|
-
const fresh = await query.execute({ id: created.id }) as any;
|
|
6776
|
-
expect(fresh.metadata.meeting_date).toBe("2026-07-16T08:00:00.000Z");
|
|
6777
|
-
expect(fresh.tags).toContain("meeting");
|
|
6778
|
-
});
|
|
6779
|
-
|
|
6780
|
-
it("create-note if_exists:update: tags + an offset date value in the SAME call still normalizes (bare, untagged note)", async () => {
|
|
6781
|
-
const tools = await declareIndexedMeetingDate();
|
|
6782
|
-
const create = tools.find((t) => t.name === "create-note")!;
|
|
6783
|
-
const query = tools.find((t) => t.name === "query-notes")!;
|
|
6784
|
-
const created = await create.execute({ content: "standup", path: "Inbox/round2-upsert" }) as any;
|
|
6785
|
-
|
|
6786
|
-
const result = await create.execute({
|
|
6787
|
-
path: "Inbox/round2-upsert",
|
|
6788
|
-
tags: ["meeting"],
|
|
6789
|
-
metadata: { meeting_date: "2026-07-16T10:00:00+02:00" },
|
|
6790
|
-
if_exists: "update",
|
|
6791
|
-
}) as any;
|
|
6792
|
-
|
|
6793
|
-
expect(result.metadata.meeting_date).toBe("2026-07-16T08:00:00.000Z");
|
|
6794
|
-
const fresh = await query.execute({ id: created.id }) as any;
|
|
6795
|
-
expect(fresh.metadata.meeting_date).toBe("2026-07-16T08:00:00.000Z");
|
|
6796
|
-
expect(fresh.tags).toContain("meeting");
|
|
6797
|
-
});
|
|
6798
|
-
|
|
6799
|
-
// ---- round 2: copy-on-write, never mutate the caller's object ----
|
|
6800
|
-
|
|
6801
|
-
it("store.createNote does NOT mutate the caller's metadata object", async () => {
|
|
6802
|
-
await declareIndexedMeetingDate();
|
|
6803
|
-
const callerMetadata = { meeting_date: "2026-07-16T10:00:00+02:00" };
|
|
6804
|
-
const callerMetadataSnapshot = { ...callerMetadata };
|
|
6805
|
-
await store.createNote("standup", { tags: ["meeting"], metadata: callerMetadata });
|
|
6806
|
-
// The caller's own object must be byte-identical to before the call —
|
|
6807
|
-
// core is a published library; a direct embedder's reference must
|
|
6808
|
-
// never change out from under them.
|
|
6809
|
-
expect(callerMetadata).toEqual(callerMetadataSnapshot);
|
|
6810
|
-
});
|
|
6811
|
-
|
|
6812
|
-
it("store.updateNote does NOT mutate the caller's metadata object", async () => {
|
|
6813
|
-
await declareIndexedMeetingDate();
|
|
6814
|
-
const note = await store.createNote("standup", { tags: ["meeting"] });
|
|
6815
|
-
const callerMetadata = { meeting_date: "2026-07-16T10:00:00+02:00" };
|
|
6816
|
-
const callerMetadataSnapshot = { ...callerMetadata };
|
|
6817
|
-
await store.updateNote(note.id, { metadata: callerMetadata });
|
|
6818
|
-
expect(callerMetadata).toEqual(callerMetadataSnapshot);
|
|
6819
|
-
});
|
|
6820
|
-
|
|
6821
|
-
it("a bare YYYY-MM-DD value passes through untouched (no offset to normalize)", async () => {
|
|
6822
|
-
const tools = await declareIndexedMeetingDate();
|
|
6823
|
-
const create = tools.find((t) => t.name === "create-note")!;
|
|
6824
|
-
const query = tools.find((t) => t.name === "query-notes")!;
|
|
6825
|
-
const created = await create.execute({
|
|
6826
|
-
content: "standup",
|
|
6827
|
-
tags: ["meeting"],
|
|
6828
|
-
metadata: { meeting_date: "2026-07-16" },
|
|
6829
|
-
}) as any;
|
|
6830
|
-
expect(created.metadata.meeting_date).toBe("2026-07-16");
|
|
6831
|
-
const fresh = await query.execute({ id: created.id }) as any;
|
|
6832
|
-
expect(fresh.metadata.meeting_date).toBe("2026-07-16");
|
|
6833
|
-
});
|
|
6834
|
-
|
|
6835
|
-
it("a value already in canonical Z-form passes through unchanged (no spurious rewrite)", async () => {
|
|
6836
|
-
const tools = await declareIndexedMeetingDate();
|
|
6837
|
-
const create = tools.find((t) => t.name === "create-note")!;
|
|
6838
|
-
const created = await create.execute({
|
|
6839
|
-
content: "standup",
|
|
6840
|
-
tags: ["meeting"],
|
|
6841
|
-
metadata: { meeting_date: "2026-07-16T09:00:00.000Z" },
|
|
6842
|
-
}) as any;
|
|
6843
|
-
expect(created.metadata.meeting_date).toBe("2026-07-16T09:00:00.000Z");
|
|
6844
|
-
});
|
|
6845
|
-
});
|
|
6846
|
-
|
|
6847
6349
|
// ---------------------------------------------------------------------------
|
|
6848
6350
|
// update-note `if_missing: "create"` — idempotent upsert (vault#309)
|
|
6849
6351
|
// ---------------------------------------------------------------------------
|
|
@@ -8626,25 +8128,6 @@ describe("vault projection (vault#271)", async () => {
|
|
|
8626
8128
|
expect(byName.priority.tags).toEqual(["project"]);
|
|
8627
8129
|
});
|
|
8628
8130
|
|
|
8629
|
-
// vault#date-field-type: `type: "date"` carries through the indexed-field
|
|
8630
|
-
// catalog like any other type — nothing in the projection filters unknown
|
|
8631
|
-
// or newer types out.
|
|
8632
|
-
it("catalogs an indexed `date` field with its declared type", async () => {
|
|
8633
|
-
const { buildVaultProjection } = await import("./vault-projection.ts");
|
|
8634
|
-
const tools = generateMcpTools(store);
|
|
8635
|
-
const updateTag = tools.find((t) => t.name === "update-tag")!;
|
|
8636
|
-
await updateTag.execute({
|
|
8637
|
-
tag: "meeting",
|
|
8638
|
-
fields: { meeting_date: { type: "date", indexed: true } },
|
|
8639
|
-
});
|
|
8640
|
-
|
|
8641
|
-
const projection = buildVaultProjection(db);
|
|
8642
|
-
const byName = Object.fromEntries(projection.indexed_fields.map((f) => [f.name, f]));
|
|
8643
|
-
expect(byName.meeting_date).toBeTruthy();
|
|
8644
|
-
expect(byName.meeting_date.type).toBe("date");
|
|
8645
|
-
expect(byName.meeting_date.tags).toEqual(["meeting"]);
|
|
8646
|
-
});
|
|
8647
|
-
|
|
8648
8131
|
it("includes the static query-hint catalog", async () => {
|
|
8649
8132
|
const { buildVaultProjection, QUERY_HINTS } = await import("./vault-projection.ts");
|
|
8650
8133
|
const projection = buildVaultProjection(db);
|
package/core/src/expand.ts
CHANGED
|
@@ -3,9 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Used by `query-notes` when `expand_links=true`. Replaces wikilink matches
|
|
5
5
|
* with delimited blocks containing the linked note's content (full mode) or
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* guards against cycles via a shared `expanded` set.
|
|
6
|
+
* metadata summary (summary mode). Deduplicates across the query and guards
|
|
7
|
+
* against cycles via a shared `expanded` set.
|
|
9
8
|
*/
|
|
10
9
|
|
|
11
10
|
import { Database } from "bun:sqlite";
|
|
@@ -153,18 +152,11 @@ function renderSummary(note: Note): string {
|
|
|
153
152
|
return `<expanded path="${pathAttr}" mode="summary">\n${summary}\n</expanded>`;
|
|
154
153
|
}
|
|
155
154
|
|
|
156
|
-
/**
|
|
157
|
-
* `metadata.summary` wins when present (no behavior change for existing
|
|
158
|
-
* callers). Absent it, falls back to the target note's lede — its opening
|
|
159
|
-
* paragraph, per `computeLede` — so a note that was never given a
|
|
160
|
-
* `metadata.summary` still yields something useful in summary-mode
|
|
161
|
-
* expansion instead of an empty block.
|
|
162
|
-
*/
|
|
163
155
|
function summaryText(note: Note): string {
|
|
164
156
|
const meta = note.metadata as Record<string, unknown> | undefined;
|
|
165
157
|
const s = meta?.summary;
|
|
166
158
|
if (typeof s === "string" && s.trim()) return s.trim();
|
|
167
|
-
return
|
|
159
|
+
return "";
|
|
168
160
|
}
|
|
169
161
|
|
|
170
162
|
function escapeAttr(s: string): string {
|
|
@@ -67,16 +67,13 @@ describe("indexed-fields: module", () => {
|
|
|
67
67
|
expect(() => validateFieldName("'; DROP TABLE notes; --")).toThrow(IndexedFieldError);
|
|
68
68
|
});
|
|
69
69
|
|
|
70
|
-
it("TYPE_MAP covers string/integer/boolean/reference
|
|
70
|
+
it("TYPE_MAP covers string/integer/boolean/reference", () => {
|
|
71
71
|
expect(TYPE_MAP.string).toBe("TEXT");
|
|
72
72
|
expect(TYPE_MAP.integer).toBe("INTEGER");
|
|
73
73
|
expect(TYPE_MAP.boolean).toBe("INTEGER");
|
|
74
74
|
// vault#typed-reference-field: `reference` stores like `string` — the
|
|
75
75
|
// metadata VALUE (an id/path/title) is what's indexed here.
|
|
76
76
|
expect(TYPE_MAP.reference).toBe("TEXT");
|
|
77
|
-
// vault#date-field-type: `date` also stores like `string` — an ISO-8601
|
|
78
|
-
// string, which sorts correctly under a plain TEXT comparison.
|
|
79
|
-
expect(TYPE_MAP.date).toBe("TEXT");
|
|
80
77
|
});
|
|
81
78
|
|
|
82
79
|
it("declareField creates column + index on first declaration", () => {
|
|
@@ -215,15 +212,12 @@ describe("update-tag: indexed flag", () => {
|
|
|
215
212
|
});
|
|
216
213
|
|
|
217
214
|
it("unsupported field type for indexing throws", async () => {
|
|
218
|
-
// vault#date-field-type: `date` JOINED the indexable subset (stores TEXT,
|
|
219
|
-
// same as string/reference) — `number` (a float) is the current example
|
|
220
|
-
// of a recognized-but-unindexable type; see TYPE_MAP.
|
|
221
215
|
expect(() =>
|
|
222
216
|
findTool("update-tag").execute({
|
|
223
217
|
tag: "project",
|
|
224
|
-
fields: { weird: { type: "
|
|
218
|
+
fields: { weird: { type: "date", indexed: true } },
|
|
225
219
|
}),
|
|
226
|
-
).toThrow(/unsupported type "
|
|
220
|
+
).toThrow(/unsupported type "date"/);
|
|
227
221
|
});
|
|
228
222
|
|
|
229
223
|
it("invalid field name for indexing throws", async () => {
|