@fitzzero/quickdraw-core 5.0.0-rc.3 → 5.0.0-rc.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/README.md +406 -80
  2. package/dist/{admin-BiVNyWdI.d.ts → admin-ByHvKBth.d.ts} +3 -172
  3. package/dist/adminSchemas-C1U6_voe.d.ts +229 -0
  4. package/dist/{auth-DlFtykbn.d.ts → auth-Cc_XKBKj.d.ts} +2 -2
  5. package/dist/{caller-D4cgj-Xl.d.ts → caller-B-7Ib98g.d.ts} +137 -11
  6. package/dist/{chunk-3ONXZP2X.js → chunk-3I7R3DZW.js} +1 -1
  7. package/dist/{chunk-3ONXZP2X.js.map → chunk-3I7R3DZW.js.map} +1 -1
  8. package/dist/chunk-4PK6O7VL.js +1292 -0
  9. package/dist/chunk-4PK6O7VL.js.map +1 -0
  10. package/dist/{chunk-CLPB6IGC.js → chunk-6G663RIR.js} +2 -2
  11. package/dist/{chunk-TNTZBUQX.js → chunk-7EWHLDGN.js} +235 -61
  12. package/dist/chunk-7EWHLDGN.js.map +1 -0
  13. package/dist/{chunk-ISNQKSHQ.js → chunk-7MPXURIO.js} +492 -1238
  14. package/dist/chunk-7MPXURIO.js.map +1 -0
  15. package/dist/{chunk-CWPBMY7P.js → chunk-DMUGWMNJ.js} +66 -10
  16. package/dist/chunk-DMUGWMNJ.js.map +1 -0
  17. package/dist/chunk-IJTZQX6Z.js +799 -0
  18. package/dist/chunk-IJTZQX6Z.js.map +1 -0
  19. package/dist/{chunk-JK5D3OXC.js → chunk-KR7K4QEY.js} +3 -2
  20. package/dist/chunk-KR7K4QEY.js.map +1 -0
  21. package/dist/chunk-OZ7DJUJY.js +805 -0
  22. package/dist/chunk-OZ7DJUJY.js.map +1 -0
  23. package/dist/{chunk-HVJ4KIYX.js → chunk-QMCIKLWC.js} +1 -14
  24. package/dist/chunk-QMCIKLWC.js.map +1 -0
  25. package/dist/{chunk-CNECM3VC.js → chunk-RDCIZR4I.js} +6 -6
  26. package/dist/{chunk-K35VD4CQ.js → chunk-TDK6S6V7.js} +4 -2
  27. package/dist/chunk-TDK6S6V7.js.map +1 -0
  28. package/dist/{chunk-5CXPEEWF.js → chunk-Y5YAG75H.js} +15 -2
  29. package/dist/chunk-Y5YAG75H.js.map +1 -0
  30. package/dist/cli/quickdraw-docs.js +349 -57
  31. package/dist/cli/quickdraw-docs.js.map +1 -1
  32. package/dist/client/index.d.ts +230 -24
  33. package/dist/client/index.js +209 -51
  34. package/dist/client/index.js.map +1 -1
  35. package/dist/{clientTypes-P_VIjnyz.d.ts → clientTypes-CKqjWKdP.d.ts} +71 -252
  36. package/dist/connection-GcBHwKgF.d.ts +271 -0
  37. package/dist/{createServer-BwhO8YlA.d.ts → createServer-6ZLgE-Vs.d.ts} +15 -7
  38. package/dist/{createTestApp-CBESgORq.d.ts → createTestApp-BTFzRxV8.d.ts} +42 -15
  39. package/dist/{envelope-CKQEC7e-.d.ts → envelope-B6TUGKsk.d.ts} +27 -9
  40. package/dist/{http-B5hNMuVY.d.ts → http-GTJIh3Hm.d.ts} +12 -8
  41. package/dist/index.d.ts +6 -6
  42. package/dist/index.js +59 -11
  43. package/dist/index.js.map +1 -1
  44. package/dist/{infer-Dlplloxi.d.ts → infer-Zs7dAWlI.d.ts} +65 -21
  45. package/dist/{metrics-BBUPqcsT.d.ts → metrics-C77VTXEa.d.ts} +2 -2
  46. package/dist/prisma/index.d.ts +4 -4
  47. package/dist/prisma/index.js +2 -4
  48. package/dist/prisma/index.js.map +1 -1
  49. package/dist/server/auth/index.d.ts +52 -14
  50. package/dist/server/auth/index.js +30 -0
  51. package/dist/server/auth/index.js.map +1 -1
  52. package/dist/server/index.d.ts +126 -31
  53. package/dist/server/index.js +185 -54
  54. package/dist/server/index.js.map +1 -1
  55. package/dist/server/mcp/index.d.ts +7 -7
  56. package/dist/server/mcp/index.js +1 -1
  57. package/dist/server/otel.d.ts +3 -3
  58. package/dist/{sharing-RrW03F9j.d.ts → sharing-F3blmuVL.d.ts} +3 -3
  59. package/dist/testing/client.d.ts +25 -164
  60. package/dist/testing/client.js +49 -578
  61. package/dist/testing/client.js.map +1 -1
  62. package/dist/testing/index.d.ts +11 -11
  63. package/dist/testing/index.js +36 -12
  64. package/dist/testing/index.js.map +1 -1
  65. package/dist/testing/mock.d.ts +218 -0
  66. package/dist/testing/mock.js +16 -0
  67. package/dist/testing/mock.js.map +1 -0
  68. package/dist/testing/prisma.d.ts +22 -2
  69. package/dist/testing/prisma.js +10 -0
  70. package/dist/testing/prisma.js.map +1 -1
  71. package/dist/{types-B3N6xKkl.d.ts → types-BBU3rSbi.d.ts} +1 -1
  72. package/dist/utils/index.d.ts +1 -1
  73. package/dist/utils/index.js +2 -2
  74. package/package.json +5 -1
  75. package/dist/chunk-5CXPEEWF.js.map +0 -1
  76. package/dist/chunk-CWPBMY7P.js.map +0 -1
  77. package/dist/chunk-DEGCLMTU.js +0 -380
  78. package/dist/chunk-DEGCLMTU.js.map +0 -1
  79. package/dist/chunk-GBM7QP5Z.js +0 -400
  80. package/dist/chunk-GBM7QP5Z.js.map +0 -1
  81. package/dist/chunk-HVJ4KIYX.js.map +0 -1
  82. package/dist/chunk-ISNQKSHQ.js.map +0 -1
  83. package/dist/chunk-JK5D3OXC.js.map +0 -1
  84. package/dist/chunk-K35VD4CQ.js.map +0 -1
  85. package/dist/chunk-TNTZBUQX.js.map +0 -1
  86. package/dist/searchSchemas-CBgKRa1b.d.ts +0 -43
  87. /package/dist/{chunk-CLPB6IGC.js.map → chunk-6G663RIR.js.map} +0 -0
  88. /package/dist/{chunk-CNECM3VC.js.map → chunk-RDCIZR4I.js.map} +0 -0
package/README.md CHANGED
@@ -25,7 +25,7 @@ single hand-written event.
25
25
  - **Testing**: a real test server, access matrices, performance budgets that
26
26
  count statements and bytes, strict development warnings.
27
27
 
28
- Design record: [`docs/rfcs/0003-v5.md`](../../docs/rfcs/0003-v5.md).
28
+ Design record: [`docs/rfcs/0003-v5.md`](https://github.com/fitzzero/quickdraw/blob/dev/docs/rfcs/0003-v5.md).
29
29
 
30
30
  ## Install
31
31
 
@@ -710,10 +710,12 @@ export const taskService = qd.defineService(task, {
710
710
  (`adminBypass: false` turns that off). A grant below `Admin` counts only
711
711
  where the form names `service`: a `Read` grant does not read every row.
712
712
  - Grants come from `principal.serviceAccess`, as `authenticate` returns it or
713
- `createServer({ auth: { loadServiceAccess } })` loads it. With
713
+ `createServer({ auth: { loadServiceAccess } })` loads it for a principal
714
+ that carries none: at a socket's handshake, for each HTTP call, and for
715
+ an in-process caller (`qd.caller`) at its first call. With
714
716
  `auth.serviceAccessSource: { model: "user", column: "serviceAccess" }`, a
715
717
  tracked write to that column refreshes the user's open sockets
716
- (`qd:access`).
718
+ (`qd:access`), and in-process callers load the grants again.
717
719
  - Policies: `owner(field)`, `jsonAcl(field, { owner? })`,
718
720
  `members({ model, entry, user, level, levels? })`, `inherit({ from, via })`,
719
721
  `anyOf(...)`, `resolver({ levelsFor, where? })` and `everyone(level)`
@@ -774,6 +776,10 @@ tracked client as `db`; the server finds the rest on it.
774
776
  not wait for (a push sent after a message, pruning what it reports dead)
775
777
  runs in `qd.run(fn, { detached: true })`, a unit of its own that flushes
776
778
  when `fn` settles (catch what the promise rejects with: nothing awaits it).
779
+ Before any server exists (a seed at boot, before `createServer`),
780
+ `qd.run(fn)` still runs `fn` in a unit of its own, and its writes reach no
781
+ one: no socket can be subscribed yet (behind a cluster, write after
782
+ `createServer` when other nodes' subscribers must hear of it).
777
783
 
778
784
  <!-- example: apps/api/src/jobs/overdue.ts#run -->
779
785
 
@@ -908,7 +914,7 @@ export const taskService = qd.defineService(task, {
908
914
  Valkey's clock in microseconds, so revisions from all nodes are one order;
909
915
  its key needs persistence or replication. A node whose Valkey connection
910
916
  comes back has its clients reconnect to catch up.
911
- [docs/deploying.md](../../docs/deploying.md) has the wiring, what holds across
917
+ [docs/deploying.md](https://github.com/fitzzero/quickdraw/blob/dev/docs/deploying.md) has the wiring, what holds across
912
918
  nodes, what it costs, and what happens when a node or Valkey stops
913
919
  answering.
914
920
 
@@ -1001,7 +1007,9 @@ export const taskService = qd.defineService(task, {
1001
1007
  scope after a change tracked writes cannot describe.
1002
1008
  - `qd:watch { s, topic }` joins a change topic: `{collection}:{scope}`,
1003
1009
  authorized like a subscribe to that scope, or `service`, which changes
1004
- whenever any row of the service does. The service topic is closed
1010
+ whenever any row of the service does, or a row of a model it lists in
1011
+ `writes` (a game's high scores, which no service owns). A query declares
1012
+ `watch: "service"` to be invalidated by it. The service topic is closed
1005
1013
  (`FORBIDDEN`) unless the service declares `watchAccess` (`"public"`,
1006
1014
  `"authenticated"` or `{ service: level }`). A watcher that loses access
1007
1015
  leaves the topic after one last `qd:changed`.
@@ -1018,7 +1026,9 @@ over three transports (design: sections 3, 8 and 10):
1018
1026
 
1019
1027
  - **Socket.IO** (protocol 5): a client connects with
1020
1028
  `auth: { token, qd: { protocol: 5, client } }`, receives `qd:hello` with the
1021
- server's limits and who it acts for, and calls through `qd:call` and
1029
+ server's limits, who it acts for and the server's id (`serverId`, new
1030
+ each time a server starts: a reconnect that brings another one reached a
1031
+ restarted server, or another node), and calls through `qd:call` and
1022
1032
  `qd:cancel`. Every socket gets the same few listeners however many methods
1023
1033
  the services have. The JSON-only parser is the default; `binary: true`
1024
1034
  restores the stock one. The socket rate limiter is on by default (600
@@ -1040,7 +1050,13 @@ over three transports (design: sections 3, 8 and 10):
1040
1050
  `./server/express`), which refuses with the `RATE_LIMITED` reply.
1041
1051
  - **In process**: `server.dispatcher.caller(principal)` or
1042
1052
  `qd.caller(principal)`: `await qd.caller(user).taskService.rename(input)`,
1043
- typed by the `contracts` of `initQuickdraw`'s types.
1053
+ typed by the `contracts` of `initQuickdraw`'s types. A principal that
1054
+ carries no `serviceAccess` gets the grants `auth.loadServiceAccess`
1055
+ loads, as a socket's handshake and an HTTP call do: at the caller's first
1056
+ call, and again at its next call after the server applied new grants to
1057
+ a user (`server.access.refresh`, a tracked write to
1058
+ `auth.serviceAccessSource`). One that carries grants (even `{}`) keeps
1059
+ exactly those. A load that fails rejects the call with `INTERNAL`.
1044
1060
 
1045
1061
  `authenticate` takes one request (`{ transport, auth, headers, socket | req }`)
1046
1062
  for both transports and returns a principal, a user id, or nothing for an
@@ -1154,15 +1170,16 @@ the method's kind does not have, is a compile error.
1154
1170
  `QueryClient` (5-minute stale time by default), and works without DOM
1155
1171
  globals (React Native).
1156
1172
 
1157
- | Member | Gives |
1158
- | ------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
1159
- | `qd.task.get.useQuery(input, options)` | TanStack's `useQuery`; errors are `QuickdrawError` with a `code` |
1160
- | `qd.task.rename.useMutation(options)` | TanStack's `useMutation`; `mutate` returns nothing, `mutateAsync` the output |
1161
- | `qd.task.useEntity(id)`, `useEntities(ids)` | live rows at the user's level: `{ data, isLoading, isRemoved, error }` |
1162
- | `qd.task.board.useCollection(scope, { view, load, limit })` | a live scope: `{ items, index, byId, totalCount, hasMore, isLoading, loadMore, refresh, ... }` |
1163
- | `qd.task.get.call(input)`, `.key(input)`, `.prefetch(queryClient, input)` | a call over the mounted provider's connection, the cache key, a prefetch |
1164
- | `qd.invalidate(qd.task.get, input?)` | invalidates through the coordinator: a read in flight is never cancelled |
1165
- | `useQuickdraw()` | `{ connection, status, isConnected, userId, serviceAccess, hello, refusal, isRateLimited }` |
1173
+ | Member | Gives |
1174
+ | ------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
1175
+ | `qd.task.get.useQuery(input, options)` | TanStack's `useQuery`; errors are `QuickdrawError` with a `code` |
1176
+ | `qd.task.rename.useMutation(options)` | TanStack's `useMutation`; `mutate` returns nothing, `mutateAsync` the output |
1177
+ | `qd.task.useEntity(id)`, `useEntities(ids)` | live rows at the user's level: `{ data, isLoading, isRemoved, error }` |
1178
+ | `qd.task.board.useCollection(scope, { view, load, limit })` | a live scope: `{ items, index, byId, pending, totalCount, hasMore, isLoading, loadMore, ... }` |
1179
+ | `qd.task.get.call(input)`, `.key(input)`, `.prefetch(queryClient, input)` | a call over the mounted provider's connection, the cache key, a prefetch |
1180
+ | `qd.invalidate(qd.task.get, input?)` | invalidates through the coordinator: a read in flight is never cancelled |
1181
+ | `qd.task.get.setData(input, updater)` | writes a cached result an event carries, at once; a read in flight is followed by one more |
1182
+ | `useQuickdraw()` | `{ connection, status, isConnected, isKnown, reconnecting, userId, serviceAccess, hello, ... }` |
1166
1183
 
1167
1184
  <!-- example: apps/web/src/components/TaskDetail.tsx#detail -->
1168
1185
 
@@ -1203,12 +1220,18 @@ export function TaskDetail({ id }: { readonly id: string }) {
1203
1220
  collection items from the moment it is sent, are dropped if it fails, and
1204
1221
  give way to the server's frame. `optimistic: false` turns that off;
1205
1222
  `optimistic: (input, cache) => ...` writes its own layers with
1206
- `patchEntity`, `removeEntity` and `patchItem`.
1223
+ `patchEntity`, `removeEntity` and `patchItem`, and adds rows with
1224
+ `addItem` and `addEntity` (below).
1207
1225
  - Live rows and collections need no refetching: frames keep them current,
1208
1226
  and after a reconnect they resume by revision. A query whose result
1209
1227
  follows writes declares `watch` in its contract; the coordinator fetches
1210
1228
  it again once per change, with at most one read in flight per key. Do not
1211
1229
  call `refetch` or `invalidateQueries` on quickdraw keys after a mutation.
1230
+ - An event that carries a query's new result (a room's roster, a score)
1231
+ writes it with `qd.<service>.<query>.setData(input, updater)` rather than
1232
+ refetching: the hooks show it at once, under the overlays of optimistic
1233
+ mutations, and a read of that key already in flight (which may predate
1234
+ the event) is followed by one more, never cancelled.
1212
1235
  - After a reconnect, the queries that are watched (they missed the changes
1213
1236
  meanwhile) or stale are refetched, each after a random delay of up to
1214
1237
  `reconnectJitterMs` (2,000 ms by default), so clients that reconnect
@@ -1227,6 +1250,87 @@ export function TaskDetail({ id }: { readonly id: string }) {
1227
1250
  - A protocol mismatch reloads the page once per session by default
1228
1251
  (`onProtocolMismatch`); `RATE_LIMITED` answers back off with jitter per
1229
1252
  kind of work.
1253
+ - Server rendering: a server never connects, so `useQuickdraw()`,
1254
+ `usePresence`, streams and overlays render the state a new connection has
1255
+ (nobody known, no rooms, nothing loaded), and the browser hydrates that
1256
+ same state even where the provider connected first (a Suspense boundary
1257
+ that hydrates late), then renders the live one.
1258
+
1259
+ A create shows its row before the server answers with `cache.addItem(collection,
1260
+ scope, item)`: the item appears in the scope at once, in its place by the
1261
+ collection's `order` (give it the order's fields; without them it goes
1262
+ last), and `useCollection`'s `pending` names it while the call is in flight.
1263
+ A refused call removes it. The reply's `id` (the created row's) and values
1264
+ replace the item's own, and once the scope's own copy arrives (its `added`
1265
+ delta, or a load) that copy shows in its place: never both, never a gap.
1266
+ `cache.addEntity(row)` adds a row to every collection of entity rows whose
1267
+ scope column (and `where`) it matches; a collection of a projection takes
1268
+ `addItem`. The item may give its own `id`, one the client made and the
1269
+ server keeps:
1270
+
1271
+ <!-- example: apps/web/src/components/AddTask.tsx#add -->
1272
+
1273
+ ```tsx
1274
+ export function TaskList({ projectId }: { readonly projectId: string }) {
1275
+ const { items, pending } = qd.task.board.useCollection(projectId);
1276
+ const create = qd.task.create.useMutation({
1277
+ // the new card shows at once, last on the board (its ordinal), until the server's arrives
1278
+ optimistic: (input, cache) =>
1279
+ cache.addItem("board", input.projectId, {
1280
+ projectId: input.projectId,
1281
+ title: input.title,
1282
+ status: "open",
1283
+ ordinal: Number.MAX_SAFE_INTEGER,
1284
+ assigneeId: null,
1285
+ }),
1286
+ });
1287
+ return (
1288
+ <>
1289
+ <ul>
1290
+ {items.map((task) => (
1291
+ // pending: the create is on its way; the card is the server's once it answers
1292
+ <li key={task.id} style={{ opacity: pending.has(task.id) ? 0.5 : 1 }}>
1293
+ {task.title}
1294
+ </li>
1295
+ ))}
1296
+ </ul>
1297
+ <button type="button" onClick={() => create.mutate({ projectId, title: "New task" })}>
1298
+ Add
1299
+ </button>
1300
+ {create.error === null ? null : <p>{`Not added: ${create.error.code}`}</p>}
1301
+ </>
1302
+ );
1303
+ }
1304
+ ```
1305
+
1306
+ `useQuickdraw()` says who the connection acts for. `userId` is `null` both
1307
+ for an anonymous socket and before the server's hello, so a gate waits for
1308
+ `isKnown` (the hello on the current credentials arrived; false again from
1309
+ new credentials until theirs). `isConnected` turns true before the hello and
1310
+ false while the connection reconnects; `reconnecting` says the user and the
1311
+ page stay meanwhile:
1312
+
1313
+ <!-- example: apps/web/src/components/AuthGate.tsx#gate -->
1314
+
1315
+ ```tsx
1316
+ export function AuthGate({ children }: { readonly children: ReactNode }) {
1317
+ // isKnown: the server's hello named the user, so userId is final (null: signed out)
1318
+ const { isKnown, userId, reconnecting } = useQuickdraw();
1319
+ if (!isKnown) {
1320
+ return <p>Connecting…</p>;
1321
+ }
1322
+ if (userId === null) {
1323
+ return <SignIn />;
1324
+ }
1325
+ // a reconnect keeps the user and the page: say so, unmount nothing
1326
+ return (
1327
+ <>
1328
+ {reconnecting ? <p role="status">Reconnecting…</p> : null}
1329
+ {children}
1330
+ </>
1331
+ );
1332
+ }
1333
+ ```
1230
1334
 
1231
1335
  ### Server components and other runtimes
1232
1336
 
@@ -1282,9 +1386,9 @@ such code, and `createInvalidationCoordinator(queryClient)` invalidates as
1282
1386
  the hooks do.
1283
1387
 
1284
1388
  A client in another language (a Godot game, a native app) speaks the wire
1285
- itself: [`docs/protocol-v5.md`](../../docs/protocol-v5.md) is its specification,
1286
- generated from the protocol's source, and [`examples/godot`](../../examples/godot)
1287
- holds a GDScript client written from it. [`docs/clients.md`](../../docs/clients.md)
1389
+ itself: [`docs/protocol-v5.md`](https://github.com/fitzzero/quickdraw/blob/dev/docs/protocol-v5.md) is its specification,
1390
+ generated from the protocol's source, and [`examples/godot`](https://github.com/fitzzero/quickdraw/tree/dev/examples/godot)
1391
+ holds a GDScript client written from it. [`docs/clients.md`](https://github.com/fitzzero/quickdraw/blob/dev/docs/clients.md)
1288
1392
  compares the three ways in.
1289
1393
 
1290
1394
  ## Kits
@@ -1759,7 +1863,10 @@ export function AdminTasks() {
1759
1863
  // [{ key: "task", serviceName, displayName }]
1760
1864
  const { services } = useAdminServices(qd);
1761
1865
  const { data } = qd.task.admin.adminList.useQuery({ page: 1, sort: { field: "title" } });
1762
- const update = qd.task.admin.adminUpdate.useMutation();
1866
+ const update = qd.task.admin.adminUpdate.useMutation({
1867
+ // adminList is a query, not live data: read the page again after this screen's own write
1868
+ onSuccess: () => qd.invalidate(qd.task.admin.adminList),
1869
+ });
1763
1870
  return (
1764
1871
  <table aria-label={services[0]?.displayName}>
1765
1872
  <tbody>
@@ -1807,7 +1914,7 @@ export function AdminTasks() {
1807
1914
  value is checked by the entity schema itself, and a value the database
1808
1915
  refuses is `VALIDATION`. A missing row is `NOT_FOUND`.
1809
1916
  - `adminMeta()` returns `{ serviceName, displayName, fields }`, one
1810
- `{ name, type, label, required, editable, showInTable, sortable, filterable, enumValues?, relationService? }`
1917
+ `{ name, type, label, required, editable, showInTable, sortable, filterable, enumValues?, relationService?, kind?, showInForm? }`
1811
1918
  per field: `type` is
1812
1919
  `string`, `number`, `boolean`, `date` (an ISO string with a date format),
1813
1920
  `enum` or `json` from the field's JSON Schema, and `relation` by override;
@@ -1824,7 +1931,21 @@ export function AdminTasks() {
1824
1931
  rows without it and `FORBIDDEN` for a write, filter or sort naming it.
1825
1932
  The write is tracked, so with `auth.serviceAccessSource` naming the column
1826
1933
  the user's open sockets get the new grants at once, on every node. Such
1827
- an Admin can grant any service, themself included.
1934
+ an Admin can grant any service, themself included. The field's
1935
+ configuration says `kind: "grants"`, so a screen with a grants editor of
1936
+ its own finds it without its name, and
1937
+ `fieldOverrides: { serviceAccess: { showInForm: false } }` keeps it out
1938
+ of a generic create or edit form (a form shows the fields whose
1939
+ `showInForm` is not `false`); the kit still reads and writes it.
1940
+ - `admin.handlers(contract, { onWrite })` runs `onWrite({ method, id,
1941
+ before?, after }, ctx, db)` after each `adminCreate`, `adminUpdate` and
1942
+ `adminDelete`, in one transaction with the write (`db` is the
1943
+ transaction's tracked client; a throw undoes the write and fails the
1944
+ call): what an admin edit must set off, such as a game reloading its
1945
+ tunables, without wrapping the kit's handlers. The rows are the entity,
1946
+ every field. Without it the kit opens no transaction. Each handler
1947
+ `admin.handlers` returns resolves with its method's output type, so a
1948
+ wrapper reads the row and returns it with no cast.
1828
1949
  - `adminSubscribers({ id })` counts the sockets subscribed to a row per
1829
1950
  access level (`{ id, count, levels, complete }`; behind a Redis adapter
1830
1951
  the counts are this server's and `complete` is `false`), and
@@ -1834,6 +1955,21 @@ export function AdminTasks() {
1834
1955
  `qd.admin`, since `admin` is reserved per service). `useAdminServices(qd)`
1835
1956
  lists the client's services whose `adminMeta` answers the user, with their
1836
1957
  display names, sharing the cache of `qd.<service>.admin.adminMeta.useQuery()`.
1958
+ It asks only the services the user's grants (from the server's hello)
1959
+ allow: `Admin` by default, what the kit's methods require;
1960
+ `useAdminServices(qd, { requires: "Moderate" })` for an `adminMeta` given
1961
+ another form, `requires: null` for every service. A service that refused
1962
+ is not asked again until the user's grant on it changes, reconnects
1963
+ included.
1964
+ - The kit's rows are not live: `adminList` and `adminGet` are queries that
1965
+ watch nothing, so a screen reads them again after its own writes (the
1966
+ `onSuccess` above), and sees other admins' writes when it next reads.
1967
+ - A screen that serves every service from its metadata (the route names the
1968
+ service) takes `adminOf(qd, key)`: the same members, keyed by what the kit
1969
+ made them for and typed by field name (`AdminScreen`: rows are
1970
+ `AdminRow`, `adminList` takes `{ page, pageSize, filter, sort: { field, direction } }`
1971
+ with names read from `adminMeta`), so no cast is needed over a union of
1972
+ keys; a method the contract does not expose is absent.
1837
1973
 
1838
1974
  ### Presence, streams and channels
1839
1975
 
@@ -1899,6 +2035,16 @@ export const taskService = qd.defineService(task, {
1899
2035
  ctx.rooms.emit(`board:${payload.projectId}`, task, "cursorMoved", payload);
1900
2036
  },
1901
2037
  },
2038
+ // once per socket that leaves app rooms, in every server this service runs in, in a unit of its own
2039
+ onRoomLeave: ({ principal, rooms }) => {
2040
+ for (const { room, last } of rooms) {
2041
+ // last: no socket of the user is in the room any more (a second tab keeps it false)
2042
+ if (principal !== null && last && room.startsWith("board:")) {
2043
+ const projectId = room.slice("board:".length);
2044
+ qd.rooms.emit(room, task, "leftBoard", { projectId, userId: principal.userId });
2045
+ }
2046
+ }
2047
+ },
1902
2048
  });
1903
2049
 
1904
2050
  // in handlers, jobs and timers
@@ -1922,14 +2068,17 @@ export function TaskRoom({
1922
2068
  readonly projectId: string;
1923
2069
  readonly taskId: string;
1924
2070
  }) {
2071
+ // the socket is in the board's room on every connection: a reconnect is a new socket in no room
2072
+ const board = useJoin(qd.task.enterBoard, { projectId });
1925
2073
  const { items } = qd.task.logs.useStream(taskId, { max: 200 });
1926
2074
  const { send, isReady } = qd.task.cursor.useChannel();
1927
2075
  const [lastX, setLastX] = useState(0);
1928
2076
  qd.task.cursorMoved.useEvent((cursor) => setLastX(cursor.x));
1929
- // user ids, after enterBoard joined the room
2077
+ // user ids, once enterBoard joined the room
1930
2078
  const here = usePresence(`board:${projectId}`);
2079
+ const move = (x: number) => isReady && board.isJoined && send({ projectId, taskId, x });
1931
2080
  return (
1932
- <div onMouseMove={(event) => isReady && send({ projectId, taskId, x: event.clientX })}>
2081
+ <div onMouseMove={(event) => move(event.clientX)}>
1933
2082
  <p>{`${String(here.length)} here; a cursor at ${String(lastX)}`}</p>
1934
2083
  <pre>{items.map((item) => item.line).join("\n")}</pre>
1935
2084
  </div>
@@ -1943,20 +2092,33 @@ export function TaskRoom({
1943
2092
  latest `seed` items per scope in memory on that process (at most 1,000 per
1944
2093
  scope and 10,000 scopes per stream; a restart empties them, and durable
1945
2094
  history is the app's: store the rows and expose a collection), and sends
1946
- `qd:stream { s, stream, scope?, item }` to the feed's subscribers,
1947
- volatile when the stream says so. `pushMany(scope, items)`
2095
+ `qd:stream [service, stream, scope, item]` (`scope` null for a global
2096
+ stream; positional, so a fast stream's frames carry no key names) to the
2097
+ feed's subscribers, volatile when the stream says so. `pushMany(scope, items)`
1948
2098
  (`pushMany(items)` for a global stream) pushes several items to one feed
1949
2099
  at once: every item is checked before any is kept or sent, and each goes
1950
2100
  out as its own frame, in order; use it rather than `push` in a loop
1951
2101
  (`no-emit-in-loop`). `qd:stream:sub` is authorized with the
1952
2102
  stream's `access` through the access engine, the scope being the row an
1953
- `entry` or `scope` form checks; a stream without `access` is closed. The
1954
- answer is the seed; `useStream` then appends, keeps the latest `max`
1955
- (default 500), and subscribes again after a reconnect, when the seed
1956
- replaces what it held. A socket holds at most 500 feeds. A subscriber
1957
- whose access is lowered is authorized again; one refused leaves the feed
1958
- and gets `qd:revoked { kind: "stream", reason: "access", s, stream, scope? }`,
1959
- and `useStream` shows `FORBIDDEN` until the next connect.
2103
+ `entry` or `scope` form checks; `access: { room }` (a name, `{ prefix }`,
2104
+ or for a scoped stream a function of the scope) opens it to the sockets
2105
+ in that app room instead, signed in or not, and a socket that leaves the
2106
+ room, or is taken out, leaves the feed; a stream without `access` is
2107
+ closed. The answer is the seed; `useStream` then appends, keeps the
2108
+ latest `max` (default 500), and subscribes again after a reconnect, when
2109
+ the seed replaces what it held. A socket holds at most 500 feeds. A
2110
+ subscriber whose access is lowered is authorized again; one refused
2111
+ leaves the feed and gets
2112
+ `qd:revoked { kind: "stream", reason: "access", s, stream, scope? }`, and
2113
+ `useStream` shows `FORBIDDEN` until the next connect.
2114
+ - A service's `streams: { <name>: { seed, validate } }` computes a stream's
2115
+ seed per subscriber, `seed: (scope, ctx) => items` (the current state,
2116
+ where the items that follow are deltas; on whichever node the subscriber
2117
+ is on, under its principal, in the tick it joins the feed; a contract
2118
+ `seed: n` and a seed function cannot both be declared), and
2119
+ `validate: "development"` checks pushed items only while the dispatcher
2120
+ checks outputs (`outputValidation`, off in production), for a hot stream:
2121
+ unchecked, an item goes out as pushed, extra keys included.
1960
2122
  - Channels: each message is `qd:ch [service, channel, payload]`, sent
1961
2123
  volatile and never answered. Per socket and channel a token bucket
1962
2124
  (`ratePerSecond`, default 30; `burst`, default twice that) drops what is
@@ -1968,10 +2130,13 @@ export function TaskRoom({
1968
2130
  scope; `{ room }`: the app room, a name like `"world"` or a function of
1969
2131
  the payload, which a call over that same socket joined) is dropped.
1970
2132
  Nothing is logged per message; a handler's error is. The socket rate
1971
- limiter does not count channels. Every requirement is the sending
1972
- socket's own: a room another socket of the user joined does not count, a
1973
- reconnected socket must join again, and behind a cluster the check runs
1974
- on the node the socket is connected to, with no round trip.
2133
+ limiter does not count channels. `{ room: { prefix: "world:" } }` takes
2134
+ a socket in any app room whose name starts with the prefix (a game of many
2135
+ worlds), and every room form gives the handler the room it matched as
2136
+ `ctx.room`, so the payload need not repeat it. Every requirement is the
2137
+ sending socket's own: a room another socket of the user joined does not
2138
+ count, a reconnected socket must join again, and behind a cluster the
2139
+ check runs on the node the socket is connected to, with no round trip.
1975
2140
  - Presence: `isOnline`, `lastSeen` (now while online, else when the user's
1976
2141
  last socket on this process disconnected), `count` and `users` (each user
1977
2142
  once, anonymous sockets left out; app rooms only) come from this process's
@@ -1986,16 +2151,24 @@ export function TaskRoom({
1986
2151
  `emitToUser(userId, ...)` check the payload first (`INTERNAL`, nothing
1987
2152
  sent, when it fails), then send the validated payload as
1988
2153
  `qd:event [service, event, payload]`; `useEvent` hears them.
2154
+ - Rooms belong to a socket: a reconnect (a lost network, `qd:rotate`, new
2155
+ credentials) is a new socket in no room, which hears none of its events
2156
+ and whose channel messages requiring it are dropped. `useJoin(member,
2157
+ input, { enabled?, onJoined? })` (from `./client`) runs the joining call
2158
+ (`enterBoard` above) on every `qd:hello` and when its input changes by
2159
+ value, never on a re-render, and shows `status` (`idle` with no socket to
2160
+ join with, `joining`, `joined`, `error`), `isJoined`, `data` and `error`;
2161
+ a refusal stands until the next hello, `RATE_LIMITED` is tried again after
2162
+ its backoff, and it never leaves the room itself.
2163
+ `connection.onHello(listener)` is the same hook without React.
1989
2164
 
1990
2165
  Code that is not a handler (a game loop, a timer, a job) reaches rooms
1991
- through `qd.rooms` (also `server.rooms`), and `onRoomLeave` hears sockets
1992
- leave:
2166
+ through `qd.rooms` (also `server.rooms`); the service's `onRoomLeave` (above)
2167
+ hears sockets leave:
1993
2168
 
1994
2169
  <!-- example: apps/api/src/services/kits/rooms.ts#rooms -->
1995
2170
 
1996
2171
  ```ts
1997
- import type { RoomLeaveHandler } from "@fitzzero/quickdraw-core/server";
1998
-
1999
2172
  const boardRoom = (projectId: string): string => `board:${projectId}`;
2000
2173
 
2001
2174
  // a timer or a game loop's tick, outside any handler: every socket in the room, on every node
@@ -2009,20 +2182,20 @@ export async function removeFromBoard(projectId: string, userId: string): Promis
2009
2182
  await qd.rooms.leave(boardRoom(projectId), { userId });
2010
2183
  }
2011
2184
 
2012
- // qd.createServer({ ..., onRoomLeave }): once per socket that leaves app rooms, in a unit of its own
2013
- export const onRoomLeave: RoomLeaveHandler<AppPrincipal> = ({ principal, rooms }) => {
2014
- for (const { room, last } of rooms) {
2015
- // last: no socket of the user is in the room any more (a second tab keeps it false)
2016
- if (principal !== null && last && room.startsWith("board:")) {
2017
- const projectId = room.slice("board:".length);
2018
- qd.rooms.emit(room, task, "leftBoard", { projectId, userId: principal.userId });
2019
- }
2020
- }
2021
- };
2185
+ // a tick loop's "is anyone watching?", at its tick rate: this node's sockets in the room,
2186
+ // anonymous ones too, with no promise (presence.count asks every node for users)
2187
+ export function hasAudience(projectId: string): boolean {
2188
+ return qd.rooms.size(boardRoom(projectId)) > 0;
2189
+ }
2022
2190
  ```
2023
2191
 
2024
2192
  - `qd.rooms.emit` and `emitToUser` are `ctx.rooms`' own, from anywhere,
2025
2193
  reaching every node behind a cluster adapter.
2194
+ - `rooms.size(room)` (on `qd.rooms`, `server.rooms` and `ctx.rooms`) counts
2195
+ the sockets in an app room on this node, anonymous ones included, at once:
2196
+ a game loop can ask it every tick. It is local by design; `presence.count`
2197
+ counts users on every node. A handler's `ctx.socketId` names the socket
2198
+ its call arrived on (`undefined` over HTTP, MCP or in process).
2026
2199
  - `leave(room, { userId })` (on `qd.rooms`, and on `ctx.rooms` beside the
2027
2200
  calling socket's own `leave(room)`) takes every socket of the user out of
2028
2201
  an app room, on every node: they hear nothing more of it, a channel that
@@ -2030,8 +2203,13 @@ export const onRoomLeave: RoomLeaveHandler<AppPrincipal> = ({ principal, rooms }
2030
2203
  `qd:presence { room, users: [] }`, and the room hears `left`. Behind a
2031
2204
  cluster it is broadcast and answered, so await it before sending what the
2032
2205
  user must not receive. Joining again is the app's to refuse.
2033
- - `createServer({ onRoomLeave })` runs once per socket that leaves app
2034
- rooms, on the node that held it: its own `leave` (`reason: "leave"`), a
2206
+ - A service's `onRoomLeave` runs once per socket that leaves app rooms, in
2207
+ every server the service runs in (`createTestApp` and a benchmark's
2208
+ server included, with nothing to wire), beside every other service's and
2209
+ `createServer({ onRoomLeave })`, the app's own; each runs in a unit of
2210
+ work of its own, and one that throws stops none of the others. It hears
2211
+ every app room a socket leaves, so check the room's name. It runs on
2212
+ the node that held the socket: its own `leave` (`reason: "leave"`), a
2035
2213
  removal (`"removed"`), or a disconnect, which leaves every app room it was
2036
2214
  in (`"disconnect"`; `qd:rotate` reconnects with a new socket). Each room
2037
2215
  comes with `last`: true when no socket of that user is in the room any
@@ -2191,11 +2369,11 @@ nothing is cached:
2191
2369
  session's token, for clients that keep no cookies (a game engine, a page
2192
2370
  in a third-party iframe), at the cost of the token being readable by the
2193
2371
  page's scripts.
2194
- - `requireSession({ sessions, jwtSecret })` guards the app's own REST
2195
- routes: the credential is read as `/me` reads it, the JWT verified once
2196
- and the session checked in the store, then `req.userId` and
2197
- `req.sessionId` are set; otherwise 401 `{ error: "UNAUTHENTICATED", message }`.
2198
- 4.1's `createRequireAuth` stays for token-keyed sessions.
2372
+ - `requireSession({ sessions, jwtSecret }, { loadPrincipal? })` guards the
2373
+ app's own REST routes (below): the credential is read as `/me` reads it,
2374
+ the JWT verified once and the session checked in the store; otherwise
2375
+ 401 `{ error: "UNAUTHENTICATED", message }`. 4.1's `createRequireAuth`
2376
+ stays for token-keyed sessions.
2199
2377
  - Rate limits: the sign-in routes share `createAuthLimiter({ max: 60 })` (60
2200
2378
  requests per 15 minutes per IP) and the session routes
2201
2379
  `createAuthStatusLimiter()` (120); pass `rateLimit: { signIn, session }`
@@ -2240,6 +2418,83 @@ nothing is cached:
2240
2418
  session for an app's own sign-in flow (login codes, an embedded activity),
2241
2419
  and `liveSession` reads a token back; both work with `socketAuth`.
2242
2420
 
2421
+ From the browser, `./client` speaks to these routes: `signInUrl(provider,
2422
+ { apiUrl, returnTo })` is the start route's URL (to navigate to; `returnTo`
2423
+ defaults to the current page's origin), and `signOut()` and
2424
+ `signOutEverywhere()` post to `logout` and `logout-all`. Each POST sends the
2425
+ session cookie (`credentials: "include"`, so the API's CORS must allow the
2426
+ web app's origin with credentials) with `Content-Type: application/json`,
2427
+ and the token `setAuthToken` stored, if any, as a bearer token; the stored
2428
+ token is forgotten either way, and a refusal or an unreachable server
2429
+ rejects with a `QuickdrawError`. A socket keeps the user it signed in as
2430
+ until it connects again, so reconnect the provider's connection after
2431
+ signing out (with a token in `auth`, clearing it does that):
2432
+
2433
+ <!-- example: apps/web/src/components/SignIn.tsx#browser -->
2434
+
2435
+ ```tsx
2436
+ export function SignIn() {
2437
+ // the kit's GET /auth/google/start: back to this page's origin with the session cookie
2438
+ return <a href={signInUrl("google", { apiUrl: API_URL })}>Sign in with Google</a>;
2439
+ }
2440
+
2441
+ export function SignOut() {
2442
+ const { connection } = useQuickdraw();
2443
+ const leave = async (): Promise<void> => {
2444
+ // POST /auth/logout with the cookie: the session is revoked, the cookie cleared
2445
+ await signOut({ apiUrl: API_URL });
2446
+ // the socket keeps its user until it connects again, as nobody now
2447
+ connection.close();
2448
+ connection.open();
2449
+ };
2450
+ return (
2451
+ <button type="button" onClick={() => void leave()}>
2452
+ Sign out
2453
+ </button>
2454
+ );
2455
+ }
2456
+ ```
2457
+
2458
+ An app's own REST route (a service worker's renewal, a webhook) signs the
2459
+ user in with `requireSession` and calls the services in process, so the
2460
+ method's validation, access check and tracked writes are a socket call's.
2461
+ `sessionOf(req)` gives the route `{ userId, sessionId, principal }`, typed,
2462
+ with no cast of `req` (`sessionOf<AppPrincipal>(req)` when `loadPrincipal`,
2463
+ like `socketAuth`'s, builds the app's own principal; by default it is
2464
+ `{ userId, kind: "user" }`); they are also set as `req.userId`,
2465
+ `req.sessionId` and `req.principal`. `qd.caller(principal)` then loads the
2466
+ user's grants as the user's sockets get them, so a method behind
2467
+ `{ service: L }` passes or answers `FORBIDDEN` as it would over a socket:
2468
+
2469
+ <!-- example: apps/api/src/auth/routes.ts#rest -->
2470
+
2471
+ ```ts
2472
+ import { httpStatus, toWire } from "@fitzzero/quickdraw-core";
2473
+ import { requireSession, sessionOf } from "@fitzzero/quickdraw-core/server/auth";
2474
+
2475
+ // 401 without a live session; the principal built as socketAuth builds a socket's
2476
+ const signedIn = requireSession(
2477
+ { sessions, jwtSecret: env.JWT_SECRET },
2478
+ { loadPrincipal: (userId): AppPrincipal => ({ userId, kind: "user" }) },
2479
+ );
2480
+
2481
+ app.get("/api/projects/:projectId/task-count", signedIn, (req, res) => {
2482
+ void (async () => {
2483
+ const { principal } = sessionOf<AppPrincipal>(req);
2484
+ try {
2485
+ // the method's validation, access check (with the user's grants) and writes, as over a socket
2486
+ const count = await qd.caller(principal).taskService.countOnBoard({
2487
+ projectId: req.params.projectId,
2488
+ });
2489
+ res.json({ count });
2490
+ } catch (error) {
2491
+ const failure = toWire(error);
2492
+ res.status(httpStatus(failure.code)).json(failure);
2493
+ }
2494
+ })();
2495
+ });
2496
+ ```
2497
+
2243
2498
  `createMemorySessionStore()` keeps sessions in the process, for development
2244
2499
  and tests. In production, store them in the database. Sessions are not live
2245
2500
  data, so nothing needs their writes tracked: with the tracked client, the
@@ -2320,8 +2575,14 @@ it("sends a rename to the other members' boards", async () => {
2320
2575
  - `app.as(principal)` calls in process and `app.connect(principal)` over a
2321
2576
  real socket (`{ call, socket, hello, close }`); both are keyed by service
2322
2577
  name. `app.frames(match?)` lists every frame the server sent, with its
2323
- socket and user; `frames.waitFor(match)` waits for one. `emitWithAck` and
2324
- `waitForEvent` send raw frames and wait for events.
2578
+ socket and user; `frames.waitFor(match)` waits for one. A query of one
2579
+ event takes `where`, a predicate over its frames typed by the event
2580
+ (`{ event: "qd:presence", where: ({ data }) => data.joined === id }`), and
2581
+ `streamFrames(contract, stream, where?, scope?)` and
2582
+ `eventFrames(contract, event, where?)` match one stream's items or one
2583
+ event's payloads, typed by the contract, to spread beside `socketId` or
2584
+ `userId`. `emitWithAck` and `waitForEvent` send raw frames and wait for
2585
+ events.
2325
2586
 
2326
2587
  ### Access matrices
2327
2588
 
@@ -2500,9 +2761,22 @@ async (Testing Library, an optional peer, is loaded lazily) and returns
2500
2761
  Testing Library's result plus `connection`, `queryClient`, `disconnect()`
2501
2762
  and `reconnect()`, which drop and restore the socket as a lost network does.
2502
2763
 
2503
- For a component test without a server, `createMockClient(contracts)` gives
2504
- the typed client's shape with stubs; give it to the components in place of
2505
- the app's client (a module mock of the file that exports `qd`, say):
2764
+ The web app's test imports the API's services across apps, as above, and
2765
+ needs a database: run such tests in a jsdom project of their own, with the
2766
+ API's global setup (the template its test databases boot from, below) and a
2767
+ setup file that calls `installJsdomShims()` from `./testing/client`. That
2768
+ adds what jsdom lacks, and keeps what it has: `scrollTo`, `scrollBy` and
2769
+ `scrollIntoView` on elements (they do nothing; a list that follows its newest
2770
+ item calls them) and `Blob.prototype.arrayBuffer` (PGlite reads a database
2771
+ dump through it). This repository runs the README's example app that way:
2772
+ `packages/core/vitest.config.ts`, project `readme`, with
2773
+ `test/readme/globalSetup.ts` and `test/readme/workerSetup.ts`.
2774
+
2775
+ For a component test or a story without a server, `createMockClient(contracts)`
2776
+ gives the typed client's shape with stubs; give it to the components in place
2777
+ of the app's client (a module mock of the file that exports `qd`, say). It
2778
+ comes from `./testing/client`, or from `@fitzzero/quickdraw-core/testing/mock`,
2779
+ which names no Testing Library, for a browser bundle (Storybook):
2506
2780
 
2507
2781
  <!-- example: apps/web/src/components/TaskBoard.test.tsx#mock -->
2508
2782
 
@@ -2522,14 +2796,52 @@ each test only when the test runner has a global `afterEach` (vitest with
2522
2796
  (`resetAfterEach: false` opts out); otherwise call `mock.$reset()` in an
2523
2797
  `afterEach` of your own, as above. Optimistic updates are not shown.
2524
2798
 
2799
+ The members' hooks need no provider. A component that reads the connection
2800
+ (`useQuickdraw()`, `usePresence(room)`) renders inside the mock's own
2801
+ provider, `mock.$Provider`, where those real hooks read the mock's session:
2802
+ who it acts for, set with `createMockClient(contracts, { session })` and
2803
+ `mock.$session({ userId, serviceAccess, isConnected, isKnown })` (each field
2804
+ left out keeps its starting value, and the reset after each test puts the
2805
+ starting session back), and the rooms `mock.$presence(room, userIds)` sets.
2806
+ Its views select members for the session's user:
2807
+
2808
+ <!-- example: apps/web/src/components/TaskBoard.test.tsx#session -->
2809
+
2810
+ ```tsx
2811
+ it("lets a signed-in user through the gate", () => {
2812
+ mock.$session({ userId: "ada", serviceAccess: { taskService: "Admin" } }); // useQuickdraw() shows it
2813
+ render(<AuthGate>Board</AuthGate>, { wrapper: mock.$Provider });
2814
+ expect(screen.getByText("Board")).toBeTruthy();
2815
+ });
2816
+ ```
2817
+
2818
+ In Storybook, a decorator renders every story inside `qd.$Provider`, and a
2819
+ story's `beforeEach` sets its session (`qd.$session(...)`) beside its data.
2820
+
2525
2821
  ### Test databases
2526
2822
 
2527
2823
  `@fitzzero/quickdraw-core/testing/prisma` gives each vitest worker a database
2528
2824
  of its own: `createPrismaTestGlobalSetup` migrates a template once per run
2529
2825
  and clones a database per worker on PostgreSQL (`TEST_DATABASE_URL`), or
2530
- boots PGlite from a cached dump without one; `workerDatabaseUrl` and
2531
- `resetDatabase` (truncates every table) do the rest. Apply `trackPrisma` to
2532
- the test client exactly as in production.
2826
+ builds a PGlite dump without one; `workerDatabaseUrl` and `resetDatabase`
2827
+ (truncates every table) do the rest. On PGlite each worker boots its own
2828
+ database from the dump with `openPgliteFromTemplate`, in milliseconds and
2829
+ under jsdom too (it reads the dump through Node's `Blob`); the app's db
2830
+ package gives its test client from it while tests run:
2831
+
2832
+ <!-- example: packages/db/src/testing.ts#worker -->
2833
+
2834
+ ```ts
2835
+ // this worker's own database: the migrated template, loaded in milliseconds
2836
+ const pglite = await openPgliteFromTemplate(TEST_TEMPLATE);
2837
+ export const prisma = new PrismaClient({ adapter: new PrismaPGlite(pglite) });
2838
+ ```
2839
+
2840
+ `TEST_TEMPLATE` is `{ migrationsDir, cacheDir, templateName }`, the same the
2841
+ global setup builds with (`buildPgliteTemplate(TEST_TEMPLATE)`, or
2842
+ `createPrismaTestGlobalSetup`), and a setup file empties the database before
2843
+ each test (`beforeEach(() => resetDatabase(prisma))`). Apply `trackPrisma`
2844
+ to the test client exactly as in production.
2533
2845
 
2534
2846
  ## Observability
2535
2847
 
@@ -2582,17 +2894,30 @@ is also a server span named `service.method`, with an error status for
2582
2894
 
2583
2895
  ## API docs from contracts
2584
2896
 
2585
- The `quickdraw-docs` command writes Markdown API docs from the contracts
2586
- alone: one page per service (its entity and field tiers, projections,
2587
- methods with their kind, input fields and output, collections, streams,
2588
- channels and events, from the schemas' JSON Schema where they have one) and
2589
- a `README.md` index. It reads contracts, never source code.
2897
+ The `quickdraw-docs` command writes Markdown API docs from the contracts:
2898
+ one page per service (its entity and field tiers, projections, methods
2899
+ with their kind, input fields and output, collections, streams, channels
2900
+ and events, from the schemas' JSON Schema where they have one) and a
2901
+ `README.md` index. It reads contracts (and, with `--services`, the
2902
+ services' definitions), never source code.
2590
2903
 
2591
2904
  ```bash
2592
2905
  quickdraw-docs packages/shared/src/index.ts --out docs/api # write the pages
2593
2906
  quickdraw-docs packages/shared/src/index.ts --out docs/api --check # exit 1 when they are stale
2907
+ quickdraw-docs packages/shared/src/index.ts --services apps/api/src/services/index.ts --out docs/api
2594
2908
  ```
2595
2909
 
2910
+ With `--services <module>` (a module exporting the services, each or in a
2911
+ list, as the server takes them; importing it must not start the server),
2912
+ each page also says who may call what, read from the services'
2913
+ definitions: an "Access" section with the row policy, whether a
2914
+ service-wide `Admin` grant passes every check, who may watch the change
2915
+ topic (`watchAccess`) and the field levels; each method's access form, in
2916
+ words, and its `rowless`; who may open a collection's scope; a channel's
2917
+ access; a stream's computed seed and when its items are checked. A
2918
+ contract the services module has no service for says so, and a service
2919
+ without a contract is an error. Pass the same flag to `--check`.
2920
+
2596
2921
  The module may export each contract, or a map of them as given to
2597
2922
  `createQuickdrawClient`. A TypeScript module loads through Node's type
2598
2923
  stripping, or through `tsx` when the project has it installed (for
@@ -2604,7 +2929,7 @@ nothing. Run `--check` in CI next to the lint step.
2604
2929
 
2605
2930
  ## Lint rules and agent guidance
2606
2931
 
2607
- [`@fitzzero/quickdraw-lint`](../../packages/lint) is the oxlint plugin and base
2932
+ [`@fitzzero/quickdraw-lint`](https://github.com/fitzzero/quickdraw/tree/dev/packages/lint) is the oxlint plugin and base
2608
2933
  config every 5.0 app extends: it reports untracked and foreign writes, nested
2609
2934
  and raw SQL writes, hand-sent frames, inline auth guards, unbounded reads,
2610
2935
  database calls and emits in loops, layering breaks, bypasses of the typed
@@ -2619,7 +2944,7 @@ takes an `id`, unless it says `rowless: true`); then lint; then a
2619
2944
  development warning as it happens (`repeated-call`, `repeated-mutation` and
2620
2945
  `repeated-invalidation` name a client loop before the rate limit does).
2621
2946
 
2622
- [`@fitzzero/quickdraw-skills`](../../packages/skills) ships agent rules and skills
2947
+ [`@fitzzero/quickdraw-skills`](https://github.com/fitzzero/quickdraw/tree/dev/packages/skills) ships agent rules and skills
2623
2948
  for quickdraw apps and links them into `.claude/` with
2624
2949
  `quickdraw-skills link`, so every app's agents read the same, current
2625
2950
  guidance:
@@ -2635,11 +2960,11 @@ guidance:
2635
2960
 
2636
2961
  ## Migrating from 4.x
2637
2962
 
2638
- [`@fitzzero/quickdraw-codemod`](../../packages/codemod) moves a 4.x app to 5.0:
2963
+ [`@fitzzero/quickdraw-codemod`](https://github.com/fitzzero/quickdraw/tree/dev/packages/codemod) moves a 4.x app to 5.0:
2639
2964
  contracts from the method maps, `defineService` from the service classes,
2640
2965
  the typed client for the hooks, and a report of everything left to decide.
2641
- [`MIGRATION.md`](../../MIGRATION.md) explains each step, the access mapping and
2642
- the defaults that changed; [`UPGRADE-PROMPT.md`](../../UPGRADE-PROMPT.md) is the
2966
+ [`MIGRATION.md`](https://github.com/fitzzero/quickdraw/blob/dev/MIGRATION.md) explains each step, the access mapping and
2967
+ the defaults that changed; [`UPGRADE-PROMPT.md`](https://github.com/fitzzero/quickdraw/blob/dev/UPGRADE-PROMPT.md) is the
2643
2968
  procedure for an agent. Both ship with the codemod, at
2644
2969
  `node_modules/@fitzzero/quickdraw-codemod/MIGRATION.md` and
2645
2970
  `node_modules/@fitzzero/quickdraw-codemod/UPGRADE-PROMPT.md`.
@@ -2654,7 +2979,7 @@ bunx @fitzzero/quickdraw-codemod@next v5 .
2654
2979
  | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
2655
2980
  | `.` | `defineContract`, `query`, `mutation`, `nullable`, `listOf`, `via`, the kits' contract halves, inference types, `QuickdrawError`, error codes, protocol types, room and topic names |
2656
2981
  | `./server` | `initQuickdraw`, `createServer`, `createDispatcher`, `createHttpRouter`, policies, `custom`, the kits' handlers, `requireRow`, `nextOrdinal`, `storageOf`, dev warnings, the Redis adapter, the socket rate limiter, env and encryption helpers |
2657
- | `./server/auth` | `createAuthRoutes`, `socketAuth`, providers (`google`, `discord`, `mock`, `guest`), session stores, `issueSession`, `liveSession`, JWT, cookie and origin helpers |
2982
+ | `./server/auth` | `createAuthRoutes`, `socketAuth`, `requireSession` and `sessionOf`, providers (`google`, `discord`, `mock`, `guest`), session stores, `issueSession`, `liveSession`, JWT, cookie and origin helpers |
2658
2983
  | `./server/express` | Express rate limits: `createAuthLimiter`, `createAuthStatusLimiter`, `createCallLimiter`, `createPublicApiLimiter`, `createWebhookLimiter` |
2659
2984
  | `./server/mcp` | `createMcpRegistry`, `describeTools`, `createMcpStdioServer`, `createMcpHttpRouter`, `bootstrapMcpServer` |
2660
2985
  | `./server/otel` | `otelOnCall` |
@@ -2663,7 +2988,8 @@ bunx @fitzzero/quickdraw-codemod@next v5 .
2663
2988
  | `./utils` | `createServerCaller`, cache keys (`methodKey`, `entityKey`, `collectionKey`), formatting, navigation, `parseJWTPayload` |
2664
2989
  | `./parser` | the JSON-only Socket.IO parser |
2665
2990
  | `./testing` | `createTestApp`, `describeAccessMatrix`, `expectBudget`, `createRecordingSink`, `DevWarningError` |
2666
- | `./testing/client` | `renderWithQuickdraw`, `createMockClient` |
2991
+ | `./testing/client` | `renderWithQuickdraw`, and everything in `./testing/mock` |
2992
+ | `./testing/mock` | `createMockClient` alone, without Testing Library: for browser bundles such as Storybook |
2667
2993
  | `./testing/prisma` | test databases on PostgreSQL or PGlite |
2668
2994
 
2669
2995
  The package also ships the `quickdraw-docs` command.
@@ -2672,7 +2998,7 @@ The package also ships the `quickdraw-docs` command.
2672
2998
 
2673
2999
  A bun workspace with turbo: `packages/core` (this package), `packages/lint`,
2674
3000
  `packages/skills` and `packages/codemod`. See
2675
- [CONTRIBUTING.md](../../CONTRIBUTING.md).
3001
+ [CONTRIBUTING.md](https://github.com/fitzzero/quickdraw/blob/dev/CONTRIBUTING.md).
2676
3002
 
2677
3003
  ```bash
2678
3004
  bun install