@openparachute/vault 0.7.3-rc.2 → 0.7.3-rc.3
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/conformance.ts +2 -1
- package/core/src/contract-typed-index.test.ts +4 -3
- package/core/src/core.test.ts +455 -0
- package/core/src/indexed-fields.test.ts +9 -3
- package/core/src/indexed-fields.ts +9 -1
- package/core/src/mcp.ts +24 -4
- package/core/src/schema-defaults.ts +85 -1
- package/core/src/store.ts +59 -3
- package/core/src/tag-schemas.ts +27 -12
- package/core/src/types.ts +1 -1
- package/package.json +1 -1
- package/src/contract-errors.test.ts +2 -1
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) ----
|
|
@@ -6050,6 +6173,113 @@ describe("schema validation (tags.fields)", async () => {
|
|
|
6050
6173
|
expect(result.validation_status.warnings[0].reason).toBe("enum_mismatch");
|
|
6051
6174
|
});
|
|
6052
6175
|
|
|
6176
|
+
// ---- `date` field type (vault#date-field-type) ----
|
|
6177
|
+
//
|
|
6178
|
+
// Motivation: today a date-ish field (e.g. a `meeting` tag's
|
|
6179
|
+
// `meeting_date`) can only be declared `type: "string"`, with "ISO date"
|
|
6180
|
+
// explained in prose — nothing can programmatically discover
|
|
6181
|
+
// date-candidate fields for a calendar view. `date` validates like
|
|
6182
|
+
// `string` but requires an ISO-8601 date or full RFC3339 timestamp,
|
|
6183
|
+
// parsed by the SAME `timestampToMs` (cursor.ts) `date_filter`'s
|
|
6184
|
+
// `updated_at` bound uses.
|
|
6185
|
+
|
|
6186
|
+
it("no warning when a `date` field's value is a bare ISO date (YYYY-MM-DD)", async () => {
|
|
6187
|
+
await store.upsertTagSchema("meeting", {
|
|
6188
|
+
fields: { meeting_date: { type: "date" } },
|
|
6189
|
+
});
|
|
6190
|
+
const tools = generateMcpTools(store);
|
|
6191
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
6192
|
+
const result = await create.execute({
|
|
6193
|
+
content: "standup",
|
|
6194
|
+
tags: ["meeting"],
|
|
6195
|
+
metadata: { meeting_date: "2026-07-09" },
|
|
6196
|
+
}) as any;
|
|
6197
|
+
expect(result.validation_status.warnings).toEqual([]);
|
|
6198
|
+
});
|
|
6199
|
+
|
|
6200
|
+
it("no warning when a `date` field's value is a full RFC3339 timestamp", async () => {
|
|
6201
|
+
await store.upsertTagSchema("meeting", {
|
|
6202
|
+
fields: { meeting_date: { type: "date" } },
|
|
6203
|
+
});
|
|
6204
|
+
const tools = generateMcpTools(store);
|
|
6205
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
6206
|
+
const result = await create.execute({
|
|
6207
|
+
content: "standup",
|
|
6208
|
+
tags: ["meeting"],
|
|
6209
|
+
metadata: { meeting_date: "2026-07-09T14:30:00.000Z" },
|
|
6210
|
+
}) as any;
|
|
6211
|
+
expect(result.validation_status.warnings).toEqual([]);
|
|
6212
|
+
});
|
|
6213
|
+
|
|
6214
|
+
it("type_mismatch warning when a `date` field's value isn't a parseable ISO string", async () => {
|
|
6215
|
+
await store.upsertTagSchema("meeting", {
|
|
6216
|
+
fields: { meeting_date: { type: "date" } },
|
|
6217
|
+
});
|
|
6218
|
+
const tools = generateMcpTools(store);
|
|
6219
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
6220
|
+
const result = await create.execute({
|
|
6221
|
+
content: "standup",
|
|
6222
|
+
tags: ["meeting"],
|
|
6223
|
+
metadata: { meeting_date: "not-a-date" },
|
|
6224
|
+
}) as any;
|
|
6225
|
+
expect(result.validation_status.warnings).toHaveLength(1);
|
|
6226
|
+
expect(result.validation_status.warnings[0].reason).toBe("type_mismatch");
|
|
6227
|
+
expect(result.validation_status.warnings[0].field).toBe("meeting_date");
|
|
6228
|
+
});
|
|
6229
|
+
|
|
6230
|
+
it("a `date` field's type_mismatch is a HARD REJECTION under strict:true, same as any other type", async () => {
|
|
6231
|
+
await store.upsertTagSchema("meeting", {
|
|
6232
|
+
fields: { meeting_date: { type: "date", strict: true } },
|
|
6233
|
+
});
|
|
6234
|
+
const tools = generateMcpTools(store);
|
|
6235
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
6236
|
+
await expect(create.execute({
|
|
6237
|
+
content: "standup",
|
|
6238
|
+
tags: ["meeting"],
|
|
6239
|
+
metadata: { meeting_date: "not-a-date" },
|
|
6240
|
+
})).rejects.toThrow();
|
|
6241
|
+
});
|
|
6242
|
+
|
|
6243
|
+
it("an indexed `date` field's type_mismatch is ALWAYS a hard rejection, independent of strict (vault#553 Decision A)", async () => {
|
|
6244
|
+
await store.upsertTagSchema("meeting", {
|
|
6245
|
+
fields: { meeting_date: { type: "date", indexed: true } },
|
|
6246
|
+
});
|
|
6247
|
+
const tools = generateMcpTools(store);
|
|
6248
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
6249
|
+
await expect(create.execute({
|
|
6250
|
+
content: "standup",
|
|
6251
|
+
tags: ["meeting"],
|
|
6252
|
+
metadata: { meeting_date: "not-a-date" },
|
|
6253
|
+
})).rejects.toThrow();
|
|
6254
|
+
});
|
|
6255
|
+
|
|
6256
|
+
// Sharp edge (docs/HTTP_API.md's "type: date" callout): a schema edit
|
|
6257
|
+
// that tightens an existing field from `string` to `date` does NOT
|
|
6258
|
+
// retroactively revalidate notes already carrying a non-ISO value —
|
|
6259
|
+
// only the field's NEXT write is checked. No migration, no data change.
|
|
6260
|
+
it("tightening an existing field from string to date does not retroactively touch existing notes", async () => {
|
|
6261
|
+
await store.upsertTagSchema("meeting", {
|
|
6262
|
+
fields: { meeting_date: { type: "string" } },
|
|
6263
|
+
});
|
|
6264
|
+
const tools = generateMcpTools(store);
|
|
6265
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
6266
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
6267
|
+
const note = await create.execute({
|
|
6268
|
+
content: "old-style note",
|
|
6269
|
+
tags: ["meeting"],
|
|
6270
|
+
metadata: { meeting_date: "sometime next week" }, // not ISO — fine under type:"string"
|
|
6271
|
+
}) as any;
|
|
6272
|
+
expect(note.validation_status.warnings).toEqual([]);
|
|
6273
|
+
|
|
6274
|
+
// Tighten the schema. This must not touch the existing note.
|
|
6275
|
+
await store.upsertTagSchema("meeting", {
|
|
6276
|
+
fields: { meeting_date: { type: "date" } },
|
|
6277
|
+
});
|
|
6278
|
+
|
|
6279
|
+
const fresh = await query.execute({ id: note.id }) as any;
|
|
6280
|
+
expect(fresh.metadata.meeting_date).toBe("sometime next week");
|
|
6281
|
+
});
|
|
6282
|
+
|
|
6053
6283
|
// vault#555 fix 3 — an enum-membership violation on an INDEXED
|
|
6054
6284
|
// (non-strict) field is stored, findable via `eq` (indexed⇒strict is
|
|
6055
6285
|
// TYPE-only, not enum-domain — a ratified policy, not a bug), but the
|
|
@@ -6346,6 +6576,212 @@ describe("schema validation (tags.fields)", async () => {
|
|
|
6346
6576
|
});
|
|
6347
6577
|
});
|
|
6348
6578
|
|
|
6579
|
+
// ---------------------------------------------------------------------------
|
|
6580
|
+
// `date` field type — offset normalization on write (vault#date-field-type
|
|
6581
|
+
// review fix). A raw TEXT compare only sorts ISO-8601 timestamps correctly
|
|
6582
|
+
// when every stored value shares the SAME offset representation.
|
|
6583
|
+
// `timestampToMs` correctly VALIDATES an explicit ±HH:MM offset, but the
|
|
6584
|
+
// value used to persist VERBATIM — a mixed-offset vault silently
|
|
6585
|
+
// mis-ordered/mis-filtered. `normalizeDateFields` (schema-defaults.ts),
|
|
6586
|
+
// wired into every store write path, rewrites a full timestamp to canonical
|
|
6587
|
+
// UTC (`Z`-suffixed) before the row is written.
|
|
6588
|
+
// ---------------------------------------------------------------------------
|
|
6589
|
+
|
|
6590
|
+
describe("date field type — offset normalization (vault#date-field-type review fix)", async () => {
|
|
6591
|
+
async function declareIndexedMeetingDate() {
|
|
6592
|
+
const tools = generateMcpTools(store);
|
|
6593
|
+
const updateTag = tools.find((t) => t.name === "update-tag")!;
|
|
6594
|
+
await updateTag.execute({
|
|
6595
|
+
tag: "meeting",
|
|
6596
|
+
fields: { meeting_date: { type: "date", indexed: true } },
|
|
6597
|
+
});
|
|
6598
|
+
return tools;
|
|
6599
|
+
}
|
|
6600
|
+
|
|
6601
|
+
// The reviewer's exact repro: "2026-07-16T10:00:00+02:00" (= 08:00Z) vs
|
|
6602
|
+
// "2026-07-16T09:00:00Z" (= 09:00Z). Persisted verbatim, a raw string
|
|
6603
|
+
// compare gets these BACKWARDS (comparing "10" vs "09" after the shared
|
|
6604
|
+
// "2026-07-16T" prefix says the +02:00 value sorts LATER, when its actual
|
|
6605
|
+
// instant — 08:00Z — is EARLIER). Post-normalization both values are
|
|
6606
|
+
// Z-form UTC, so the string compare IS the instant compare.
|
|
6607
|
+
it("order_by ASC returns mixed-offset values in true chronological order", async () => {
|
|
6608
|
+
const tools = await declareIndexedMeetingDate();
|
|
6609
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
6610
|
+
await create.execute({
|
|
6611
|
+
content: "earlier (offset form)",
|
|
6612
|
+
tags: ["meeting"],
|
|
6613
|
+
metadata: { meeting_date: "2026-07-16T10:00:00+02:00" }, // = 08:00Z
|
|
6614
|
+
});
|
|
6615
|
+
await create.execute({
|
|
6616
|
+
content: "later (Z form)",
|
|
6617
|
+
tags: ["meeting"],
|
|
6618
|
+
metadata: { meeting_date: "2026-07-16T09:00:00Z" }, // = 09:00Z
|
|
6619
|
+
});
|
|
6620
|
+
|
|
6621
|
+
const asc = await store.queryNotes({ orderBy: "meeting_date" });
|
|
6622
|
+
expect(asc.map((n) => n.content)).toEqual(["earlier (offset form)", "later (Z form)"]);
|
|
6623
|
+
});
|
|
6624
|
+
|
|
6625
|
+
it("a gt bound correctly excludes an earlier instant expressed with an offset", async () => {
|
|
6626
|
+
const tools = await declareIndexedMeetingDate();
|
|
6627
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
6628
|
+
await create.execute({
|
|
6629
|
+
content: "before threshold (offset form, = 08:00Z)",
|
|
6630
|
+
tags: ["meeting"],
|
|
6631
|
+
metadata: { meeting_date: "2026-07-16T10:00:00+02:00" },
|
|
6632
|
+
});
|
|
6633
|
+
await create.execute({
|
|
6634
|
+
content: "after threshold (Z form, = 09:00Z)",
|
|
6635
|
+
tags: ["meeting"],
|
|
6636
|
+
metadata: { meeting_date: "2026-07-16T09:00:00Z" },
|
|
6637
|
+
});
|
|
6638
|
+
|
|
6639
|
+
// Threshold sits BETWEEN the two instants (08:30Z). Pre-fix, the
|
|
6640
|
+
// unnormalized "+02:00" value's raw string ("...10:00:00+02:00")
|
|
6641
|
+
// compared GREATER than the threshold string despite its instant being
|
|
6642
|
+
// earlier — a false match. Post-fix it correctly falls below.
|
|
6643
|
+
const results = await store.queryNotes({
|
|
6644
|
+
metadata: { meeting_date: { gt: "2026-07-16T08:30:00Z" } },
|
|
6645
|
+
});
|
|
6646
|
+
expect(results.map((n) => n.content)).toEqual(["after threshold (Z form, = 09:00Z)"]);
|
|
6647
|
+
});
|
|
6648
|
+
|
|
6649
|
+
it("create-note normalizes an offset timestamp to canonical Z-form; the stored/returned value is not the verbatim input", async () => {
|
|
6650
|
+
const tools = await declareIndexedMeetingDate();
|
|
6651
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
6652
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
6653
|
+
const created = await create.execute({
|
|
6654
|
+
content: "standup",
|
|
6655
|
+
tags: ["meeting"],
|
|
6656
|
+
metadata: { meeting_date: "2026-07-16T10:00:00+02:00" },
|
|
6657
|
+
}) as any;
|
|
6658
|
+
|
|
6659
|
+
// The create response itself already reflects the normalized value...
|
|
6660
|
+
expect(created.metadata.meeting_date).toBe("2026-07-16T08:00:00.000Z");
|
|
6661
|
+
// ...and so does a fresh read-back (round-trips through the DB, not an echo).
|
|
6662
|
+
const fresh = await query.execute({ id: created.id }) as any;
|
|
6663
|
+
expect(fresh.metadata.meeting_date).toBe("2026-07-16T08:00:00.000Z");
|
|
6664
|
+
});
|
|
6665
|
+
|
|
6666
|
+
it("update-note (merge-patch) normalizes an offset timestamp the same way create-note does", async () => {
|
|
6667
|
+
const tools = await declareIndexedMeetingDate();
|
|
6668
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
6669
|
+
const update = tools.find((t) => t.name === "update-note")!;
|
|
6670
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
6671
|
+
const created = await create.execute({
|
|
6672
|
+
content: "standup",
|
|
6673
|
+
tags: ["meeting"],
|
|
6674
|
+
}) as any;
|
|
6675
|
+
|
|
6676
|
+
await update.execute({
|
|
6677
|
+
id: created.id,
|
|
6678
|
+
metadata: { meeting_date: "2026-07-16T10:00:00+02:00" },
|
|
6679
|
+
force: true,
|
|
6680
|
+
});
|
|
6681
|
+
|
|
6682
|
+
const fresh = await query.execute({ id: created.id }) as any;
|
|
6683
|
+
expect(fresh.metadata.meeting_date).toBe("2026-07-16T08:00:00.000Z");
|
|
6684
|
+
});
|
|
6685
|
+
|
|
6686
|
+
// ---- round 2: tag-add + date-metadata in the SAME call (vault#date-
|
|
6687
|
+
// field-type review round 2) ----
|
|
6688
|
+
//
|
|
6689
|
+
// `store.tagNote` (the actual tag mutation) runs AFTER the core UPDATE in
|
|
6690
|
+
// both mcp.ts call sites that combine tags + metadata in one item — the
|
|
6691
|
+
// reviewer live-verified that without passing the PROJECTED tag set into
|
|
6692
|
+
// `store.updateNote`, normalization resolved the schema against the
|
|
6693
|
+
// note's STALE pre-write tags and missed a field newly declared by the
|
|
6694
|
+
// tag being added in this same call.
|
|
6695
|
+
|
|
6696
|
+
it("update-note: tags.add + an offset date value in the SAME call still normalizes (bare, untagged note)", async () => {
|
|
6697
|
+
const tools = await declareIndexedMeetingDate();
|
|
6698
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
6699
|
+
const update = tools.find((t) => t.name === "update-note")!;
|
|
6700
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
6701
|
+
// Bare, untagged note — "meeting" (and its meeting_date:date field) is
|
|
6702
|
+
// NOT yet on this note; it's added in the SAME update-note call below.
|
|
6703
|
+
const created = await create.execute({ content: "standup" }) as any;
|
|
6704
|
+
|
|
6705
|
+
const result = await update.execute({
|
|
6706
|
+
id: created.id,
|
|
6707
|
+
tags: { add: ["meeting"] },
|
|
6708
|
+
metadata: { meeting_date: "2026-07-16T10:00:00+02:00" },
|
|
6709
|
+
force: true,
|
|
6710
|
+
}) as any;
|
|
6711
|
+
|
|
6712
|
+
expect(result.metadata.meeting_date).toBe("2026-07-16T08:00:00.000Z");
|
|
6713
|
+
const fresh = await query.execute({ id: created.id }) as any;
|
|
6714
|
+
expect(fresh.metadata.meeting_date).toBe("2026-07-16T08:00:00.000Z");
|
|
6715
|
+
expect(fresh.tags).toContain("meeting");
|
|
6716
|
+
});
|
|
6717
|
+
|
|
6718
|
+
it("create-note if_exists:update: tags + an offset date value in the SAME call still normalizes (bare, untagged note)", async () => {
|
|
6719
|
+
const tools = await declareIndexedMeetingDate();
|
|
6720
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
6721
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
6722
|
+
const created = await create.execute({ content: "standup", path: "Inbox/round2-upsert" }) as any;
|
|
6723
|
+
|
|
6724
|
+
const result = await create.execute({
|
|
6725
|
+
path: "Inbox/round2-upsert",
|
|
6726
|
+
tags: ["meeting"],
|
|
6727
|
+
metadata: { meeting_date: "2026-07-16T10:00:00+02:00" },
|
|
6728
|
+
if_exists: "update",
|
|
6729
|
+
}) as any;
|
|
6730
|
+
|
|
6731
|
+
expect(result.metadata.meeting_date).toBe("2026-07-16T08:00:00.000Z");
|
|
6732
|
+
const fresh = await query.execute({ id: created.id }) as any;
|
|
6733
|
+
expect(fresh.metadata.meeting_date).toBe("2026-07-16T08:00:00.000Z");
|
|
6734
|
+
expect(fresh.tags).toContain("meeting");
|
|
6735
|
+
});
|
|
6736
|
+
|
|
6737
|
+
// ---- round 2: copy-on-write, never mutate the caller's object ----
|
|
6738
|
+
|
|
6739
|
+
it("store.createNote does NOT mutate the caller's metadata object", async () => {
|
|
6740
|
+
await declareIndexedMeetingDate();
|
|
6741
|
+
const callerMetadata = { meeting_date: "2026-07-16T10:00:00+02:00" };
|
|
6742
|
+
const callerMetadataSnapshot = { ...callerMetadata };
|
|
6743
|
+
await store.createNote("standup", { tags: ["meeting"], metadata: callerMetadata });
|
|
6744
|
+
// The caller's own object must be byte-identical to before the call —
|
|
6745
|
+
// core is a published library; a direct embedder's reference must
|
|
6746
|
+
// never change out from under them.
|
|
6747
|
+
expect(callerMetadata).toEqual(callerMetadataSnapshot);
|
|
6748
|
+
});
|
|
6749
|
+
|
|
6750
|
+
it("store.updateNote does NOT mutate the caller's metadata object", async () => {
|
|
6751
|
+
await declareIndexedMeetingDate();
|
|
6752
|
+
const note = await store.createNote("standup", { tags: ["meeting"] });
|
|
6753
|
+
const callerMetadata = { meeting_date: "2026-07-16T10:00:00+02:00" };
|
|
6754
|
+
const callerMetadataSnapshot = { ...callerMetadata };
|
|
6755
|
+
await store.updateNote(note.id, { metadata: callerMetadata });
|
|
6756
|
+
expect(callerMetadata).toEqual(callerMetadataSnapshot);
|
|
6757
|
+
});
|
|
6758
|
+
|
|
6759
|
+
it("a bare YYYY-MM-DD value passes through untouched (no offset to normalize)", async () => {
|
|
6760
|
+
const tools = await declareIndexedMeetingDate();
|
|
6761
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
6762
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
6763
|
+
const created = await create.execute({
|
|
6764
|
+
content: "standup",
|
|
6765
|
+
tags: ["meeting"],
|
|
6766
|
+
metadata: { meeting_date: "2026-07-16" },
|
|
6767
|
+
}) as any;
|
|
6768
|
+
expect(created.metadata.meeting_date).toBe("2026-07-16");
|
|
6769
|
+
const fresh = await query.execute({ id: created.id }) as any;
|
|
6770
|
+
expect(fresh.metadata.meeting_date).toBe("2026-07-16");
|
|
6771
|
+
});
|
|
6772
|
+
|
|
6773
|
+
it("a value already in canonical Z-form passes through unchanged (no spurious rewrite)", async () => {
|
|
6774
|
+
const tools = await declareIndexedMeetingDate();
|
|
6775
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
6776
|
+
const created = await create.execute({
|
|
6777
|
+
content: "standup",
|
|
6778
|
+
tags: ["meeting"],
|
|
6779
|
+
metadata: { meeting_date: "2026-07-16T09:00:00.000Z" },
|
|
6780
|
+
}) as any;
|
|
6781
|
+
expect(created.metadata.meeting_date).toBe("2026-07-16T09:00:00.000Z");
|
|
6782
|
+
});
|
|
6783
|
+
});
|
|
6784
|
+
|
|
6349
6785
|
// ---------------------------------------------------------------------------
|
|
6350
6786
|
// update-note `if_missing: "create"` — idempotent upsert (vault#309)
|
|
6351
6787
|
// ---------------------------------------------------------------------------
|
|
@@ -8128,6 +8564,25 @@ describe("vault projection (vault#271)", async () => {
|
|
|
8128
8564
|
expect(byName.priority.tags).toEqual(["project"]);
|
|
8129
8565
|
});
|
|
8130
8566
|
|
|
8567
|
+
// vault#date-field-type: `type: "date"` carries through the indexed-field
|
|
8568
|
+
// catalog like any other type — nothing in the projection filters unknown
|
|
8569
|
+
// or newer types out.
|
|
8570
|
+
it("catalogs an indexed `date` field with its declared type", async () => {
|
|
8571
|
+
const { buildVaultProjection } = await import("./vault-projection.ts");
|
|
8572
|
+
const tools = generateMcpTools(store);
|
|
8573
|
+
const updateTag = tools.find((t) => t.name === "update-tag")!;
|
|
8574
|
+
await updateTag.execute({
|
|
8575
|
+
tag: "meeting",
|
|
8576
|
+
fields: { meeting_date: { type: "date", indexed: true } },
|
|
8577
|
+
});
|
|
8578
|
+
|
|
8579
|
+
const projection = buildVaultProjection(db);
|
|
8580
|
+
const byName = Object.fromEntries(projection.indexed_fields.map((f) => [f.name, f]));
|
|
8581
|
+
expect(byName.meeting_date).toBeTruthy();
|
|
8582
|
+
expect(byName.meeting_date.type).toBe("date");
|
|
8583
|
+
expect(byName.meeting_date.tags).toEqual(["meeting"]);
|
|
8584
|
+
});
|
|
8585
|
+
|
|
8131
8586
|
it("includes the static query-hint catalog", async () => {
|
|
8132
8587
|
const { buildVaultProjection, QUERY_HINTS } = await import("./vault-projection.ts");
|
|
8133
8588
|
const projection = buildVaultProjection(db);
|
|
@@ -67,13 +67,16 @@ 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/date", () => {
|
|
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");
|
|
77
80
|
});
|
|
78
81
|
|
|
79
82
|
it("declareField creates column + index on first declaration", () => {
|
|
@@ -212,12 +215,15 @@ describe("update-tag: indexed flag", () => {
|
|
|
212
215
|
});
|
|
213
216
|
|
|
214
217
|
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.
|
|
215
221
|
expect(() =>
|
|
216
222
|
findTool("update-tag").execute({
|
|
217
223
|
tag: "project",
|
|
218
|
-
fields: { weird: { type: "
|
|
224
|
+
fields: { weird: { type: "number", indexed: true } },
|
|
219
225
|
}),
|
|
220
|
-
).toThrow(/unsupported type "
|
|
226
|
+
).toThrow(/unsupported type "number"/);
|
|
221
227
|
});
|
|
222
228
|
|
|
223
229
|
it("invalid field name for indexing throws", async () => {
|
|
@@ -27,13 +27,21 @@ export type SqliteType = "TEXT" | "INTEGER";
|
|
|
27
27
|
// `string` — because the metadata VALUE (an id/path/title) is what's
|
|
28
28
|
// indexed here; the resolved graph link is a separate concern maintained by
|
|
29
29
|
// `core/src/store.ts`'s write path. See tag-schemas.ts's `VALID_FIELD_TYPES`.
|
|
30
|
-
|
|
30
|
+
// `date` is also stored as TEXT: an indexed `date` field holds an ISO-8601
|
|
31
|
+
// string (validated by `tag-schemas.ts`'s `defaultMatchesType` /
|
|
32
|
+
// `schema-defaults.ts`'s `valueMatchesType`), and ISO-8601 strings are
|
|
33
|
+
// lexicographically comparable — the same reason TEXT-stored `updated_at`
|
|
34
|
+
// range queries already work — so `gt`/`gte`/`lt`/`lte`, `date_filter`, and
|
|
35
|
+
// `order_by` all fall out of the generic TEXT-column machinery with no
|
|
36
|
+
// type-specific SQL.
|
|
37
|
+
export type FieldType = "string" | "integer" | "boolean" | "reference" | "date";
|
|
31
38
|
|
|
32
39
|
export const TYPE_MAP: Record<FieldType, SqliteType> = {
|
|
33
40
|
string: "TEXT",
|
|
34
41
|
integer: "INTEGER",
|
|
35
42
|
boolean: "INTEGER",
|
|
36
43
|
reference: "TEXT",
|
|
44
|
+
date: "TEXT",
|
|
37
45
|
};
|
|
38
46
|
|
|
39
47
|
export interface IndexedField {
|
package/core/src/mcp.ts
CHANGED
|
@@ -1355,6 +1355,12 @@ A note's response carries \`existed\` (true/false) whenever ITS \`if_exists\` wa
|
|
|
1355
1355
|
...updates,
|
|
1356
1356
|
actor: writeActor,
|
|
1357
1357
|
via: writeVia,
|
|
1358
|
+
// `tagsForSchemaResolution` (vault#date-field-type review round
|
|
1359
|
+
// 2) — same bug/fix as the update-note handler: `store.tagNote`
|
|
1360
|
+
// below runs AFTER this UPDATE, so without the PROJECTED tag
|
|
1361
|
+
// set here, `date`-field normalization inside store.updateNote
|
|
1362
|
+
// would miss a field newly declared by an incoming tag.
|
|
1363
|
+
tagsForSchemaResolution: [...projectedTags],
|
|
1358
1364
|
});
|
|
1359
1365
|
}
|
|
1360
1366
|
|
|
@@ -2070,10 +2076,18 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
2070
2076
|
// --- Strict-schema gate (vault#299 Part A) ---
|
|
2071
2077
|
// Validate the PROSPECTIVE shape (final tags + merged metadata,
|
|
2072
2078
|
// including a state_transition's `to`) before the write so a
|
|
2073
|
-
// rejection leaves the note untouched.
|
|
2079
|
+
// rejection leaves the note untouched. `projectedTags` is hoisted
|
|
2080
|
+
// out of this block (not just used here) — the `store.updateNote`
|
|
2081
|
+
// call below also needs it, as `tagsForSchemaResolution`, so that
|
|
2082
|
+
// `date`-field normalization sees a tag ADDED in this SAME call
|
|
2083
|
+
// (vault#date-field-type review round 2 — the actual `store.tagNote`
|
|
2084
|
+
// mutation happens AFTER the core UPDATE, so without this the
|
|
2085
|
+
// schema resolution inside `store.updateNote` would still be
|
|
2086
|
+
// looking at the note's stale pre-write tag set).
|
|
2087
|
+
let projectedTags: Set<string>;
|
|
2074
2088
|
{
|
|
2075
2089
|
const removeSet = new Set<string>((item.tags as any)?.remove ?? []);
|
|
2076
|
-
|
|
2090
|
+
projectedTags = new Set<string>((note.tags ?? []).filter((t) => !removeSet.has(t)));
|
|
2077
2091
|
for (const t of ((item.tags as any)?.add as string[] | undefined) ?? []) projectedTags.add(t);
|
|
2078
2092
|
const baseMeta = updates.metadata ?? ((note.metadata as Record<string, unknown>) ?? {});
|
|
2079
2093
|
const projectedMeta = stItem !== undefined
|
|
@@ -2117,6 +2131,12 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
2117
2131
|
// updated_at on a no-op).
|
|
2118
2132
|
updates.actor = writeActor;
|
|
2119
2133
|
updates.via = writeVia;
|
|
2134
|
+
// `tagsForSchemaResolution` (vault#date-field-type review round
|
|
2135
|
+
// 2) — the PROJECTED final tag set, so `date`-field
|
|
2136
|
+
// normalization inside store.updateNote sees a tag ADDED in
|
|
2137
|
+
// this same call, not just the note's pre-write tags. See the
|
|
2138
|
+
// comment on `projectedTags`'s declaration above.
|
|
2139
|
+
updates.tagsForSchemaResolution = [...projectedTags];
|
|
2120
2140
|
// store.updateNote routes through noteOps.updateNote, which runs
|
|
2121
2141
|
// the UPDATE (with optional `AND updated_at IS ?`) atomically and
|
|
2122
2142
|
// throws ConflictError on mismatch. No mutations have happened
|
|
@@ -2380,11 +2400,11 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
2380
2400
|
additionalProperties: {
|
|
2381
2401
|
type: "object",
|
|
2382
2402
|
properties: {
|
|
2383
|
-
type: { type: "string", description: "Field type: string, boolean, integer, number, array, object, reference — all
|
|
2403
|
+
type: { type: "string", description: "Field type: string, boolean, integer, number, array, object, reference, date — all eight are accepted for storage + advisory validation; any OTHER value is rejected outright (error_type invalid_field_type, vault#555 — bundled with every other violation in the same call, see the `update-tag` tool description). Only string/integer/boolean/reference/date are INDEXABLE (see `indexed` below); declaring `indexed: true` with number/array/object is rejected (unsupported_indexed_type / invalid_indexed_field). `reference` is a DUAL-WRITE type (typed-reference-field): the value is stored + validated exactly like `string` (pass a note id, path, or title), AND create-note/update-note additionally resolve that value to a note and maintain a graph `links` edge from this note to it, with `relationship` set to the field name — kept in sync on every write that changes the field (a new value re-points the link; clearing the field drops it). A target that doesn't resolve yet is queued and backfills automatically, same as a structured `links` entry — see `docs/design/typed-reference-field.md`. `date` stores/validates exactly like `string`, but the value must be an ISO-8601 date (`2026-07-09`) or full timestamp (`2026-07-09T00:00:00.000Z`) — an unparseable value is a type_mismatch (advisory) or a rejected write (`strict: true` / `indexed: true`), same treatment as any other type mismatch. A full timestamp carrying an explicit `±HH:MM` offset is normalized to canonical UTC (`Z`-suffixed) on write — the offset is accepted, but not persisted verbatim — so indexed `date` fields sort/filter correctly under the TEXT comparison `gt`/`gte`/`lt`/`lte`/`date_filter`/`order_by` all use; a bare date (no time component) is left as-is." },
|
|
2384
2404
|
description: { type: "string" },
|
|
2385
2405
|
enum: { type: "array", items: { type: "string" }, description: "Allowed values. Does NOT auto-backfill — a note that omits this field stays without it unless `default` is also set (vault#553; the pre-0.7.0 behavior of silently defaulting to the first enum value is retired). Set `default` explicitly if you want backfill." },
|
|
2386
2406
|
default: { description: "Explicit backfill value (vault#553) applied when a note gains this tag without setting the field. Must conform to this field's own `type` (and `enum`, if declared) — a non-conforming default is rejected (invalid_default / invalid_field_default) rather than silently stored. Omit entirely to leave the field ABSENT (not backfilled) on notes that don't set it — this is what makes `exists:false` a trustworthy \"never set\" query." },
|
|
2387
|
-
indexed: { type: "boolean", description: "When true, a generated column + index are maintained on notes.metadata.<field>, making it queryable via metadata operator objects and order_by. Global: all tags declaring the field must agree on both type and indexed. Only string/integer/boolean/reference are indexable. Indexed ⇒ a type-mismatched write is HARD-REJECTED (schema_validation), not just warned — vault#553." },
|
|
2407
|
+
indexed: { type: "boolean", description: "When true, a generated column + index are maintained on notes.metadata.<field>, making it queryable via metadata operator objects and order_by. Global: all tags declaring the field must agree on both type and indexed. Only string/integer/boolean/reference/date are indexable. Indexed ⇒ a type-mismatched write is HARD-REJECTED (schema_validation), not just warned — vault#553." },
|
|
2388
2408
|
strict: { type: "boolean", description: "vault#299. Default false (advisory). When true, ALL of this field's declared constraints (type + enum + required + cardinality) are ENFORCED — a violating write is rejected with a schema_validation error, not just warned. All-or-nothing per field; free-form fields on a strict tag simply leave strict off. Note: `indexed: true` fields enforce their TYPE constraint regardless of this flag (vault#553)." },
|
|
2389
2409
|
required: { type: "boolean", description: "vault#299. The field must be present + non-null on a note with this tag. Advisory unless `strict: true`." },
|
|
2390
2410
|
cardinality: { type: "string", enum: ["one", "many"], description: "vault#299. 'one' (scalar, default) or 'many' (array). Advisory unless `strict: true`." },
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
*/
|
|
42
42
|
|
|
43
43
|
import { Database } from "bun:sqlite";
|
|
44
|
+
import { timestampToMs } from "./cursor.js";
|
|
44
45
|
|
|
45
46
|
// ---------------------------------------------------------------------------
|
|
46
47
|
// Types
|
|
@@ -57,7 +58,7 @@ export interface SchemaField {
|
|
|
57
58
|
* `type_mismatch` warning that previously fired on every integer-shaped
|
|
58
59
|
* field because the validator had no `"integer"` case. See vault#310.
|
|
59
60
|
*/
|
|
60
|
-
type?: "string" | "number" | "integer" | "boolean" | "array" | "object" | "reference";
|
|
61
|
+
type?: "string" | "number" | "integer" | "boolean" | "array" | "object" | "reference" | "date";
|
|
61
62
|
enum?: string[];
|
|
62
63
|
description?: string;
|
|
63
64
|
/**
|
|
@@ -320,6 +321,82 @@ export function resolveNoteSchemas(
|
|
|
320
321
|
return { effectiveTags, mergedFields, conflicts };
|
|
321
322
|
}
|
|
322
323
|
|
|
324
|
+
/**
|
|
325
|
+
* Normalize `date`-typed field VALUES in `metadata` to canonical UTC ISO
|
|
326
|
+
* form (`Z`-suffixed) BEFORE persisting (vault#date-field-type — mixed-
|
|
327
|
+
* offset corruption, caught in review).
|
|
328
|
+
*
|
|
329
|
+
* The bug: every consumer of an indexed `date` field — `buildOperatorClause`
|
|
330
|
+
* (query-operators.ts), `date_filter` and `order_by` (notes.ts) — compares
|
|
331
|
+
* the generated `meta_<field>` column as raw TEXT. ISO-8601 strings sort
|
|
332
|
+
* correctly under a TEXT compare ONLY when every value shares the same
|
|
333
|
+
* offset representation. `timestampToMs` (cursor.ts) — the validator both
|
|
334
|
+
* `defaultMatchesType` and `valueMatchesType` reuse — correctly ACCEPTS an
|
|
335
|
+
* explicit `±HH:MM` offset (validation was never the gap), but a value like
|
|
336
|
+
* `"2026-07-16T10:00:00+02:00"` (= `08:00Z`) persisted VERBATIM sorts AFTER
|
|
337
|
+
* `"2026-07-16T09:00:00Z"` (= `09:00Z`) under a raw string compare, even
|
|
338
|
+
* though the actual instant is earlier — a mixed-offset vault silently gets
|
|
339
|
+
* wrong range-query/order_by/date_filter results. This is the exact bug
|
|
340
|
+
* class `updated_at` got a dedicated ms-mirror column for (vault#585/#586,
|
|
341
|
+
* see `notes.ts`'s `dateFilter` block) that never extended to user-declared
|
|
342
|
+
* `date` fields.
|
|
343
|
+
*
|
|
344
|
+
* The fix is normalize-on-write, not reject-on-write (matching the
|
|
345
|
+
* "paths are normalized on write" precedent — instant preserved,
|
|
346
|
+
* representation canonicalized) — rejecting offsets would defeat the
|
|
347
|
+
* type's own motivation (calendar integrations and other emitters commonly
|
|
348
|
+
* produce offset timestamps, not always `Z`). Only a FULL timestamp (has a
|
|
349
|
+
* time component) is rewritten, to `new Date(ms).toISOString()` — always
|
|
350
|
+
* UTC, millisecond precision, `Z`-suffixed. A bare `YYYY-MM-DD` value is
|
|
351
|
+
* left untouched: it has no offset to normalize, is already canonical, and
|
|
352
|
+
* prefix-sorts correctly against full timestamps sharing the same calendar
|
|
353
|
+
* day. A value that fails `timestampToMs` is left untouched too — that's a
|
|
354
|
+
* `type_mismatch` for `valueMatchesType` to catch, not this function's job.
|
|
355
|
+
*
|
|
356
|
+
* COPY-ON-WRITE, never mutates the input `metadata` object (vault#date-
|
|
357
|
+
* field-type review round 2) — `core/` is a published library; a direct
|
|
358
|
+
* embedder holding a reference to the object THEY passed in must never see
|
|
359
|
+
* it change out from under them. Returns the SAME reference when nothing
|
|
360
|
+
* needs rewriting (the common case — no unnecessary allocation on the
|
|
361
|
+
* no-op path) or `metadata` is undefined; returns a freshly shallow-copied
|
|
362
|
+
* object, with only the rewritten field(s) replaced, the first time a
|
|
363
|
+
* rewrite is actually needed. Every call site must use the RETURNED value
|
|
364
|
+
* (not assume its input was mutated) — see `store.createNote`/`updateNote`/
|
|
365
|
+
* `createNotes`/`createNoteRaw`, all of which reassign their local
|
|
366
|
+
* `opts`/`updates`/`input` binding from the return rather than relying on a
|
|
367
|
+
* side effect.
|
|
368
|
+
*
|
|
369
|
+
* Called from `store.createNote`/`updateNote`/`createNotes`/`createNoteRaw`
|
|
370
|
+
* — the lowest chokepoint every write path (MCP, REST, import) funnels
|
|
371
|
+
* through — BEFORE the row is persisted, so the value that's validated by
|
|
372
|
+
* `validateNote`/`valueMatchesType` upstream (offset-tolerant, so
|
|
373
|
+
* normalization can't newly fail a write) and the value that's written +
|
|
374
|
+
* echoed back on the response are the SAME normalized string.
|
|
375
|
+
*/
|
|
376
|
+
export function normalizeDateFields(
|
|
377
|
+
resolved: ResolvedSchemas,
|
|
378
|
+
note: { tags?: string[]; metadata?: Record<string, unknown> },
|
|
379
|
+
): Record<string, unknown> | undefined {
|
|
380
|
+
const metadata = note.metadata;
|
|
381
|
+
if (!metadata) return metadata;
|
|
382
|
+
const { mergedFields } = resolveNoteSchemas(resolved, note);
|
|
383
|
+
let copy: Record<string, unknown> | undefined;
|
|
384
|
+
for (const [fieldName, { spec }] of mergedFields) {
|
|
385
|
+
if (spec.type !== "date") continue;
|
|
386
|
+
const value = metadata[fieldName];
|
|
387
|
+
if (typeof value !== "string") continue;
|
|
388
|
+
// Bare `YYYY-MM-DD` (no time component) has no offset to normalize.
|
|
389
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) continue;
|
|
390
|
+
const ms = timestampToMs(value);
|
|
391
|
+
if (ms === null) continue; // unparseable — valueMatchesType's job, not ours
|
|
392
|
+
const canonical = new Date(ms).toISOString();
|
|
393
|
+
if (canonical === value) continue;
|
|
394
|
+
if (!copy) copy = { ...metadata }; // lazy: only copy once a rewrite is due
|
|
395
|
+
copy[fieldName] = canonical;
|
|
396
|
+
}
|
|
397
|
+
return copy ?? metadata;
|
|
398
|
+
}
|
|
399
|
+
|
|
323
400
|
function fieldSpecsEqual(a: SchemaField, b: SchemaField): boolean {
|
|
324
401
|
if (a.type !== b.type) return false;
|
|
325
402
|
if (!stringArraysEqual(a.enum, b.enum)) return false;
|
|
@@ -390,6 +467,13 @@ function valueMatchesType(value: unknown, type: SchemaField["type"], cardinality
|
|
|
390
467
|
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
391
468
|
}
|
|
392
469
|
return typeof value === "string";
|
|
470
|
+
// `date` validates like `string` — an ISO-8601 date (`YYYY-MM-DD`) or
|
|
471
|
+
// full RFC3339 timestamp. Reuses `cursor.ts`'s `timestampToMs` (the SAME
|
|
472
|
+
// UTC-correct parser `date_filter`'s `updated_at` bound uses), not a
|
|
473
|
+
// second date parser — see tag-schemas.ts's `VALID_FIELD_TYPES` doc
|
|
474
|
+
// comment.
|
|
475
|
+
case "date":
|
|
476
|
+
return typeof value === "string" && timestampToMs(value) !== null;
|
|
393
477
|
}
|
|
394
478
|
}
|
|
395
479
|
|
package/core/src/store.ts
CHANGED
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
loadSchemaConfig,
|
|
39
39
|
validateNote as runValidateNote,
|
|
40
40
|
resolveNoteSchemas,
|
|
41
|
+
normalizeDateFields,
|
|
41
42
|
type ResolvedSchemas,
|
|
42
43
|
type ValidationStatus,
|
|
43
44
|
} from "./schema-defaults.js";
|
|
@@ -500,6 +501,16 @@ export class BunSqliteStore implements Store {
|
|
|
500
501
|
// ---- Notes ----
|
|
501
502
|
|
|
502
503
|
async createNote(content: string, opts?: { id?: string; path?: string; tags?: string[]; metadata?: Record<string, unknown>; created_at?: string; extension?: string; actor?: string | null; via?: string | null }): Promise<Note> {
|
|
504
|
+
// Normalize `date`-typed field values to canonical UTC ISO form BEFORE
|
|
505
|
+
// the write (vault#date-field-type — mixed-offset TEXT-compare
|
|
506
|
+
// corruption). COPY-ON-WRITE (round 2) — reassign the local `opts`
|
|
507
|
+
// binding from the returned value rather than mutating the caller's
|
|
508
|
+
// object; see `normalizeDateFields`'s doc comment.
|
|
509
|
+
if (opts?.metadata) {
|
|
510
|
+
const normalized = normalizeDateFields(this.getSchemaConfig(), { tags: opts.tags, metadata: opts.metadata });
|
|
511
|
+
if (normalized !== opts.metadata) opts = { ...opts, metadata: normalized };
|
|
512
|
+
}
|
|
513
|
+
|
|
503
514
|
const note = noteOps.createNote(this.db, content, opts);
|
|
504
515
|
|
|
505
516
|
if (content) {
|
|
@@ -548,6 +559,20 @@ export class BunSqliteStore implements Store {
|
|
|
548
559
|
actor?: string | null;
|
|
549
560
|
via?: string | null;
|
|
550
561
|
if_updated_at?: string;
|
|
562
|
+
/**
|
|
563
|
+
* `date`-field normalization override (vault#date-field-type review
|
|
564
|
+
* round 2). By default, `normalizeDateFields` below resolves the note's
|
|
565
|
+
* effective schema against its CURRENT tags in the DB — correct when
|
|
566
|
+
* this call doesn't also change tags. A caller that adds a tag IN THIS
|
|
567
|
+
* SAME logical update (via a separate `store.tagNote` issued right
|
|
568
|
+
* after this call — see mcp.ts's `update-note` handler and the batch
|
|
569
|
+
* upsert "update"/"replace" branch) must pass the PROJECTED final tag
|
|
570
|
+
* set here instead, or a newly-added tag's `type: "date"` field never
|
|
571
|
+
* gets seen (the schema resolution would still be looking at the
|
|
572
|
+
* pre-write tag set) and its offset-bearing value would persist
|
|
573
|
+
* verbatim. Ignored when `updates.metadata` is undefined.
|
|
574
|
+
*/
|
|
575
|
+
tagsForSchemaResolution?: string[];
|
|
551
576
|
},
|
|
552
577
|
): Promise<Note> {
|
|
553
578
|
let oldPath: string | undefined;
|
|
@@ -561,7 +586,19 @@ export class BunSqliteStore implements Store {
|
|
|
561
586
|
if (updates.path !== undefined || updates.metadata !== undefined) {
|
|
562
587
|
const existing = noteOps.getNote(this.db, id);
|
|
563
588
|
if (updates.path !== undefined) oldPath = existing?.path;
|
|
564
|
-
if (updates.metadata !== undefined)
|
|
589
|
+
if (updates.metadata !== undefined) {
|
|
590
|
+
priorMetadataForRefs = existing?.metadata;
|
|
591
|
+
// Normalize `date`-typed field values to canonical UTC ISO form
|
|
592
|
+
// BEFORE the write (vault#date-field-type — mixed-offset TEXT-
|
|
593
|
+
// compare corruption). COPY-ON-WRITE (round 2) — reassign the local
|
|
594
|
+
// `updates` binding from the returned value rather than mutating the
|
|
595
|
+
// caller's object; see `normalizeDateFields`'s doc comment.
|
|
596
|
+
// `tagsForSchemaResolution` (when the caller is ALSO adding a tag in
|
|
597
|
+
// this same logical update) wins over the note's current DB tags.
|
|
598
|
+
const tagsForResolution = updates.tagsForSchemaResolution ?? existing?.tags;
|
|
599
|
+
const normalized = normalizeDateFields(this.getSchemaConfig(), { tags: tagsForResolution, metadata: updates.metadata });
|
|
600
|
+
if (normalized !== updates.metadata) updates = { ...updates, metadata: normalized };
|
|
601
|
+
}
|
|
565
602
|
}
|
|
566
603
|
|
|
567
604
|
const note = noteOps.updateNote(this.db, id, updates);
|
|
@@ -1034,7 +1071,18 @@ export class BunSqliteStore implements Store {
|
|
|
1034
1071
|
// ---- Bulk Operations ----
|
|
1035
1072
|
|
|
1036
1073
|
async createNotes(inputs: noteOps.BulkNoteInput[]): Promise<Note[]> {
|
|
1037
|
-
|
|
1074
|
+
// Same pre-write `date`-field normalization as singleton createNote
|
|
1075
|
+
// (vault#date-field-type) — this bulk path bypasses it otherwise.
|
|
1076
|
+
// COPY-ON-WRITE (round 2) — build a new array with only the items that
|
|
1077
|
+
// actually need a rewrite replaced; never mutate the caller's `inputs`
|
|
1078
|
+
// or its element objects.
|
|
1079
|
+
const schemaConfig = this.getSchemaConfig();
|
|
1080
|
+
const normalizedInputs = inputs.map((input) => {
|
|
1081
|
+
if (!input.metadata) return input;
|
|
1082
|
+
const normalized = normalizeDateFields(schemaConfig, { tags: input.tags, metadata: input.metadata });
|
|
1083
|
+
return normalized !== input.metadata ? { ...input, metadata: normalized } : input;
|
|
1084
|
+
});
|
|
1085
|
+
const notes = noteOps.createNotes(this.db, normalizedInputs);
|
|
1038
1086
|
for (const note of notes) {
|
|
1039
1087
|
// Bulk path needs the same config-cache invalidation as singleton
|
|
1040
1088
|
// createNote — without it, a batch that includes `_tags/*` notes
|
|
@@ -1244,7 +1292,7 @@ export class BunSqliteStore implements Store {
|
|
|
1244
1292
|
const mapped = indexedFieldOps.mapFieldType(spec.type);
|
|
1245
1293
|
if (!mapped) {
|
|
1246
1294
|
throw new indexedFieldOps.IndexedFieldError(
|
|
1247
|
-
`field "${fieldName}" has unsupported type "${spec.type}" for indexing (supported: string, integer, boolean, reference)`,
|
|
1295
|
+
`field "${fieldName}" has unsupported type "${spec.type}" for indexing (supported: string, integer, boolean, reference, date)`,
|
|
1248
1296
|
);
|
|
1249
1297
|
}
|
|
1250
1298
|
// Throws IndexedFieldError on an invalid identifier (e.g. kebab-case).
|
|
@@ -1376,6 +1424,14 @@ export class BunSqliteStore implements Store {
|
|
|
1376
1424
|
* place.)
|
|
1377
1425
|
*/
|
|
1378
1426
|
async createNoteRaw(content: string, opts?: { id?: string; path?: string; tags?: string[]; metadata?: Record<string, unknown>; created_at?: string; extension?: string }): Promise<Note> {
|
|
1427
|
+
// Same pre-write `date`-field normalization as createNote
|
|
1428
|
+
// (vault#date-field-type) — the legacy Obsidian importer (obsidian.ts)
|
|
1429
|
+
// funnels through this path and bypasses createNote's copy otherwise.
|
|
1430
|
+
// COPY-ON-WRITE (round 2) — see normalizeDateFields's doc comment.
|
|
1431
|
+
if (opts?.metadata) {
|
|
1432
|
+
const normalized = normalizeDateFields(this.getSchemaConfig(), { tags: opts.tags, metadata: opts.metadata });
|
|
1433
|
+
if (normalized !== opts.metadata) opts = { ...opts, metadata: normalized };
|
|
1434
|
+
}
|
|
1379
1435
|
return noteOps.createNote(this.db, content, opts);
|
|
1380
1436
|
}
|
|
1381
1437
|
|
package/core/src/tag-schemas.ts
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
import { Database } from "bun:sqlite";
|
|
19
19
|
import { mapFieldType, validateFieldName } from "./indexed-fields.js";
|
|
20
20
|
import { loadTagHierarchy, findParentCycle } from "./tag-hierarchy.js";
|
|
21
|
+
import { timestampToMs } from "./cursor.js";
|
|
21
22
|
|
|
22
23
|
// ---------------------------------------------------------------------------
|
|
23
24
|
// Types
|
|
@@ -192,7 +193,7 @@ export class InvalidFieldDefaultError extends Error {
|
|
|
192
193
|
|
|
193
194
|
/**
|
|
194
195
|
* Thrown by `upsertTagRecord` (core/src/store.ts's chokepoint) when a
|
|
195
|
-
* field's declared `type` isn't one of the
|
|
196
|
+
* field's declared `type` isn't one of the recognized values (vault#555
|
|
196
197
|
* — `update-tag{fields:{weird:{type:"frobnicator"}}}` used to be accepted
|
|
197
198
|
* and persisted verbatim, no error, for any NON-indexed field: the only
|
|
198
199
|
* existing type check, `mapFieldType`, ran solely on `indexed: true` fields
|
|
@@ -255,13 +256,13 @@ export function validateFieldDefault(field: string, spec: TagFieldSchema): TagFi
|
|
|
255
256
|
|
|
256
257
|
/**
|
|
257
258
|
* The full recognized vocabulary for `TagFieldSchema.type` — storage/
|
|
258
|
-
* advisory validation accepts all
|
|
259
|
-
* `reference` are INDEXABLE (that narrower subset is
|
|
260
|
-
* `TYPE_MAP`, enforced separately via `mapFieldType`
|
|
261
|
-
* fields). Matches `defaultMatchesType`'s switch and
|
|
262
|
-
* `SchemaField.type` union — kept in lockstep by hand
|
|
263
|
-
* deliberately-decoupled modules (see `validateFieldDefault`'s
|
|
264
|
-
* for why they don't cross-import).
|
|
259
|
+
* advisory validation accepts all eight; only `string`/`integer`/`boolean`/
|
|
260
|
+
* `reference`/`date` are INDEXABLE (that narrower subset is
|
|
261
|
+
* `indexed-fields.ts`'s `TYPE_MAP`, enforced separately via `mapFieldType`
|
|
262
|
+
* for `indexed: true` fields). Matches `defaultMatchesType`'s switch and
|
|
263
|
+
* `schema-defaults.ts`'s `SchemaField.type` union — kept in lockstep by hand
|
|
264
|
+
* across the two deliberately-decoupled modules (see `validateFieldDefault`'s
|
|
265
|
+
* doc comment for why they don't cross-import).
|
|
265
266
|
*
|
|
266
267
|
* `reference` (vault#typed-reference-field) is a dual-write field type: the
|
|
267
268
|
* value is stored + validated exactly like `string` (an id/path/title the
|
|
@@ -270,11 +271,18 @@ export function validateFieldDefault(field: string, spec: TagFieldSchema): TagFi
|
|
|
270
271
|
* to a note and maintains a graph `links` edge from this note to the
|
|
271
272
|
* resolved target, with `relationship` set to the field name. See
|
|
272
273
|
* `docs/design/typed-reference-field.md` for the full design + known gaps.
|
|
274
|
+
*
|
|
275
|
+
* `date` stores/validates like `string` (an ISO-8601 date or timestamp) so
|
|
276
|
+
* indexed `date` fields sort correctly under a plain TEXT comparison — see
|
|
277
|
+
* `defaultMatchesType`'s `"date"` case and `schema-defaults.ts`'s
|
|
278
|
+
* `valueMatchesType`, both of which reuse `cursor.ts`'s `timestampToMs`
|
|
279
|
+
* (the SAME UTC-correct parser `date_filter`'s `updated_at` bound uses) so
|
|
280
|
+
* there's exactly one ISO-parsing implementation in the codebase, not two.
|
|
273
281
|
*/
|
|
274
|
-
export const VALID_FIELD_TYPES = ["string", "number", "integer", "boolean", "array", "object", "reference"] as const;
|
|
282
|
+
export const VALID_FIELD_TYPES = ["string", "number", "integer", "boolean", "array", "object", "reference", "date"] as const;
|
|
275
283
|
|
|
276
284
|
/**
|
|
277
|
-
* Validate that a field's declared `type` is one of the
|
|
285
|
+
* Validate that a field's declared `type` is one of the recognized
|
|
278
286
|
* values (vault#555). Returns `null` when `type` is unset (own-field checks
|
|
279
287
|
* elsewhere already treat an unset type as "nothing to check against") or
|
|
280
288
|
* recognized; otherwise a {@link TagFieldViolation} with `reason:
|
|
@@ -328,7 +336,7 @@ export function collectOwnFieldDefaultAndTypeViolations(
|
|
|
328
336
|
return violations;
|
|
329
337
|
}
|
|
330
338
|
|
|
331
|
-
/** Same
|
|
339
|
+
/** Same eight-type vocabulary as `TagFieldSchema.type`'s doc comment. Unknown/unset types pass (nothing to check against). */
|
|
332
340
|
function defaultMatchesType(value: unknown, type: string): boolean {
|
|
333
341
|
switch (type) {
|
|
334
342
|
case "string":
|
|
@@ -348,6 +356,13 @@ function defaultMatchesType(value: unknown, type: string): boolean {
|
|
|
348
356
|
// VALID_FIELD_TYPES's doc comment.
|
|
349
357
|
case "reference":
|
|
350
358
|
return typeof value === "string";
|
|
359
|
+
// `date` accepts an ISO-8601 date (`YYYY-MM-DD`) or full RFC3339
|
|
360
|
+
// timestamp — the SAME grammar `date_filter`'s `updated_at` bound
|
|
361
|
+
// parses (`timestampToMs`, imported from cursor.ts), not a second,
|
|
362
|
+
// independently-drifting date parser. See VALID_FIELD_TYPES's doc
|
|
363
|
+
// comment.
|
|
364
|
+
case "date":
|
|
365
|
+
return typeof value === "string" && timestampToMs(value) !== null;
|
|
351
366
|
default:
|
|
352
367
|
return true;
|
|
353
368
|
}
|
|
@@ -767,7 +782,7 @@ export function collectTagFieldViolations(
|
|
|
767
782
|
violations.push({
|
|
768
783
|
field: fieldName,
|
|
769
784
|
reason: "unsupported_indexed_type",
|
|
770
|
-
message: `field "${fieldName}" has unsupported type "${spec.type}" for indexing (supported: string, integer, boolean, reference)`,
|
|
785
|
+
message: `field "${fieldName}" has unsupported type "${spec.type}" for indexing (supported: string, integer, boolean, reference, date)`,
|
|
771
786
|
});
|
|
772
787
|
} else {
|
|
773
788
|
try {
|
package/core/src/types.ts
CHANGED
|
@@ -450,7 +450,7 @@ export interface Store {
|
|
|
450
450
|
*/
|
|
451
451
|
getNoteByPath(path: string, extension?: string): Promise<Note | null>;
|
|
452
452
|
getNotes(ids: string[]): Promise<Note[]>;
|
|
453
|
-
updateNote(id: string, updates: { content?: string; append?: string; prepend?: string; path?: string; extension?: string; metadata?: Record<string, unknown>; created_at?: string; skipUpdatedAt?: boolean; actor?: string | null; via?: string | null; if_updated_at?: string }): Promise<Note>;
|
|
453
|
+
updateNote(id: string, updates: { content?: string; append?: string; prepend?: string; path?: string; extension?: string; metadata?: Record<string, unknown>; created_at?: string; skipUpdatedAt?: boolean; actor?: string | null; via?: string | null; if_updated_at?: string; tagsForSchemaResolution?: string[] }): Promise<Note>;
|
|
454
454
|
/**
|
|
455
455
|
* Set a note's `created_at` and `updated_at` explicitly. Import-only:
|
|
456
456
|
* used by the portable-md round-trip path to restore timestamps from
|
package/package.json
CHANGED
|
@@ -315,7 +315,7 @@ describe("contract: error taxonomy — #554 (flipped from todo)", () => {
|
|
|
315
315
|
expect(record?.fields ?? null).toBeFalsy();
|
|
316
316
|
});
|
|
317
317
|
|
|
318
|
-
// Positive control — every one of the
|
|
318
|
+
// Positive control — every one of the recognized types (indexable or
|
|
319
319
|
// not) is accepted without complaint.
|
|
320
320
|
it("REST PUT /api/tags/:name accepts every recognized field type", async () => {
|
|
321
321
|
const req = new Request("http://localhost/api/tags/widget", {
|
|
@@ -329,6 +329,7 @@ describe("contract: error taxonomy — #554 (flipped from todo)", () => {
|
|
|
329
329
|
e: { type: "array" },
|
|
330
330
|
f: { type: "object" },
|
|
331
331
|
g: { type: "reference" },
|
|
332
|
+
h: { type: "date" },
|
|
332
333
|
},
|
|
333
334
|
}),
|
|
334
335
|
});
|