{"_id":"@ayushmaanagarwal1211/convex-offset-pagination","name":"@ayushmaanagarwal1211/convex-offset-pagination","dist-tags":{"latest":"0.1.0"},"versions":{"0.1.0":{"name":"@ayushmaanagarwal1211/convex-offset-pagination","version":"0.1.0","description":"Offset-based pagination for Convex — page numbers, total counts, and random page access powered by @convex-dev/aggregate.","keywords":["convex","pagination","offset-pagination","page-numbers","convex-component"],"homepage":"https://github.com/ayushmaanagarwal1211/convex-offset-pagination#readme","repository":{"type":"git","url":"git+https://github.com/ayushmaanagarwal1211/convex-offset-pagination.git"},"bugs":{"url":"https://github.com/ayushmaanagarwal1211/convex-offset-pagination/issues"},"license":"Apache-2.0","author":{"name":"ayushmaanagarwal1211"},"type":"module","main":"./dist/client/index.js","types":"./dist/client/index.d.ts","exports":{".":{"types":"./dist/client/index.d.ts","default":"./dist/client/index.js"}},"scripts":{"build":"tsc","dev":"tsc --watch","typecheck":"tsc --noEmit","lint":"eslint src/"},"dependencies":{"@convex-dev/aggregate":"^0.2.1"},"peerDependencies":{"convex":"^1.24.8"},"devDependencies":{"convex":"^1.33.1","typescript":"~5.5.0"},"_id":"@ayushmaanagarwal1211/convex-offset-pagination@0.1.0","_nodeVersion":"24.1.0","_npmVersion":"11.3.0","dist":{"integrity":"sha512-5pUzhSkk2kAqc3PnmxOSB9M/Emb5dZnxMYm7p9YNLEhDvpLzPVawBIyOS3z+HX/uTaKojhUBY6ByymDZIthncQ==","shasum":"a2c7a8e0c8f117012ae3445d6746e3a1b7a66612","tarball":"https://registry.npmjs.org/@ayushmaanagarwal1211/convex-offset-pagination/-/convex-offset-pagination-0.1.0.tgz","fileCount":8,"unpackedSize":54064,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEUCIQCU3iGQwLZKN/wigXREWqm4psKGUQoFGD/7t9OgACFP4wIgcuiqwEm1Pzi+1U7x4XPW6FOeLKlzb+0dIzDAVY8HVsk="}]},"_npmUser":{"name":"ayushmaan_agarwal","email":"agarwalayushmaan88@gmail.com"},"directories":{},"maintainers":[{"name":"ayushmaan_agarwal","email":"agarwalayushmaan88@gmail.com"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/convex-offset-pagination_0.1.0_1773942639964_0.422263981478221"},"_hasShrinkwrap":false}},"time":{"created":"2026-03-19T17:50:39.835Z","0.1.0":"2026-03-19T17:50:40.116Z","modified":"2026-03-19T17:50:40.340Z"},"maintainers":[{"name":"ayushmaan_agarwal","email":"agarwalayushmaan88@gmail.com"}],"description":"Offset-based pagination for Convex — page numbers, total counts, and random page access powered by @convex-dev/aggregate.","homepage":"https://github.com/ayushmaanagarwal1211/convex-offset-pagination#readme","keywords":["convex","pagination","offset-pagination","page-numbers","convex-component"],"repository":{"type":"git","url":"git+https://github.com/ayushmaanagarwal1211/convex-offset-pagination.git"},"author":{"name":"ayushmaanagarwal1211"},"bugs":{"url":"https://github.com/ayushmaanagarwal1211/convex-offset-pagination/issues"},"license":"Apache-2.0","readme":"# @ayushmaanagarwal1211/convex-offset-pagination\n\n[![npm version](https://badge.fury.io/js/%40convex-dev%2Foffset-pagination.svg)](https://www.npmjs.com/package/@ayushmaanagarwal1211/convex-offset-pagination)\n\nOffset-based pagination for [Convex](https://convex.dev) — page numbers, total counts, and random page access.\n\nConvex natively supports **cursor-based pagination**, which is ideal for infinite scroll. But many UIs need traditional **page-number navigation** (\"Page 3 of 42\", \"Jump to page 10\"). This library fills that gap.\n\nBuilt on top of [`@convex-dev/aggregate`](https://github.com/get-convex/aggregate), which maintains a B-tree for **O(log n)** count and offset lookups.\n\n<div align=\"center\">\n  <img src=\"https://img.shields.io/badge/convex-%5E1.24.8-blue\" alt=\"Convex version\" />\n  <img src=\"https://img.shields.io/badge/license-Apache--2.0-green\" alt=\"License\" />\n</div>\n\n## Features\n\n- **Page-number pagination** — request any page by number, not just \"next\"\n- **Total count** — always know total matching documents without scanning\n- **Page metadata** — `totalPages`, `currentPage`, `hasNextPage`, `hasPreviousPage`\n- **Namespace support** — paginate subsets independently (per user, per album, per org)\n- **Ascending / descending** sort order\n- **Trigger integration** — auto-sync via `convex-helpers` triggers\n- **O(log n) performance** — no full table scans\n\n## When to use this\n\n| Use case | Built-in cursor pagination | This library |\n|----------|---------------------------|--------------|\n| Infinite scroll / \"Load more\" | Yes | No |\n| \"Page 1, 2, 3 ... 42\" navigation | No | **Yes** |\n| \"Showing 41-60 of 423 results\" | No | **Yes** |\n| Jump to arbitrary page | No | **Yes** |\n| Total count without scanning | No | **Yes** |\n\n## Installation\n\n```bash\nnpm install @ayushmaanagarwal1211/convex-offset-pagination @convex-dev/aggregate\n```\n\nBoth packages are required. `@convex-dev/aggregate` is the underlying component that maintains the B-tree index.\n\n## Setup\n\n### 1. Register the aggregate component\n\n```ts\n// convex/convex.config.ts\nimport { defineApp } from \"convex/server\";\nimport aggregate from \"@convex-dev/aggregate/convex.config.js\";\n\nconst app = defineApp();\napp.use(aggregate);\nexport default app;\n```\n\n### 2. Create a paginator instance\n\n```ts\n// convex/photos.ts\nimport { OffsetPagination } from \"@ayushmaanagarwal1211/convex-offset-pagination\";\nimport { components } from \"./_generated/api\";\nimport { DataModel } from \"./_generated/dataModel\";\n\nconst paginatedPhotos = new OffsetPagination<DataModel, \"photos\">(\n  components.aggregate,\n  {\n    sortKey: (doc) => doc._creationTime,\n  },\n);\n```\n\n**With namespaces** (paginate per-album, per-user, etc.):\n\n```ts\nconst paginatedPhotos = new OffsetPagination<DataModel, \"photos\">(\n  components.aggregate,\n  {\n    sortKey: (doc) => doc._creationTime,\n    namespace: (doc) => doc.albumId,\n  },\n);\n```\n\n### 3. Keep the aggregate in sync\n\nCall `insert`, `delete`, and `replace` in your mutations alongside your `ctx.db` writes:\n\n```ts\nimport { mutation } from \"./_generated/server\";\nimport { v } from \"convex/values\";\n\nexport const addPhoto = mutation({\n  args: { url: v.string(), albumId: v.string() },\n  handler: async (ctx, args) => {\n    const id = await ctx.db.insert(\"photos\", args);\n    const doc = (await ctx.db.get(id))!;\n    await paginatedPhotos.insert(ctx, doc);\n    return id;\n  },\n});\n\nexport const deletePhoto = mutation({\n  args: { id: v.id(\"photos\") },\n  handler: async (ctx, args) => {\n    const doc = (await ctx.db.get(args.id))!;\n    await paginatedPhotos.delete(ctx, doc);\n    await ctx.db.delete(args.id);\n  },\n});\n\nexport const updatePhoto = mutation({\n  args: { id: v.id(\"photos\"), caption: v.string() },\n  handler: async (ctx, args) => {\n    const oldDoc = (await ctx.db.get(args.id))!;\n    await ctx.db.patch(args.id, { caption: args.caption });\n    const newDoc = (await ctx.db.get(args.id))!;\n    await paginatedPhotos.replace(ctx, oldDoc, newDoc);\n  },\n});\n```\n\n#### Alternative: Auto-sync with triggers\n\nIf you use [`convex-helpers`](https://github.com/get-convex/convex-helpers), you can auto-sync without manual calls:\n\n```ts\nimport { Triggers } from \"convex-helpers/server/triggers\";\nimport { DataModel } from \"./_generated/dataModel\";\n\nconst triggers = new Triggers<DataModel>();\ntriggers.register(\"photos\", paginatedPhotos.trigger());\n\nexport const { mutation } = triggers.makeFunctions({ mutation: rawMutation });\n```\n\n### 4. Query with pagination\n\n```ts\nimport { query } from \"./_generated/server\";\nimport { v } from \"convex/values\";\n\nexport const listPhotos = query({\n  args: {\n    page: v.number(),\n    limit: v.number(),\n    albumId: v.optional(v.string()),\n  },\n  handler: async (ctx, args) => {\n    return paginatedPhotos.paginate(ctx, \"photos\", {\n      page: args.page,\n      limit: args.limit,\n      namespace: args.albumId,\n    });\n  },\n});\n```\n\n**Response:**\n\n```json\n{\n  \"items\": [{ \"_id\": \"...\", \"url\": \"...\", \"albumId\": \"...\" }],\n  \"totalCount\": 423,\n  \"totalPages\": 43,\n  \"currentPage\": 5,\n  \"hasNextPage\": true,\n  \"hasPreviousPage\": true\n}\n```\n\n### 5. Use in React\n\n```tsx\nimport { useQuery } from \"convex/react\";\nimport { api } from \"../convex/_generated/api\";\nimport { useState } from \"react\";\n\nfunction PhotoGallery({ albumId }: { albumId: string }) {\n  const [page, setPage] = useState(1);\n  const result = useQuery(api.photos.listPhotos, {\n    page,\n    limit: 20,\n    albumId,\n  });\n\n  if (!result) return <div>Loading...</div>;\n\n  return (\n    <div>\n      {result.items.map((photo) => (\n        <img key={photo._id} src={photo.url} />\n      ))}\n\n      <div>\n        Page {result.currentPage} of {result.totalPages}\n        ({result.totalCount} photos)\n      </div>\n\n      <button\n        disabled={!result.hasPreviousPage}\n        onClick={() => setPage((p) => p - 1)}\n      >\n        Previous\n      </button>\n      <button\n        disabled={!result.hasNextPage}\n        onClick={() => setPage((p) => p + 1)}\n      >\n        Next\n      </button>\n    </div>\n  );\n}\n```\n\n## API Reference\n\n### `new OffsetPagination(aggregateComponent, config)`\n\n| Parameter | Type | Description |\n|-----------|------|-------------|\n| `aggregateComponent` | `ComponentApi` | `components.aggregate` from your generated code |\n| `config.sortKey` | `(doc) => number` | Extracts the sort key (e.g. `doc._creationTime`) |\n| `config.namespace` | `(doc) => string` | Optional. Scopes pagination to independent subsets |\n\n### Query Methods\n\n#### `paginate(ctx, tableName, args)` → `OffsetPaginationResult`\n\nPrimary API. Returns a page of fully-hydrated documents with pagination metadata.\n\n| Arg | Type | Default | Description |\n|-----|------|---------|-------------|\n| `args.page` | `number` | required | 1-indexed page number |\n| `args.limit` | `number` | required | Items per page |\n| `args.namespace` | `string` | — | Scope to a namespace |\n| `args.order` | `\"asc\" \\| \"desc\"` | `\"asc\"` | Sort direction |\n\n**Returns `OffsetPaginationResult<Doc>`:**\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `items` | `Doc[]` | Documents on the current page |\n| `totalCount` | `number` | Total matching documents |\n| `totalPages` | `number` | `ceil(totalCount / limit)` |\n| `currentPage` | `number` | Current page (1-indexed) |\n| `hasNextPage` | `boolean` | Whether next page exists |\n| `hasPreviousPage` | `boolean` | Whether previous page exists |\n\n#### `count(ctx, namespace?)` → `number`\n\nReturns the total number of documents, optionally scoped by namespace.\n\n#### `getPage(ctx, args)` → `{ ids, totalCount }`\n\nLow-level API. Returns document IDs at the requested offset without hydrating full documents. Useful if you need to post-process IDs yourself.\n\n### Write Methods\n\n| Method | When to call |\n|--------|-------------|\n| `insert(ctx, doc)` | After `ctx.db.insert()` |\n| `delete(ctx, doc)` | Before `ctx.db.delete()` |\n| `replace(ctx, oldDoc, newDoc)` | After `ctx.db.patch()` or `ctx.db.replace()` |\n| `trigger()` | Returns a trigger for `convex-helpers` auto-sync |\n| `idempotentTrigger()` | Safe trigger variant for backfills |\n\n## Backfilling Existing Data\n\nIf you add this to a table that already has data, backfill with a migration:\n\n```ts\nimport { internalMutation } from \"./_generated/server\";\n\nexport const backfill = internalMutation({\n  handler: async (ctx) => {\n    const docs = await ctx.db.query(\"photos\").collect();\n    for (const doc of docs) {\n      // idempotentTrigger or insertIfDoesNotExist handles re-runs safely\n      await paginatedPhotos.insert(ctx, doc);\n    }\n  },\n});\n```\n\nFor large tables, use `convex-helpers` migrations with batching.\n\n## Important: Namespaces Are Isolated\n\nNamespaces in `@convex-dev/aggregate` create **separate B-trees**. You **cannot** query across all namespaces at once. This means if you need both \"show all items\" and \"show items filtered by category\", you need **two aggregate instances**:\n\n```ts\n// convex/convex.config.ts\nimport aggregate from \"@convex-dev/aggregate/convex.config.js\";\n\nconst app = defineApp();\napp.use(aggregate);                               // global (all items)\napp.use(aggregate, { name: \"aggregateByAlbum\" });  // per-album\n```\n\n```ts\n// Global paginator — for \"All\" view\nconst allPhotos = new OffsetPagination<DataModel, \"photos\">(\n  components.aggregate,\n  { sortKey: (doc) => doc._creationTime },\n);\n\n// Namespaced paginator — for filtered views\nconst photosByAlbum = new OffsetPagination<DataModel, \"photos\">(\n  components.aggregateByAlbum,\n  {\n    sortKey: (doc) => doc._creationTime,\n    namespace: (doc) => doc.albumId,\n  },\n);\n\n// Keep BOTH in sync on every write\nexport const add = mutation({\n  handler: async (ctx, args) => {\n    const id = await ctx.db.insert(\"photos\", args);\n    const doc = (await ctx.db.get(id))!;\n    await allPhotos.insert(ctx, doc);\n    await photosByAlbum.insert(ctx, doc);\n    return id;\n  },\n});\n\n// Query the right one based on filter\nexport const list = query({\n  handler: async (ctx, { page, limit, albumId }) => {\n    if (albumId) {\n      return photosByAlbum.paginate(ctx, \"photos\", { page, limit, namespace: albumId });\n    }\n    return allPhotos.paginate(ctx, \"photos\", { page, limit });\n  },\n});\n```\n\n## Multiple Paginators\n\nYou can create multiple paginators for different tables. Each needs its own aggregate component instance:\n\n```ts\n// convex/convex.config.ts\nimport aggregate from \"@convex-dev/aggregate/convex.config.js\";\n\nconst app = defineApp();\napp.use(aggregate);                           // for photos\napp.use(aggregate, { name: \"aggregateUsers\" }); // for users\n```\n\n```ts\nconst paginatedPhotos = new OffsetPagination<DataModel, \"photos\">(\n  components.aggregate,\n  { sortKey: (doc) => doc._creationTime },\n);\n\nconst paginatedUsers = new OffsetPagination<DataModel, \"users\">(\n  components.aggregateUsers,\n  { sortKey: (doc) => doc._creationTime },\n);\n```\n\n## How It Works\n\n1. `@convex-dev/aggregate` maintains a B-tree index over your table\n2. `count()` traverses the tree in **O(log n)** — no full scan\n3. `at(offset)` finds the document at any position in **O(log n)**\n4. `paginate()` calls `count()` once, then `at()` for each item on the page\n5. Total cost per page: **O(pageSize x log n)**\n\nFor a table with 1 million documents and a page size of 20, each page request does ~20 x 20 = ~400 node lookups — fast enough for real-time queries.\n\n## Comparison with Cursor-Based Pagination\n\n| Feature | Cursor-Based (built-in) | Offset-Based (this library) |\n|---------|------------------------|-------------------------------|\n| Jump to page N | No | Yes |\n| Total count | No | Yes |\n| \"Page X of Y\" UI | No | Yes |\n| Infinite scroll | Best choice | Possible but not ideal |\n| Consistent under concurrent writes | Yes | Best-effort |\n| Setup cost | None | Requires aggregate sync |\n| Performance | O(page size) | O(page size x log n) |\n\n## License\n\n[Apache-2.0](./LICENSE)\n","readmeFilename":"README.md","_rev":"1-ed93eee2444f451f36fd322b4be9dc4e"}