@openparachute/vault 0.7.3-rc.1 → 0.7.3-rc.10
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 +5 -3
- package/core/src/attachment/policy.test.ts +66 -0
- package/core/src/attachment/policy.ts +131 -0
- package/core/src/attachment/tickets.test.ts +45 -0
- package/core/src/attachment/tickets.ts +117 -0
- package/core/src/attachment-tickets-tool.test.ts +286 -0
- package/core/src/conformance.ts +2 -1
- package/core/src/contract-typed-index.test.ts +4 -3
- package/core/src/core.test.ts +521 -4
- package/core/src/display-title.test.ts +190 -0
- package/core/src/embedding/chunker.test.ts +97 -0
- package/core/src/embedding/chunker.ts +180 -0
- package/core/src/embedding/provider.ts +108 -0
- package/core/src/embedding/staleness.test.ts +87 -0
- package/core/src/embedding/staleness.ts +83 -0
- package/core/src/embedding/vector-codec.test.ts +99 -0
- package/core/src/embedding/vector-codec.ts +60 -0
- package/core/src/embedding/vectors.test.ts +163 -0
- package/core/src/embedding/vectors.ts +135 -0
- package/core/src/expand.ts +11 -3
- package/core/src/indexed-fields.test.ts +9 -3
- package/core/src/indexed-fields.ts +9 -1
- package/core/src/lede.test.ts +96 -0
- package/core/src/mcp-semantic-search.test.ts +160 -0
- package/core/src/mcp.ts +381 -10
- package/core/src/notes.semantic-search.test.ts +131 -0
- package/core/src/notes.ts +313 -6
- package/core/src/query-warnings.ts +20 -0
- package/core/src/schema-defaults.ts +85 -1
- package/core/src/schema-v27-note-vectors.test.ts +178 -0
- package/core/src/schema.ts +81 -1
- package/core/src/search-fts-v25.test.ts +9 -1
- package/core/src/search-query.test.ts +42 -0
- package/core/src/search-query.ts +27 -0
- package/core/src/search-title-boost.test.ts +125 -0
- package/core/src/seed-packs.test.ts +117 -1
- package/core/src/seed-packs.ts +217 -1
- package/core/src/store.semantic-search.test.ts +236 -0
- package/core/src/store.ts +162 -5
- package/core/src/tag-schemas.ts +27 -12
- package/core/src/types.ts +55 -1
- package/core/src/vault-projection.ts +48 -0
- package/package.json +6 -1
- package/src/add-pack.test.ts +32 -0
- package/src/attachment-tickets.test.ts +350 -0
- package/src/attachment-tickets.ts +264 -0
- package/src/cli.ts +5 -1
- package/src/contract-errors.test.ts +2 -1
- package/src/embedding/capability.test.ts +33 -0
- package/src/embedding/capability.ts +34 -0
- package/src/embedding/external-api.test.ts +154 -0
- package/src/embedding/external-api.ts +144 -0
- package/src/embedding/onnx-transformers.test.ts +113 -0
- package/src/embedding/onnx-transformers.ts +141 -0
- package/src/embedding/select.test.ts +99 -0
- package/src/embedding/select.ts +92 -0
- package/src/embedding-worker.test.ts +300 -0
- package/src/embedding-worker.ts +226 -0
- package/src/mcp-http.ts +13 -1
- package/src/mcp-tools.ts +49 -14
- package/src/onboarding-seed.test.ts +64 -0
- package/src/routes.ts +146 -92
- package/src/routing.ts +17 -0
- package/src/semantic-search-routes.test.ts +161 -0
- package/src/server.ts +21 -2
- package/src/vault-embeddings-capability.test.ts +82 -0
- package/src/vault-store-embedding-wiring.test.ts +69 -0
- package/src/vault-store.ts +53 -2
- package/src/vault.test.ts +35 -11
package/core/src/conformance.ts
CHANGED
|
@@ -78,7 +78,8 @@ function toStrictSchemaField(spec: TagFieldSchema): SchemaField {
|
|
|
78
78
|
spec.type === "integer" ||
|
|
79
79
|
spec.type === "boolean" ||
|
|
80
80
|
spec.type === "array" ||
|
|
81
|
-
spec.type === "object"
|
|
81
|
+
spec.type === "object" ||
|
|
82
|
+
spec.type === "date"
|
|
82
83
|
) {
|
|
83
84
|
out.type = spec.type;
|
|
84
85
|
}
|
|
@@ -220,14 +220,15 @@ describe("contract: typed indexes — Decision B: explicit-default-only enum bac
|
|
|
220
220
|
});
|
|
221
221
|
|
|
222
222
|
describe("contract: typed indexes — Decision C: honest type list (#553, flipped from todo)", () => {
|
|
223
|
-
it("the update-tag field-type description clarifies only string/integer/boolean/reference are indexable", () => {
|
|
223
|
+
it("the update-tag field-type description clarifies only string/integer/boolean/reference/date are indexable", () => {
|
|
224
224
|
const updateTag = generateMcpTools(store).find((t) => t.name === "update-tag")!;
|
|
225
225
|
const typeDesc = (updateTag.inputSchema as any).properties.fields.additionalProperties.properties.type.description as string;
|
|
226
226
|
// Honest about the full storage/advisory vocabulary AND the indexable subset.
|
|
227
227
|
expect(typeDesc).toContain("number");
|
|
228
228
|
// vault#typed-reference-field: `reference` joined the indexable subset
|
|
229
|
-
// alongside string/integer/boolean.
|
|
230
|
-
|
|
229
|
+
// alongside string/integer/boolean. vault#date-field-type: `date` joined
|
|
230
|
+
// the same subset — it stores TEXT (ISO-8601), same as string/reference.
|
|
231
|
+
expect(typeDesc).toContain("Only string/integer/boolean/reference/date are INDEXABLE");
|
|
231
232
|
});
|
|
232
233
|
|
|
233
234
|
it("declaring indexed:true with an unindexable type (number) is rejected", async () => {
|
package/core/src/core.test.ts
CHANGED
|
@@ -1478,6 +1478,42 @@ 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
|
+
|
|
1481
1517
|
// ---- updated_at filter (vault#285 friction point 1.5) ----
|
|
1482
1518
|
//
|
|
1483
1519
|
// Incremental-rebuild flows ask "what changed since X." Like `created_at`,
|
|
@@ -2230,6 +2266,50 @@ describe("queryNotes", async () => {
|
|
|
2230
2266
|
store.queryNotes({ metadata: { priority: { in: 5 } as any } }),
|
|
2231
2267
|
).rejects.toThrow(/expects an array/);
|
|
2232
2268
|
});
|
|
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
|
+
});
|
|
2233
2313
|
});
|
|
2234
2314
|
});
|
|
2235
2315
|
|
|
@@ -4556,11 +4636,54 @@ describe("MCP tools", async () => {
|
|
|
4556
4636
|
e: { type: "array" },
|
|
4557
4637
|
f: { type: "object" },
|
|
4558
4638
|
g: { type: "reference" },
|
|
4639
|
+
h: { type: "date" },
|
|
4559
4640
|
},
|
|
4560
4641
|
}) as any;
|
|
4561
4642
|
expect(result.fields.a.type).toBe("string");
|
|
4562
4643
|
expect(result.fields.f.type).toBe("object");
|
|
4563
4644
|
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();
|
|
4564
4687
|
});
|
|
4565
4688
|
|
|
4566
4689
|
// ---- Typed reference field: indexed value + auto-link (vault#typed-reference-field) ----
|
|
@@ -5770,8 +5893,10 @@ describe("query-notes link expansion", async () => {
|
|
|
5770
5893
|
expect(result[0].preview).toBeTruthy();
|
|
5771
5894
|
});
|
|
5772
5895
|
|
|
5773
|
-
it("expand_mode=summary with no metadata.summary renders empty body inline", async () => {
|
|
5774
|
-
|
|
5896
|
+
it("expand_mode=summary with no metadata.summary AND no lede (title-only note) renders empty body inline", async () => {
|
|
5897
|
+
// Single-line content: the whole thing IS the title, so there's no
|
|
5898
|
+
// paragraph after it for computeLede to fall back to.
|
|
5899
|
+
await store.createNote("unsummarized title-only body", { path: "Plain" });
|
|
5775
5900
|
await store.createNote("see [[Plain]]", { path: "Src" });
|
|
5776
5901
|
const tools = generateMcpTools(store);
|
|
5777
5902
|
const query = tools.find((t) => t.name === "query-notes")!;
|
|
@@ -5782,8 +5907,68 @@ describe("query-notes link expansion", async () => {
|
|
|
5782
5907
|
expand_mode: "summary",
|
|
5783
5908
|
}) as any;
|
|
5784
5909
|
expect(result.content).toContain('mode="summary"');
|
|
5785
|
-
//
|
|
5786
|
-
expect(result.content).not.toContain("unsummarized body");
|
|
5910
|
+
// No summary, no lede — we still get the block but with nothing between delimiters.
|
|
5911
|
+
expect(result.content).not.toContain("unsummarized title-only body");
|
|
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");
|
|
5787
5972
|
});
|
|
5788
5973
|
});
|
|
5789
5974
|
|
|
@@ -6050,6 +6235,113 @@ describe("schema validation (tags.fields)", async () => {
|
|
|
6050
6235
|
expect(result.validation_status.warnings[0].reason).toBe("enum_mismatch");
|
|
6051
6236
|
});
|
|
6052
6237
|
|
|
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
|
+
|
|
6053
6345
|
// vault#555 fix 3 — an enum-membership violation on an INDEXED
|
|
6054
6346
|
// (non-strict) field is stored, findable via `eq` (indexed⇒strict is
|
|
6055
6347
|
// TYPE-only, not enum-domain — a ratified policy, not a bug), but the
|
|
@@ -6346,6 +6638,212 @@ describe("schema validation (tags.fields)", async () => {
|
|
|
6346
6638
|
});
|
|
6347
6639
|
});
|
|
6348
6640
|
|
|
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
|
+
|
|
6349
6847
|
// ---------------------------------------------------------------------------
|
|
6350
6848
|
// update-note `if_missing: "create"` — idempotent upsert (vault#309)
|
|
6351
6849
|
// ---------------------------------------------------------------------------
|
|
@@ -8128,6 +8626,25 @@ describe("vault projection (vault#271)", async () => {
|
|
|
8128
8626
|
expect(byName.priority.tags).toEqual(["project"]);
|
|
8129
8627
|
});
|
|
8130
8628
|
|
|
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
|
+
|
|
8131
8648
|
it("includes the static query-hint catalog", async () => {
|
|
8132
8649
|
const { buildVaultProjection, QUERY_HINTS } = await import("./vault-projection.ts");
|
|
8133
8650
|
const projection = buildVaultProjection(db);
|