From b2d49556ddce833b7fc573121537fbd46c3bd589 Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Wed, 15 Jul 2026 22:20:09 -0500 Subject: [PATCH 1/3] feat(dashnote): paginate note queries Co-Authored-By: Claude --- example-apps/dashnote/src/dash/queries.ts | 140 +++++++++++++++++--- example-apps/dashnote/test/queries.test.ts | 145 ++++++++++++++++++++- 2 files changed, 269 insertions(+), 16 deletions(-) diff --git a/example-apps/dashnote/src/dash/queries.ts b/example-apps/dashnote/src/dash/queries.ts index aca7ead9..cd607b5b 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,42 @@ function toNote(id: string | null, raw: DashNoteQueryDocument): NoteRecord { }; } -export function normalizeNotes(results: DashNoteQueryResults): NoteRecord[] { +interface QueryPageEntries { + entries: Array<[string | null, DashNoteQueryDocument]>; + resultCount: number; + lastId: string | null; +} + +function queryPageEntries(results: DashNoteQueryResults): QueryPageEntries { if (Array.isArray(results)) { - return results - .filter(Boolean) - .map((doc) => toNote(null, doc as DashNoteQueryDocument)); + const documents = results.filter(Boolean) as DashNoteQueryDocument[]; + const last = documents.at(-1); + return { + entries: documents.map((document) => [null, document]), + resultCount: results.length, + lastId: last ? toNote(null, last).id || null : null, + }; } + 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)); + results instanceof Map ? Array.from(results.entries()) : Object.entries(results); + return { + entries: entries + .filter((entry): entry is [string, DashNoteQueryDocument] => + Boolean(entry[1]), + ) + .map(([id, document]) => [id, document]), + resultCount: entries.length, + // The cursor must come from the last entry in the SDK's server-ordered + // result, before any client-side sorting or filtering. + lastId: entries.at(-1)?.[0] ?? null, + }; +} + +export function normalizeNotes(results: DashNoteQueryResults): NoteRecord[] { + return queryPageEntries(results).entries.map(([id, document]) => + toNote(id, document), + ); } export function normalizeSingleNote( @@ -87,20 +119,35 @@ 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}.`, + ); + } + const results = await sdk.documents.query({ dataContractId: contractId, documentTypeName: "note", @@ -110,9 +157,72 @@ export async function listMyNotes({ ["$updatedAt", "asc"], ], limit, + ...(startAfter ? { startAfter } : {}), }); + const page = queryPageEntries(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.entries.map(([id, document]) => toNote(id, document)), + 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 { + log?.("Loading your notes…"); + const notes: NoteRecord[] = []; + const seenNoteIds = new Set(); + const seenCursors = new Set(); + let startAfter: string | undefined; + + for (;;) { + const page = await listMyNotesPage({ + sdk, + contractId, + ownerId, + startAfter, + limit, + }); + + for (const note of page.notes) { + if (seenNoteIds.has(note.id)) continue; + seenNoteIds.add(note.id); + notes.push(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 notes.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..69bb9f03 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,25 @@ 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, + }; +} + describe("normalizeNotes", () => { it("normalizes arrays, maps, and revision values", () => { const notes = normalizeNotes( @@ -86,6 +110,125 @@ describe("listMyNotes", () => { }); 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 notes = await listMyNotes({ + sdk: sdk as never, + contractId: "contract-1", + ownerId: "owner-1", + limit: 2, + }); + + expect(sdk.documents.query).toHaveBeenNthCalledWith(1, { + dataContractId: "contract-1", + documentTypeName: "note", + where: [["$ownerId", "==", "owner-1"]], + orderBy: [ + ["$ownerId", "asc"], + ["$updatedAt", "asc"], + ], + limit: 2, + }); + expect(sdk.documents.query).toHaveBeenNthCalledWith(2, { + dataContractId: "contract-1", + documentTypeName: "note", + where: [["$ownerId", "==", "owner-1"]], + orderBy: [ + ["$ownerId", "asc"], + ["$updatedAt", "asc"], + ], + limit: 2, + startAfter: "note-middle", + }); + 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", () => { From 8bf695fd5f2b180131c9439b98b934074c18682c Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Wed, 15 Jul 2026 22:22:39 -0500 Subject: [PATCH 2/3] docs(dashnote): explain cursor pagination Co-Authored-By: Claude --- example-apps/dashnote/CLAUDE.md | 2 +- example-apps/dashnote/README.md | 19 +++++++++-- .../dashnote/src/components/HowItWorks.tsx | 14 ++++++-- example-apps/dashnote/src/dash/queries.ts | 5 ++- example-apps/dashnote/test/queries.test.ts | 33 +++++++++++++++++-- 5 files changed, 62 insertions(+), 11 deletions(-) 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 cd607b5b..ecc3916e 100644 --- a/example-apps/dashnote/src/dash/queries.ts +++ b/example-apps/dashnote/src/dash/queries.ts @@ -152,9 +152,12 @@ export async function listMyNotesPage({ 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 } : {}), diff --git a/example-apps/dashnote/test/queries.test.ts b/example-apps/dashnote/test/queries.test.ts index 69bb9f03..b9dc4649 100644 --- a/example-apps/dashnote/test/queries.test.ts +++ b/example-apps/dashnote/test/queries.test.ts @@ -104,7 +104,7 @@ describe("listMyNotes", () => { where: [["$ownerId", "==", "owner-1"]], orderBy: [ ["$ownerId", "asc"], - ["$updatedAt", "asc"], + ["$createdAt", "asc"], ], limit: 100, }); @@ -152,7 +152,7 @@ describe("listMyNotes", () => { where: [["$ownerId", "==", "owner-1"]], orderBy: [ ["$ownerId", "asc"], - ["$updatedAt", "asc"], + ["$createdAt", "asc"], ], limit: 2, }); @@ -162,7 +162,7 @@ describe("listMyNotes", () => { where: [["$ownerId", "==", "owner-1"]], orderBy: [ ["$ownerId", "asc"], - ["$updatedAt", "asc"], + ["$createdAt", "asc"], ], limit: 2, startAfter: "note-middle", @@ -174,6 +174,33 @@ describe("listMyNotes", () => { ]); }); + 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([ From ed12e2093c9c943a49dbd26ba9a745d4fa7645ea Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Wed, 15 Jul 2026 23:03:18 -0500 Subject: [PATCH 3/3] refactor(dashnote): simplify pagination flow Co-Authored-By: Claude --- example-apps/dashnote/src/dash/queries.ts | 68 ++++++++++++---------- example-apps/dashnote/test/queries.test.ts | 57 +++++++++--------- 2 files changed, 64 insertions(+), 61 deletions(-) diff --git a/example-apps/dashnote/src/dash/queries.ts b/example-apps/dashnote/src/dash/queries.ts index ecc3916e..49d9041a 100644 --- a/example-apps/dashnote/src/dash/queries.ts +++ b/example-apps/dashnote/src/dash/queries.ts @@ -73,42 +73,50 @@ function toNote(id: string | null, raw: DashNoteQueryDocument): NoteRecord { }; } -interface QueryPageEntries { - entries: Array<[string | null, DashNoteQueryDocument]>; +interface QueryPage { + notes: NoteRecord[]; resultCount: number; lastId: string | null; } -function queryPageEntries(results: DashNoteQueryResults): QueryPageEntries { +/** + * 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)) { - const documents = results.filter(Boolean) as DashNoteQueryDocument[]; - const last = documents.at(-1); + const notes: NoteRecord[] = []; + for (const document of results) { + if (!document) continue; + notes.push(toNote(null, document as DashNoteQueryDocument)); + } return { - entries: documents.map((document) => [null, document]), + notes, resultCount: results.length, - lastId: last ? toNote(null, last).id || null : null, + lastId: notes.at(-1)?.id || null, }; } - const entries = - results instanceof Map ? Array.from(results.entries()) : Object.entries(results); + 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)); + } return { - entries: entries - .filter((entry): entry is [string, DashNoteQueryDocument] => - Boolean(entry[1]), - ) - .map(([id, document]) => [id, document]), - resultCount: entries.length, - // The cursor must come from the last entry in the SDK's server-ordered - // result, before any client-side sorting or filtering. - lastId: entries.at(-1)?.[0] ?? null, + notes, + resultCount: rawEntries.length, + lastId: rawEntries.at(-1)?.[0] ?? null, }; } export function normalizeNotes(results: DashNoteQueryResults): NoteRecord[] { - return queryPageEntries(results).entries.map(([id, document]) => - toNote(id, document), - ); + return queryPage(results).notes; } export function normalizeSingleNote( @@ -135,12 +143,14 @@ export async function listMyNotesPage({ ownerId, startAfter, limit = MAX_QUERY_LIMIT, + log, }: { sdk: DashSdk; contractId: string; ownerId: string; startAfter?: string; limit?: number; + log?: Logger; }): Promise { if (!Number.isInteger(limit) || limit < 1 || limit > MAX_QUERY_LIMIT) { throw new RangeError( @@ -148,6 +158,7 @@ export async function listMyNotesPage({ ); } + log?.(startAfter ? "Loading next page of notes…" : "Loading your notes…"); const results = await sdk.documents.query({ dataContractId: contractId, documentTypeName: "note", @@ -162,7 +173,7 @@ export async function listMyNotesPage({ limit, ...(startAfter ? { startAfter } : {}), }); - const page = queryPageEntries(results); + const page = queryPage(results); const nextCursor = page.resultCount === limit ? page.lastId : null; if (page.resultCount === limit && !nextCursor) { @@ -173,7 +184,7 @@ export async function listMyNotesPage({ } return { - notes: page.entries.map(([id, document]) => toNote(id, document)), + notes: page.notes, nextCursor, }; } @@ -196,9 +207,7 @@ export async function listMyNotes({ limit?: number; log?: Logger; }): Promise { - log?.("Loading your notes…"); - const notes: NoteRecord[] = []; - const seenNoteIds = new Set(); + const notesById = new Map(); const seenCursors = new Set(); let startAfter: string | undefined; @@ -209,12 +218,11 @@ export async function listMyNotes({ ownerId, startAfter, limit, + log, }); for (const note of page.notes) { - if (seenNoteIds.has(note.id)) continue; - seenNoteIds.add(note.id); - notes.push(note); + if (!notesById.has(note.id)) notesById.set(note.id, note); } if (!page.nextCursor) break; @@ -225,7 +233,7 @@ export async function listMyNotes({ startAfter = page.nextCursor; } - return notes.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 b9dc4649..57257b5f 100644 --- a/example-apps/dashnote/test/queries.test.ts +++ b/example-apps/dashnote/test/queries.test.ts @@ -37,6 +37,20 @@ function noteDocument(updatedAt: number) { }; } +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( @@ -98,16 +112,7 @@ describe("listMyNotes", () => { ownerId: "owner-1", }); - expect(sdk.documents.query).toHaveBeenCalledWith({ - dataContractId: "contract-1", - documentTypeName: "note", - where: [["$ownerId", "==", "owner-1"]], - orderBy: [ - ["$ownerId", "asc"], - ["$createdAt", "asc"], - ], - limit: 100, - }); + expect(sdk.documents.query).toHaveBeenCalledWith(ownerNotesQuery(100)); expect(notes.map((note) => note.id)).toEqual(["note-new", "note-old"]); }); @@ -138,35 +143,25 @@ describe("listMyNotes", () => { ]), 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(sdk.documents.query).toHaveBeenNthCalledWith(1, { - dataContractId: "contract-1", - documentTypeName: "note", - where: [["$ownerId", "==", "owner-1"]], - orderBy: [ - ["$ownerId", "asc"], - ["$createdAt", "asc"], - ], - limit: 2, - }); - expect(sdk.documents.query).toHaveBeenNthCalledWith(2, { - dataContractId: "contract-1", - documentTypeName: "note", - where: [["$ownerId", "==", "owner-1"]], - orderBy: [ - ["$ownerId", "asc"], - ["$createdAt", "asc"], - ], - limit: 2, - startAfter: "note-middle", - }); + 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",