@openparachute/vault 0.7.2-rc.5 → 0.7.2-rc.6

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.
@@ -3,7 +3,7 @@ import { Database } from "bun:sqlite";
3
3
  import { SqliteStore } from "./store.js";
4
4
  import { generateMcpTools } from "./mcp.js";
5
5
  import { initSchema } from "./schema.js";
6
- import { decodeCursor } from "./cursor.js";
6
+ import { decodeCursor, timestampToMs } from "./cursor.js";
7
7
  import { traverseLinks } from "./links.js";
8
8
  import * as indexedFieldOps from "./indexed-fields.js";
9
9
  import { resolveLinkTarget } from "./wikilinks.js";
@@ -1493,11 +1493,15 @@ describe("queryNotes", async () => {
1493
1493
  await store.updateNote(b.id, { append: " edit" });
1494
1494
 
1495
1495
  // Pin each note's updated_at deterministically so the assertion isn't
1496
- // racing real wall-clock writes from the test harness.
1497
- db.prepare("UPDATE notes SET updated_at = ? WHERE id = ?")
1498
- .run("2026-01-15T00:00:00.000Z", a.id);
1499
- db.prepare("UPDATE notes SET updated_at = ? WHERE id = ?")
1500
- .run("2026-04-25T00:00:00.000Z", b.id);
1496
+ // racing real wall-clock writes from the test harness. Mirror
1497
+ // production (vault#586): EVERY write maintains `updated_at_ms` in
1498
+ // lockstep with `updated_at` — the vault#585 fix compares the ms
1499
+ // mirror, not the TEXT column, so a raw pin that only touched the
1500
+ // TEXT column would silently stop affecting this filter.
1501
+ db.prepare("UPDATE notes SET updated_at = ?, updated_at_ms = ? WHERE id = ?")
1502
+ .run("2026-01-15T00:00:00.000Z", Date.parse("2026-01-15T00:00:00.000Z"), a.id);
1503
+ db.prepare("UPDATE notes SET updated_at = ?, updated_at_ms = ? WHERE id = ?")
1504
+ .run("2026-04-25T00:00:00.000Z", Date.parse("2026-04-25T00:00:00.000Z"), b.id);
1501
1505
 
1502
1506
  const results = await store.queryNotes({
1503
1507
  dateFilter: { field: "updated_at", from: "2026-04-01" },
@@ -1519,16 +1523,69 @@ describe("queryNotes", async () => {
1519
1523
  it("dateFilter on updated_at honors the upper-bound exclusive `to`", async () => {
1520
1524
  const a = await store.createNote("inside-window");
1521
1525
  const b = await store.createNote("after-window");
1522
- db.prepare("UPDATE notes SET updated_at = ? WHERE id = ?")
1523
- .run("2026-04-25T00:00:00.000Z", a.id);
1524
- db.prepare("UPDATE notes SET updated_at = ? WHERE id = ?")
1525
- .run("2026-05-15T00:00:00.000Z", b.id);
1526
+ db.prepare("UPDATE notes SET updated_at = ?, updated_at_ms = ? WHERE id = ?")
1527
+ .run("2026-04-25T00:00:00.000Z", Date.parse("2026-04-25T00:00:00.000Z"), a.id);
1528
+ db.prepare("UPDATE notes SET updated_at = ?, updated_at_ms = ? WHERE id = ?")
1529
+ .run("2026-05-15T00:00:00.000Z", Date.parse("2026-05-15T00:00:00.000Z"), b.id);
1526
1530
 
1527
1531
  const results = await store.queryNotes({
1528
1532
  dateFilter: { field: "updated_at", from: "2026-04-01", to: "2026-05-01" },
1529
1533
  });
1530
1534
  expect(results.map((n) => n.content)).toEqual(["inside-window"]);
1531
1535
  });
1536
+
1537
+ // ---- vault#585: date_filter on updated_at compares the ms mirror ----
1538
+ //
1539
+ // Simulates a post-vault#586-migration vault: `updated_at_ms` is
1540
+ // correctly backfilled from the non-canonical stored `updated_at` TEXT
1541
+ // via `timestampToMs` (exactly what `migrateToV26` does on import), but
1542
+ // the TEXT column itself stays non-canonical — import stores frontmatter
1543
+ // timestamps VERBATIM. Before vault#585, `date_filter` on `updated_at`
1544
+ // still compared the RAW TEXT column lexicographically — wrong on these
1545
+ // non-canonical forms even though the correct ms value already sat in
1546
+ // `updated_at_ms`.
1547
+ function pinNonCanonicalUpdatedAt(id: string, rawTimestamp: string) {
1548
+ const ms = timestampToMs(rawTimestamp);
1549
+ if (ms === null) throw new Error(`test fixture bug: unparseable timestamp ${rawTimestamp}`);
1550
+ db.prepare("UPDATE notes SET updated_at = ?, updated_at_ms = ? WHERE id = ?")
1551
+ .run(rawTimestamp, ms, id);
1552
+ }
1553
+
1554
+ it("dateFilter `from` on updated_at is correct against a space-form non-canonical timestamp (vault#585)", async () => {
1555
+ // "2024-11-02 14:30:00" (space separator, no zone) is UTC-correct
1556
+ // 14:30 on Nov 2 per timestampToMs — 4.5 hours AFTER the 10:00 bound,
1557
+ // so it MUST be included. Lexicographic TEXT comparison disagrees:
1558
+ // the space (0x20) sorts BEFORE 'T' (0x54), so the space-form string
1559
+ // compares as LESS than the canonical `T`-separated bound on the same
1560
+ // calendar day — the old TEXT-compare code wrongly EXCLUDED it.
1561
+ const included = await store.createNote("space-form, after bound");
1562
+ pinNonCanonicalUpdatedAt(included.id, "2024-11-02 14:30:00");
1563
+ const excluded = await store.createNote("canonical, before bound");
1564
+ pinNonCanonicalUpdatedAt(excluded.id, "2024-11-01T00:00:00.000Z");
1565
+
1566
+ const results = await store.queryNotes({
1567
+ dateFilter: { field: "updated_at", from: "2024-11-02T10:00:00.000Z" },
1568
+ });
1569
+ expect(results.map((n) => n.content)).toEqual(["space-form, after bound"]);
1570
+ });
1571
+
1572
+ it("dateFilter `to` on updated_at is correct against an offset non-canonical timestamp (vault#585)", async () => {
1573
+ // "2024-11-02T21:00:00+13:00" is UTC-correct 08:00 on Nov 2 per
1574
+ // timestampToMs (21:00 minus the 13-hour offset) — BEFORE the 10:00
1575
+ // bound, so it MUST be included (`to` is the exclusive-upper `<`).
1576
+ // Lexicographic TEXT comparison disagrees: the wall-clock hour "21"
1577
+ // sorts AFTER the bound's "10" as a plain string, so the old
1578
+ // TEXT-compare code wrongly EXCLUDED it.
1579
+ const included = await store.createNote("offset, actually before bound");
1580
+ pinNonCanonicalUpdatedAt(included.id, "2024-11-02T21:00:00+13:00");
1581
+ const excluded = await store.createNote("canonical, after bound");
1582
+ pinNonCanonicalUpdatedAt(excluded.id, "2024-11-02T15:00:00.000Z");
1583
+
1584
+ const results = await store.queryNotes({
1585
+ dateFilter: { field: "updated_at", to: "2024-11-02T10:00:00.000Z" },
1586
+ });
1587
+ expect(results.map((n) => n.content)).toEqual(["offset, actually before bound"]);
1588
+ });
1532
1589
  });
1533
1590
 
1534
1591
  it("sorts ascending and descending", async () => {
@@ -2114,6 +2171,52 @@ describe("queryNotes", async () => {
2114
2171
  expect(store.queryNotes({ orderBy: "foo" })).rejects.toThrow(/not indexed/);
2115
2172
  });
2116
2173
 
2174
+ // ---- vault#585: order_by "updated_at" is a pseudo-field like link_count ----
2175
+ //
2176
+ // Before vault#585, `order_by: "updated_at"` always threw
2177
+ // FIELD_NOT_INDEXED — `updated_at` is a native `notes` column, not a
2178
+ // declared-indexed metadata field, and the generic order_by branch
2179
+ // gated every field (including this one) through `requireIndexedField`
2180
+ // (see the 2026-06 MCP-tool-description-audit CHANGELOG entry — this
2181
+ // was documented, intentional behavior, not a silent mis-sort). This
2182
+ // test proves the NEW capability lands correct from day one: ordering
2183
+ // on the integer `updated_at_ms` mirror (vault#586), immune to the
2184
+ // TEXT-lexicographic trap a plain `ORDER BY updated_at` would fall
2185
+ // into — a space-form timestamp's ' ' (0x20) always sorts BEFORE any
2186
+ // canonical `T`-separated timestamp's 'T' (0x54) on the same calendar
2187
+ // day, regardless of the actual wall-clock instant either encodes.
2188
+ it("order_by: \"updated_at\" sorts correctly across canonical + non-canonical timestamps (vault#585)", async () => {
2189
+ const p = await store.createNote("P earliest (canonical)");
2190
+ const q = await store.createNote("Q middle (space-form, non-canonical)");
2191
+ const r = await store.createNote("R latest (canonical)");
2192
+ const pin = (id: string, raw: string) => {
2193
+ const ms = timestampToMs(raw);
2194
+ if (ms === null) throw new Error(`test fixture bug: unparseable timestamp ${raw}`);
2195
+ db.prepare("UPDATE notes SET updated_at = ?, updated_at_ms = ? WHERE id = ?").run(raw, ms, id);
2196
+ };
2197
+ // True chronological order: P (08:00) < Q (12:00) < R (16:00). Q's
2198
+ // space-form TEXT ("2024-11-02 12:00:00") sorts BEFORE P's canonical
2199
+ // TEXT ("2024-11-02T08:00:00.000Z") under a plain string compare
2200
+ // (' ' < 'T'), which is exactly backwards.
2201
+ pin(p.id, "2024-11-02T08:00:00.000Z");
2202
+ pin(q.id, "2024-11-02 12:00:00");
2203
+ pin(r.id, "2024-11-02T16:00:00.000Z");
2204
+
2205
+ const asc = await store.queryNotes({ orderBy: "updated_at", sort: "asc" });
2206
+ expect(asc.map((n) => n.content)).toEqual([
2207
+ "P earliest (canonical)",
2208
+ "Q middle (space-form, non-canonical)",
2209
+ "R latest (canonical)",
2210
+ ]);
2211
+
2212
+ const desc = await store.queryNotes({ orderBy: "updated_at", sort: "desc" });
2213
+ expect(desc.map((n) => n.content)).toEqual([
2214
+ "R latest (canonical)",
2215
+ "Q middle (space-form, non-canonical)",
2216
+ "P earliest (canonical)",
2217
+ ]);
2218
+ });
2219
+
2117
2220
  it("unknown operator throws UNKNOWN_OPERATOR with supported-op list", async () => {
2118
2221
  await seedIndexedPriorities();
2119
2222
  expect(
package/core/src/mcp.ts CHANGED
@@ -419,7 +419,7 @@ Response shape (vault#550 — three variants, pick by what you passed):
419
419
  last_updated_by: { type: "string", description: "Write-attribution filter (vault#298): only notes whose MOST RECENT write was attributed to this principal. Exact match; indexed." },
420
420
  created_via: { type: "string", description: "Write-attribution filter (vault#298): only notes FIRST written through this interface/channel — e.g. `mcp`, `surface:<name>`, `agent:<id>`, `operator`, `api`. Exact match; indexed." },
421
421
  last_updated_via: { type: "string", description: "Write-attribution filter (vault#298): only notes whose MOST RECENT write came through this interface/channel. Exact match; indexed." },
422
- order_by: { type: "string", description: "Sort by an indexed metadata field instead of `created_at`. Field must be declared `indexed: true`; errors otherwise. The special value `link_count` sorts by link DEGREE (both-directions raw row count) — no declaration needed — matching the `include_link_count` field for every note. Direction is taken from `sort` (default 'asc'); `created_at` is appended as a stable tiebreaker." },
422
+ order_by: { type: "string", description: "Sort by an indexed metadata field instead of `created_at`. Field must be declared `indexed: true`; errors otherwise. Two special values need no declaration: `link_count` sorts by link DEGREE (both-directions raw row count), matching the `include_link_count` field for every note; `updated_at` (vault#585) sorts on the integer `updated_at_ms` mirror column — correct on non-canonical/imported timestamps — with `id` as the tiebreaker. Direction is taken from `sort` (default 'asc'); for other fields `created_at` is appended as a stable tiebreaker." },
423
423
  date_from: { type: "string", description: "Start date (ISO, inclusive). Filters on `created_at` (vault ingestion time). Shorthand for `date_filter: { field: 'created_at', from }`." },
424
424
  date_to: { type: "string", description: "End date (ISO, exclusive). Filters on `created_at` (vault ingestion time). Shorthand for `date_filter: { field: 'created_at', to }`." },
425
425
  date_filter: {
package/core/src/notes.ts CHANGED
@@ -905,6 +905,50 @@ function validateIsoDateValue(field: string, value: string): void {
905
905
  }
906
906
  }
907
907
 
908
+ /**
909
+ * Resolve a `date_filter` bound on the `updated_at` field to a millisecond
910
+ * epoch, for comparison against the integer `n.updated_at_ms` mirror column
911
+ * instead of the TEXT `n.updated_at` (vault#585 — the same TEXT-vs-ms class
912
+ * of bug vault#586 closed for the cursor keyset: a non-canonical stored
913
+ * timestamp — space-form, offset, no-`Z` — sorts/compares WRONG
914
+ * lexicographically).
915
+ *
916
+ * Runs `validateIsoDateValue` first — same loud `INVALID_QUERY` contract a
917
+ * caller already gets for a malformed bound — then converts with
918
+ * `timestampToMs` (the UTC-correct parse `updated_at_ms` itself was
919
+ * backfilled with). `timestampToMs` returning `null` here would mean the
920
+ * bound matched `Date.parse`'s more permissive grammar (e.g. a non-ISO
921
+ * locale string) but not `timestampToMs`'s stricter one — a defensive
922
+ * fallback, not an expected path for any bound `validateIsoDateValue`'s own
923
+ * error message advertises as valid ("2026-07-09" / full ISO timestamps).
924
+ * Surfaces the SAME `INVALID_QUERY` shape rather than binding `NaN`.
925
+ *
926
+ * One deliberate tightening for `updated_at` only: an ISO *shorthand* bound
927
+ * like `"2024"` or `"2024-06"` passes `Date.parse` (so `validateIsoDateValue`
928
+ * accepted it, and on a canonical vault it compared correctly via lexicographic
929
+ * prefix) but fails `timestampToMs`'s stricter `TIMESTAMP_RE`, so it now
930
+ * returns `INVALID_QUERY` here — loud, not silent. `date_filter` on
931
+ * `created_at` (no ms mirror) still accepts the shorthand; the asymmetry is
932
+ * intentional (ambiguous precision on a range bound).
933
+ */
934
+ function resolveUpdatedAtBoundMs(field: string, value: string): number {
935
+ validateIsoDateValue(field, value);
936
+ const ms = timestampToMs(value);
937
+ if (ms === null) {
938
+ throw new QueryError(
939
+ `invalid date value for "${field}": ${JSON.stringify(value)} — must be an ISO-8601 date/timestamp (e.g. "2026-07-09" or "2026-07-09T00:00:00.000Z").`,
940
+ "INVALID_QUERY",
941
+ {
942
+ error_type: "invalid_query",
943
+ field,
944
+ got: value,
945
+ hint: "pass an ISO-8601 date or timestamp",
946
+ },
947
+ );
948
+ }
949
+ return ms;
950
+ }
951
+
908
952
  /**
909
953
  * Build the shared WHERE-clause conditions + bound params for the filter
910
954
  * surface both `queryNotes` and `aggregateNotes` apply: tag membership
@@ -1169,15 +1213,17 @@ function buildFilterConditions(db: Database, opts: QueryOpts): { conditions: str
1169
1213
  const filter = opts.dateFilter!;
1170
1214
  const field = filter.field ?? "created_at";
1171
1215
  let column: string;
1216
+ // vault#585: `updated_at` compares against the integer `updated_at_ms`
1217
+ // mirror (vault#586), not the TEXT `n.updated_at` — a TEXT compare
1218
+ // mis-filters non-canonical rows (space-form / offset / no-`Z`) the same
1219
+ // way the pre-v26 cursor keyset did. `created_at` has no ms mirror and
1220
+ // stays a TEXT compare (out of scope — tracked separately, see vault#585
1221
+ // follow-up note in the fix PR).
1222
+ const isUpdatedAt = field === "updated_at";
1172
1223
  if (field === "created_at") {
1173
1224
  column = "n.created_at";
1174
- } else if (field === "updated_at") {
1175
- // NOTE (vault#586 follow-up): this range filter compares the TEXT
1176
- // `updated_at`, which is inconsistent on non-canonical rows the same way
1177
- // the cursor keyset was pre-v26. Cursor pagination moved to the integer
1178
- // `updated_at_ms` column; `date_filter`/`order_by`/export `--since` still
1179
- // read the string and are tracked separately. Behavior unchanged here.
1180
- column = "n.updated_at";
1225
+ } else if (isUpdatedAt) {
1226
+ column = "n.updated_at_ms";
1181
1227
  } else {
1182
1228
  // Re-uses the same indexed-field gate as `metadata` operator queries
1183
1229
  // and `orderBy` so the error message and contract are consistent.
@@ -1185,14 +1231,24 @@ function buildFilterConditions(db: Database, opts: QueryOpts): { conditions: str
1185
1231
  column = `"meta_${field}"`;
1186
1232
  }
1187
1233
  if (filter.from !== undefined) {
1188
- validateIsoDateValue(field === "created_at" ? "date_filter.from" : `date_filter.from (${field})`, filter.from);
1234
+ const label = field === "created_at" ? "date_filter.from" : `date_filter.from (${field})`;
1189
1235
  conditions.push(`${column} >= ?`);
1190
- params.push(filter.from);
1236
+ if (isUpdatedAt) {
1237
+ params.push(resolveUpdatedAtBoundMs(label, filter.from));
1238
+ } else {
1239
+ validateIsoDateValue(label, filter.from);
1240
+ params.push(filter.from);
1241
+ }
1191
1242
  }
1192
1243
  if (filter.to !== undefined) {
1193
- validateIsoDateValue(field === "created_at" ? "date_filter.to" : `date_filter.to (${field})`, filter.to);
1244
+ const label = field === "created_at" ? "date_filter.to" : `date_filter.to (${field})`;
1194
1245
  conditions.push(`${column} < ?`);
1195
- params.push(filter.to);
1246
+ if (isUpdatedAt) {
1247
+ params.push(resolveUpdatedAtBoundMs(label, filter.to));
1248
+ } else {
1249
+ validateIsoDateValue(label, filter.to);
1250
+ params.push(filter.to);
1251
+ }
1196
1252
  }
1197
1253
  } else if (hasLegacyDate) {
1198
1254
  if (opts.dateFrom) {
@@ -1316,6 +1372,18 @@ export function queryNotes(db: Database, opts: QueryOpts, _outUpdatedAtMs?: Map<
1316
1372
  // without it, two notes sharing an `updated_at_ms` would be at the mercy
1317
1373
  // of SQLite's row order and the next page could miss or duplicate one.
1318
1374
  orderBy = "n.updated_at_ms ASC, n.id ASC";
1375
+ } else if (opts.orderBy === "updated_at") {
1376
+ // `updated_at` is a pseudo-field like `link_count` below — it's a native
1377
+ // notes column, not a declared-indexed metadata field, so it bypasses
1378
+ // `requireIndexedField` (vault#585). Prior to this, `order_by:
1379
+ // "updated_at"` always 400'd FIELD_NOT_INDEXED (a documented, intentional
1380
+ // reject — see the 2026-06 MCP-tool-description-audit CHANGELOG entry);
1381
+ // it never silently sorted on the TEXT column. Ordering on the integer
1382
+ // `updated_at_ms` mirror (vault#586) from the start means this new
1383
+ // capability is UTC-correct on day one — no TEXT-lexicographic pass ever
1384
+ // shipped for it. `id` is the explicit tiebreak for stable ordering when
1385
+ // two notes share a millisecond, matching the cursor keyset's tiebreak.
1386
+ orderBy = `n.updated_at_ms ${direction}, n.id ${direction}`;
1319
1387
  } else if (opts.orderBy === "link_count") {
1320
1388
  // `link_count` is a pseudo-field — like `created_at`/`updated_at` in the
1321
1389
  // dateFilter block above, it bypasses `requireIndexedField` (it's not a
@@ -24,6 +24,7 @@ import { join } from "path";
24
24
  import { tmpdir } from "os";
25
25
 
26
26
  import { SqliteStore } from "./store.js";
27
+ import { timestampToMs } from "./cursor.js";
27
28
  import { getIndexedField } from "./indexed-fields.js";
28
29
  import { buildVaultProjection } from "./vault-projection.js";
29
30
  import {
@@ -593,6 +594,48 @@ describe("exportVaultToDir", async () => {
593
594
  }
594
595
  });
595
596
 
597
+ it("respects --since correctly on non-canonical `updated_at` (vault#585)", async () => {
598
+ // Independent db/store pair (not the describe's shared `store`) so the
599
+ // test can reach the raw `updated_at` / `updated_at_ms` columns
600
+ // directly — same pattern used elsewhere in this file (pruneOrphans,
601
+ // case-collision describes).
602
+ const db = new Database(":memory:");
603
+ const localStore = new SqliteStore(db);
604
+
605
+ const included = await localStore.createNote("space-form, after since", { id: "inc", path: "inc" });
606
+ const excluded = await localStore.createNote("canonical, before since", { id: "exc", path: "exc" });
607
+
608
+ // "2024-11-02 14:30:00" (space-form, no zone) is UTC-correct 14:30 on
609
+ // Nov 2 per timestampToMs — AFTER the 10:00 `since` bound below, so it
610
+ // MUST be exported. A TEXT `>=` comparison (the pre-vault#585 behavior)
611
+ // disagrees: the space (0x20) sorts BEFORE 'T' (0x54), so this string
612
+ // compares as LESS than the canonical `since` bound on the same
613
+ // calendar day — the old code wrongly EXCLUDED it from the incremental
614
+ // export.
615
+ const includedRaw = "2024-11-02 14:30:00";
616
+ const excludedRaw = "2024-11-01T00:00:00.000Z";
617
+ const includedMs = timestampToMs(includedRaw);
618
+ const excludedMs = timestampToMs(excludedRaw);
619
+ if (includedMs === null || excludedMs === null) {
620
+ throw new Error("test fixture bug: unparseable timestamp");
621
+ }
622
+ db.prepare("UPDATE notes SET updated_at = ?, updated_at_ms = ? WHERE id = ?")
623
+ .run(includedRaw, includedMs, included.id);
624
+ db.prepare("UPDATE notes SET updated_at = ?, updated_at_ms = ? WHERE id = ?")
625
+ .run(excludedRaw, excludedMs, excluded.id);
626
+
627
+ const outDir = join(tmpBase, "since-non-canonical");
628
+ const stats = await exportVaultToDir(localStore, {
629
+ outDir,
630
+ since: "2024-11-02T10:00:00.000Z",
631
+ exportedAt: "2026-05-12T00:00:00.000Z",
632
+ });
633
+
634
+ expect(stats.filtered_by_since).toBe(true);
635
+ expect(existsSync(join(outDir, "inc.md"))).toBe(true);
636
+ expect(existsSync(join(outDir, "exc.md"))).toBe(false);
637
+ });
638
+
596
639
  it("re-export with same exportedAt produces byte-identical output (idempotency)", async () => {
597
640
  await store.createNote("body", { id: "n1", path: "Inbox/x", tags: ["b", "a"] });
598
641
  await store.upsertTagSchema("a", { description: "tag-a" });
@@ -78,6 +78,7 @@ import type { Store, Note, Link, Attachment } from "./types.js";
78
78
  import type { TagRecord } from "./tag-schemas.js";
79
79
  import { ParentCycleError } from "./tag-schemas.js";
80
80
  import { transactionAsync } from "./txn.js";
81
+ import { timestampToMs } from "./cursor.js";
81
82
 
82
83
  // ---------------------------------------------------------------------------
83
84
  // Format constants
@@ -1627,9 +1628,40 @@ function disambiguateFilename(path: string, extension: string, noteId: string):
1627
1628
  return `${path}__${idShort}.${extension}`;
1628
1629
  }
1629
1630
 
1631
+ /**
1632
+ * Incremental-export inclusion test (vault#585). Compares millisecond
1633
+ * epochs via {@link timestampToMs} — the SAME UTC-correct parse the
1634
+ * `notes.updated_at_ms` column mirror was backfilled with (vault#586
1635
+ * `migrateToV26`) — instead of a TEXT `>=` comparison on the stored ISO
1636
+ * string. Import stores frontmatter timestamps VERBATIM, so an
1637
+ * aged/imported vault routinely carries non-canonical `updated_at`
1638
+ * (space-separated `2024-11-02 14:30:00`, a `+02:00` offset, no trailing
1639
+ * `Z`); those sort WRONG against a canonical `.toISOString()` bound under
1640
+ * plain string comparison, silently including or excluding notes from an
1641
+ * incremental export.
1642
+ *
1643
+ * The fallback chain mirrors `migrateToV26`'s exactly — `updatedAt` →
1644
+ * `createdAt` → the `0` (epoch) sentinel — so this function's answer for
1645
+ * any given note is identical to what filtering on the real
1646
+ * `updated_at_ms` column would produce; a note whose stored timestamp is
1647
+ * genuinely unparseable sorts as "ancient" and drops out of an
1648
+ * incremental export rather than being (wrongly) always-included or
1649
+ * always-excluded by an arbitrary string comparison.
1650
+ *
1651
+ * `since` is caller input (CLI `--since`), already validated upstream
1652
+ * (`Date.parse`-checked in `src/cli.ts` before this is ever reached from
1653
+ * the CLI) — `timestampToMs` is tried first for UTC-correctness on the
1654
+ * same non-canonical shapes, falling back to `Date.parse` for any exotic
1655
+ * shape `timestampToMs`'s stricter grammar doesn't cover. A genuinely
1656
+ * unparseable `since` (only reachable by calling the engine directly,
1657
+ * bypassing the CLI guard) yields `NaN`, against which `>=` is always
1658
+ * `false` — every note is excluded rather than the prior TEXT compare's
1659
+ * arbitrary (and possibly all-inclusive) result.
1660
+ */
1630
1661
  function shouldIncludeForSince(note: Note, since: string): boolean {
1631
- const stamp = note.updatedAt ?? note.createdAt;
1632
- return stamp >= since;
1662
+ const stampMs = timestampToMs(note.updatedAt) ?? timestampToMs(note.createdAt) ?? 0;
1663
+ const sinceMs = timestampToMs(since) ?? Date.parse(since);
1664
+ return stampMs >= sinceMs;
1633
1665
  }
1634
1666
 
1635
1667
  // ---------------------------------------------------------------------------
package/core/src/types.ts CHANGED
@@ -237,6 +237,14 @@ export interface QueryOpts {
237
237
  // raw row count — using the same directional-sum definition as the
238
238
  // `linkCount` response field, so the sort key equals the field value for
239
239
  // every note (self-loops included). See `queryNotes`/`getLinkCounts`.
240
+ //
241
+ // The pseudo-field `updated_at` is also special-cased (vault#585): it
242
+ // orders on the integer `notes.updated_at_ms` mirror column (vault#586),
243
+ // NOT the TEXT `updated_at`, with `id` appended as the tiebreaker instead
244
+ // of `created_at` — a TEXT sort mis-orders non-canonical stored timestamps
245
+ // (space-form / offset / no-`Z`) the same way the pre-v26 cursor keyset
246
+ // did. `created_at` itself has no ms mirror and is not special-cased here
247
+ // (its default/no-`orderBy` sort still reads the TEXT column).
240
248
  orderBy?: string;
241
249
  limit?: number;
242
250
  offset?: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.7.2-rc.5",
3
+ "version": "0.7.2-rc.6",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",
package/src/vault.test.ts CHANGED
@@ -2226,10 +2226,13 @@ describe("HTTP /notes", async () => {
2226
2226
  const a = await store.createNote("untouched", { id: "ua", path: "ua" });
2227
2227
  const b = await store.createNote("modified", { id: "ub", path: "ub" });
2228
2228
  // Bump b's updated_at into the test window, leave a's at its createdAt.
2229
- db.prepare("UPDATE notes SET updated_at = ? WHERE id = ?")
2230
- .run("2026-01-15T00:00:00.000Z", a.id);
2231
- db.prepare("UPDATE notes SET updated_at = ? WHERE id = ?")
2232
- .run("2026-04-25T00:00:00.000Z", b.id);
2229
+ // Pin BOTH `updated_at` and `updated_at_ms` mirrors production
2230
+ // (vault#586: every real write keeps them in lockstep) — the vault#585
2231
+ // fix compares the ms mirror, not the TEXT column.
2232
+ db.prepare("UPDATE notes SET updated_at = ?, updated_at_ms = ? WHERE id = ?")
2233
+ .run("2026-01-15T00:00:00.000Z", Date.parse("2026-01-15T00:00:00.000Z"), a.id);
2234
+ db.prepare("UPDATE notes SET updated_at = ?, updated_at_ms = ? WHERE id = ?")
2235
+ .run("2026-04-25T00:00:00.000Z", Date.parse("2026-04-25T00:00:00.000Z"), b.id);
2233
2236
 
2234
2237
  const res = await handleNotes(
2235
2238
  mkReq("GET", "/notes?meta[updated_at][gte]=2026-04-01&include_content=true"),
@@ -3370,10 +3373,11 @@ describe("HTTP /notes", async () => {
3370
3373
  test("`meta[updated_at][gte]=…` routes to dateFilter on n.updated_at", async () => {
3371
3374
  const a = await store.createNote("untouched");
3372
3375
  const b = await store.createNote("modified");
3373
- db.prepare("UPDATE notes SET updated_at = ? WHERE id = ?")
3374
- .run("2026-01-15T00:00:00.000Z", a.id);
3375
- db.prepare("UPDATE notes SET updated_at = ? WHERE id = ?")
3376
- .run("2026-04-25T00:00:00.000Z", b.id);
3376
+ // Pin BOTH columns (vault#585/#586 see the identical note above).
3377
+ db.prepare("UPDATE notes SET updated_at = ?, updated_at_ms = ? WHERE id = ?")
3378
+ .run("2026-01-15T00:00:00.000Z", Date.parse("2026-01-15T00:00:00.000Z"), a.id);
3379
+ db.prepare("UPDATE notes SET updated_at = ?, updated_at_ms = ? WHERE id = ?")
3380
+ .run("2026-04-25T00:00:00.000Z", Date.parse("2026-04-25T00:00:00.000Z"), b.id);
3377
3381
  const res = await handleNotes(
3378
3382
  mkReq("GET", "/notes?meta[updated_at][gte]=2026-04-01&include_content=true"),
3379
3383
  store,