@openparachute/vault 0.6.0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -0
- package/core/src/content-range.test.ts +374 -0
- package/core/src/content-range.ts +185 -0
- package/core/src/links.ts +76 -21
- package/core/src/mcp.ts +53 -1
- package/core/src/notes.ts +128 -40
- package/core/src/query-perf-routing.test.ts +208 -0
- package/core/src/schema.ts +30 -1
- package/package.json +1 -1
- package/src/content-range-routes.test.ts +178 -0
- package/src/github-device-flow.test.ts +265 -6
- package/src/github-device-flow.ts +297 -45
- package/src/mirror-credentials.test.ts +20 -0
- package/src/mirror-credentials.ts +6 -2
- package/src/mirror-routes.test.ts +778 -19
- package/src/mirror-routes.ts +313 -26
- package/src/routes.ts +69 -3
- package/src/routing.ts +8 -0
- package/web/ui/dist/assets/index-BPgyIjR7.js +61 -0
- package/web/ui/dist/index.html +1 -1
- package/web/ui/dist/assets/index-CGL256oe.js +0 -60
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression pins for the 2026-06-10 query-perf work:
|
|
3
|
+
*
|
|
4
|
+
* 1. Plain `{field: value}` metadata equality on an INDEXED field now routes
|
|
5
|
+
* through the generated column as an index prefilter, keeping the
|
|
6
|
+
* original json_extract clause as a residual predicate. Results must be
|
|
7
|
+
* IDENTICAL to the JSON-scan path — property-tested here by giving every
|
|
8
|
+
* note twin fields (`*_idx` indexed, `*_raw` not) carrying the SAME
|
|
9
|
+
* value, then asserting every probe returns the same ids through both.
|
|
10
|
+
*
|
|
11
|
+
* 2. Tag membership is a semijoin (no `JOIN note_tags` + `DISTINCT n.*`),
|
|
12
|
+
* so a note matched by SEVERAL tags in one query must still appear once.
|
|
13
|
+
*
|
|
14
|
+
* 3. `getLinksHydratedForNotes` (the batched include_links path) must agree
|
|
15
|
+
* with per-note `getLinksHydrated`, including self-loops and links whose
|
|
16
|
+
* endpoints are both on the page.
|
|
17
|
+
*/
|
|
18
|
+
import { describe, it, expect, beforeEach } from "bun:test";
|
|
19
|
+
import { Database } from "bun:sqlite";
|
|
20
|
+
import { SqliteStore } from "./store.js";
|
|
21
|
+
import { queryNotes } from "./notes.js";
|
|
22
|
+
import { getLinksHydrated, getLinksHydratedForNotes } from "./links.js";
|
|
23
|
+
|
|
24
|
+
let db: Database;
|
|
25
|
+
let store: SqliteStore;
|
|
26
|
+
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
db = new Database(":memory:");
|
|
29
|
+
store = new SqliteStore(db);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("plain metadata equality: indexed routing is result-identical to the JSON scan", () => {
|
|
33
|
+
/**
|
|
34
|
+
* Edge values exercised through BOTH paths. Some of these never match
|
|
35
|
+
* (today's documented scan behavior — e.g. a JS number binds as its JSON
|
|
36
|
+
* text and SQLite won't equate INTEGER 5 with TEXT '5'); the pin is that
|
|
37
|
+
* indexed routing reproduces the scan's verdict EXACTLY, match or not.
|
|
38
|
+
*/
|
|
39
|
+
const STORED_VALUES: unknown[] = [
|
|
40
|
+
"pending",
|
|
41
|
+
"",
|
|
42
|
+
"5", // numeric-looking string
|
|
43
|
+
5, // JSON number — affinity edge case vs "5"
|
|
44
|
+
0,
|
|
45
|
+
-1.5,
|
|
46
|
+
true,
|
|
47
|
+
false,
|
|
48
|
+
null,
|
|
49
|
+
"naïve-Ünïcode ✨",
|
|
50
|
+
{ nested: { deep: 1 } },
|
|
51
|
+
["a", "b"],
|
|
52
|
+
undefined, // field absent from metadata
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
const PROBES: unknown[] = [
|
|
56
|
+
"pending",
|
|
57
|
+
"",
|
|
58
|
+
"5",
|
|
59
|
+
5,
|
|
60
|
+
0,
|
|
61
|
+
-1.5,
|
|
62
|
+
true,
|
|
63
|
+
false,
|
|
64
|
+
null,
|
|
65
|
+
"naïve-Ünïcode ✨",
|
|
66
|
+
"missing-everywhere",
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
async function seedTwinFieldFixture(sqliteType: "string" | "integer") {
|
|
70
|
+
// `status_idx` is indexed (string) via a tag schema; `status_raw` is not.
|
|
71
|
+
await store.upsertTagRecord("thing", {
|
|
72
|
+
fields: { status_idx: { type: sqliteType, indexed: true } },
|
|
73
|
+
});
|
|
74
|
+
for (let i = 0; i < STORED_VALUES.length; i++) {
|
|
75
|
+
const v = STORED_VALUES[i];
|
|
76
|
+
const metadata: Record<string, unknown> = { seq: i };
|
|
77
|
+
if (v !== undefined) {
|
|
78
|
+
metadata.status_idx = v;
|
|
79
|
+
metadata.status_raw = v;
|
|
80
|
+
}
|
|
81
|
+
await store.createNote(`note ${i}`, { tags: ["thing"], metadata });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
for (const sqliteType of ["string", "integer"] as const) {
|
|
86
|
+
it(`every probe matches the same notes through the indexed and scan paths (${sqliteType} column)`, async () => {
|
|
87
|
+
await seedTwinFieldFixture(sqliteType);
|
|
88
|
+
for (const probe of PROBES) {
|
|
89
|
+
const viaIndexed = queryNotes(db, { metadata: { status_idx: probe } }).map((n) => n.id);
|
|
90
|
+
const viaScan = queryNotes(db, { metadata: { status_raw: probe } }).map((n) => n.id);
|
|
91
|
+
expect(viaIndexed).toEqual(viaScan);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
it("string probe on the indexed field actually matches (sanity: not vacuous)", async () => {
|
|
97
|
+
await seedTwinFieldFixture("string");
|
|
98
|
+
const hits = queryNotes(db, { metadata: { status_idx: "pending" } });
|
|
99
|
+
expect(hits.length).toBe(1);
|
|
100
|
+
expect(hits[0]!.metadata?.seq).toBe(0);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("indexed plain equality agrees with the operator form for string values", async () => {
|
|
104
|
+
await seedTwinFieldFixture("string");
|
|
105
|
+
for (const probe of ["pending", "", "missing-everywhere", "naïve-Ünïcode ✨"]) {
|
|
106
|
+
const plain = queryNotes(db, { metadata: { status_idx: probe } }).map((n) => n.id);
|
|
107
|
+
const operator = queryNotes(db, { metadata: { status_idx: { eq: probe } } }).map((n) => n.id);
|
|
108
|
+
expect(plain).toEqual(operator);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("plain equality on a non-indexed field still works (no FIELD_NOT_INDEXED error)", async () => {
|
|
113
|
+
await store.createNote("a", { metadata: { color: "blue" } });
|
|
114
|
+
await store.createNote("b", { metadata: { color: "red" } });
|
|
115
|
+
const hits = queryNotes(db, { metadata: { color: "blue" } });
|
|
116
|
+
expect(hits.length).toBe(1);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe("tag semijoin: no duplicate rows without DISTINCT", () => {
|
|
121
|
+
it("a note carrying several matching tags appears once under tag_match=any", async () => {
|
|
122
|
+
await store.upsertTagRecord("capture/voice", { parent_names: ["capture"] });
|
|
123
|
+
await store.upsertTagRecord("capture/text", { parent_names: ["capture"] });
|
|
124
|
+
// Tagged with the parent AND two children — the old JOIN produced 3 rows.
|
|
125
|
+
const note = await store.createNote("multi", {
|
|
126
|
+
tags: ["capture", "capture/voice", "capture/text"],
|
|
127
|
+
});
|
|
128
|
+
await store.createNote("single", { tags: ["capture/voice"] });
|
|
129
|
+
|
|
130
|
+
const viaExpansion = await store.queryNotes({ tags: ["capture"] });
|
|
131
|
+
expect(viaExpansion.map((n) => n.id).filter((id) => id === note.id).length).toBe(1);
|
|
132
|
+
expect(viaExpansion.length).toBe(2);
|
|
133
|
+
|
|
134
|
+
const viaAny = await store.queryNotes({
|
|
135
|
+
tags: ["capture", "capture/voice", "capture/text"],
|
|
136
|
+
tagMatch: "any",
|
|
137
|
+
});
|
|
138
|
+
expect(viaAny.map((n) => n.id).filter((id) => id === note.id).length).toBe(1);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("tag_match=all still requires every input tag", async () => {
|
|
142
|
+
const both = await store.createNote("both", { tags: ["a", "b"] });
|
|
143
|
+
await store.createNote("only-a", { tags: ["a"] });
|
|
144
|
+
const hits = await store.queryNotes({ tags: ["a", "b"], tagMatch: "all" });
|
|
145
|
+
expect(hits.map((n) => n.id)).toEqual([both.id]);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe("getLinksHydratedForNotes agrees with per-note getLinksHydrated", () => {
|
|
150
|
+
it("matches per-note hydration across self-loops, shared links, and isolated notes", async () => {
|
|
151
|
+
const a = await store.createNote("a", { tags: ["x"] });
|
|
152
|
+
const b = await store.createNote("b", { tags: ["y"], metadata: { k: 1 } });
|
|
153
|
+
const c = await store.createNote("c", { path: "Notes/c" });
|
|
154
|
+
const lonely = await store.createNote("lonely");
|
|
155
|
+
|
|
156
|
+
await store.createLink(a.id, b.id, "mentions"); // both endpoints on page
|
|
157
|
+
await store.createLink(b.id, a.id, "replies"); // reverse direction
|
|
158
|
+
await store.createLink(a.id, a.id, "self"); // self-loop
|
|
159
|
+
await store.createLink(c.id, a.id, "references"); // inbound from page note
|
|
160
|
+
await store.createLink(b.id, c.id, "cites", { via: "test" });
|
|
161
|
+
|
|
162
|
+
const pageIds = [a.id, b.id, c.id, lonely.id];
|
|
163
|
+
const batch = getLinksHydratedForNotes(db, pageIds);
|
|
164
|
+
|
|
165
|
+
for (const id of pageIds) {
|
|
166
|
+
const single = getLinksHydrated(db, id);
|
|
167
|
+
const batched = batch.get(id)!;
|
|
168
|
+
const key = (l: { sourceId: string; targetId: string; relationship: string }) =>
|
|
169
|
+
`${l.sourceId}→${l.targetId}:${l.relationship}`;
|
|
170
|
+
// Same link set per note…
|
|
171
|
+
expect(batched.map(key).sort()).toEqual(single.map(key).sort());
|
|
172
|
+
// …ordered newest-first like the single-note SQL.
|
|
173
|
+
const stamps = batched.map((l) => l.createdAt);
|
|
174
|
+
expect([...stamps].sort().reverse()).toEqual(stamps);
|
|
175
|
+
// …with identical hydrated summaries (path, tags, metadata).
|
|
176
|
+
const summaries = (ls: typeof single) =>
|
|
177
|
+
new Map(ls.map((l) => [key(l), JSON.stringify([l.sourceNote, l.targetNote])]));
|
|
178
|
+
expect(summaries(batched)).toEqual(summaries(single));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// The self-loop appears once in a's list (not duplicated by the
|
|
182
|
+
// source/target double-walk), matching the single-note query.
|
|
183
|
+
const selfLoops = batch.get(a.id)!.filter((l) => l.sourceId === a.id && l.targetId === a.id);
|
|
184
|
+
expect(selfLoops.length).toBe(1);
|
|
185
|
+
|
|
186
|
+
expect(batch.get(lonely.id)).toEqual([]);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe("v22 keyset index", () => {
|
|
191
|
+
it("exists after initSchema and covers (updated_at, id)", () => {
|
|
192
|
+
const row = db
|
|
193
|
+
.prepare("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_notes_updated'")
|
|
194
|
+
.get();
|
|
195
|
+
expect(row).toBeTruthy();
|
|
196
|
+
const plan = db
|
|
197
|
+
.prepare(
|
|
198
|
+
`EXPLAIN QUERY PLAN
|
|
199
|
+
SELECT n.id FROM notes n
|
|
200
|
+
WHERE (n.updated_at > ? OR (n.updated_at = ? AND n.id > ?))
|
|
201
|
+
ORDER BY n.updated_at ASC, n.id ASC LIMIT 10`,
|
|
202
|
+
)
|
|
203
|
+
.all("2025-01-01", "2025-01-01", "x") as { detail: string }[];
|
|
204
|
+
const details = plan.map((r) => r.detail).join(" | ");
|
|
205
|
+
expect(details).toContain("idx_notes_updated");
|
|
206
|
+
expect(details).not.toContain("TEMP B-TREE");
|
|
207
|
+
});
|
|
208
|
+
});
|
package/core/src/schema.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { Database } from "bun:sqlite";
|
|
|
2
2
|
import { normalizePath } from "./paths.js";
|
|
3
3
|
import { rebuildIndexes } from "./indexed-fields.js";
|
|
4
4
|
|
|
5
|
-
export const SCHEMA_VERSION =
|
|
5
|
+
export const SCHEMA_VERSION = 22;
|
|
6
6
|
|
|
7
7
|
export const SCHEMA_SQL = `
|
|
8
8
|
-- Notes: the universal record.
|
|
@@ -474,6 +474,11 @@ export function initSchema(db: Database): void {
|
|
|
474
474
|
// above, so this is a defensive confirmation hook for upgrading vaults.
|
|
475
475
|
migrateToV21(db);
|
|
476
476
|
|
|
477
|
+
// Migrate v21 → v22: composite index notes(updated_at, id) backing cursor
|
|
478
|
+
// keyset pagination and date_filter on updated_at — both were full table
|
|
479
|
+
// scans. See the 2026-06-10 query-perf measurements.
|
|
480
|
+
migrateToV22(db);
|
|
481
|
+
|
|
477
482
|
// Rebuild any generated columns + indexes declared in indexed_fields.
|
|
478
483
|
// No-op for a fresh vault; idempotent on existing vaults.
|
|
479
484
|
rebuildIndexes(db);
|
|
@@ -1121,6 +1126,30 @@ function migrateToV21(db: Database): void {
|
|
|
1121
1126
|
`);
|
|
1122
1127
|
}
|
|
1123
1128
|
|
|
1129
|
+
/**
|
|
1130
|
+
* Migrate v21 → v22: composite B-tree on notes(updated_at, id).
|
|
1131
|
+
*
|
|
1132
|
+
* Two query shapes ride it (2026-06-10 perf measurements — both were full
|
|
1133
|
+
* table scans + temp-B-tree sorts before):
|
|
1134
|
+
*
|
|
1135
|
+
* 1. Cursor keyset pagination (vault#313): the predicate
|
|
1136
|
+
* `updated_at > ? OR (updated_at = ? AND id > ?)` with
|
|
1137
|
+
* `ORDER BY updated_at ASC, id ASC` matches the composite key exactly,
|
|
1138
|
+
* so a "since last checked" poll seeks straight to the watermark and
|
|
1139
|
+
* streams in order — no scan, no sort.
|
|
1140
|
+
* 2. `date_filter: { field: "updated_at" }` range queries (incremental
|
|
1141
|
+
* rebuild flows, vault#285 1.5).
|
|
1142
|
+
*
|
|
1143
|
+
* Lives here (not SCHEMA_SQL) following the idx_tokens_vault_name precedent:
|
|
1144
|
+
* migrations run after every column-shape change, so the index statement
|
|
1145
|
+
* never races an older table shape. Fresh vaults reach this through the
|
|
1146
|
+
* same initSchema path — CREATE INDEX IF NOT EXISTS is idempotent.
|
|
1147
|
+
*/
|
|
1148
|
+
function migrateToV22(db: Database): void {
|
|
1149
|
+
if (!hasTable(db, "notes")) return;
|
|
1150
|
+
db.exec("CREATE INDEX IF NOT EXISTS idx_notes_updated ON notes(updated_at, id)");
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1124
1153
|
function hasTable(db: Database, name: string): boolean {
|
|
1125
1154
|
const row = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get(name);
|
|
1126
1155
|
return !!row;
|
package/package.json
CHANGED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* REST face of content range / pagination (bounded reads for large notes).
|
|
3
|
+
*
|
|
4
|
+
* Exercises all four GET shapes that accept `content_offset` /
|
|
5
|
+
* `content_length`:
|
|
6
|
+
* - GET /notes?id=… (single, folded into the collection route)
|
|
7
|
+
* - GET /notes/:idOrPath (single point read)
|
|
8
|
+
* - GET /notes?… (structured list)
|
|
9
|
+
* - GET /notes?search=… (full-text list)
|
|
10
|
+
*
|
|
11
|
+
* Slice mechanics (codepoint boundaries, reassembly invariant) are pinned
|
|
12
|
+
* in core/src/content-range.test.ts; this suite covers the HTTP wiring:
|
|
13
|
+
* param parsing, 400s, per-shape application, and the no-params
|
|
14
|
+
* regression. Fully sandboxed — in-memory SQLite, no daemon.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
|
18
|
+
import { Database } from "bun:sqlite";
|
|
19
|
+
import { BunStore } from "./vault-store.ts";
|
|
20
|
+
import { handleNotes } from "./routes.ts";
|
|
21
|
+
|
|
22
|
+
let db: Database;
|
|
23
|
+
let store: BunStore;
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
db = new Database(":memory:");
|
|
27
|
+
store = new BunStore(db);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
db.close();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const BASE = "http://localhost/api";
|
|
35
|
+
|
|
36
|
+
function get(path: string): Promise<Response> {
|
|
37
|
+
return handleNotes(new Request(`${BASE}/notes${path}`, { method: "GET" }), store, pathSub(path));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Extract the handleNotes subpath ("/<id>" for point reads, "" otherwise). */
|
|
41
|
+
function pathSub(path: string): string {
|
|
42
|
+
const m = path.match(/^\/([^?/]+)/);
|
|
43
|
+
return m ? `/${m[1]}` : "";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe("REST content range — single note", () => {
|
|
47
|
+
it("GET /notes?id=… honors content_length and adds the range fields", async () => {
|
|
48
|
+
const note = await store.createNote("0123456789");
|
|
49
|
+
const res = await get(`?id=${note.id}&content_length=4`);
|
|
50
|
+
expect(res.status).toBe(200);
|
|
51
|
+
const body: any = await res.json();
|
|
52
|
+
expect(body.content).toBe("0123");
|
|
53
|
+
expect(body.content_offset).toBe(0);
|
|
54
|
+
expect(body.content_total_length).toBe(10);
|
|
55
|
+
expect(body.content_next_offset).toBe(4);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("GET /notes/:id honors content_offset (tail read to the end)", async () => {
|
|
59
|
+
const note = await store.createNote("hello world", { path: "tail-note" });
|
|
60
|
+
const res = await get(`/${note.id}?content_offset=6`);
|
|
61
|
+
expect(res.status).toBe(200);
|
|
62
|
+
const body: any = await res.json();
|
|
63
|
+
expect(body.content).toBe("world");
|
|
64
|
+
expect(body.content_total_length).toBe(11);
|
|
65
|
+
expect(body.content_next_offset).toBeNull();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("paged loop over REST reassembles multi-byte content byte-identically", async () => {
|
|
69
|
+
const content = "ab\u{1F600}cd 你好 ".repeat(20).trim();
|
|
70
|
+
const note = await store.createNote(content);
|
|
71
|
+
let offset = 0;
|
|
72
|
+
let assembled = "";
|
|
73
|
+
for (;;) {
|
|
74
|
+
const res = await get(`/${note.id}?content_offset=${offset}&content_length=16`);
|
|
75
|
+
expect(res.status).toBe(200);
|
|
76
|
+
const body: any = await res.json();
|
|
77
|
+
expect(Buffer.byteLength(body.content, "utf8")).toBeLessThanOrEqual(16);
|
|
78
|
+
assembled += body.content;
|
|
79
|
+
if (body.content_next_offset === null) break;
|
|
80
|
+
offset = body.content_next_offset;
|
|
81
|
+
}
|
|
82
|
+
expect(assembled).toBe(content);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("offset past end → 200 with empty slice and null next_offset", async () => {
|
|
86
|
+
const note = await store.createNote("abc");
|
|
87
|
+
const res = await get(`?id=${note.id}&content_offset=500`);
|
|
88
|
+
expect(res.status).toBe(200);
|
|
89
|
+
const body: any = await res.json();
|
|
90
|
+
expect(body.content).toBe("");
|
|
91
|
+
expect(body.content_next_offset).toBeNull();
|
|
92
|
+
expect(body.content_total_length).toBe(3);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("include_content=false + range params → 400 INVALID_QUERY", async () => {
|
|
96
|
+
const note = await store.createNote("abc");
|
|
97
|
+
const res = await get(`?id=${note.id}&include_content=false&content_length=8`);
|
|
98
|
+
expect(res.status).toBe(400);
|
|
99
|
+
const body: any = await res.json();
|
|
100
|
+
expect(body.code).toBe("INVALID_QUERY");
|
|
101
|
+
expect(body.error).toContain("include_content");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("zero / sub-minimum / malformed budgets → 400 INVALID_QUERY", async () => {
|
|
105
|
+
const note = await store.createNote("abc");
|
|
106
|
+
for (const qs of ["content_length=0", "content_length=2", "content_length=abc", "content_offset=-1"]) {
|
|
107
|
+
const res = await get(`?id=${note.id}&${qs}`);
|
|
108
|
+
expect(res.status).toBe(400);
|
|
109
|
+
const body: any = await res.json();
|
|
110
|
+
expect(body.code).toBe("INVALID_QUERY");
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("no range params → response shape unchanged (regression)", async () => {
|
|
115
|
+
const note = await store.createNote("plain body", { path: "plain" });
|
|
116
|
+
const res = await get(`/${note.id}`);
|
|
117
|
+
expect(res.status).toBe(200);
|
|
118
|
+
const body: any = await res.json();
|
|
119
|
+
expect(body.content).toBe("plain body");
|
|
120
|
+
expect("content_total_length" in body).toBe(false);
|
|
121
|
+
expect("content_next_offset" in body).toBe(false);
|
|
122
|
+
expect("content_offset" in body).toBe(false);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe("REST content range — list shapes", () => {
|
|
127
|
+
it("structured list with include_content=true applies the window per note", async () => {
|
|
128
|
+
await store.createNote("first body first body", { tags: ["paged"] });
|
|
129
|
+
await store.createNote("second body second body", { tags: ["paged"] });
|
|
130
|
+
const res = await get(`?tag=paged&include_content=true&content_length=6`);
|
|
131
|
+
expect(res.status).toBe(200);
|
|
132
|
+
const body: any[] = await res.json();
|
|
133
|
+
expect(body.length).toBe(2);
|
|
134
|
+
for (const n of body) {
|
|
135
|
+
expect(Buffer.byteLength(n.content, "utf8")).toBeLessThanOrEqual(6);
|
|
136
|
+
expect(typeof n.content_total_length).toBe("number");
|
|
137
|
+
expect(n.content_next_offset).toBe(6);
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("structured list on the lean default + range params → 400", async () => {
|
|
142
|
+
await store.createNote("body", { tags: ["paged"] });
|
|
143
|
+
const res = await get(`?tag=paged&content_length=8`);
|
|
144
|
+
expect(res.status).toBe(400);
|
|
145
|
+
const body: any = await res.json();
|
|
146
|
+
expect(body.code).toBe("INVALID_QUERY");
|
|
147
|
+
expect(body.error).toContain("include_content");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("search list with include_content=true applies the window per note", async () => {
|
|
151
|
+
await store.createNote("the quick brown fox jumps over the lazy dog");
|
|
152
|
+
const res = await get(`?search=fox&include_content=true&content_length=9`);
|
|
153
|
+
expect(res.status).toBe(200);
|
|
154
|
+
const body: any[] = await res.json();
|
|
155
|
+
expect(body.length).toBe(1);
|
|
156
|
+
expect(Buffer.byteLength(body[0].content, "utf8")).toBeLessThanOrEqual(9);
|
|
157
|
+
expect(body[0].content_total_length).toBe(
|
|
158
|
+
Buffer.byteLength("the quick brown fox jumps over the lazy dog", "utf8"),
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("search list on the lean default + range params → 400", async () => {
|
|
163
|
+
await store.createNote("the quick brown fox");
|
|
164
|
+
const res = await get(`?search=fox&content_length=8`);
|
|
165
|
+
expect(res.status).toBe(400);
|
|
166
|
+
const body: any = await res.json();
|
|
167
|
+
expect(body.code).toBe("INVALID_QUERY");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("list without range params → no range fields injected (regression)", async () => {
|
|
171
|
+
await store.createNote("body here", { tags: ["paged"] });
|
|
172
|
+
const res = await get(`?tag=paged&include_content=true`);
|
|
173
|
+
expect(res.status).toBe(200);
|
|
174
|
+
const body: any[] = await res.json();
|
|
175
|
+
expect("content_total_length" in body[0]).toBe(false);
|
|
176
|
+
expect("content_next_offset" in body[0]).toBe(false);
|
|
177
|
+
});
|
|
178
|
+
});
|