{"_id":"@emeryld/rrroutes-server-data","_rev":"3-80f6db6af205da804978f41ca4a179ad","name":"@emeryld/rrroutes-server-data","dist-tags":{"latest":"1.2.0"},"versions":{"1.0.1":{"name":"@emeryld/rrroutes-server-data","version":"1.0.1","_id":"@emeryld/rrroutes-server-data@1.0.1","maintainers":[{"name":"emeryld","email":"karambiri.emery@gmail.com"}],"homepage":"https://github.com/EmeryK-1/RRRoutes#readme","bugs":{"url":"https://github.com/EmeryK-1/RRRoutes/issues"},"dist":{"shasum":"320d4cef6ede5e442ace795c44c6b224a242721c","tarball":"https://registry.npmjs.org/@emeryld/rrroutes-server-data/-/rrroutes-server-data-1.0.1.tgz","fileCount":22,"integrity":"sha512-yhiSkwZNkxZ9CL0SWU9NhJ8JLuST16ENNWJKecW+K9yNDC3x+f7RhcVQQlz+B36P1+2BVN3PV8kIJrr0joniRg==","signatures":[{"sig":"MEYCIQD3PosJ01AoZRPpD+bvP9E0rgpqu2Vf3Ftqq3JpchhjvQIhALA2QlEq0O8BN7Wh+DOrZY0VDGsFmIAhi8C0zkfTjdMh","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":469152},"main":"dist/index.cjs","type":"module","_from":"file:emeryld-rrroutes-server-data-1.0.1.tgz","types":"dist/index.d.ts","module":"dist/index.mjs","exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.mjs","require":"./dist/index.cjs"},"./expose":{"types":"./dist/expose.d.ts","import":"./dist/expose.mjs","require":"./dist/expose.cjs"}},"private":false,"scripts":{"test":"NODE_OPTIONS=--experimental-vm-modules jest --config ../../jest.base.config.js --watchman=false --runInBand","build":"pnpm run clean && pnpm run build:js && pnpm run build:types","clean":"rimraf dist","build:js":"tsup --config tsup.config.ts","typecheck":"tsc -p tsconfig.json --noEmit","build:types":"tsc -p tsconfig.build.json"},"_npmUser":{"name":"emeryld","email":"karambiri.emery@gmail.com"},"_resolved":"/private/var/folders/5x/zf5c1y3x3fb758ncq40308m80000gn/T/358a6f7f1651c0ddf075150e6dd65057/emeryld-rrroutes-server-data-1.0.1.tgz","_integrity":"sha512-yhiSkwZNkxZ9CL0SWU9NhJ8JLuST16ENNWJKecW+K9yNDC3x+f7RhcVQQlz+B36P1+2BVN3PV8kIJrr0joniRg==","repository":{"url":"git+https://github.com/EmeryK-1/RRRoutes.git","type":"git"},"_npmVersion":"10.9.4","description":"ORM-agnostic query, mutation and keyset feed services for RRRoutes, plus opinionated resource exposure","directories":{},"_nodeVersion":"22.22.0","dependencies":{"@emeryld/rrroutes-contract":"^2.10.6"},"_hasShrinkwrap":false,"devDependencies":{"zod":"4.3.6","@jest/globals":"^30.4.1","@emeryld/rrroutes-server":"^2.10.2"},"peerDependencies":{"zod":"^4.0.0","@emeryld/rrroutes-server":"^2.10.2"},"peerDependenciesMeta":{"@emeryld/rrroutes-server":{"optional":true}},"_npmOperationalInternal":{"tmp":"tmp/rrroutes-server-data_1.0.1_1785497773711_0.17858936662591351","host":"s3://npm-registry-packages-npm-production"}},"1.0.4":{"name":"@emeryld/rrroutes-server-data","version":"1.0.4","_id":"@emeryld/rrroutes-server-data@1.0.4","maintainers":[{"name":"emeryld","email":"karambiri.emery@gmail.com"}],"homepage":"https://github.com/EmeryK-1/RRRoutes#readme","bugs":{"url":"https://github.com/EmeryK-1/RRRoutes/issues"},"dist":{"shasum":"963e12eab65392f4f0ba258ad4fa61b352e051c8","tarball":"https://registry.npmjs.org/@emeryld/rrroutes-server-data/-/rrroutes-server-data-1.0.4.tgz","fileCount":22,"integrity":"sha512-Y1w89zHsl7PHzdKruvLGmRPkrXwHMqiWRQ5t3iSo1cB7Pwu+ByMcPndOpTCRMVl3k8uZLlRMTC6UDBg/Acp7yg==","signatures":[{"sig":"MEUCIQDrTVAoae0DTGzt3UDJRwG+zvjWC3Suhn+ol8f8oCEcNQIgaX7jtEh9qmGpo8zSBXEEzTjJHZytsgWrHALKTppIeaw=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":470626},"main":"dist/index.cjs","type":"module","_from":"file:emeryld-rrroutes-server-data-1.0.4.tgz","types":"dist/index.d.ts","module":"dist/index.mjs","exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.mjs","require":"./dist/index.cjs"},"./expose":{"types":"./dist/expose/index.d.ts","import":"./dist/expose.mjs","require":"./dist/expose.cjs"}},"private":false,"scripts":{"test":"NODE_OPTIONS=--experimental-vm-modules jest --config ../../jest.base.config.js --watchman=false --runInBand","build":"pnpm run clean && pnpm run build:js && pnpm run build:types","clean":"rimraf dist","build:js":"tsup --config tsup.config.ts","typecheck":"tsc -p tsconfig.json --noEmit","build:types":"tsc -p tsconfig.build.json"},"_npmUser":{"name":"emeryld","email":"karambiri.emery@gmail.com"},"_resolved":"/private/var/folders/5x/zf5c1y3x3fb758ncq40308m80000gn/T/2e00a86ef5ef7527e93a70c7d2694e97/emeryld-rrroutes-server-data-1.0.4.tgz","_integrity":"sha512-Y1w89zHsl7PHzdKruvLGmRPkrXwHMqiWRQ5t3iSo1cB7Pwu+ByMcPndOpTCRMVl3k8uZLlRMTC6UDBg/Acp7yg==","repository":{"url":"git+https://github.com/EmeryK-1/RRRoutes.git","type":"git"},"_npmVersion":"10.9.4","description":"ORM-agnostic query, mutation and keyset feed services for RRRoutes, plus opinionated resource exposure","directories":{},"_nodeVersion":"22.22.0","dependencies":{"@emeryld/rrroutes-contract":"^2.10.6"},"_hasShrinkwrap":false,"devDependencies":{"zod":"4.3.6","@jest/globals":"^30.4.1","@emeryld/rrroutes-server":"^2.10.2"},"peerDependencies":{"zod":"^4.0.0","@emeryld/rrroutes-server":"^2.10.2"},"peerDependenciesMeta":{"@emeryld/rrroutes-server":{"optional":true}},"_npmOperationalInternal":{"tmp":"tmp/rrroutes-server-data_1.0.4_1786314792306_0.1935515787491251","host":"s3://npm-registry-packages-npm-production"}},"1.2.0":{"name":"@emeryld/rrroutes-server-data","description":"ORM-agnostic query, mutation and keyset feed services for RRRoutes, plus opinionated resource exposure","version":"1.2.0","private":false,"type":"module","main":"dist/index.cjs","module":"dist/index.mjs","types":"dist/index.d.ts","exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.mjs","require":"./dist/index.cjs"},"./expose":{"types":"./dist/expose/index.d.ts","import":"./dist/expose.mjs","require":"./dist/expose.cjs"}},"dependencies":{"@emeryld/rrroutes-contract":"^2.11.0"},"peerDependencies":{"zod":"^4.0.0","@emeryld/rrroutes-server":"^2.11.0"},"peerDependenciesMeta":{"@emeryld/rrroutes-server":{"optional":true}},"devDependencies":{"@jest/globals":"^30.4.1","zod":"4.3.6","@emeryld/rrroutes-server":"^2.11.0"},"repository":{"type":"git","url":"git+https://github.com/EmeryK-1/RRRoutes.git"},"scripts":{"clean":"rimraf dist","build":"pnpm run clean && pnpm run build:js && pnpm run build:types","build:js":"tsup --config tsup.config.ts","build:types":"tsc -p tsconfig.build.json","typecheck":"tsc -p tsconfig.json --noEmit","test":"NODE_OPTIONS=--experimental-vm-modules jest --config ../../jest.base.config.js --watchman=false --runInBand"},"_id":"@emeryld/rrroutes-server-data@1.2.0","bugs":{"url":"https://github.com/EmeryK-1/RRRoutes/issues"},"homepage":"https://github.com/EmeryK-1/RRRoutes#readme","_integrity":"sha512-b+PH5DR16A+qNQVefyL3aMn3oOxCT2BkbCVfwmrs74KUytkL94RFm8UbvQQNLSFHN51InMqSX3miG9g8IQ5siQ==","_resolved":"/private/var/folders/5x/zf5c1y3x3fb758ncq40308m80000gn/T/407c3105641d4fd86163234db96abe76/emeryld-rrroutes-server-data-1.2.0.tgz","_from":"file:emeryld-rrroutes-server-data-1.2.0.tgz","_nodeVersion":"22.22.0","_npmVersion":"10.9.4","dist":{"integrity":"sha512-b+PH5DR16A+qNQVefyL3aMn3oOxCT2BkbCVfwmrs74KUytkL94RFm8UbvQQNLSFHN51InMqSX3miG9g8IQ5siQ==","shasum":"6fe62a7b1aa81f3af40d32c5e962310265ce3a45","tarball":"https://registry.npmjs.org/@emeryld/rrroutes-server-data/-/rrroutes-server-data-1.2.0.tgz","fileCount":22,"unpackedSize":513262,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEUCIAQmLMXlWEQTIqN1+T2neEiU9eggNEeAga+s6n2A84ARAiEAinN4s1jJmVxM049DG17Zz8EEs+71EhXsQWFIfwOCxzU="}]},"_npmUser":{"name":"emeryld","email":"karambiri.emery@gmail.com"},"directories":{},"maintainers":[{"name":"emeryld","email":"karambiri.emery@gmail.com"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/rrroutes-server-data_1.2.0_1786381000905_0.4611264143246805"},"_hasShrinkwrap":false}},"time":{"created":"2026-07-31T11:36:13.508Z","modified":"2026-08-10T16:56:41.163Z","1.0.1":"2026-07-31T11:36:13.865Z","1.0.4":"2026-08-09T22:33:12.461Z","1.2.0":"2026-08-10T16:56:41.041Z"},"bugs":{"url":"https://github.com/EmeryK-1/RRRoutes/issues"},"homepage":"https://github.com/EmeryK-1/RRRoutes#readme","repository":{"type":"git","url":"git+https://github.com/EmeryK-1/RRRoutes.git"},"description":"ORM-agnostic query, mutation and keyset feed services for RRRoutes, plus opinionated resource exposure","maintainers":[{"name":"emeryld","email":"karambiri.emery@gmail.com"}],"readme":"# @emeryld/rrroutes-server-data\n\nOpinionated query, mutation, and keyset-feed services for RRRoutes resources,\nplus `exposeRRRoutesResource` — one declaration that mounts a service on an\nExpress router and wires its writes to socket broadcasts.\n\nThe package is **ORM-agnostic**. Every service is written against a small driver\ninterface you implement once per model, so the feed engine, cursor format, and\nservice pipeline are reusable whether you run Prisma, Drizzle, Kysely, or raw\nSQL.\n\n## Installation\n\n<!-- docs:installation:start -->\n\n```sh\npnpm add @emeryld/rrroutes-server-data\n```\n\n<!-- docs:installation:end -->\n\n## Prerequisites\n\n<!-- docs:prerequisites:start -->\n\n- `zod` ^4.0.0\n<!-- docs:prerequisites:end -->\n\n`@emeryld/rrroutes-contract` is a dependency. `@emeryld/rrroutes-server` is an\n**optional** peer needed only by the `./expose` entry point — the data services\nimport without it.\n\n## Entry points\n\n<!-- docs:entrypoints:start -->\n\n| Import path                            | Purpose                                                                                                                                           | Runtime | Status | Additional requirements  |\n| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | ------ | ------------------------ |\n| `@emeryld/rrroutes-server-data`        | ORM-agnostic query, mutation, and keyset-feed services written against a driver interface rather than an ORM.                                     | node    | stable | —                        |\n| `@emeryld/rrroutes-server-data/expose` | Mounts a built service onto an Express router and a socket connection in one declaration, deriving controllers from the service's own route keys. | node    | stable | @emeryld/rrroutes-server |\n\n<!-- docs:entrypoints:end -->\n\n## Compatibility\n\n<!-- docs:compatibility:start -->\n\nNode only, ESM + CJS. Feeds require a keyset sorter whose last column is unique;\na driver that cannot cheaply `count` still works and reports `total: 0`. The\nPrisma adapter deliberately lives in the consuming app rather than here.\n\n<!-- docs:compatibility:end -->\n\n## Quick start\n\n### 1. Bind the package to your context\n\nEvery service is curried on your application's `Ctx`, so it is stated once.\n\n```ts\nimport { createRRRoutesData } from '@emeryld/rrroutes-server-data'\nimport { BadRequestError, NotFoundError } from '@/errors/ApiError'\n\nexport const data = createRRRoutesData<Ctx>({\n  errors: {\n    badRequest: (message) => new BadRequestError(message),\n    notFound: (message) => new NotFoundError(message),\n  },\n  logger: (ctx) => ctx.logger,\n  profiler: (ctx) => ctx.profiler,\n  cursorViewer: (ctx) => ctx.bearer.userId,\n  debug: process.env.NODE_ENV !== 'production',\n})\n```\n\nOnly `errors` is required. `logger` and `profiler` fall back to no-ops, so\nobservability is opt-in and never changes behavior.\n\n### 2. Implement a driver\n\nOne driver per model. The generic parameters carry your ORM's own types\nthrough untouched, so a Prisma adapter stays exactly as type-safe as\nhand-written Prisma calls.\n\n```ts\nimport type { RRRoutesDataDriver } from '@emeryld/rrroutes-server-data'\nimport type { Prisma } from '@prisma'\n\nexport const postDriver: RRRoutesDataDriver<\n  Ctx,\n  Prisma.PostWhereInput,\n  Prisma.PostSelect,\n  Prisma.PostOrderByWithRelationInput[],\n  PostRow,\n  Prisma.PostCreateInput,\n  Prisma.PostUpdateInput & { id: string },\n  { id: string },\n  PostRow\n> = {\n  findMany: (ctx, { where, select, orderBy, take, skip }) =>\n    ctx.db.post.findMany({ where, select, orderBy, take, skip }),\n  findFirst: (ctx, { where, select }) =>\n    ctx.db.post.findFirst({ where, select }),\n  count: (ctx, { where }) => ctx.db.post.count({ where }),\n  aggregate: (ctx, args) => ctx.db.post.aggregate(args as never),\n  deleteMany: async (ctx, { where }) => ({\n    deleted: (await ctx.db.post.deleteMany({ where })).count,\n  }),\n  create: (ctx, input) => ctx.db.post.create({ data: input }),\n  update: (ctx, { id, ...data }) => ctx.db.post.update({ where: { id }, data }),\n  delete: (ctx, { id }) => ctx.db.post.delete({ where: { id } }),\n\n  // The one piece of query-language syntax the feed engine needs.\n  and: (clauses) => ({ AND: clauses }),\n}\n```\n\n`count`, `aggregate`, and `deleteMany` are optional. A feed over a source that\ncannot cheaply count still works — it reports `total: 0`.\n\n### 3. Build a feed\n\n```ts\nimport { defineKeysetSorter } from '@emeryld/rrroutes-server-data'\n\nconst operators = {\n  and: (clauses) => ({ AND: clauses }),\n  or: (clauses) => ({ OR: clauses }),\n  compare: (field, op, value) => ({\n    [field]: op === 'eq' ? value : { [op]: value },\n  }),\n}\n\nexport const postFeed = data\n  .createFeedService('posts', postDriver)\n  .where<PostFeedQuery>((ctx, query) => ({ authorId: query.authorId }))\n  .extract<PostRow>(() => ({ id: true, title: true, createdAt: true }))\n  .sortBy({\n    recent: defineKeysetSorter({\n      // Most significant first. The last column MUST be unique.\n      columns: [\n        {\n          field: 'createdAt',\n          get: (row) => row.createdAt,\n          deserialize: (v) => new Date(v),\n        },\n        { field: 'id', get: (row) => row.id },\n      ],\n      operators,\n    }),\n  })\n  .specs({\n    defaultSortBy: 'recent',\n    defaultSortDir: 'desc',\n    maxLimit: 50,\n    anchorWhere: (id) => ({ id }),\n  })\n```\n\n### 4. Build a service and expose it\n\n```ts\nconst posts = data\n  .createRRRoutesService({\n    name: 'post',\n    query: postQueryService,\n    feeds: { recent: postFeed },\n    repository: postDriver,\n    idFromRecord: (record) => record.id,\n  })\n  .build({\n    routes,\n    keys: {\n      create: 'POST /posts',\n      get: 'GET /posts/:id',\n      update: 'PATCH /posts/:id',\n      delete: 'DELETE /posts/:id',\n      feeds: { recent: 'GET /posts/feed' },\n    },\n    id: {\n      fromParams: (args) => args.params.id,\n      fromFeedItem: (item) => item.id,\n    },\n    process: {\n      create: {\n        preProcess: ({ ctx, body }) => ({ ...body, authorId: ctx.userId }),\n      },\n      update: { preProcess: ({ id, body }) => ({ id, ...body }) },\n      delete: { preProcess: ({ id }) => ({ id }) },\n      query: {\n        preProcess: ({ ctx, queryService, id }) => queryService.byId(ctx, id),\n      },\n      feeds: {\n        recent: {\n          preProcess: ({ ctx, queryService, itemId }) =>\n            queryService.byId(ctx, itemId),\n        },\n      },\n    },\n  })\n\nexposeRRRoutesResource({\n  service: posts,\n  server, // from createRRRoute(router, ...)\n  realtime: {\n    connection, // from createSocketConnections(io, ...)\n    on: {\n      create: ({ id, out }) => ({\n        eventName: 'post:created',\n        payload: out,\n        toRooms: [`post:${id}`, 'posts:all'],\n      }),\n    },\n  },\n})\n```\n\n## Detailed usage\n\n### The write pipeline\n\nEvery write runs the same five stages:\n\n```\nguards -> preProcess -> repository -> canonical read -> events\n```\n\nThe **canonical read** is the point of the design. After a create or update,\nthe service re-reads the resource through the GET route's own `query.preProcess`\nrather than shaping the repository record directly. That is what makes a create\nresponse byte-identical to a later fetch of the same resource, so a client can\nseed its cache from a mutation result without drift.\n\n`delete` inverts the order: it resolves the public payload **before** removing\nthe row (nothing to read afterwards), then returns it with `delete: true`.\n\nHooks by stage:\n\n| Hook         | Runs                     | Typical use                                  |\n| ------------ | ------------------------ | -------------------------------------------- |\n| `guards`     | before anything else     | authorization, precondition checks           |\n| `preProcess` | after guards             | map the request into repository input        |\n| `events`     | after the canonical read | outbound notifications, audit, cache busting |\n\nGuards and events run **sequentially** in array order, awaiting each. A throwing\nguard aborts the operation.\n\n### `id`, and why `idFromRecord` sits on the seed\n\nIdentity extraction is split across two places on purpose:\n\n- `idFromRecord` is on the **seed**, next to the repository.\n- `fromParams` / `fromFeedItem` / `fromPut` are on **`build`**, next to the routes.\n\nThat split is not cosmetic. `idFromRecord` is the sole inference site for `TId`,\nand on the seed the record type is already fixed. Declared alongside the route\nkeys it would resolve to `unknown`, because every other position depends on\nroute types still being inferred in the same pass.\n\n`update` uses the **post-write** identity (`idFromRecord(record)`) for the\ncanonical read and every event, so a repository that relocates or re-keys a\nrecord still reports the right id.\n\n### Feed pagination\n\nThree mutually exclusive modes per request:\n\n| Mode     | Request                            | Notes                           |\n| -------- | ---------------------------------- | ------------------------------- |\n| Forward  | `cursor` + `direction: 'next'`     | The default                     |\n| Backward | `cursor` + `direction: 'previous'` | Rows are returned in feed order |\n| Anchored | `anchorItemId`                     | Centers a page on one row       |\n\nAnchored pages balance the budget around the anchor and spend any slack from a\nshort side on the other, so an anchor at the head or tail still returns a full\npage.\n\nCombining `anchorItemId` with a cursor, or passing `direction` without a\ncursor, is rejected as a bad request.\n\n### Cursors\n\nCursors are base64url `{ v, feed, context, sortBy, dir, direction, value }`.\nThe `context` is a hash of the feed name, `cursorVersion`, the non-pagination\nquery, the viewer (`cursorViewer`), and any `cursorContext` you add.\n\nA cursor is therefore rejected when replayed against a different query,\na different viewer, a different ordering, or a different feed. This is a\ncorrectness feature, not an inconvenience: a cursor is a position in a specific\nresult set, and reusing it elsewhere silently skips or repeats rows.\n\nConsequences worth knowing:\n\n- **Renaming a feed invalidates every outstanding cursor.** The name is in the\n  payload.\n- **Changing what `cursorViewer` returns invalidates that viewer's cursors.**\n- **Bump `cursorVersion`** deliberately when you change what `where` produces\n  for the same query.\n\nThe v2 payload shape is a wire format. Do not reorder or rename its fields.\n\n### Sorters\n\n`defineKeysetSorter` generates the lexicographic keyset filter for you:\n\n```\n(c1 > C1) OR (c1 = C1 AND c2 > C2) OR (c1 = C1 AND c2 = C2 AND c3 > C3)\n```\n\nWriting that by hand is where feed bugs live — one wrong clause silently drops\nor duplicates rows across a page boundary.\n\n**The final column must be unique across the feed.** Without a unique\ntiebreaker, rows sharing every ordering value can be skipped or repeated.\n\n`defineOffsetSorter` covers orderings that cannot express a keyset boundary\n(relevance ranking, seeded shuffles). Feeds using it reject anchored and\nprevious-page requests, because neither can be emulated with an offset.\n\n### Access gates\n\n`createQueryService` wraps a bag of read functions so an access gate runs before\neach call:\n\n```ts\nconst postQueryService = data.createQueryService(\n  'post',\n  {\n    byId: (ctx, id) => ctx.db.post.findUniqueOrThrow({ where: { id } }),\n  },\n  async ({ ctx, id }) => ctx.can('read', 'post', id),\n)\n```\n\nThe gate's id is read from the call's second argument — either the value itself\nor its `.id`. Calls with no discoverable id pass through ungated; those are\nlist/aggregate reads whose own `where` clause is expected to scope results.\n\n`service.gate({ ctx, id })` runs it explicitly from a guard or a custom read.\n\n### `exposeRRRoutesResource`\n\nGenerates a controller for every route key the service declares, then registers\nthem. It handles three things you would otherwise get wrong by hand:\n\n1. **Response envelope.** Services return the unwrapped payload; RRRoutes leaves\n   declare `{ out, meta }`. Generated controllers re-wrap.\n2. **Registration order.** Express matches in registration order, so\n   `GET /posts/:id` registered before `GET /posts/feed` swallows the feed route.\n   Controllers are sorted by specificity — fewer path parameters first —\n   regardless of the order your keys are declared in.\n3. **Broadcast isolation.** A socket emit that throws is routed to `onError`,\n   never to the HTTP response. The write already succeeded and the client is\n   owed its answer.\n\nFeed routes map onto the envelope as `{ out: items, meta: { …pagination } }`,\nwhich is where `createCursorPagination` on the client looks for `nextCursor`\nand `previousCursor`. **Feed routes must therefore declare an\n`outputMetaSchema`** — the contract's default `meta` is an optional string and\nwill reject an object.\n\n```ts\nresource('/posts').sub(\n  resource('/feed')\n    .get({\n      outputSchema: z.array(PostOut),\n      outputMetaSchema: z.object({\n        total: z.number(),\n        hasNext: z.boolean(),\n        hasPrevious: z.boolean(),\n        nextCursor: z.string().optional(),\n        previousCursor: z.string().optional(),\n        anchorItemId: z.string().optional(),\n      }),\n      feed: true,\n    })\n    .done(),\n)\n```\n\nOverride with `feedResponse` if your clients expect a different shape.\n\n### Rooms mirror the client\n\n`realtime.on` returns the rooms a write broadcasts to. Those names must match\nwhat the client's `toRooms` mapper subscribes to in\n`@emeryld/rrroutes-client`'s socketed-route helper. Keeping the two readable\nside by side is the point — a resource's server rooms and client rooms are the\nsame vocabulary.\n\n### `put` (upsert)\n\nDeclaring a `put` key adds a `service.put` that dispatches by payload:\n\n| Payload                | Dispatches to |\n| ---------------------- | ------------- |\n| `{ delete: true, id }` | `delete`      |\n| `{ id, ... }`          | `update`      |\n| no id                  | `create`      |\n\n`delete: true` without an id is a bad request. Results carry a `delete` boolean\nso a client can branch without a second lookup.\n\n## Testing\n\n`createMemoryDriver` is exported for testing your services without a database:\n\n```ts\nimport { createMemoryDriver } from '@emeryld/rrroutes-server-data'\n\nconst driver = createMemoryDriver<Ctx, PostRow>({ rows: seed })\n```\n\nIts where-language is `{AND}`, `{OR}`, `{field, op, value}`, or `{}` for\nmatch-all. It **throws** on any other clause rather than matching everything —\na silent widen is how a mis-shaped `anchorWhere` hides.\n\n## Edge cases and notes\n\n- The default `anchorWhere` emits `{ id: anchorItemId }`. Supply your own for\n  composite keys, a non-`id` primary key, or any driver whose where-language\n  does not accept that shape.\n- `fromFeedItem` is required only when the resource declares feeds. Omitting it\n  on a feed-bearing resource throws a clear error at call time.\n- Feeds request `limit + 1` rows to detect further pages, so a driver's `take`\n  must be honored exactly.\n- `hasPrevious`/`hasNext` on the side you did **not** page toward is inferred\n  from the presence of a cursor, not from a probe query.\n- `includeCallerStackInError` stitches the issuing frame onto driver errors, so\n  an async failure names the feed that caused it.\n\n## Full-stack guide\n\nThis package's chapters of the [full-stack guide](../../docs/fullstack-guide.md) —\nthe data layer behind the API set up in\n[`@emeryld/rrroutes-server`](../server/README.md#full-stack-guide).\n\n<!-- fullstack:step id=\"server-driver\" group=\"server\" number=\"5.3\" title=\"Write the driver\" runtimes=\"node\" -->\n\n`server-data` is written against a small driver interface rather than an ORM, so\nthe feed engine, the cursor format and the write pipeline are reusable whether\nthe storage underneath is Prisma, Drizzle, Kysely or SQL. `and` is the only\npiece of query syntax the engine needs to know: it is how a caller's filter is\nintersected with a keyset boundary.\n\n```ts title=\"apps/api/src/posts/posts.driver.ts\"\nimport type {\n  RRRoutesReadDriver,\n  RepositoryConfig,\n} from '@emeryld/rrroutes-server-data'\nimport type { Prisma, Post } from '@prisma/client'\n\nimport { prisma } from '../db'\nimport type { ApiCtx } from '../server'\n\nexport const postReads: RRRoutesReadDriver<\n  ApiCtx,\n  Prisma.PostWhereInput,\n  Prisma.PostSelect,\n  Prisma.PostOrderByWithRelationInput[],\n  Post\n> = {\n  findMany: (_ctx, { where, select, orderBy, take, skip }) =>\n    prisma.post.findMany({ where, select, orderBy, take, skip }),\n  findFirst: (_ctx, { where, select }) =>\n    prisma.post.findFirst({ where, select }),\n  count: (_ctx, { where }) => prisma.post.count({ where }),\n  and: (clauses) => ({ AND: clauses }),\n}\n\nexport const postWrites: RepositoryConfig<\n  ApiCtx,\n  Prisma.PostCreateInput,\n  { id: string; data: Prisma.PostUpdateInput },\n  { id: string },\n  Post\n> = {\n  create: (_ctx, input) => prisma.post.create({ data: input }),\n  update: (_ctx, { id, data }) => prisma.post.update({ where: { id }, data }),\n  delete: (_ctx, { id }) => prisma.post.delete({ where: { id } }),\n}\n```\n\n> **Note — Reads and writes are separate on purpose**\n> A resource can be read-only, and the mutation service is generic over its own\n> input types rather than over the read model's.\n\n> **Reference** — `@emeryld/rrroutes-server-data`\n\n<!-- fullstack:step:end -->\n\n<!-- fullstack:step id=\"server-service\" group=\"server\" number=\"5.4\" title=\"Build the service\" runtimes=\"node\" -->\n\n`createRRRoutesData` fixes the context type and the error constructors once. On\ntop of it, a feed service is declared as a chain — filter, select, order, page —\nand the resource service pairs that with the write pipeline: guards,\n`preProcess`, and events, per operation. `build` is where the route keys arrive\nand everything becomes typed against the contract.\n\n```ts title=\"apps/api/src/posts/posts.service.ts\"\nimport {\n  createRRRoutesData,\n  defineKeysetSorter,\n} from '@emeryld/rrroutes-server-data'\n\nimport { registry } from '@app/shared/contract/posts.routes'\nimport type { ApiCtx } from '../server'\nimport { postReads, postWrites } from './posts.driver'\n\nexport const data = createRRRoutesData<ApiCtx>({\n  errors: {\n    badRequest: (message) => new BadRequestError(message),\n    notFound: (message) => new NotFoundError(message),\n  },\n  logger,\n  profiler,\n})\n\n/** Newest first, tie-broken by id so page boundaries cannot skip a row. */\nconst recentSorter = defineKeysetSorter({\n  columns: [\n    { field: 'createdAt', get: (row) => row.createdAt },\n    { field: 'id', get: (row) => row.id },\n  ],\n  operators: {\n    and: (clauses) => ({ AND: clauses }),\n    or: (clauses) => ({ OR: clauses }),\n    compare: (field, op, value) => ({ [field]: { [op]: value } }),\n  },\n})\n\nconst recentFeed = data\n  .createFeedService('posts.recent', postReads)\n  .where((ctx, query) => ({\n    deletedAt: null,\n    ...(query.tag ? { tags: { has: query.tag } } : {}),\n    OR: [{ visibility: 'public' }, { authorId: ctx.userId }],\n  }))\n  .extract(() => ({\n    id: true,\n    authorId: true,\n    title: true,\n    body: true,\n    createdAt: true,\n  }))\n  .sortBy({ recent: recentSorter })\n  .specs({ maxLimit: 50, defaultSortBy: 'recent', defaultSortDir: 'desc' })\n\nexport const postsService = data\n  .createRRRoutesService({\n    name: 'post',\n    query: {\n      byId: async (ctx: ApiCtx, id: string) => {\n        const post = await postReads.findFirst(ctx, { where: { id } })\n        if (!post) throw new NotFoundError(`No post ${id}`)\n        return post\n      },\n    },\n    feeds: { recent: recentFeed },\n    repository: postWrites,\n    idFromRecord: (post) => post.id,\n  })\n  .build({\n    routes: registry.byKey,\n    keys: {\n      create: 'POST /v1/posts',\n      get: 'GET /v1/posts/:postId',\n      update: 'PATCH /v1/posts/:postId',\n      delete: 'DELETE /v1/posts/:postId',\n      feeds: { recent: 'GET /v1/posts/feed' },\n    },\n    accessGate: async ({ ctx, id, operation }) => {\n      if (operation === 'get') return true\n      return (await ownerOf(id)) === ctx.userId\n    },\n    id: {\n      fromParams: ({ params }) => params.postId,\n      fromFeedItem: (post) => post.id,\n    },\n    process: {\n      create: {\n        guards: [({ ctx }) => assertNotSuspended(ctx.userId)],\n        preProcess: ({ ctx, body }) => ({ ...body, authorId: ctx.userId }),\n        events: [({ out }) => search.index(out)],\n      },\n      update: {\n        preProcess: ({ id, body }) => ({ id, data: body }),\n      },\n      delete: {\n        preProcess: ({ id }) => ({ id }),\n      },\n      feeds: {\n        recent: {\n          preProcess: ({ ctx, queryService, itemId }) =>\n            queryService.byId(ctx, itemId),\n          // One unreadable row drops out of the page instead of failing it.\n          onItemError: ({ feed, error }) =>\n            logger.warn('feed item dropped', { feed, error }),\n        },\n      },\n    },\n  })\n```\n\n> **Note — Why the last keyset column must be unique**\n> Rows sharing every ordering value can be skipped or repeated at a page\n> boundary. The final column — a primary key, normally — is what makes the\n> cursor total.\n\n> **Note — A partial page beats an empty screen**\n> By default a feed page is all-or-nothing: any item failing to hydrate fails\n> the request. For feeds that mix in rows the viewer may have partly lost access\n> to, `onItemError` drops the item instead. `total` and the cursors are\n> deliberately left untouched, because they describe the underlying query the\n> next page must be fetched against.\n\n> **Note — Ordering that has no keyset**\n> Relevance ranking and seeded shuffles cannot express a keyset boundary.\n> `defineOffsetSorter` compiles those to offset pagination, and the cursor\n> format absorbs the difference.\n\n> **Reference** — `@emeryld/rrroutes-server-data`\n\n<!-- fullstack:step:end -->\n\n<!-- fullstack:step id=\"server-expose\" group=\"server\" number=\"5.5\" title=\"Mount it, and broadcast\" runtimes=\"node\" -->\n\nThe last connection in the loop. Controllers are derived from the service's own\nroute keys — so adding a feed or renaming a key cannot leave a stale controller\nbehind — and the realtime hooks turn each write into the event the clients in\ngroups 3 and 4 are already reducing into their caches.\n\n```ts title=\"apps/api/src/posts/posts.expose.ts\"\nimport { exposeRRRoutesResource } from '@emeryld/rrroutes-server-data/expose'\n\nimport { server } from '../server'\nimport { connection } from '../sockets'\nimport { postsService } from './posts.service'\n\nexposeRRRoutesResource({\n  service: postsService,\n  server,\n  realtime: {\n    connection,\n    on: {\n      create: ({ out }) => ({\n        eventName: 'post:created',\n        payload: out,\n        toRooms: ['posts:all'],\n      }),\n      update: ({ id, out }) => ({\n        eventName: 'post:updated',\n        payload: out,\n        toRooms: [`post:${id}`, 'posts:all'],\n      }),\n      delete: ({ id }) => ({\n        eventName: 'post:deleted',\n        payload: { id, socketDelete: true },\n        toRooms: [`post:${id}`, 'posts:all'],\n      }),\n    },\n    // A failed broadcast must never fail the HTTP response that caused it.\n    onError: ({ operation, error }) =>\n      logger.error('broadcast failed', { operation, error }),\n  },\n})\n```\n\n```ts title=\"apps/api/src/activities/activities.expose.ts\"\nimport { exposeRRRoutesResource } from '@emeryld/rrroutes-server-data/expose'\n\n/**\n * Deriving controllers from route keys mounts *every* declared key. A resource\n * that declares full CRUD but is only meant to be readable names the closed\n * roles instead of quietly omitting a controller.\n */\nexposeRRRoutesResource({\n  service: activitiesService,\n  server,\n  disable: ['create', 'update', 'delete'],\n})\n```\n\n> **Note — Rooms match, by construction**\n> The room strings here are the same ones `toRooms` derives on the client in\n> step 2.3. That correspondence is the one thing in this guide that no type\n> checks — which is why both sides derive them from the record's id rather than\n> writing them at call sites.\n\n> **Note — Route ordering is handled**\n> Express matches in registration order, so `GET /v1/posts/:postId` registered\n> first would swallow `GET /v1/posts/feed`. Static paths are sorted ahead of\n> parameterised siblings regardless of the order the keys were declared in.\n\n> **Note — Disabled is not absent**\n> A disabled role stays in the contract and appears in the inspector marked as\n> such, but mounts no handler — requests fall through to a 404. A role that\n> matches nothing throws, because the failure this guards against is a write\n> meant to be closed being open.\n\n> **Reference** — `@emeryld/rrroutes-server-data/expose`\n\n<!-- fullstack:step:end -->\n\n## Scripts\n\n```bash\npnpm --filter @emeryld/rrroutes-server-data test\npnpm --filter @emeryld/rrroutes-server-data typecheck\npnpm --filter @emeryld/rrroutes-server-data build\n```\n","readmeFilename":"README.md"}