@openparachute/vault 0.7.2-rc.5 → 0.7.2-rc.7
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 +113 -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/types.ts +8 -0
- package/package.json +1 -1
- package/src/routes.ts +49 -2
- package/src/server.ts +9 -1
- package/src/storage.test.ts +122 -2
- package/src/vault.test.ts +12 -8
package/core/src/core.test.ts
CHANGED
|
@@ -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
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
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.
|
|
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 (
|
|
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
|
// ---------------------------------------------------------------------------
|
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
package/src/routes.ts
CHANGED
|
@@ -4255,7 +4255,30 @@ async function handleRetryLegacyInBody(
|
|
|
4255
4255
|
// existing importers are unaffected.
|
|
4256
4256
|
// ---------------------------------------------------------------------------
|
|
4257
4257
|
|
|
4258
|
-
const MAX_UPLOAD_BYTES = 100 * 1024 * 1024; // 100MB
|
|
4258
|
+
export const MAX_UPLOAD_BYTES = 100 * 1024 * 1024; // 100MB
|
|
4259
|
+
|
|
4260
|
+
/**
|
|
4261
|
+
* Transport-level `Bun.serve` `maxRequestBodySize` ceiling (vault#588 FIX 2).
|
|
4262
|
+
*
|
|
4263
|
+
* The app-level caps above (`MAX_JSON_BODY_BYTES` 10MB, `MAX_UPLOAD_BYTES`
|
|
4264
|
+
* 100MB) only run AFTER the transport has already buffered the body: the
|
|
4265
|
+
* `Content-Length` pre-check in `parseJsonBody` short-circuits early, but a
|
|
4266
|
+
* chunked request with no `Content-Length` header falls through to the
|
|
4267
|
+
* post-parse backstop — which still means Bun buffered the whole thing into
|
|
4268
|
+
* memory first. Bun's *default* `maxRequestBodySize` (128MB) happened to sit
|
|
4269
|
+
* above `MAX_UPLOAD_BYTES` and so never bit in practice, but it was never
|
|
4270
|
+
* actually configured — an accident of the default, not a deliberate
|
|
4271
|
+
* ceiling wired to this codebase's own limits.
|
|
4272
|
+
*
|
|
4273
|
+
* Set explicitly here so the ceiling is legible and reconciled: it MUST be
|
|
4274
|
+
* >= `MAX_UPLOAD_BYTES` (a legitimate max-size attachment upload must not be
|
|
4275
|
+
* rejected at the transport layer before `/upload`'s own 100MB check ever
|
|
4276
|
+
* runs) with headroom for multipart overhead (boundary delimiters, per-part
|
|
4277
|
+
* headers — a few hundred bytes in practice, but the 20MB margin is
|
|
4278
|
+
* generous rather than exact). `MAX_JSON_BODY_BYTES` (10MB) is well under
|
|
4279
|
+
* `MAX_UPLOAD_BYTES`, so it never drives this number.
|
|
4280
|
+
*/
|
|
4281
|
+
export const MAX_REQUEST_BODY_BYTES = MAX_UPLOAD_BYTES + 20 * 1024 * 1024; // 120MB
|
|
4259
4282
|
|
|
4260
4283
|
// Storage upload policy: DENY-LIST (vault#517). A knowledge vault stores
|
|
4261
4284
|
// arbitrary files — ebooks, office docs, datasets, archives, binaries — so we
|
|
@@ -4359,7 +4382,31 @@ export async function handleStorage(
|
|
|
4359
4382
|
const assets = assetsDir(vault);
|
|
4360
4383
|
|
|
4361
4384
|
if (req.method === "POST" && path === "/upload") {
|
|
4362
|
-
|
|
4385
|
+
// vault#588 FIX 1 — `req.formData()` throws on a malformed/non-multipart
|
|
4386
|
+
// body (bad boundary, truncated body, wrong Content-Type entirely). Left
|
|
4387
|
+
// uncaught, that throw escapes to server.ts's generic top-level catch: a
|
|
4388
|
+
// 500 with no `error_type` — the multipart-transport analog of the
|
|
4389
|
+
// `req.json()` gap LB7 fixed for the JSON routes (`parseJsonBody` above).
|
|
4390
|
+
// Catch it here and return the same `invalid_request` taxonomy entry
|
|
4391
|
+
// LB7b uses for "syntactically-parseable-transport but wrong/unusable
|
|
4392
|
+
// shape" (see docs/HTTP_API.md's error-taxonomy table) rather than
|
|
4393
|
+
// minting a new `invalid_form` type for what's ultimately the same bucket.
|
|
4394
|
+
// Typed off `req.formData()`'s own return, not the DOM lib `FormData`
|
|
4395
|
+
// global — `req: Request` resolves through undici's ambient types here,
|
|
4396
|
+
// whose `FormData` isn't structurally assignable to lib.dom's.
|
|
4397
|
+
let form: Awaited<ReturnType<typeof req.formData>>;
|
|
4398
|
+
try {
|
|
4399
|
+
form = await req.formData();
|
|
4400
|
+
} catch {
|
|
4401
|
+
return json(
|
|
4402
|
+
{
|
|
4403
|
+
error: "Request body must be valid multipart/form-data",
|
|
4404
|
+
error_type: "invalid_request",
|
|
4405
|
+
hint: "expected a multipart/form-data body with a `file` field",
|
|
4406
|
+
},
|
|
4407
|
+
400,
|
|
4408
|
+
);
|
|
4409
|
+
}
|
|
4363
4410
|
const file = form.get("file");
|
|
4364
4411
|
if (!(file instanceof File)) {
|
|
4365
4412
|
return json({ error: "file is required", error_type: "missing_required_field", field: "file" }, 400);
|
package/src/server.ts
CHANGED
|
@@ -27,7 +27,7 @@ import { loadVaultTriggers } from "./triggers-api.ts";
|
|
|
27
27
|
import { route } from "./routing.ts";
|
|
28
28
|
import { startTranscriptionWorker, registerTranscriptionHook, type TranscriptionWorker } from "./transcription-worker.ts";
|
|
29
29
|
import { setTranscriptionWorker } from "./transcription-registry.ts";
|
|
30
|
-
import { assetsDir } from "./routes.ts";
|
|
30
|
+
import { assetsDir, MAX_REQUEST_BODY_BYTES } from "./routes.ts";
|
|
31
31
|
import { resolveScribeAuthToken, ensureScribeBearer } from "./scribe-env.ts";
|
|
32
32
|
import { getCachedScribeUrl } from "./scribe-discovery.ts";
|
|
33
33
|
import { TranscribeCppProvider } from "./transcription/providers/transcribe-cpp.ts";
|
|
@@ -502,6 +502,14 @@ const server = Bun.serve({
|
|
|
502
502
|
port,
|
|
503
503
|
hostname,
|
|
504
504
|
idleTimeout: 120, // seconds — webhook triggers can take a while
|
|
505
|
+
// vault#588 FIX 2 — explicit transport-level body-size ceiling. Without
|
|
506
|
+
// this, Bun's *default* maxRequestBodySize (128MB) is the only backstop
|
|
507
|
+
// for a chunked request with no Content-Length header (the app-level JSON
|
|
508
|
+
// cap in parseJsonBody's post-parse branch still buffers the full body
|
|
509
|
+
// first). MAX_REQUEST_BODY_BYTES is MAX_UPLOAD_BYTES (100MB) + headroom —
|
|
510
|
+
// see its doc comment in routes.ts for the reconciliation with the
|
|
511
|
+
// /upload and JSON-body app-level caps.
|
|
512
|
+
maxRequestBodySize: MAX_REQUEST_BODY_BYTES,
|
|
505
513
|
websocket: subscribeWs.handlers,
|
|
506
514
|
async fetch(req, server) {
|
|
507
515
|
const url = new URL(req.url);
|
package/src/storage.test.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
|
|
17
|
-
import { rmSync, existsSync, mkdirSync, writeFileSync } from "fs";
|
|
17
|
+
import { rmSync, existsSync, mkdirSync, writeFileSync, readFileSync } from "fs";
|
|
18
18
|
import { join } from "path";
|
|
19
19
|
import { tmpdir } from "os";
|
|
20
20
|
import { Database } from "bun:sqlite";
|
|
@@ -30,7 +30,7 @@ const testDir = join(
|
|
|
30
30
|
process.env.PARACHUTE_HOME = testDir;
|
|
31
31
|
process.env.ASSETS_DIR = join(testDir, "assets");
|
|
32
32
|
|
|
33
|
-
const { handleStorage } = await import("./routes.ts");
|
|
33
|
+
const { handleStorage, MAX_UPLOAD_BYTES, MAX_REQUEST_BODY_BYTES } = await import("./routes.ts");
|
|
34
34
|
const { expandTokenTagScope } = await import("./tag-scope.ts");
|
|
35
35
|
|
|
36
36
|
// The upload-allowlist tests never touch the store (POST /upload writes to
|
|
@@ -176,6 +176,126 @@ describe("storage upload allowlist", () => {
|
|
|
176
176
|
});
|
|
177
177
|
});
|
|
178
178
|
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
// vault#588 FIX 1 — a malformed/non-multipart POST /upload body used to
|
|
181
|
+
// throw uncaught out of `req.formData()`, escaping to server.ts's generic
|
|
182
|
+
// top-level catch: a 500 with no `error_type`. Same class LB7 (vault.test.ts)
|
|
183
|
+
// fixed for `req.json()` on the JSON-bodied mutating routes, applied to the
|
|
184
|
+
// multipart transport. Every assertion here MUST fail without the fix.
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
describe("storage upload — malformed multipart body (vault#588 FIX 1)", () => {
|
|
187
|
+
test("garbage body + a multipart Content-Type header -> 400 invalid_request, not 500", async () => {
|
|
188
|
+
const req = new Request("http://localhost:1940/storage/upload", {
|
|
189
|
+
method: "POST",
|
|
190
|
+
body: "not a valid multipart body at all",
|
|
191
|
+
headers: { "Content-Type": "multipart/form-data; boundary=----doesNotMatchBody" },
|
|
192
|
+
});
|
|
193
|
+
const res = await handleStorage(req, "/upload", "default", uploadStore);
|
|
194
|
+
expect(res.status).toBe(400);
|
|
195
|
+
const body = (await res.json()) as { error_type: string };
|
|
196
|
+
expect(body.error_type).toBe("invalid_request");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("plain-text body with no multipart Content-Type at all -> 400 invalid_request, not 500", async () => {
|
|
200
|
+
const req = new Request("http://localhost:1940/storage/upload", {
|
|
201
|
+
method: "POST",
|
|
202
|
+
body: "just some text, not multipart",
|
|
203
|
+
headers: { "Content-Type": "text/plain" },
|
|
204
|
+
});
|
|
205
|
+
const res = await handleStorage(req, "/upload", "default", uploadStore);
|
|
206
|
+
expect(res.status).toBe(400);
|
|
207
|
+
const body = (await res.json()) as { error_type: string };
|
|
208
|
+
expect(body.error_type).toBe("invalid_request");
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("empty body -> 400 invalid_request, not 500", async () => {
|
|
212
|
+
const req = new Request("http://localhost:1940/storage/upload", {
|
|
213
|
+
method: "POST",
|
|
214
|
+
body: "",
|
|
215
|
+
headers: { "Content-Type": "multipart/form-data; boundary=----empty" },
|
|
216
|
+
});
|
|
217
|
+
const res = await handleStorage(req, "/upload", "default", uploadStore);
|
|
218
|
+
expect(res.status).toBe(400);
|
|
219
|
+
const body = (await res.json()) as { error_type: string };
|
|
220
|
+
expect(body.error_type).toBe("invalid_request");
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("well-formed multipart form MISSING the `file` field -> 400 missing_required_field (unchanged, not a TypeError)", async () => {
|
|
224
|
+
const form = new FormData();
|
|
225
|
+
form.set("not_file", "some value");
|
|
226
|
+
const req = new Request("http://localhost:1940/storage/upload", { method: "POST", body: form });
|
|
227
|
+
const res = await handleStorage(req, "/upload", "default", uploadStore);
|
|
228
|
+
expect(res.status).toBe(400);
|
|
229
|
+
const body = (await res.json()) as { error_type: string; field: string };
|
|
230
|
+
expect(body.error_type).toBe("missing_required_field");
|
|
231
|
+
expect(body.field).toBe("file");
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test("a well-formed upload still succeeds (regression — the catch doesn't swallow the happy path)", async () => {
|
|
235
|
+
const res = await handleStorage(uploadRequest("still-works.pdf", "application/pdf"), "/upload", "default", uploadStore);
|
|
236
|
+
expect(res.status).toBe(201);
|
|
237
|
+
const body = (await res.json()) as { mimeType: string };
|
|
238
|
+
expect(body.mimeType).toBe("application/pdf");
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// ---------------------------------------------------------------------------
|
|
243
|
+
// vault#588 FIX 2 — explicit Bun.serve `maxRequestBodySize` transport ceiling.
|
|
244
|
+
// The JSON-body cap (MAX_JSON_BODY_BYTES) only gates AFTER the transport
|
|
245
|
+
// already buffered the body when Content-Length is absent (chunked
|
|
246
|
+
// requests); before this fix, the only backstop was Bun's unconfigured
|
|
247
|
+
// default (128MB). MAX_REQUEST_BODY_BYTES must sit >= MAX_UPLOAD_BYTES (the
|
|
248
|
+
// /upload app-level cap) with headroom, or a legitimate max-size attachment
|
|
249
|
+
// upload would be rejected at the transport layer before /upload's own
|
|
250
|
+
// 100MB check ever runs. These pin the reconciliation and the wiring;
|
|
251
|
+
// see routes.ts's MAX_REQUEST_BODY_BYTES doc comment for the full rationale.
|
|
252
|
+
// ---------------------------------------------------------------------------
|
|
253
|
+
describe("transport-level request-body ceiling (vault#588 FIX 2)", () => {
|
|
254
|
+
test("MAX_REQUEST_BODY_BYTES is comfortably >= MAX_UPLOAD_BYTES (never caps a legitimate attachment below its own app-level limit)", () => {
|
|
255
|
+
expect(MAX_UPLOAD_BYTES).toBe(100 * 1024 * 1024);
|
|
256
|
+
expect(MAX_REQUEST_BODY_BYTES).toBeGreaterThanOrEqual(MAX_UPLOAD_BYTES);
|
|
257
|
+
// Pin the chosen ceiling (100MB upload cap + 20MB multipart-overhead
|
|
258
|
+
// headroom = 120MB) so a future edit that silently narrows it fails loudly.
|
|
259
|
+
expect(MAX_REQUEST_BODY_BYTES).toBe(120 * 1024 * 1024);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test("server.ts wires maxRequestBodySize to MAX_REQUEST_BODY_BYTES on the Bun.serve config", () => {
|
|
263
|
+
// A config-level assertion (per vault#588's own guidance — a real
|
|
264
|
+
// >MAX_REQUEST_BODY_BYTES streaming test isn't worth the fixture cost).
|
|
265
|
+
// server.ts's `Bun.serve({...})` is a module-level side effect (it boots
|
|
266
|
+
// the real listener on import), so we can't import it in-test; read the
|
|
267
|
+
// source instead to confirm the constant is actually wired in, not just
|
|
268
|
+
// defined and forgotten.
|
|
269
|
+
const serverSrc = readFileSync(join(import.meta.dir, "server.ts"), "utf8");
|
|
270
|
+
expect(serverSrc).toMatch(/import\s*\{[^}]*MAX_REQUEST_BODY_BYTES[^}]*\}\s*from\s*"\.\/routes\.ts"/);
|
|
271
|
+
expect(serverSrc).toMatch(/maxRequestBodySize:\s*MAX_REQUEST_BODY_BYTES/);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test("a legitimate upload at exactly MAX_UPLOAD_BYTES still succeeds (not capped below its own limit)", async () => {
|
|
275
|
+
const bytes = new Uint8Array(MAX_UPLOAD_BYTES);
|
|
276
|
+
const file = new File([bytes], "max-size.bin", { type: "application/octet-stream" });
|
|
277
|
+
const form = new FormData();
|
|
278
|
+
form.set("file", file);
|
|
279
|
+
const req = new Request("http://localhost:1940/storage/upload", { method: "POST", body: form });
|
|
280
|
+
const res = await handleStorage(req, "/upload", "default", uploadStore);
|
|
281
|
+
expect(res.status).toBe(201);
|
|
282
|
+
const body = (await res.json()) as { size: number };
|
|
283
|
+
expect(body.size).toBe(MAX_UPLOAD_BYTES);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test("one byte over MAX_UPLOAD_BYTES still rejects at the app-level gate (413 file_too_large, unaffected by the transport ceiling)", async () => {
|
|
287
|
+
const bytes = new Uint8Array(MAX_UPLOAD_BYTES + 1);
|
|
288
|
+
const file = new File([bytes], "over-size.bin", { type: "application/octet-stream" });
|
|
289
|
+
const form = new FormData();
|
|
290
|
+
form.set("file", file);
|
|
291
|
+
const req = new Request("http://localhost:1940/storage/upload", { method: "POST", body: form });
|
|
292
|
+
const res = await handleStorage(req, "/upload", "default", uploadStore);
|
|
293
|
+
expect(res.status).toBe(413);
|
|
294
|
+
const body = (await res.json()) as { error_type: string };
|
|
295
|
+
expect(body.error_type).toBe("file_too_large");
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
|
|
179
299
|
// ---------------------------------------------------------------------------
|
|
180
300
|
// GET byte-serve tag-scope enforcement (C0 adversarial-audit finding).
|
|
181
301
|
//
|
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
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
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
|
-
|
|
3374
|
-
|
|
3375
|
-
|
|
3376
|
-
|
|
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,
|