diff --git a/example-apps/dashnote/CLAUDE.md b/example-apps/dashnote/CLAUDE.md
index ec48bd1d..860846e3 100644
--- a/example-apps/dashnote/CLAUDE.md
+++ b/example-apps/dashnote/CLAUDE.md
@@ -50,7 +50,7 @@ Schema lives in [src/dash/contract.ts](src/dash/contract.ts) as `NOTE_SCHEMAS`.
- **Create note**: `sdk.documents.create({ document, identityKey, signer })` where `document = new Document({ properties: { title?, message }, documentTypeName: "note", dataContractId, ownerId })`
- **Update note**: fetch existing via `sdk.documents.get(...)`, bump `revision = BigInt(existing.revision) + 1n`, then `sdk.documents.replace({ document, identityKey, signer })`
- **Delete note**: `sdk.documents.delete({ document: { id, ownerId, dataContractId, documentTypeName: "note" }, identityKey, signer })`
-- **List my notes**: `sdk.documents.query({ dataContractId, documentTypeName: "note", where: [["$ownerId", "==", ownerId]], orderBy: [["$ownerId", "asc"], ["$updatedAt", "asc"]], limit })`
+- **List my notes**: `listMyNotesPage` calls `sdk.documents.query({ dataContractId, documentTypeName: "note", where: [["$ownerId", "==", ownerId]], orderBy: [["$ownerId", "asc"], ["$createdAt", "asc"]], limit, startAfter? })`; `listMyNotes` walks every page, then sorts the complete list newest-first by `$updatedAt` for display.
- **Get one note**: `sdk.documents.get(contractId, "note", noteId)`
`normalizeNotes()` and `normalizeSingleNote()` in [queries.ts](src/dash/queries.ts) flatten whatever shape `sdk.documents.query` / `sdk.documents.get` returns (array, Map, or plain object) into `NoteRecord[]` so UI code never branches on it.
diff --git a/example-apps/dashnote/README.md b/example-apps/dashnote/README.md
index 6f858c97..c93de48e 100644
--- a/example-apps/dashnote/README.md
+++ b/example-apps/dashnote/README.md
@@ -55,7 +55,7 @@ npm run preview # Serve the production build
## Contract schema notes
- The schema in [`src/dash/contract.ts`](./src/dash/contract.ts) defines a single document type, `note`, with `title` (optional, max 120 chars), `message` (required, max 10000 chars), and required Platform-managed `$createdAt` / `$updatedAt`.
-- Indices: `byOwnerUpdated` (`$ownerId`, `$updatedAt`) and `byOwnerCreated` (`$ownerId`, `$createdAt`) — both are how the recent-notes list paginates and sorts.
+- Indices: `byOwnerUpdated` (`$ownerId`, `$updatedAt`) and `byOwnerCreated` (`$ownerId`, `$createdAt`). The list walks the immutable creation-time index for stable cursor pagination, then sorts the completed result by `$updatedAt` for display.
- `documentsMutable: true` and `canBeDeleted: true` — notes are editable and deletable.
- `keepsHistory` is forced to `false`. `keepsHistory: true` triggers [dashpay/platform#3165](https://github.com/dashpay/platform/issues/3165), where `sdk.contracts.fetch()` returns undefined and breaks `sdk.documents.query` with "Data contract not found". This is why v1 only shows revision metadata, not previous versions of notes.
@@ -73,9 +73,22 @@ Every SDK call lives in its own file under [`src/dash/`](./src/dash/). Open the
| Create a note | [`src/dash/createNote.ts`](./src/dash/createNote.ts) | `sdk.documents.create` |
| Update a note | [`src/dash/updateNote.ts`](./src/dash/updateNote.ts) | `sdk.documents.get` + `sdk.documents.replace` |
| Delete a note | [`src/dash/deleteNote.ts`](./src/dash/deleteNote.ts) | `sdk.documents.delete` |
-| List my notes | [`src/dash/queries.ts`](./src/dash/queries.ts) | `sdk.documents.query` |
+| List my notes | [`src/dash/queries.ts`](./src/dash/queries.ts) | `sdk.documents.query` + `startAfter` |
| Get one note | [`src/dash/queries.ts`](./src/dash/queries.ts) | `sdk.documents.get` |
+### Query pagination
+
+[`listMyNotesPage`](./src/dash/queries.ts) demonstrates the Evo SDK's document cursor directly:
+
+1. The first query sends no cursor.
+2. A full page uses the **last document ID in the SDK's returned `Map` order** as `startAfter`.
+3. `startAfter` is exclusive, so the boundary document is not repeated on the next page.
+4. A short or empty page is final. If the final populated page is exactly full, one extra empty query confirms completion.
+
+Every page keeps the same contract, document type, owner filter, creation-time ordering, and limit. The cursor is captured before client-side sorting; sorting a page first and then taking its last item would use the wrong index position and can skip or repeat documents. `listMyNotes` walks the pages, deduplicates defensively by document ID, and only then sorts the complete result by `$updatedAt` for the existing newest-first UI.
+
+The traversal uses the immutable `byOwnerCreated` index rather than the mutable `$updatedAt` index, so editing a note while pages are loading cannot move it across a cursor boundary. Like other live cursor APIs, `documents.query` does not provide a cross-request snapshot: deleting the cursor document can invalidate the next request, and documents created after the traversal finishes appear on the next reload.
+
Update flow always fetches the document first to read its current revision, then submits a replace with `revision = BigInt(existing.revision ?? 0) + 1n`. Replays without bumping the revision are rejected by the state transition.
Supporting files:
@@ -112,7 +125,7 @@ For deeper architecture and gotchas, see [`CLAUDE.md`](./CLAUDE.md).
The Vitest suite covers:
- contract schema and registration config
-- note query normalization and sorting
+- note query normalization, cursor pagination, and sorting
- create / update / delete mutation helpers
- WIF login (`loginWithPrivateKey`), secret-shape detection, and WIF preview hook
- DPNS name resolution
diff --git a/example-apps/dashnote/src/components/HowItWorks.tsx b/example-apps/dashnote/src/components/HowItWorks.tsx
index 231a13df..4678b222 100644
--- a/example-apps/dashnote/src/components/HowItWorks.tsx
+++ b/example-apps/dashnote/src/components/HowItWorks.tsx
@@ -87,7 +87,11 @@ const OPS = [
sdk: "documents.get → replace",
},
{ op: "Delete a note", file: "deleteNote.ts", sdk: "documents.delete" },
- { op: "List my notes", file: "queries.ts", sdk: "documents.query" },
+ {
+ op: "List my notes",
+ file: "queries.ts",
+ sdk: "documents.query + startAfter",
+ },
{ op: "Register a contract", file: "contract.ts", sdk: "contracts.publish" },
];
@@ -98,6 +102,10 @@ const READING_ORDER = [
file: "src/dash/updateNote.ts",
caption: "Fetch → bump revision → replace.",
},
+ {
+ file: "src/dash/queries.ts",
+ caption: "Cursor pagination with startAfter.",
+ },
{
file: "src/components/NotesWorkspace.tsx",
caption: "Cache + revalidation.",
@@ -247,8 +255,8 @@ export function HowItWorks() {
Recommended source files
Read these in order to build up the mental model: schema first, then
- the simplest write, then the read-bump-replace pattern, and finally
- the UI layer that owns caching and revalidation.
+ the simplest write, the read-bump-replace pattern, cursor pagination,
+ and finally the UI layer that owns caching and revalidation.
{READING_ORDER.map((item, i) => (
diff --git a/example-apps/dashnote/src/dash/queries.ts b/example-apps/dashnote/src/dash/queries.ts
index aca7ead9..49d9041a 100644
--- a/example-apps/dashnote/src/dash/queries.ts
+++ b/example-apps/dashnote/src/dash/queries.ts
@@ -2,7 +2,14 @@
* Read-side queries against the note contract.
*
* SDK methods:
- * sdk.documents.query({ dataContractId, documentTypeName, where, orderBy, limit })
+ * sdk.documents.query({
+ * dataContractId,
+ * documentTypeName,
+ * where,
+ * orderBy,
+ * limit,
+ * startAfter,
+ * })
* sdk.documents.get(contractId, documentTypeName, documentId)
*/
import type { Logger } from "../lib/logger";
@@ -66,17 +73,50 @@ function toNote(id: string | null, raw: DashNoteQueryDocument): NoteRecord {
};
}
-export function normalizeNotes(results: DashNoteQueryResults): NoteRecord[] {
+interface QueryPage {
+ notes: NoteRecord[];
+ resultCount: number;
+ lastId: string | null;
+}
+
+/**
+ * Flatten an SDK query result into notes while preserving server order and the
+ * raw page size. `lastId` always comes from the final server-ordered entry
+ * before any client-side sorting — Map/object keys first, otherwise the
+ * document's own id.
+ */
+function queryPage(results: DashNoteQueryResults): QueryPage {
if (Array.isArray(results)) {
- return results
- .filter(Boolean)
- .map((doc) => toNote(null, doc as DashNoteQueryDocument));
+ const notes: NoteRecord[] = [];
+ for (const document of results) {
+ if (!document) continue;
+ notes.push(toNote(null, document as DashNoteQueryDocument));
+ }
+ return {
+ notes,
+ resultCount: results.length,
+ lastId: notes.at(-1)?.id || null,
+ };
+ }
+
+ const rawEntries =
+ results instanceof Map
+ ? Array.from(results.entries())
+ : Object.entries(results);
+ const notes: NoteRecord[] = [];
+ for (const [id, document] of rawEntries) {
+ if (!document) continue;
+ notes.push(toNote(id, document as DashNoteQueryDocument));
}
- const entries =
- results instanceof Map ? Object.fromEntries(results) : results;
- return Object.entries(entries)
- .filter(([, doc]) => Boolean(doc))
- .map(([id, doc]) => toNote(id, doc as DashNoteQueryDocument));
+ return {
+ notes,
+ resultCount: rawEntries.length,
+ lastId: rawEntries.at(-1)?.[0] ?? null,
+ };
+}
+
+export function normalizeNotes(results: DashNoteQueryResults): NoteRecord[] {
+ return queryPage(results).notes;
}
export function normalizeSingleNote(
@@ -87,32 +127,113 @@ export function normalizeSingleNote(
return toNote(id, raw as DashNoteQueryDocument);
}
-export async function listMyNotes({
+export interface NotePage {
+ notes: NoteRecord[];
+ nextCursor: string | null;
+}
+
+/**
+ * Fetch one index-ordered page. `startAfter` is an exclusive document-ID
+ * cursor, so the next page starts immediately after the final entry returned by
+ * the previous query.
+ */
+export async function listMyNotesPage({
sdk,
contractId,
ownerId,
+ startAfter,
limit = MAX_QUERY_LIMIT,
log,
}: {
sdk: DashSdk;
contractId: string;
ownerId: string;
+ startAfter?: string;
limit?: number;
log?: Logger;
-}): Promise {
- log?.("Loading your notes…");
+}): Promise {
+ if (!Number.isInteger(limit) || limit < 1 || limit > MAX_QUERY_LIMIT) {
+ throw new RangeError(
+ `Note page size must be an integer from 1 to ${MAX_QUERY_LIMIT}.`,
+ );
+ }
+
+ log?.(startAfter ? "Loading next page of notes…" : "Loading your notes…");
const results = await sdk.documents.query({
dataContractId: contractId,
documentTypeName: "note",
where: [["$ownerId", "==", ownerId]],
+ // Traverse the immutable creation-time index so editing a note between page
+ // requests cannot move it across the cursor boundary. The completed list is
+ // still sorted by $updatedAt for the existing recent-notes UX.
orderBy: [
["$ownerId", "asc"],
- ["$updatedAt", "asc"],
+ ["$createdAt", "asc"],
],
limit,
+ ...(startAfter ? { startAfter } : {}),
});
+ const page = queryPage(results);
+ const nextCursor = page.resultCount === limit ? page.lastId : null;
+
+ if (page.resultCount === limit && !nextCursor) {
+ throw new Error("Document query page did not expose a cursor ID.");
+ }
+ if (nextCursor && nextCursor === startAfter) {
+ throw new Error("Document pagination made no progress.");
+ }
+
+ return {
+ notes: page.notes,
+ nextCursor,
+ };
+}
+
+/**
+ * Walk every page with the SDK's exclusive `startAfter` cursor. Pages stay in
+ * server index order until their cursor has been captured; only the completed
+ * list is sorted newest-first for display.
+ */
+export async function listMyNotes({
+ sdk,
+ contractId,
+ ownerId,
+ limit = MAX_QUERY_LIMIT,
+ log,
+}: {
+ sdk: DashSdk;
+ contractId: string;
+ ownerId: string;
+ limit?: number;
+ log?: Logger;
+}): Promise {
+ const notesById = new Map();
+ const seenCursors = new Set();
+ let startAfter: string | undefined;
+
+ for (;;) {
+ const page = await listMyNotesPage({
+ sdk,
+ contractId,
+ ownerId,
+ startAfter,
+ limit,
+ log,
+ });
+
+ for (const note of page.notes) {
+ if (!notesById.has(note.id)) notesById.set(note.id, note);
+ }
+
+ if (!page.nextCursor) break;
+ if (seenCursors.has(page.nextCursor)) {
+ throw new Error("Document pagination repeated a cursor.");
+ }
+ seenCursors.add(page.nextCursor);
+ startAfter = page.nextCursor;
+ }
- return normalizeNotes(results).sort(
+ return [...notesById.values()].sort(
(left, right) => (right.updatedAt ?? 0) - (left.updatedAt ?? 0),
);
}
diff --git a/example-apps/dashnote/test/queries.test.ts b/example-apps/dashnote/test/queries.test.ts
index 5590a39f..57257b5f 100644
--- a/example-apps/dashnote/test/queries.test.ts
+++ b/example-apps/dashnote/test/queries.test.ts
@@ -2,7 +2,12 @@
import { describe, expect, it, vi } from "vitest";
-import { getNote, listMyNotes, normalizeNotes } from "../src/dash/queries";
+import {
+ getNote,
+ listMyNotes,
+ listMyNotesPage,
+ normalizeNotes,
+} from "../src/dash/queries";
function makeSdk(result: unknown) {
return {
@@ -13,6 +18,39 @@ function makeSdk(result: unknown) {
};
}
+function makePagedSdk(...results: unknown[]) {
+ const sdk = makeSdk(undefined);
+ for (const result of results) {
+ sdk.documents.query.mockResolvedValueOnce(result);
+ }
+ return sdk;
+}
+
+function noteDocument(updatedAt: number) {
+ return {
+ $ownerId: "owner-1",
+ $createdAt: updatedAt,
+ $updatedAt: updatedAt,
+ title: `Note ${updatedAt}`,
+ message: "Body",
+ $revision: 0,
+ };
+}
+
+function ownerNotesQuery(limit: number, startAfter?: string) {
+ return {
+ dataContractId: "contract-1",
+ documentTypeName: "note",
+ where: [["$ownerId", "==", "owner-1"]],
+ orderBy: [
+ ["$ownerId", "asc"],
+ ["$createdAt", "asc"],
+ ],
+ limit,
+ ...(startAfter ? { startAfter } : {}),
+ };
+}
+
describe("normalizeNotes", () => {
it("normalizes arrays, maps, and revision values", () => {
const notes = normalizeNotes(
@@ -74,18 +112,145 @@ describe("listMyNotes", () => {
ownerId: "owner-1",
});
- expect(sdk.documents.query).toHaveBeenCalledWith({
- dataContractId: "contract-1",
- documentTypeName: "note",
- where: [["$ownerId", "==", "owner-1"]],
- orderBy: [
- ["$ownerId", "asc"],
- ["$updatedAt", "asc"],
- ],
- limit: 100,
+ expect(sdk.documents.query).toHaveBeenCalledWith(ownerNotesQuery(100));
+ expect(notes.map((note) => note.id)).toEqual(["note-new", "note-old"]);
+ });
+
+ it("uses the last server-ordered ID as the exclusive next-page cursor", async () => {
+ const sdk = makeSdk(
+ new Map([
+ ["note-new", noteDocument(5000)],
+ ["note-old", noteDocument(1000)],
+ ]),
+ );
+
+ const page = await listMyNotesPage({
+ sdk: sdk as never,
+ contractId: "contract-1",
+ ownerId: "owner-1",
+ limit: 2,
+ });
+
+ expect(page.notes.map((note) => note.id)).toEqual(["note-new", "note-old"]);
+ expect(page.nextCursor).toBe("note-old");
+ });
+
+ it("walks first, next, and final pages without skipping the boundary", async () => {
+ const sdk = makePagedSdk(
+ new Map([
+ ["note-old", noteDocument(1000)],
+ ["note-middle", noteDocument(2000)],
+ ]),
+ new Map([["note-new", noteDocument(3000)]]),
+ );
+ const log = vi.fn();
+
+ const notes = await listMyNotes({
+ sdk: sdk as never,
+ contractId: "contract-1",
+ ownerId: "owner-1",
+ limit: 2,
+ log,
});
+
+ expect(log.mock.calls.map(([message]) => message)).toEqual([
+ "Loading your notes…",
+ "Loading next page of notes…",
+ ]);
+ expect(sdk.documents.query).toHaveBeenNthCalledWith(1, ownerNotesQuery(2));
+ expect(sdk.documents.query).toHaveBeenNthCalledWith(
+ 2,
+ ownerNotesQuery(2, "note-middle"),
+ );
+ expect(notes.map((note) => note.id)).toEqual([
+ "note-new",
+ "note-middle",
+ "note-old",
+ ]);
+ });
+
+ it("deduplicates an overlapping page by document ID", async () => {
+ const sdk = makePagedSdk(
+ new Map([
+ ["note-old", noteDocument(1000)],
+ ["note-middle", noteDocument(2000)],
+ ]),
+ new Map([
+ ["note-middle", noteDocument(2000)],
+ ["note-new", noteDocument(3000)],
+ ]),
+ new Map(),
+ );
+
+ const notes = await listMyNotes({
+ sdk: sdk as never,
+ contractId: "contract-1",
+ ownerId: "owner-1",
+ limit: 2,
+ });
+
+ expect(notes.map((note) => note.id)).toEqual([
+ "note-new",
+ "note-middle",
+ "note-old",
+ ]);
+ });
+
+ it("queries once more when the final populated page is exactly full", async () => {
+ const sdk = makePagedSdk(
+ new Map([
+ ["note-old", noteDocument(1000)],
+ ["note-new", noteDocument(2000)],
+ ]),
+ new Map(),
+ );
+
+ const notes = await listMyNotes({
+ sdk: sdk as never,
+ contractId: "contract-1",
+ ownerId: "owner-1",
+ limit: 2,
+ });
+
+ expect(sdk.documents.query).toHaveBeenCalledTimes(2);
+ expect(sdk.documents.query).toHaveBeenLastCalledWith(
+ expect.objectContaining({ startAfter: "note-new" }),
+ );
expect(notes.map((note) => note.id)).toEqual(["note-new", "note-old"]);
});
+
+ it("rejects a page that repeats the exclusive cursor", async () => {
+ const sdk = makeSdk(
+ new Map([
+ ["note-old", noteDocument(1000)],
+ ["note-cursor", noteDocument(2000)],
+ ]),
+ );
+
+ await expect(
+ listMyNotesPage({
+ sdk: sdk as never,
+ contractId: "contract-1",
+ ownerId: "owner-1",
+ startAfter: "note-cursor",
+ limit: 2,
+ }),
+ ).rejects.toThrow("Document pagination made no progress.");
+ });
+
+ it("rejects page sizes outside the SDK's 1 to 100 range", async () => {
+ const sdk = makeSdk(new Map());
+
+ await expect(
+ listMyNotesPage({
+ sdk: sdk as never,
+ contractId: "contract-1",
+ ownerId: "owner-1",
+ limit: 0,
+ }),
+ ).rejects.toThrow("Note page size must be an integer from 1 to 100.");
+ expect(sdk.documents.query).not.toHaveBeenCalled();
+ });
});
describe("getNote", () => {