@openparachute/vault 0.7.2-rc.4 → 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.
- package/core/src/core.test.ts +497 -10
- package/core/src/mcp.ts +1 -1
- package/core/src/notes.ts +79 -11
- package/core/src/portable-md.test.ts +43 -0
- package/core/src/portable-md.ts +34 -2
- package/core/src/schema-defaults.ts +17 -2
- package/core/src/store.ts +345 -56
- package/core/src/types.ts +8 -0
- package/core/src/wikilinks.ts +29 -0
- package/package.json +1 -1
- package/src/vault.test.ts +12 -8
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 (
|
|
1175
|
-
|
|
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
|
-
|
|
1234
|
+
const label = field === "created_at" ? "date_filter.from" : `date_filter.from (${field})`;
|
|
1189
1235
|
conditions.push(`${column} >= ?`);
|
|
1190
|
-
|
|
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
|
-
|
|
1244
|
+
const label = field === "created_at" ? "date_filter.to" : `date_filter.to (${field})`;
|
|
1194
1245
|
conditions.push(`${column} < ?`);
|
|
1195
|
-
|
|
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" });
|
package/core/src/portable-md.ts
CHANGED
|
@@ -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
|
|
1632
|
-
|
|
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
|
// ---------------------------------------------------------------------------
|
|
@@ -348,7 +348,7 @@ function jsonTypeOf(value: unknown): string {
|
|
|
348
348
|
return typeof value; // "string" | "number" | "boolean" | "object" | ...
|
|
349
349
|
}
|
|
350
350
|
|
|
351
|
-
function valueMatchesType(value: unknown, type: SchemaField["type"]): boolean {
|
|
351
|
+
function valueMatchesType(value: unknown, type: SchemaField["type"], cardinality?: SchemaField["cardinality"]): boolean {
|
|
352
352
|
if (type === undefined) return true;
|
|
353
353
|
switch (type) {
|
|
354
354
|
case "string":
|
|
@@ -373,7 +373,22 @@ function valueMatchesType(value: unknown, type: SchemaField["type"]): boolean {
|
|
|
373
373
|
// the write path (core/src/store.ts) separately resolves this value to
|
|
374
374
|
// a note and maintains a graph link; see tag-schemas.ts's
|
|
375
375
|
// `VALID_FIELD_TYPES` doc comment for the full contract.
|
|
376
|
+
//
|
|
377
|
+
// `cardinality: "many"` (vault#typed-reference-field gap #2) is a
|
|
378
|
+
// one-to-MANY reference field — the value is an ARRAY of reference
|
|
379
|
+
// strings, one per linked note, not a single string. Validate the
|
|
380
|
+
// shape a "many" reference actually takes: an array whose elements are
|
|
381
|
+
// ALL non-empty-typed strings (per-item type check — the ARRAY shape
|
|
382
|
+
// itself is separately covered by the `cardinality_mismatch` check
|
|
383
|
+
// below in `validateNote`, so this only needs to judge element types).
|
|
384
|
+
// Without this branch, EVERY valid `cardinality:"many"` reference write
|
|
385
|
+
// fired a self-contradictory `type_mismatch` ("should be reference, got
|
|
386
|
+
// array") — an array is exactly what "many" asks for; only a
|
|
387
|
+
// non-string ELEMENT should ever fail this check.
|
|
376
388
|
case "reference":
|
|
389
|
+
if (cardinality === "many") {
|
|
390
|
+
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
391
|
+
}
|
|
377
392
|
return typeof value === "string";
|
|
378
393
|
}
|
|
379
394
|
}
|
|
@@ -435,7 +450,7 @@ export function validateNote(
|
|
|
435
450
|
|
|
436
451
|
if (absent) continue;
|
|
437
452
|
|
|
438
|
-
if (spec.type && !valueMatchesType(value, spec.type)) {
|
|
453
|
+
if (spec.type && !valueMatchesType(value, spec.type, spec.cardinality)) {
|
|
439
454
|
// Decision A (vault#553): an INDEXED field's type is a query
|
|
440
455
|
// contract — a type-mismatched write poisons range-query ordering
|
|
441
456
|
// (SQLite's TEXT-sorts-above-INTEGER affinity) regardless of whether
|