@openparachute/vault 0.4.7-rc.2 → 0.4.8-rc.4

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.
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Unit tests for the opaque-cursor primitives (vault#313).
3
+ *
4
+ * Integration tests against `queryNotesPaged` live in core.test.ts under
5
+ * `describe("cursor pagination")`. This file pins down the
6
+ * encode/decode/hash invariants directly so a regression in the codec
7
+ * surfaces here before it reaches the wider query pipeline.
8
+ */
9
+
10
+ import { describe, it, expect } from "bun:test";
11
+ import {
12
+ CURSOR_VERSION,
13
+ CursorError,
14
+ computeQueryHash,
15
+ decodeCursor,
16
+ encodeCursor,
17
+ isoToMillis,
18
+ millisToIso,
19
+ } from "./cursor.js";
20
+
21
+ describe("cursor codec", () => {
22
+ it("encodes + decodes round-trips a payload", () => {
23
+ const payload = {
24
+ v: CURSOR_VERSION,
25
+ last_updated_at: 1714000000000,
26
+ last_id: "note-xyz",
27
+ query_hash: "a".repeat(64),
28
+ };
29
+ const cursor = encodeCursor(payload);
30
+ expect(typeof cursor).toBe("string");
31
+ expect(cursor.length).toBeGreaterThan(0);
32
+ // base64url has no `+`, `/`, or `=` padding.
33
+ expect(cursor).not.toMatch(/[+/=]/);
34
+
35
+ const decoded = decodeCursor(cursor);
36
+ expect(decoded).toEqual(payload);
37
+ });
38
+
39
+ it("decodeCursor rejects an empty string", () => {
40
+ try {
41
+ decodeCursor("");
42
+ throw new Error("expected throw");
43
+ } catch (err: any) {
44
+ expect(err).toBeInstanceOf(CursorError);
45
+ expect(err.code).toBe("cursor_invalid");
46
+ }
47
+ });
48
+
49
+ it("decodeCursor rejects non-JSON inside a valid base64url", () => {
50
+ const bogus = Buffer.from("not-json", "utf8").toString("base64url");
51
+ try {
52
+ decodeCursor(bogus);
53
+ throw new Error("expected throw");
54
+ } catch (err: any) {
55
+ expect(err).toBeInstanceOf(CursorError);
56
+ expect(err.code).toBe("cursor_invalid");
57
+ }
58
+ });
59
+
60
+ it("decodeCursor rejects a wrong version number", () => {
61
+ const cursor = encodeCursor({
62
+ v: 999,
63
+ last_updated_at: 0,
64
+ last_id: "",
65
+ query_hash: "abc",
66
+ } as any);
67
+ try {
68
+ decodeCursor(cursor);
69
+ throw new Error("expected throw");
70
+ } catch (err: any) {
71
+ expect(err.code).toBe("cursor_invalid");
72
+ expect(err.message).toContain("schema version");
73
+ }
74
+ });
75
+
76
+ it("decodeCursor rejects missing or wrong-type fields", () => {
77
+ // Missing last_id.
78
+ const missing = Buffer.from(
79
+ JSON.stringify({ v: CURSOR_VERSION, last_updated_at: 0, query_hash: "x" }),
80
+ ).toString("base64url");
81
+ expect(() => decodeCursor(missing)).toThrow();
82
+
83
+ // last_updated_at NaN.
84
+ const nan = encodeCursor({
85
+ v: CURSOR_VERSION,
86
+ last_updated_at: NaN,
87
+ last_id: "",
88
+ query_hash: "x",
89
+ });
90
+ expect(() => decodeCursor(nan)).toThrow();
91
+ });
92
+ });
93
+
94
+ describe("computeQueryHash", () => {
95
+ it("is stable across key-order permutations", () => {
96
+ const h1 = computeQueryHash({
97
+ tags: ["alpha"],
98
+ path: "p",
99
+ metadata: { status: "open" },
100
+ });
101
+ const h2 = computeQueryHash({
102
+ metadata: { status: "open" },
103
+ path: "p",
104
+ tags: ["alpha"],
105
+ });
106
+ expect(h1).toBe(h2);
107
+ });
108
+
109
+ it("is stable across tag-array order permutations", () => {
110
+ const h1 = computeQueryHash({ tags: ["a", "b", "c"] });
111
+ const h2 = computeQueryHash({ tags: ["c", "b", "a"] });
112
+ expect(h1).toBe(h2);
113
+ });
114
+
115
+ it("treats `tags: []` and missing `tags` as equivalent (both mean 'no tag filter')", () => {
116
+ const h1 = computeQueryHash({});
117
+ const h2 = computeQueryHash({ tags: [] });
118
+ expect(h1).toBe(h2);
119
+ });
120
+
121
+ it("changes when the query filters change", () => {
122
+ const h1 = computeQueryHash({ tags: ["a"] });
123
+ const h2 = computeQueryHash({ tags: ["b"] });
124
+ expect(h1).not.toBe(h2);
125
+
126
+ const h3 = computeQueryHash({ path: "p" });
127
+ expect(h3).not.toBe(h1);
128
+ });
129
+
130
+ it("returns a 64-char hex string (sha256)", () => {
131
+ const h = computeQueryHash({ tags: ["x"] });
132
+ expect(h).toMatch(/^[0-9a-f]{64}$/);
133
+ });
134
+
135
+ it("nested metadata operator clauses hash stably under key reorder", () => {
136
+ // `{gte: 5, lt: 10}` and `{lt: 10, gte: 5}` are semantically identical
137
+ // at the SQL layer (AND-conjunction of clauses); the hash must match.
138
+ const h1 = computeQueryHash({ metadata: { priority: { gte: 5, lt: 10 } } });
139
+ const h2 = computeQueryHash({ metadata: { priority: { lt: 10, gte: 5 } } });
140
+ expect(h1).toBe(h2);
141
+ });
142
+
143
+ it("dateFilter contributes to the hash", () => {
144
+ const h1 = computeQueryHash({ dateFilter: { field: "created_at", from: "2026-01-01" } });
145
+ const h2 = computeQueryHash({ dateFilter: { field: "created_at", from: "2026-02-01" } });
146
+ expect(h1).not.toBe(h2);
147
+ });
148
+ });
149
+
150
+ describe("isoToMillis / millisToIso", () => {
151
+ it("round-trips", () => {
152
+ const iso = "2026-04-15T12:34:56.789Z";
153
+ const ms = isoToMillis(iso);
154
+ expect(millisToIso(ms)).toBe(iso);
155
+ });
156
+
157
+ it("rejects malformed ISO", () => {
158
+ expect(() => isoToMillis("not-a-date")).toThrow();
159
+ });
160
+ });
@@ -0,0 +1,272 @@
1
+ /**
2
+ * Opaque cursors for `query-notes` (vault#313).
3
+ *
4
+ * Agent loops want "give me notes I haven't seen since last call." Today's
5
+ * pattern — pass `dateFilter: { field: "updated_at", from: <iso> }` and
6
+ * track the timestamp client-side — is brittle: the client has to remember
7
+ * the watermark, two notes at the same millisecond may collide, and a
8
+ * second call landed mid-millisecond can miss or double-count rows.
9
+ *
10
+ * The opaque-cursor pattern (Stripe, GitHub, et al.) fixes this. The server
11
+ * returns a `next_cursor: string` on each query response; the client passes
12
+ * it back on the next call and the server resumes from exactly where it
13
+ * left off. The cursor is base64url-encoded JSON the client must not
14
+ * inspect — internal layout can evolve without breaking callers.
15
+ *
16
+ * # Cursor payload
17
+ *
18
+ * ```ts
19
+ * {
20
+ * v: 1, // schema version
21
+ * last_updated_at: number, // millisecond epoch of the last seen note
22
+ * last_id: string, // ID of the last seen note — tiebreaker
23
+ * query_hash: string, // sha256 of normalized query params (hex)
24
+ * }
25
+ * ```
26
+ *
27
+ * - `last_updated_at` is millisecond epoch (not ISO) so cursor bytes stay
28
+ * compact and the tiebreaker math is integer.
29
+ * - `last_id` is the tiebreaker — when two notes share `updated_at`, the
30
+ * keyset query advances `id > last_id` at that timestamp so neither is
31
+ * skipped nor returned twice.
32
+ * - `query_hash` binds the cursor to the exact query it was minted for.
33
+ * Passing a cursor minted on `tag: "foo"` into a call for `tag: "bar"`
34
+ * would silently return the wrong page; mismatch raises a structured
35
+ * 400 (`cursor_query_mismatch`) instead.
36
+ *
37
+ * # Why JSON inside base64url
38
+ *
39
+ * A flat-string format (`<ts>:<id>:<hash>`) is two characters shorter but
40
+ * forecloses on optional fields. JSON gives us a schema-versioned envelope
41
+ * — if v2 needs additional state (e.g. a search-relevance secondary key),
42
+ * old clients keep working and new clients can read both.
43
+ *
44
+ * # Race safety
45
+ *
46
+ * The cursor stores the maximum-`updated_at`+`id` of the LAST returned
47
+ * page. The next call's keyset predicate is:
48
+ *
49
+ * (updated_at > last_updated_at)
50
+ * OR (updated_at = last_updated_at AND id > last_id)
51
+ *
52
+ * A note written between calls A and B at a brand-new `updated_at` is
53
+ * picked up by the first half of the predicate. A note written at the
54
+ * exact same `updated_at` as the cursor's watermark (uncommon — wall-clock
55
+ * collisions are rare at millisecond resolution but not impossible) is
56
+ * picked up by the tiebreaker because the SQL `ORDER BY updated_at ASC,
57
+ * id ASC` ensures stable interleaving with the prior page. Without the
58
+ * tiebreaker, two notes sharing an `updated_at` would be at the mercy of
59
+ * SQLite's row order, which is "stable in practice" but not contract.
60
+ */
61
+
62
+ import { createHash } from "node:crypto";
63
+
64
+ export const CURSOR_VERSION = 1;
65
+
66
+ export interface CursorPayload {
67
+ /** Schema version. Bumped if the cursor layout changes incompatibly. */
68
+ v: number;
69
+ /** Millisecond epoch of the last note returned. */
70
+ last_updated_at: number;
71
+ /** ID of the last note returned — tiebreaker for same-ms collisions. */
72
+ last_id: string;
73
+ /** sha256(hex) of normalized query params. Mismatch → cursor_query_mismatch. */
74
+ query_hash: string;
75
+ }
76
+
77
+ /**
78
+ * Thrown when a caller passes a malformed or stale cursor. The wrapping
79
+ * layer (MCP / REST) catches and surfaces a 400 with the structured code
80
+ * — callers should drop the cursor and restart the iteration.
81
+ */
82
+ export class CursorError extends Error {
83
+ override name = "CursorError";
84
+ code: "cursor_invalid" | "cursor_query_mismatch";
85
+ constructor(message: string, code: "cursor_invalid" | "cursor_query_mismatch") {
86
+ super(message);
87
+ this.code = code;
88
+ }
89
+ }
90
+
91
+ /** Encode a cursor payload to a base64url-safe opaque string. */
92
+ export function encodeCursor(payload: CursorPayload): string {
93
+ const json = JSON.stringify(payload);
94
+ return Buffer.from(json, "utf8").toString("base64url");
95
+ }
96
+
97
+ /** Decode a cursor string. Throws `CursorError` on any structural problem. */
98
+ export function decodeCursor(cursor: string): CursorPayload {
99
+ if (typeof cursor !== "string" || cursor.length === 0) {
100
+ throw new CursorError("cursor must be a non-empty string", "cursor_invalid");
101
+ }
102
+ let json: string;
103
+ try {
104
+ json = Buffer.from(cursor, "base64url").toString("utf8");
105
+ } catch {
106
+ throw new CursorError("cursor is not valid base64url", "cursor_invalid");
107
+ }
108
+ let parsed: unknown;
109
+ try {
110
+ parsed = JSON.parse(json);
111
+ } catch {
112
+ throw new CursorError("cursor payload is not valid JSON", "cursor_invalid");
113
+ }
114
+ if (!parsed || typeof parsed !== "object") {
115
+ throw new CursorError("cursor payload must be an object", "cursor_invalid");
116
+ }
117
+ const p = parsed as Record<string, unknown>;
118
+ if (typeof p.v !== "number" || p.v !== CURSOR_VERSION) {
119
+ throw new CursorError(
120
+ `cursor schema version mismatch (expected ${CURSOR_VERSION}, got ${String(p.v)})`,
121
+ "cursor_invalid",
122
+ );
123
+ }
124
+ if (typeof p.last_updated_at !== "number" || !Number.isFinite(p.last_updated_at)) {
125
+ throw new CursorError("cursor.last_updated_at must be a finite number", "cursor_invalid");
126
+ }
127
+ if (typeof p.last_id !== "string") {
128
+ throw new CursorError("cursor.last_id must be a string", "cursor_invalid");
129
+ }
130
+ if (typeof p.query_hash !== "string" || p.query_hash.length === 0) {
131
+ throw new CursorError("cursor.query_hash must be a non-empty string", "cursor_invalid");
132
+ }
133
+ return {
134
+ v: p.v,
135
+ last_updated_at: p.last_updated_at,
136
+ last_id: p.last_id,
137
+ query_hash: p.query_hash,
138
+ };
139
+ }
140
+
141
+ /**
142
+ * Shape of query parameters that participate in the query-hash.
143
+ *
144
+ * Pagination / cursor parameters themselves are excluded — bumping `limit`
145
+ * or advancing the cursor must NOT invalidate the cursor. Output-shape
146
+ * parameters (`include_content`, etc.) are also excluded — they don't
147
+ * affect *which* rows are returned, just how each row is rendered.
148
+ *
149
+ * The fields here are the *result-set-affecting* inputs. Any future filter
150
+ * added to `QueryOpts` should also be added here.
151
+ */
152
+ export interface QueryHashInputs {
153
+ tags?: string[];
154
+ tagMatch?: "all" | "any";
155
+ excludeTags?: string[];
156
+ hasTags?: boolean;
157
+ hasLinks?: boolean;
158
+ path?: string;
159
+ pathPrefix?: string;
160
+ extension?: string | string[];
161
+ ids?: string[];
162
+ metadata?: Record<string, unknown>;
163
+ dateFrom?: string;
164
+ dateTo?: string;
165
+ dateFilter?: { field?: string; from?: string; to?: string };
166
+ sort?: "asc" | "desc";
167
+ orderBy?: string;
168
+ }
169
+
170
+ /**
171
+ * Compute a stable hash of the query parameters.
172
+ *
173
+ * Stability matters: a caller that passes `{tag: "x", path_prefix: "p"}`
174
+ * on call 1 and `{path_prefix: "p", tag: "x"}` on call 2 (same query,
175
+ * different object-key order) must get the same hash. We achieve this
176
+ * by canonicalizing — sorting array fields (where order is irrelevant),
177
+ * recursively sorting object keys, and stringifying with a deterministic
178
+ * key order.
179
+ *
180
+ * `undefined` fields are dropped before hashing. An empty `tags: []` and
181
+ * an unset `tags` produce the same hash (both mean "no tag filter"), so
182
+ * a caller that conditionally sets it doesn't accidentally invalidate
183
+ * their cursor.
184
+ *
185
+ * Returned as a hex sha256 digest — 64 chars, fits comfortably in the
186
+ * base64url cursor envelope.
187
+ */
188
+ export function computeQueryHash(inputs: QueryHashInputs): string {
189
+ const canonical = canonicalize(inputs);
190
+ const json = JSON.stringify(canonical);
191
+ return createHash("sha256").update(json, "utf8").digest("hex");
192
+ }
193
+
194
+ /**
195
+ * Canonicalize a value for stable hashing.
196
+ *
197
+ * - Drops `undefined` properties (object keys with `undefined` values).
198
+ * - Drops empty arrays at the top level (treated equivalent to unset).
199
+ * - Sorts string-array fields where order doesn't affect query semantics
200
+ * (`tags`, `excludeTags`, `ids`, `extension` when array-shaped).
201
+ * - Recursively sorts plain-object keys so JSON.stringify is order-stable.
202
+ * - Primitives and arrays of primitives pass through unchanged (after the
203
+ * array-sort rule above).
204
+ *
205
+ * Inside `metadata`, sub-object keys (operator-clause shapes like
206
+ * `{eq, gte, lt}`) are sorted too — the engine treats `{gte: 5, lt: 10}`
207
+ * and `{lt: 10, gte: 5}` identically, so the cursor binding should as well.
208
+ */
209
+ function canonicalize(value: unknown): unknown {
210
+ if (value === null || value === undefined) return null;
211
+ if (typeof value !== "object") return value;
212
+ if (Array.isArray(value)) {
213
+ // Don't sort arbitrary arrays — order may be semantic (e.g. an `in`
214
+ // operator's array value is order-irrelevant to SQLite, but cursor
215
+ // semantics defer to the caller). For the known order-irrelevant
216
+ // string-array fields we sort at the top-level canonicalization;
217
+ // deep arrays pass through unchanged so a caller's intent is preserved.
218
+ return (value as unknown[]).map((v) => canonicalize(v));
219
+ }
220
+ // Plain object. Sort keys, drop undefineds, sort known order-irrelevant
221
+ // string-array fields.
222
+ const ORDER_IRRELEVANT_STRING_ARRAYS = new Set([
223
+ "tags",
224
+ "excludeTags",
225
+ "ids",
226
+ "extension",
227
+ ]);
228
+ const out: Record<string, unknown> = {};
229
+ const keys = Object.keys(value as object).sort();
230
+ for (const k of keys) {
231
+ const v = (value as Record<string, unknown>)[k];
232
+ if (v === undefined) continue;
233
+ if (Array.isArray(v) && v.length === 0) continue;
234
+ if (ORDER_IRRELEVANT_STRING_ARRAYS.has(k) && Array.isArray(v) && v.every((x) => typeof x === "string")) {
235
+ out[k] = [...(v as string[])].sort();
236
+ continue;
237
+ }
238
+ out[k] = canonicalize(v);
239
+ }
240
+ return out;
241
+ }
242
+
243
+ /**
244
+ * Parse an ISO-8601 timestamp to millisecond epoch.
245
+ *
246
+ * SQLite stores `updated_at` as a string ISO timestamp (set on insert /
247
+ * update by the store layer). The cursor pipes that string out as a
248
+ * millisecond integer for compact serialization. This helper exists so
249
+ * the call sites (mint-cursor + decode-cursor-into-SQL-predicate) share
250
+ * exactly one conversion, with NaN guarded.
251
+ */
252
+ export function isoToMillis(iso: string): number {
253
+ const ms = Date.parse(iso);
254
+ if (!Number.isFinite(ms)) {
255
+ throw new CursorError(`invalid ISO timestamp for cursor: ${iso}`, "cursor_invalid");
256
+ }
257
+ return ms;
258
+ }
259
+
260
+ /**
261
+ * Convert millisecond epoch back to an ISO-8601 timestamp string.
262
+ *
263
+ * Used to translate the cursor's `last_updated_at` into the form SQLite
264
+ * compares (`n.updated_at` is a TEXT column carrying ISO strings). ISO
265
+ * timestamps sort correctly lexicographically when they're all in the same
266
+ * canonical form (Z-suffixed, fixed millisecond precision) — every
267
+ * timestamp vault mints goes through `new Date(...).toISOString()` so the
268
+ * lex-order matches the millis-order.
269
+ */
270
+ export function millisToIso(ms: number): string {
271
+ return new Date(ms).toISOString();
272
+ }
package/core/src/mcp.ts CHANGED
@@ -2,6 +2,7 @@ import { Database } from "bun:sqlite";
2
2
  import type { Store, Note } from "./types.js";
3
3
  import * as noteOps from "./notes.js";
4
4
  import { filterMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError } from "./notes.js";
5
+ import { QueryError } from "./query-operators.js";
5
6
  import * as linkOps from "./links.js";
6
7
  import * as tagSchemaOps from "./tag-schemas.js";
7
8
  import type { TagFieldSchema } from "./tag-schemas.js";
@@ -189,6 +190,11 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
189
190
  sort: { type: "string", enum: ["asc", "desc"], description: "Sort by created_at" },
190
191
  limit: { type: "number", description: "Max results (default 50)" },
191
192
  offset: { type: "number", description: "Pagination offset (default 0)" },
193
+ cursor: {
194
+ type: "string",
195
+ description:
196
+ "Opaque cursor for 'since last checked' agent loops (vault#313). First call: omit. The response will include `next_cursor` — pass it on the subsequent call to receive only notes created or updated since the prior page. The cursor binds to the query's filters (tag, path, metadata, etc.); changing them between calls returns a structured `cursor_query_mismatch` error. Pagination via cursor orders results by `updated_at ASC` and is mutually exclusive with `order_by` and `sort: \"desc\"`. The response shape switches to `{notes, next_cursor}` when this parameter is present.",
197
+ },
192
198
  include_content: { type: "boolean", description: "Include note content (default: true for single, false for list)" },
193
199
  include_metadata: {
194
200
  oneOf: [
@@ -254,8 +260,32 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
254
260
  nearScope = new Set([anchor.id, ...traversed.map((t) => t.noteId)]);
255
261
  }
256
262
 
263
+ // --- Cursor mode (vault#313) ---
264
+ // When the caller passes `cursor`, the response shape switches to
265
+ // `{notes, next_cursor}` and `queryNotesPaged` handles the keyset
266
+ // pagination. Cursor mode is incompatible with full-text search
267
+ // (FTS owns its own ordering — relevance, not updated_at) and
268
+ // graph-neighborhood scoping (`near` would have to rebuild the
269
+ // neighborhood every call to be cursor-stable; we punt for now).
270
+ // Both surface as INVALID_QUERY rather than silently returning
271
+ // wrong rows.
272
+ const cursorMode = typeof params.cursor === "string" && params.cursor.length > 0;
273
+ if (cursorMode && params.search) {
274
+ throw new QueryError(
275
+ `cursor is incompatible with full-text search — FTS has its own ordering. Use date_filter on updated_at for since-last-checked search.`,
276
+ "INVALID_QUERY",
277
+ );
278
+ }
279
+ if (cursorMode && params.near) {
280
+ throw new QueryError(
281
+ `cursor is incompatible with near (graph neighborhood). Resolve the neighborhood first, then iterate with cursor + ids.`,
282
+ "INVALID_QUERY",
283
+ );
284
+ }
285
+
257
286
  // --- Full-text search ---
258
287
  let results: Note[];
288
+ let nextCursor: string | null = null;
259
289
  if (params.search) {
260
290
  // Normalize tag param
261
291
  const tags = normalizeTags(params.tag);
@@ -277,12 +307,13 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
277
307
  // unknown keys silently; aliasing here closes the silent-no-op gap.
278
308
  const excludeTagsRaw = params.exclude_tags ?? params.excludeTags ?? params.exclude_tag;
279
309
  const excludeTags = normalizeTags(excludeTagsRaw);
280
- // Route through `store.queryNotes` (not `noteOps.queryNotes`) so
281
- // tag-hierarchy expansion fires for MCP callers the same as for
282
- // HTTP REST callers — `tag: "manual"` matches descendants declared
283
- // via `_tags/*` config notes. The previous direct-noteOps call
284
- // bypassed the wrapper and silently dropped hierarchy expansion.
285
- results = await store.queryNotes({
310
+ // Route through `store.queryNotes`/`queryNotesPaged` (not the raw
311
+ // `noteOps` exports) so tag-hierarchy expansion fires for MCP
312
+ // callers the same as for HTTP REST callers — `tag: "manual"`
313
+ // matches descendants declared via `_tags/*` config notes. The
314
+ // previous direct-noteOps call bypassed the wrapper and silently
315
+ // dropped hierarchy expansion.
316
+ const queryOpts = {
286
317
  tags,
287
318
  tagMatch: (params.tag_match as "all" | "any") ?? (tags && tags.length > 1 ? "any" : undefined),
288
319
  excludeTags,
@@ -307,7 +338,15 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
307
338
  orderBy: params.order_by as string | undefined,
308
339
  limit: (params.limit as number) ?? 50,
309
340
  offset: params.offset as number | undefined,
310
- });
341
+ cursor: cursorMode ? (params.cursor as string) : undefined,
342
+ };
343
+ if (cursorMode) {
344
+ const page = await store.queryNotesPaged(queryOpts);
345
+ results = page.notes;
346
+ nextCursor = page.next_cursor;
347
+ } else {
348
+ results = await store.queryNotes(queryOpts);
349
+ }
311
350
  }
312
351
 
313
352
  // For full-text search the post-filter is still the right shape — FTS
@@ -347,9 +386,14 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
347
386
  if (params.include_attachments) enriched.attachments = await store.getAttachments(n.id);
348
387
  enrichedOut.push(enriched);
349
388
  }
389
+ // Cursor mode wraps the list in `{notes, next_cursor}` so callers can
390
+ // chain calls without tracking a watermark client-side. Legacy
391
+ // callers (no `cursor` param) still get the flat array.
392
+ if (cursorMode) return { notes: enrichedOut, next_cursor: nextCursor };
350
393
  return enrichedOut;
351
394
  }
352
395
 
396
+ if (cursorMode) return { notes: output, next_cursor: nextCursor };
353
397
  return output;
354
398
  },
355
399
  },