Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion example-apps/dashnote/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
19 changes: 16 additions & 3 deletions example-apps/dashnote/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand Down
14 changes: 11 additions & 3 deletions example-apps/dashnote/src/components/HowItWorks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
];

Expand All @@ -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.",
Expand Down Expand Up @@ -247,8 +255,8 @@ export function HowItWorks() {
<div className={SECTION_LABEL}>Recommended source files</div>
<p className={SECTION_INTRO}>
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.
</p>
<ol className="mt-4 space-y-1">
{READING_ORDER.map((item, i) => (
Expand Down
151 changes: 136 additions & 15 deletions example-apps/dashnote/src/dash/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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(
Expand All @@ -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<NoteRecord[]> {
log?.("Loading your notes…");
}): Promise<NotePage> {
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<NoteRecord[]> {
const notesById = new Map<string, NoteRecord>();
const seenCursors = new Set<string>();
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),
);
}
Expand Down
Loading