Skip to content

Commit 91b7379

Browse files
authored
Merge pull request #41 from pmorris-dev/pagination-final-phase
feat: add TypeScript API for keyset pagination
2 parents a386d2e + ab10d04 commit 91b7379

25 files changed

Lines changed: 3676 additions & 5 deletions

README.md

Lines changed: 134 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ without Tauri:
6161
| -------------------- | --------------- | ---------------- | ------------------- |
6262
| SELECT (multiple) | `fetchAll()` | Read pool | Multiple concurrent |
6363
| SELECT (single) | `fetchOne()` | Read pool | Multiple concurrent |
64+
| SELECT (paginated) | `fetchPage()` | Read pool | Multiple concurrent |
6465
| INSERT/UPDATE/DELETE | `execute()` | Write connection | Serialized |
6566
| DDL (CREATE, etc.) | `execute()` | Write connection | Serialized |
6667

@@ -268,6 +269,64 @@ if (user) {
268269
}
269270
```
270271

272+
### Pagination
273+
274+
When working with large result sets, loading all rows at once can cause
275+
performance degradation and excessive memory usage on both the Rust and
276+
TypeScript sides. The plugin provides built-in pagination to fetch data in
277+
fixed-size pages, keeping memory usage bounded and queries fast regardless
278+
of total row count.
279+
280+
#### Why Keyset Pagination
281+
282+
The plugin uses keyset (cursor-based) pagination rather than traditional
283+
OFFSET-based pagination. With OFFSET, the database must scan and discard
284+
all skipped rows on every page request, making deeper pages progressively
285+
slower. Keyset pagination uses indexed column values from the last row of
286+
the current page to seek directly to the next page, keeping query time
287+
constant no matter how far you paginate.
288+
289+
```typescript
290+
import type { KeysetColumn } from '@silvermine/tauri-plugin-sqlite';
291+
292+
type Post = { id: number; title: string; category: string; score: number };
293+
294+
const keyset: KeysetColumn[] = [
295+
{ name: 'category', direction: 'asc' },
296+
{ name: 'score', direction: 'desc' },
297+
{ name: 'id', direction: 'asc' },
298+
];
299+
300+
// First page
301+
const page = await db.fetchPage<Post>(
302+
'SELECT id, title, category, score FROM posts',
303+
[],
304+
keyset,
305+
25,
306+
);
307+
308+
// Next page (forward) — pass the cursor from the previous page
309+
if (page.nextCursor) {
310+
const nextPage = await db.fetchPage<Post>(
311+
'SELECT id, title, category, score FROM posts',
312+
[],
313+
keyset,
314+
25,
315+
).after(page.nextCursor);
316+
317+
// Previous page (backward) — rows are returned in original sort order
318+
const prevPage = await db.fetchPage<Post>(
319+
'SELECT id, title, category, score FROM posts',
320+
[],
321+
keyset,
322+
25,
323+
).before(page.nextCursor);
324+
}
325+
```
326+
327+
The base query must not contain `ORDER BY` or `LIMIT` clauses — the builder
328+
appends these automatically based on the keyset definition.
329+
271330
### Transactions
272331

273332
For most cases, use `executeTransaction()` to run multiple statements atomically:
@@ -338,8 +397,8 @@ Each attached database gets a schema name that acts as a namespace for its
338397
tables.
339398

340399
**Builder Pattern:** All query methods (`execute`, `executeTransaction`,
341-
`fetchAll`, `fetchOne`) return builders that support `.attach()` for
342-
cross-database operations.
400+
`fetchAll`, `fetchOne`, `fetchPage`) return builders that support `.attach()`
401+
for cross-database operations.
343402

344403
```typescript
345404
// Join data from multiple databases
@@ -509,6 +568,7 @@ await db.remove(); // Close and DELETE database file(s) - irreversible
509568
| `beginInterruptibleTransaction(statements)` | Begin interruptible transaction, returns `InterruptibleTransaction` |
510569
| `fetchAll<T>(query, values?)` | Execute SELECT, return all rows |
511570
| `fetchOne<T>(query, values?)` | Execute SELECT, return single row or `undefined` |
571+
| `fetchPage<T>(query, values, keyset, pageSize)` | Keyset pagination, returns `FetchPageBuilder` |
512572
| `close()` | Close connection, returns `true` if was loaded |
513573
| `remove()` | Close and delete database file(s), returns `true` if was loaded |
514574
| `observe(tables, config?)` | Enable change observation for tables |
@@ -517,12 +577,15 @@ await db.remove(); // Close and DELETE database file(s) - irreversible
517577

518578
### Builder Methods
519579

520-
All query methods (`execute`, `executeTransaction`, `fetchAll`, `fetchOne`)
521-
return builders that are directly awaitable and support method chaining:
580+
All query methods (`execute`, `executeTransaction`, `fetchAll`, `fetchOne`,
581+
`fetchPage`) return builders that are directly awaitable and support method
582+
chaining:
522583

523584
| Method | Description |
524585
| ------ | ----------- |
525586
| `attach(specs)` | Attach databases for cross-database queries, returns `this` |
587+
| `after(cursor)` | Set cursor for forward pagination (`FetchPageBuilder` only), returns `this` |
588+
| `before(cursor)` | Set cursor for backward pagination (`FetchPageBuilder` only), returns `this` |
526589
| `await builder` | Execute the query (builders implement `PromiseLike`) |
527590

528591
### InterruptibleTransaction Methods
@@ -569,6 +632,19 @@ interface ObserverConfig {
569632
captureValues?: boolean; // default: true
570633
}
571634

635+
type SortDirection = 'asc' | 'desc';
636+
637+
interface KeysetColumn {
638+
name: string; // Column name in the query result set
639+
direction: SortDirection;
640+
}
641+
642+
interface KeysetPage<T = Record<string, SqlValue>> {
643+
rows: T[];
644+
nextCursor: SqlValue[] | null; // Cursor to continue pagination, null when no more pages
645+
hasMore: boolean;
646+
}
647+
572648
type ChangeOperation = 'insert' | 'update' | 'delete';
573649

574650
type ColumnValue =
@@ -647,6 +723,47 @@ if let Some(user_data) = user {
647723
}
648724
```
649725

726+
### Pagination (Rust)
727+
728+
See [Pagination](#pagination) above for background on why the plugin uses
729+
keyset pagination. The Rust API works the same way via `fetch_page`:
730+
731+
```rust
732+
use sqlx_sqlite_toolkit::pagination::KeysetColumn;
733+
734+
let keyset = vec![
735+
KeysetColumn::asc("category"),
736+
KeysetColumn::desc("score"),
737+
KeysetColumn::asc("id"),
738+
];
739+
740+
// First page
741+
let page = db.fetch_page(
742+
"SELECT id, title, category, score FROM posts".into(),
743+
vec![],
744+
keyset.clone(),
745+
25,
746+
).await?;
747+
748+
// Next page (forward)
749+
if let Some(cursor) = page.next_cursor {
750+
let next = db.fetch_page(
751+
"SELECT id, title, category, score FROM posts".into(),
752+
vec![],
753+
keyset.clone(),
754+
25,
755+
).after(cursor.clone()).await?;
756+
757+
// Previous page (backward) — rows returned in original sort order
758+
let prev = db.fetch_page(
759+
"SELECT id, title, category, score FROM posts".into(),
760+
vec![],
761+
keyset,
762+
25,
763+
).before(cursor).await?;
764+
}
765+
```
766+
650767
### Simple Transactions
651768

652769
Use `execute_transaction()` for atomic execution of multiple statements:
@@ -771,6 +888,7 @@ db.remove().await?; // Close and DELETE database file(s)
771888
| `begin_interruptible_transaction()` | Begin interruptible transaction (builder) |
772889
| `fetch_all(query, values)` | Fetch all rows |
773890
| `fetch_one(query, values)` | Fetch single row |
891+
| `fetch_page(query, values, keyset, page_size)` | Keyset pagination (builder, supports `.after()`, `.before()`, `.attach()`) |
774892
| `close()` | Close connection |
775893
| `remove()` | Close and delete database file(s) |
776894

@@ -818,6 +936,18 @@ fn main() {
818936
}
819937
```
820938

939+
## Examples
940+
941+
Working Tauri demo apps are in the [`examples/`](examples) directory:
942+
943+
* **[`observer-demo`](examples/observer-demo)** — Real-time change
944+
notifications with live streaming of inserts, updates, and deletes
945+
* **[`pagination-demo`](examples/pagination-demo)** — Keyset pagination
946+
with a virtualized list and performance metrics
947+
948+
See the [toolkit crate README](crates/sqlx-sqlite-toolkit/README.md#examples)
949+
for setup instructions.
950+
821951
## Development
822952

823953
This project follows

api-iife.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/sqlx-sqlite-toolkit/README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,24 @@ All errors provide an `error_code()` method returning a machine-readable string:
278278
| `INVALID_COLUMN_NAME` | Keyset column name contains invalid characters |
279279
| `CONFLICTING_CURSORS` | Both `after` and `before` cursors provided |
280280

281+
## Examples
282+
283+
Working Tauri apps demonstrating the toolkit's features are in the
284+
[`examples/`](../../examples) directory:
285+
286+
| App | Description |
287+
| --- | ----------- |
288+
| [`observer-demo`](../../examples/observer-demo) | Real-time change notifications using the observer subsystem — subscribe to table changes and see inserts, updates, and deletes streamed live |
289+
| [`pagination-demo`](../../examples/pagination-demo) | Keyset pagination with a virtualized list — browse large datasets page-by-page with forward/backward navigation and performance metrics |
290+
291+
Both are Vue 3 + Tauri apps. To run one:
292+
293+
```bash
294+
cd examples/observer-demo # or pagination-demo
295+
npm install
296+
cargo tauri dev
297+
```
298+
281299
## Development
282300

283301
```bash
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+
<title>SQLite Pagination Demo</title>
7+
</head>
8+
<body>
9+
<div id="app"></div>
10+
<script type="module" src="/src/main.ts"></script>
11+
</body>
12+
</html>

0 commit comments

Comments
 (0)