@fitzzero/quickdraw-core 5.0.0-rc.3 → 5.0.0-rc.5
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.
- package/README.md +508 -97
- package/dist/{admin-BiVNyWdI.d.ts → admin-CsXR7Zwy.d.ts} +3 -172
- package/dist/adminSchemas-Dufu8r7a.d.ts +229 -0
- package/dist/{auth-DlFtykbn.d.ts → auth-CMyRkfjZ.d.ts} +33 -4
- package/dist/{caller-D4cgj-Xl.d.ts → caller-C2sBNmyT.d.ts} +138 -12
- package/dist/{chunk-3ONXZP2X.js → chunk-3I7R3DZW.js} +1 -1
- package/dist/{chunk-3ONXZP2X.js.map → chunk-3I7R3DZW.js.map} +1 -1
- package/dist/{chunk-CLPB6IGC.js → chunk-6G663RIR.js} +2 -2
- package/dist/{chunk-CNECM3VC.js → chunk-HKXLRZAO.js} +6 -6
- package/dist/{chunk-JK5D3OXC.js → chunk-KR7K4QEY.js} +3 -2
- package/dist/chunk-KR7K4QEY.js.map +1 -0
- package/dist/{chunk-K35VD4CQ.js → chunk-NKVWV3FL.js} +4 -2
- package/dist/chunk-NKVWV3FL.js.map +1 -0
- package/dist/chunk-NTKYU2M5.js +851 -0
- package/dist/chunk-NTKYU2M5.js.map +1 -0
- package/dist/{chunk-HVJ4KIYX.js → chunk-OM6LDFJL.js} +1 -14
- package/dist/chunk-OM6LDFJL.js.map +1 -0
- package/dist/{chunk-ISNQKSHQ.js → chunk-RSYYHO3Q.js} +549 -1250
- package/dist/chunk-RSYYHO3Q.js.map +1 -0
- package/dist/{chunk-5CXPEEWF.js → chunk-RVGJG5P2.js} +15 -2
- package/dist/chunk-RVGJG5P2.js.map +1 -0
- package/dist/{chunk-CWPBMY7P.js → chunk-TSQGR7GH.js} +66 -10
- package/dist/chunk-TSQGR7GH.js.map +1 -0
- package/dist/{chunk-TNTZBUQX.js → chunk-UZVC74SC.js} +367 -86
- package/dist/chunk-UZVC74SC.js.map +1 -0
- package/dist/{chunk-B4IOFSM6.js → chunk-X2YCUIEK.js} +16 -5
- package/dist/chunk-X2YCUIEK.js.map +1 -0
- package/dist/chunk-Y6IH3B4W.js +805 -0
- package/dist/chunk-Y6IH3B4W.js.map +1 -0
- package/dist/chunk-ZHPUY3QZ.js +1354 -0
- package/dist/chunk-ZHPUY3QZ.js.map +1 -0
- package/dist/cli/quickdraw-docs.js +376 -62
- package/dist/cli/quickdraw-docs.js.map +1 -1
- package/dist/client/index.d.ts +237 -24
- package/dist/client/index.js +235 -51
- package/dist/client/index.js.map +1 -1
- package/dist/{clientTypes-P_VIjnyz.d.ts → clientTypes-BPdAdy5t.d.ts} +150 -253
- package/dist/connection-Dld74yBn.d.ts +278 -0
- package/dist/{createServer-BwhO8YlA.d.ts → createServer-BAHtmsBo.d.ts} +15 -7
- package/dist/{createTestApp-CBESgORq.d.ts → createTestApp-BhDUhx8B.d.ts} +42 -15
- package/dist/{envelope-CKQEC7e-.d.ts → envelope-VWu-kXQf.d.ts} +32 -9
- package/dist/{http-B5hNMuVY.d.ts → http-DL5mLeFE.d.ts} +18 -14
- package/dist/index.d.ts +6 -6
- package/dist/index.js +72 -11
- package/dist/index.js.map +1 -1
- package/dist/{infer-Dlplloxi.d.ts → infer-Dw21kmLw.d.ts} +78 -21
- package/dist/{metrics-BBUPqcsT.d.ts → metrics-C0WvB7-I.d.ts} +2 -2
- package/dist/prisma/index.d.ts +4 -4
- package/dist/prisma/index.js +141 -11
- package/dist/prisma/index.js.map +1 -1
- package/dist/server/auth/index.d.ts +66 -29
- package/dist/server/auth/index.js +56 -4
- package/dist/server/auth/index.js.map +1 -1
- package/dist/server/index.d.ts +155 -31
- package/dist/server/index.js +241 -55
- package/dist/server/index.js.map +1 -1
- package/dist/server/mcp/index.d.ts +7 -7
- package/dist/server/mcp/index.js +2 -2
- package/dist/server/otel.d.ts +3 -3
- package/dist/{sharing-RrW03F9j.d.ts → sharing-BSTPqvgD.d.ts} +3 -3
- package/dist/testing/client.d.ts +25 -164
- package/dist/testing/client.js +49 -578
- package/dist/testing/client.js.map +1 -1
- package/dist/testing/index.d.ts +11 -11
- package/dist/testing/index.js +37 -13
- package/dist/testing/index.js.map +1 -1
- package/dist/testing/mock.d.ts +223 -0
- package/dist/testing/mock.js +16 -0
- package/dist/testing/mock.js.map +1 -0
- package/dist/testing/prisma.d.ts +22 -2
- package/dist/testing/prisma.js +10 -0
- package/dist/testing/prisma.js.map +1 -1
- package/dist/{types-B3N6xKkl.d.ts → types-Bwg3kIEc.d.ts} +1 -1
- package/dist/utils/index.d.ts +1 -1
- package/dist/utils/index.js +2 -2
- package/package.json +5 -1
- package/dist/chunk-5CXPEEWF.js.map +0 -1
- package/dist/chunk-B4IOFSM6.js.map +0 -1
- package/dist/chunk-CWPBMY7P.js.map +0 -1
- package/dist/chunk-DEGCLMTU.js +0 -380
- package/dist/chunk-DEGCLMTU.js.map +0 -1
- package/dist/chunk-GBM7QP5Z.js +0 -400
- package/dist/chunk-GBM7QP5Z.js.map +0 -1
- package/dist/chunk-HVJ4KIYX.js.map +0 -1
- package/dist/chunk-ISNQKSHQ.js.map +0 -1
- package/dist/chunk-JK5D3OXC.js.map +0 -1
- package/dist/chunk-K35VD4CQ.js.map +0 -1
- package/dist/chunk-TNTZBUQX.js.map +0 -1
- package/dist/searchSchemas-CBgKRa1b.d.ts +0 -43
- /package/dist/{chunk-CLPB6IGC.js.map → chunk-6G663RIR.js.map} +0 -0
- /package/dist/{chunk-CNECM3VC.js.map → chunk-HKXLRZAO.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`](
|
|
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
|
|
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)`
|
|
@@ -751,6 +753,18 @@ tracked client as `db`; the server finds the rest on it.
|
|
|
751
753
|
recorded with its row ids, merged per row, and handed to the flush sinks
|
|
752
754
|
once the response has been sent, with one revision per flush. A handler
|
|
753
755
|
may return `db.task.update(...)` without awaiting it.
|
|
756
|
+
- A write that changed nothing is not recorded, so it sends no frame, no
|
|
757
|
+
collection delta, no topic change and no `refreshEntry`: an
|
|
758
|
+
`updateMany`, `updateManyAndReturn` or `deleteMany` that matched no row;
|
|
759
|
+
a `data` (or an upsert's `update`) with nothing to write, `{}`; and an
|
|
760
|
+
update that sets only columns the framework reads before writing (scope,
|
|
761
|
+
`where`, membership, owner and access columns) to the values they held.
|
|
762
|
+
An upsert with `update: {}` ("make sure this row exists") reads the row
|
|
763
|
+
first in its place: one statement when the row is there, and the upsert
|
|
764
|
+
after it when it is not. A write that sets any other column is recorded
|
|
765
|
+
whatever it wrote, since its old value was never read; the `@updatedAt`
|
|
766
|
+
column Prisma moves on an unchanged write is not signalled, as no write's
|
|
767
|
+
is on its own.
|
|
754
768
|
- Writes inside `db.$transaction` join the unit only when it commits; a
|
|
755
769
|
rollback drops them. Prefer the interactive form
|
|
756
770
|
(`db.$transaction(async (tx) => ...)`): an array-form
|
|
@@ -774,6 +788,10 @@ tracked client as `db`; the server finds the rest on it.
|
|
|
774
788
|
not wait for (a push sent after a message, pruning what it reports dead)
|
|
775
789
|
runs in `qd.run(fn, { detached: true })`, a unit of its own that flushes
|
|
776
790
|
when `fn` settles (catch what the promise rejects with: nothing awaits it).
|
|
791
|
+
Before any server exists (a seed at boot, before `createServer`),
|
|
792
|
+
`qd.run(fn)` still runs `fn` in a unit of its own, and its writes reach no
|
|
793
|
+
one: no socket can be subscribed yet (behind a cluster, write after
|
|
794
|
+
`createServer` when other nodes' subscribers must hear of it).
|
|
777
795
|
|
|
778
796
|
<!-- example: apps/api/src/jobs/overdue.ts#run -->
|
|
779
797
|
|
|
@@ -876,7 +894,19 @@ export const taskService = qd.defineService(task, {
|
|
|
876
894
|
rows (a `Date` is fine where the wire has a string, extra columns are
|
|
877
895
|
dropped); with `map`, it returns what `map` takes.
|
|
878
896
|
- Fields the contract's `fields` map puts above the caller's level on a row
|
|
879
|
-
are stripped from that caller's copy, after any shared run.
|
|
897
|
+
are stripped from that caller's copy, after any shared run. Only a
|
|
898
|
+
projection output is stripped (`"entity"`, a named projection, and
|
|
899
|
+
`nullable(...)` or `listOf(...)` of one): a method whose output is a
|
|
900
|
+
schema of its own sends what its handler returns to every caller its
|
|
901
|
+
access admits, so a tiered key in that schema (`email` in a hand-written
|
|
902
|
+
`{ id, name, email }`) reaches callers below its level. When a server is
|
|
903
|
+
made, each such method raises the development warning
|
|
904
|
+
`[quickdraw:tiered-field-in-output]` (thrown by
|
|
905
|
+
`createTestApp({ strictWarnings: true })`, so the test app fails to
|
|
906
|
+
start): answer `"entity"` or a projection, or leave the key out. Not
|
|
907
|
+
warned: a method whose access admits no caller below the field's level
|
|
908
|
+
(`{ service: "Admin" }` with the Admin bypass on, `{ entry: L }` with `L`
|
|
909
|
+
at the field's level or above), and an output without JSON Schema (Zod 3).
|
|
880
910
|
- `affects` names rows of other services a write changes too
|
|
881
911
|
(`{ service, id: column }`, or `{ service, id: (row) => ids, columns }`);
|
|
882
912
|
they are sent again after the flush, one hop.
|
|
@@ -908,7 +938,7 @@ export const taskService = qd.defineService(task, {
|
|
|
908
938
|
Valkey's clock in microseconds, so revisions from all nodes are one order;
|
|
909
939
|
its key needs persistence or replication. A node whose Valkey connection
|
|
910
940
|
comes back has its clients reconnect to catch up.
|
|
911
|
-
[docs/deploying.md](
|
|
941
|
+
[docs/deploying.md](https://github.com/fitzzero/quickdraw/blob/dev/docs/deploying.md) has the wiring, what holds across
|
|
912
942
|
nodes, what it costs, and what happens when a node or Valkey stops
|
|
913
943
|
answering.
|
|
914
944
|
|
|
@@ -1001,7 +1031,15 @@ export const taskService = qd.defineService(task, {
|
|
|
1001
1031
|
scope after a change tracked writes cannot describe.
|
|
1002
1032
|
- `qd:watch { s, topic }` joins a change topic: `{collection}:{scope}`,
|
|
1003
1033
|
authorized like a subscribe to that scope, or `service`, which changes
|
|
1004
|
-
whenever any row of the service does
|
|
1034
|
+
whenever any row of the service does, or a row of a model it lists in
|
|
1035
|
+
`writes` (a game's high scores, which no service owns). A query declares
|
|
1036
|
+
`watch: "service"` to be invalidated by it. A query over some of those
|
|
1037
|
+
models names them, `watch: { service: ["gameScore"] }` (the service's
|
|
1038
|
+
`model` and models in its `writes`; `defineService` refuses others), and
|
|
1039
|
+
is invalidated only after a flush that wrote one of them: high scores are
|
|
1040
|
+
not read again when a chat membership the same service writes changes.
|
|
1041
|
+
It is the same topic: its `qd:changed` frame names the models that
|
|
1042
|
+
changed it (`models`). The service topic is closed
|
|
1005
1043
|
(`FORBIDDEN`) unless the service declares `watchAccess` (`"public"`,
|
|
1006
1044
|
`"authenticated"` or `{ service: level }`). A watcher that loses access
|
|
1007
1045
|
leaves the topic after one last `qd:changed`.
|
|
@@ -1018,7 +1056,9 @@ over three transports (design: sections 3, 8 and 10):
|
|
|
1018
1056
|
|
|
1019
1057
|
- **Socket.IO** (protocol 5): a client connects with
|
|
1020
1058
|
`auth: { token, qd: { protocol: 5, client } }`, receives `qd:hello` with the
|
|
1021
|
-
server's limits
|
|
1059
|
+
server's limits, who it acts for and the server's id (`serverId`, new
|
|
1060
|
+
each time a server starts: a reconnect that brings another one reached a
|
|
1061
|
+
restarted server, or another node), and calls through `qd:call` and
|
|
1022
1062
|
`qd:cancel`. Every socket gets the same few listeners however many methods
|
|
1023
1063
|
the services have. The JSON-only parser is the default; `binary: true`
|
|
1024
1064
|
restores the stock one. The socket rate limiter is on by default (600
|
|
@@ -1031,7 +1071,9 @@ over three transports (design: sections 3, 8 and 10):
|
|
|
1031
1071
|
principal comes from the session cookie (`__Host-session` over HTTPS,
|
|
1032
1072
|
`session` over plain HTTP or with `COOKIE_DOMAIN`, as the auth routes name
|
|
1033
1073
|
it) or an `Authorization: Bearer` token through the same
|
|
1034
|
-
`authenticate
|
|
1074
|
+
`authenticate`, which is told which one it was (`request.credential`):
|
|
1075
|
+
`socketAuth` answers a cookie sent from a page outside `allowedOrigins`
|
|
1076
|
+
with `FORBIDDEN`, as it refuses such a socket; the reply is `{ ok: true, d }` or
|
|
1035
1077
|
`{ ok: false, e: { code, message, data? } }` with the code's HTTP status.
|
|
1036
1078
|
Works on Express 4 and 5, and on a bare Node server. Move it with
|
|
1037
1079
|
`http: { path }`, turn it off with `http: false`, or mount
|
|
@@ -1040,7 +1082,13 @@ over three transports (design: sections 3, 8 and 10):
|
|
|
1040
1082
|
`./server/express`), which refuses with the `RATE_LIMITED` reply.
|
|
1041
1083
|
- **In process**: `server.dispatcher.caller(principal)` or
|
|
1042
1084
|
`qd.caller(principal)`: `await qd.caller(user).taskService.rename(input)`,
|
|
1043
|
-
typed by the `contracts` of `initQuickdraw`'s types.
|
|
1085
|
+
typed by the `contracts` of `initQuickdraw`'s types. A principal that
|
|
1086
|
+
carries no `serviceAccess` gets the grants `auth.loadServiceAccess`
|
|
1087
|
+
loads, as a socket's handshake and an HTTP call do: at the caller's first
|
|
1088
|
+
call, and again at its next call after the server applied new grants to
|
|
1089
|
+
a user (`server.access.refresh`, a tracked write to
|
|
1090
|
+
`auth.serviceAccessSource`). One that carries grants (even `{}`) keeps
|
|
1091
|
+
exactly those. A load that fails rejects the call with `INTERNAL`.
|
|
1044
1092
|
|
|
1045
1093
|
`authenticate` takes one request (`{ transport, auth, headers, socket | req }`)
|
|
1046
1094
|
for both transports and returns a principal, a user id, or nothing for an
|
|
@@ -1154,15 +1202,16 @@ the method's kind does not have, is a compile error.
|
|
|
1154
1202
|
`QueryClient` (5-minute stale time by default), and works without DOM
|
|
1155
1203
|
globals (React Native).
|
|
1156
1204
|
|
|
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,
|
|
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
|
-
| `
|
|
1205
|
+
| Member | Gives |
|
|
1206
|
+
| ------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
|
|
1207
|
+
| `qd.task.get.useQuery(input, options)` | TanStack's `useQuery`; errors are `QuickdrawError` with a `code` |
|
|
1208
|
+
| `qd.task.rename.useMutation(options)` | TanStack's `useMutation`; `mutate` returns nothing, `mutateAsync` the output |
|
|
1209
|
+
| `qd.task.useEntity(id)`, `useEntities(ids)` | live rows at the user's level: `{ data, isLoading, isRemoved, error }` |
|
|
1210
|
+
| `qd.task.board.useCollection(scope, { view, load, limit })` | a live scope: `{ items, index, byId, pending, totalCount, hasMore, isLoading, loadMore, ... }` |
|
|
1211
|
+
| `qd.task.get.call(input)`, `.key(input)`, `.prefetch(queryClient, input)` | a call over the mounted provider's connection, the cache key, a prefetch |
|
|
1212
|
+
| `qd.invalidate(qd.task.get, input?)` | invalidates through the coordinator: a read in flight is never cancelled |
|
|
1213
|
+
| `qd.task.get.setData(input, updater)` | writes a cached result an event carries, at once; a read in flight is followed by one more |
|
|
1214
|
+
| `useQuickdraw()` | `{ connection, status, isConnected, isKnown, reconnecting, userId, serviceAccess, hello, ... }` |
|
|
1166
1215
|
|
|
1167
1216
|
<!-- example: apps/web/src/components/TaskDetail.tsx#detail -->
|
|
1168
1217
|
|
|
@@ -1194,6 +1243,19 @@ export function TaskDetail({ id }: { readonly id: string }) {
|
|
|
1194
1243
|
}
|
|
1195
1244
|
```
|
|
1196
1245
|
|
|
1246
|
+
- A row that does not exist is refused the way a row the reader may not
|
|
1247
|
+
see is: `FORBIDDEN`, for a subscribe and for a method whose access names
|
|
1248
|
+
the row (`{ entry }`), since a policy that cannot find the row grants
|
|
1249
|
+
nothing (fail closed), and telling the two apart would tell a stranger
|
|
1250
|
+
which ids exist (a service-wide `Admin` grant, which needs no row to
|
|
1251
|
+
pass, gets `NOT_FOUND`). So a page cannot tell "deleted" from "no access" by the
|
|
1252
|
+
refusal: it learns of a delete while it holds the row, live: the `r`
|
|
1253
|
+
frame sets `useEntity`'s `isRemoved` (as above), and a collection's
|
|
1254
|
+
`removed` delta takes the item out of `items`. A page opened after the
|
|
1255
|
+
delete (a link, a reload, a reconnect after the row went) gets only
|
|
1256
|
+
`FORBIDDEN`; where the difference matters, say "not found or not shared
|
|
1257
|
+
with you", or ask a method of the parent row (a list of the project's
|
|
1258
|
+
tasks) that can answer without leaking.
|
|
1197
1259
|
- A field the contract's `fields` map tiers (`notes` here) is optional in
|
|
1198
1260
|
every row type a reader gets (`useEntity`, collection items, `"entity"`
|
|
1199
1261
|
outputs, `EntityOf`, `ItemOf`), because a reader below its level receives
|
|
@@ -1203,12 +1265,18 @@ export function TaskDetail({ id }: { readonly id: string }) {
|
|
|
1203
1265
|
collection items from the moment it is sent, are dropped if it fails, and
|
|
1204
1266
|
give way to the server's frame. `optimistic: false` turns that off;
|
|
1205
1267
|
`optimistic: (input, cache) => ...` writes its own layers with
|
|
1206
|
-
`patchEntity`, `removeEntity` and `patchItem
|
|
1268
|
+
`patchEntity`, `removeEntity` and `patchItem`, and adds rows with
|
|
1269
|
+
`addItem` and `addEntity` (below).
|
|
1207
1270
|
- Live rows and collections need no refetching: frames keep them current,
|
|
1208
1271
|
and after a reconnect they resume by revision. A query whose result
|
|
1209
1272
|
follows writes declares `watch` in its contract; the coordinator fetches
|
|
1210
1273
|
it again once per change, with at most one read in flight per key. Do not
|
|
1211
1274
|
call `refetch` or `invalidateQueries` on quickdraw keys after a mutation.
|
|
1275
|
+
- An event that carries a query's new result (a room's roster, a score)
|
|
1276
|
+
writes it with `qd.<service>.<query>.setData(input, updater)` rather than
|
|
1277
|
+
refetching: the hooks show it at once, under the overlays of optimistic
|
|
1278
|
+
mutations, and a read of that key already in flight (which may predate
|
|
1279
|
+
the event) is followed by one more, never cancelled.
|
|
1212
1280
|
- After a reconnect, the queries that are watched (they missed the changes
|
|
1213
1281
|
meanwhile) or stale are refetched, each after a random delay of up to
|
|
1214
1282
|
`reconnectJitterMs` (2,000 ms by default), so clients that reconnect
|
|
@@ -1227,6 +1295,94 @@ export function TaskDetail({ id }: { readonly id: string }) {
|
|
|
1227
1295
|
- A protocol mismatch reloads the page once per session by default
|
|
1228
1296
|
(`onProtocolMismatch`); `RATE_LIMITED` answers back off with jitter per
|
|
1229
1297
|
kind of work.
|
|
1298
|
+
- Server rendering: a server never connects, so `useQuickdraw()`,
|
|
1299
|
+
`usePresence`, streams and overlays render the state a new connection has
|
|
1300
|
+
(nobody known, no rooms, nothing loaded), and the browser hydrates that
|
|
1301
|
+
same state even where the provider connected first (a Suspense boundary
|
|
1302
|
+
that hydrates late), then renders the live one.
|
|
1303
|
+
|
|
1304
|
+
A create shows its row before the server answers with `cache.addItem(collection,
|
|
1305
|
+
scope, item)`: the item appears in the scope at once, in its place by the
|
|
1306
|
+
collection's `order` (give it the order's fields; without them it goes
|
|
1307
|
+
last), and `useCollection`'s `pending` names it while the call is in flight.
|
|
1308
|
+
A refused call removes it, unless it was added with `{ onRefused: "keep" }`
|
|
1309
|
+
(`cache.addItem(collection, scope, item, { onRefused: "keep" })`, or the
|
|
1310
|
+
same for `addEntity`): it then leaves `items` for `useCollection`'s
|
|
1311
|
+
`refused`, each `{ item, error, dismiss(), retry() }`, until the app
|
|
1312
|
+
dismisses it or sends the same call again with `retry()` (which shows it
|
|
1313
|
+
`pending` again, and never rejects: a second refusal shows in `refused`
|
|
1314
|
+
again), so a chat shows a failed message with "retry" without a copy of
|
|
1315
|
+
its own. The reply's `id` (the created row's) and values
|
|
1316
|
+
replace the item's own, and once the scope's own copy arrives (its `added`
|
|
1317
|
+
delta, or a load) that copy shows in its place: never both, never a gap.
|
|
1318
|
+
`cache.addEntity(row)` adds a row to every collection of entity rows whose
|
|
1319
|
+
scope column (and `where`) it matches; a collection of a projection takes
|
|
1320
|
+
`addItem`. The item may give its own `id`, one the client made and the
|
|
1321
|
+
server keeps:
|
|
1322
|
+
|
|
1323
|
+
<!-- example: apps/web/src/components/AddTask.tsx#add -->
|
|
1324
|
+
|
|
1325
|
+
```tsx
|
|
1326
|
+
export function TaskList({ projectId }: { readonly projectId: string }) {
|
|
1327
|
+
const { items, pending } = qd.task.board.useCollection(projectId);
|
|
1328
|
+
const create = qd.task.create.useMutation({
|
|
1329
|
+
// the new card shows at once, last on the board (its ordinal), until the server's arrives
|
|
1330
|
+
optimistic: (input, cache) =>
|
|
1331
|
+
cache.addItem("board", input.projectId, {
|
|
1332
|
+
projectId: input.projectId,
|
|
1333
|
+
title: input.title,
|
|
1334
|
+
status: "open",
|
|
1335
|
+
ordinal: Number.MAX_SAFE_INTEGER,
|
|
1336
|
+
assigneeId: null,
|
|
1337
|
+
}),
|
|
1338
|
+
});
|
|
1339
|
+
return (
|
|
1340
|
+
<>
|
|
1341
|
+
<ul>
|
|
1342
|
+
{items.map((task) => (
|
|
1343
|
+
// pending: the create is on its way; the card is the server's once it answers
|
|
1344
|
+
<li key={task.id} style={{ opacity: pending.has(task.id) ? 0.5 : 1 }}>
|
|
1345
|
+
{task.title}
|
|
1346
|
+
</li>
|
|
1347
|
+
))}
|
|
1348
|
+
</ul>
|
|
1349
|
+
<button type="button" onClick={() => create.mutate({ projectId, title: "New task" })}>
|
|
1350
|
+
Add
|
|
1351
|
+
</button>
|
|
1352
|
+
{create.error === null ? null : <p>{`Not added: ${create.error.code}`}</p>}
|
|
1353
|
+
</>
|
|
1354
|
+
);
|
|
1355
|
+
}
|
|
1356
|
+
```
|
|
1357
|
+
|
|
1358
|
+
`useQuickdraw()` says who the connection acts for. `userId` is `null` both
|
|
1359
|
+
for an anonymous socket and before the server's hello, so a gate waits for
|
|
1360
|
+
`isKnown` (the hello on the current credentials arrived; false again from
|
|
1361
|
+
new credentials until theirs). `isConnected` turns true before the hello and
|
|
1362
|
+
false while the connection reconnects; `reconnecting` says the user and the
|
|
1363
|
+
page stay meanwhile:
|
|
1364
|
+
|
|
1365
|
+
<!-- example: apps/web/src/components/AuthGate.tsx#gate -->
|
|
1366
|
+
|
|
1367
|
+
```tsx
|
|
1368
|
+
export function AuthGate({ children }: { readonly children: ReactNode }) {
|
|
1369
|
+
// isKnown: the server's hello named the user, so userId is final (null: signed out)
|
|
1370
|
+
const { isKnown, userId, reconnecting } = useQuickdraw();
|
|
1371
|
+
if (!isKnown) {
|
|
1372
|
+
return <p>Connecting…</p>;
|
|
1373
|
+
}
|
|
1374
|
+
if (userId === null) {
|
|
1375
|
+
return <SignIn />;
|
|
1376
|
+
}
|
|
1377
|
+
// a reconnect keeps the user and the page: say so, unmount nothing
|
|
1378
|
+
return (
|
|
1379
|
+
<>
|
|
1380
|
+
{reconnecting ? <p role="status">Reconnecting…</p> : null}
|
|
1381
|
+
{children}
|
|
1382
|
+
</>
|
|
1383
|
+
);
|
|
1384
|
+
}
|
|
1385
|
+
```
|
|
1230
1386
|
|
|
1231
1387
|
### Server components and other runtimes
|
|
1232
1388
|
|
|
@@ -1282,9 +1438,9 @@ such code, and `createInvalidationCoordinator(queryClient)` invalidates as
|
|
|
1282
1438
|
the hooks do.
|
|
1283
1439
|
|
|
1284
1440
|
A client in another language (a Godot game, a native app) speaks the wire
|
|
1285
|
-
itself: [`docs/protocol-v5.md`](
|
|
1286
|
-
generated from the protocol's source, and [`examples/godot`](
|
|
1287
|
-
holds a GDScript client written from it. [`docs/clients.md`](
|
|
1441
|
+
itself: [`docs/protocol-v5.md`](https://github.com/fitzzero/quickdraw/blob/dev/docs/protocol-v5.md) is its specification,
|
|
1442
|
+
generated from the protocol's source, and [`examples/godot`](https://github.com/fitzzero/quickdraw/tree/dev/examples/godot)
|
|
1443
|
+
holds a GDScript client written from it. [`docs/clients.md`](https://github.com/fitzzero/quickdraw/blob/dev/docs/clients.md)
|
|
1288
1444
|
compares the three ways in.
|
|
1289
1445
|
|
|
1290
1446
|
## Kits
|
|
@@ -1293,8 +1449,10 @@ The methods most services write by hand, as one-line opt-ins (design:
|
|
|
1293
1449
|
section 12). Each kit's contract half comes from the package root and makes
|
|
1294
1450
|
ordinary contract entries; its handlers come from `./server`. Lint's
|
|
1295
1451
|
`prefer-kit` reports a method written by hand that a kit implements (`get`,
|
|
1296
|
-
`list`, `create`, `getTask`, ...) in a service that uses no kit
|
|
1297
|
-
|
|
1452
|
+
`list`, `create`, `getTask`, ...) in a service that uses no kit, and one
|
|
1453
|
+
that duplicates what a kit spread beside it serves (a `getNote` beside
|
|
1454
|
+
`crud.handlers(note, { access: { get } })`); one that must stay
|
|
1455
|
+
hand-written says why in a `// quickdraw: hand-written because ...`
|
|
1298
1456
|
comment above it.
|
|
1299
1457
|
|
|
1300
1458
|
### Read/write kit
|
|
@@ -1759,7 +1917,10 @@ export function AdminTasks() {
|
|
|
1759
1917
|
// [{ key: "task", serviceName, displayName }]
|
|
1760
1918
|
const { services } = useAdminServices(qd);
|
|
1761
1919
|
const { data } = qd.task.admin.adminList.useQuery({ page: 1, sort: { field: "title" } });
|
|
1762
|
-
const update = qd.task.admin.adminUpdate.useMutation(
|
|
1920
|
+
const update = qd.task.admin.adminUpdate.useMutation({
|
|
1921
|
+
// adminList is a query, not live data: read the page again after this screen's own write
|
|
1922
|
+
onSuccess: () => qd.invalidate(qd.task.admin.adminList),
|
|
1923
|
+
});
|
|
1763
1924
|
return (
|
|
1764
1925
|
<table aria-label={services[0]?.displayName}>
|
|
1765
1926
|
<tbody>
|
|
@@ -1807,7 +1968,7 @@ export function AdminTasks() {
|
|
|
1807
1968
|
value is checked by the entity schema itself, and a value the database
|
|
1808
1969
|
refuses is `VALIDATION`. A missing row is `NOT_FOUND`.
|
|
1809
1970
|
- `adminMeta()` returns `{ serviceName, displayName, fields }`, one
|
|
1810
|
-
`{ name, type, label, required, editable, showInTable, sortable, filterable, enumValues?, relationService? }`
|
|
1971
|
+
`{ name, type, label, required, editable, showInTable, sortable, filterable, enumValues?, relationService?, kind?, showInForm? }`
|
|
1811
1972
|
per field: `type` is
|
|
1812
1973
|
`string`, `number`, `boolean`, `date` (an ISO string with a date format),
|
|
1813
1974
|
`enum` or `json` from the field's JSON Schema, and `relation` by override;
|
|
@@ -1824,7 +1985,26 @@ export function AdminTasks() {
|
|
|
1824
1985
|
rows without it and `FORBIDDEN` for a write, filter or sort naming it.
|
|
1825
1986
|
The write is tracked, so with `auth.serviceAccessSource` naming the column
|
|
1826
1987
|
the user's open sockets get the new grants at once, on every node. Such
|
|
1827
|
-
an Admin can grant any service, themself included.
|
|
1988
|
+
an Admin can grant any service, themself included. The field's
|
|
1989
|
+
configuration says `kind: "grants"`, so a screen with a grants editor of
|
|
1990
|
+
its own finds it without its name, and
|
|
1991
|
+
`fieldOverrides: { serviceAccess: { showInForm: false } }` keeps it out
|
|
1992
|
+
of a generic create or edit form (a form shows the fields whose
|
|
1993
|
+
`showInForm` is not `false`); the kit still reads and writes it.
|
|
1994
|
+
- `admin.handlers(contract, { onWrite })` runs `onWrite({ method, id,
|
|
1995
|
+
before?, after }, ctx, db)` after each `adminCreate`, `adminUpdate` and
|
|
1996
|
+
`adminDelete`, in one transaction with the write (`db` is the
|
|
1997
|
+
transaction's tracked client; a throw undoes the write and fails the
|
|
1998
|
+
call): what an admin edit must set off, such as a game reloading its
|
|
1999
|
+
tunables, without wrapping the kit's handlers. The rows are the entity,
|
|
2000
|
+
every field. `onCommitted({ method, id, before?, after }, ctx)` runs once
|
|
2001
|
+
the write has committed (after `onWrite`'s transaction), in a detached
|
|
2002
|
+
unit of work of its own: the reply does not wait for it, its writes flush
|
|
2003
|
+
on their own, and a throw is logged, the write standing. Apply an edit to
|
|
2004
|
+
what runs in memory (a game's simulation) there, so an edit that rolled
|
|
2005
|
+
back is never applied. Without either hook the kit opens no transaction.
|
|
2006
|
+
Each handler `admin.handlers` returns resolves with its method's output
|
|
2007
|
+
type, so a wrapper reads the row and returns it with no cast.
|
|
1828
2008
|
- `adminSubscribers({ id })` counts the sockets subscribed to a row per
|
|
1829
2009
|
access level (`{ id, count, levels, complete }`; behind a Redis adapter
|
|
1830
2010
|
the counts are this server's and `complete` is `false`), and
|
|
@@ -1834,6 +2014,21 @@ export function AdminTasks() {
|
|
|
1834
2014
|
`qd.admin`, since `admin` is reserved per service). `useAdminServices(qd)`
|
|
1835
2015
|
lists the client's services whose `adminMeta` answers the user, with their
|
|
1836
2016
|
display names, sharing the cache of `qd.<service>.admin.adminMeta.useQuery()`.
|
|
2017
|
+
It asks only the services the user's grants (from the server's hello)
|
|
2018
|
+
allow: `Admin` by default, what the kit's methods require;
|
|
2019
|
+
`useAdminServices(qd, { requires: "Moderate" })` for an `adminMeta` given
|
|
2020
|
+
another form, `requires: null` for every service. A service that refused
|
|
2021
|
+
is not asked again until the user's grant on it changes, reconnects
|
|
2022
|
+
included.
|
|
2023
|
+
- The kit's rows are not live: `adminList` and `adminGet` are queries that
|
|
2024
|
+
watch nothing, so a screen reads them again after its own writes (the
|
|
2025
|
+
`onSuccess` above), and sees other admins' writes when it next reads.
|
|
2026
|
+
- A screen that serves every service from its metadata (the route names the
|
|
2027
|
+
service) takes `adminOf(qd, key)`: the same members, keyed by what the kit
|
|
2028
|
+
made them for and typed by field name (`AdminScreen`: rows are
|
|
2029
|
+
`AdminRow`, `adminList` takes `{ page, pageSize, filter, sort: { field, direction } }`
|
|
2030
|
+
with names read from `adminMeta`), so no cast is needed over a union of
|
|
2031
|
+
keys; a method the contract does not expose is absent.
|
|
1837
2032
|
|
|
1838
2033
|
### Presence, streams and channels
|
|
1839
2034
|
|
|
@@ -1899,6 +2094,16 @@ export const taskService = qd.defineService(task, {
|
|
|
1899
2094
|
ctx.rooms.emit(`board:${payload.projectId}`, task, "cursorMoved", payload);
|
|
1900
2095
|
},
|
|
1901
2096
|
},
|
|
2097
|
+
// once per socket that leaves app rooms, in every server this service runs in, in a unit of its own
|
|
2098
|
+
onRoomLeave: ({ principal, rooms }) => {
|
|
2099
|
+
for (const { room, last } of rooms) {
|
|
2100
|
+
// last: no socket of the user is in the room any more (a second tab keeps it false)
|
|
2101
|
+
if (principal !== null && last && room.startsWith("board:")) {
|
|
2102
|
+
const projectId = room.slice("board:".length);
|
|
2103
|
+
qd.rooms.emit(room, task, "leftBoard", { projectId, userId: principal.userId });
|
|
2104
|
+
}
|
|
2105
|
+
}
|
|
2106
|
+
},
|
|
1902
2107
|
});
|
|
1903
2108
|
|
|
1904
2109
|
// in handlers, jobs and timers
|
|
@@ -1922,14 +2127,17 @@ export function TaskRoom({
|
|
|
1922
2127
|
readonly projectId: string;
|
|
1923
2128
|
readonly taskId: string;
|
|
1924
2129
|
}) {
|
|
2130
|
+
// the socket is in the board's room on every connection: a reconnect is a new socket in no room
|
|
2131
|
+
const board = useJoin(qd.task.enterBoard, { projectId });
|
|
1925
2132
|
const { items } = qd.task.logs.useStream(taskId, { max: 200 });
|
|
1926
2133
|
const { send, isReady } = qd.task.cursor.useChannel();
|
|
1927
2134
|
const [lastX, setLastX] = useState(0);
|
|
1928
2135
|
qd.task.cursorMoved.useEvent((cursor) => setLastX(cursor.x));
|
|
1929
|
-
// user ids,
|
|
2136
|
+
// user ids, once enterBoard joined the room
|
|
1930
2137
|
const here = usePresence(`board:${projectId}`);
|
|
2138
|
+
const move = (x: number) => isReady && board.isJoined && send({ projectId, taskId, x });
|
|
1931
2139
|
return (
|
|
1932
|
-
<div onMouseMove={(event) =>
|
|
2140
|
+
<div onMouseMove={(event) => move(event.clientX)}>
|
|
1933
2141
|
<p>{`${String(here.length)} here; a cursor at ${String(lastX)}`}</p>
|
|
1934
2142
|
<pre>{items.map((item) => item.line).join("\n")}</pre>
|
|
1935
2143
|
</div>
|
|
@@ -1943,20 +2151,33 @@ export function TaskRoom({
|
|
|
1943
2151
|
latest `seed` items per scope in memory on that process (at most 1,000 per
|
|
1944
2152
|
scope and 10,000 scopes per stream; a restart empties them, and durable
|
|
1945
2153
|
history is the app's: store the rows and expose a collection), and sends
|
|
1946
|
-
`qd:stream
|
|
1947
|
-
|
|
2154
|
+
`qd:stream [service, stream, scope, item]` (`scope` null for a global
|
|
2155
|
+
stream; positional, so a fast stream's frames carry no key names) to the
|
|
2156
|
+
feed's subscribers, volatile when the stream says so. `pushMany(scope, items)`
|
|
1948
2157
|
(`pushMany(items)` for a global stream) pushes several items to one feed
|
|
1949
2158
|
at once: every item is checked before any is kept or sent, and each goes
|
|
1950
2159
|
out as its own frame, in order; use it rather than `push` in a loop
|
|
1951
2160
|
(`no-emit-in-loop`). `qd:stream:sub` is authorized with the
|
|
1952
2161
|
stream's `access` through the access engine, the scope being the row an
|
|
1953
|
-
`entry` or `scope` form checks;
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
2162
|
+
`entry` or `scope` form checks; `access: { room }` (a name, `{ prefix }`,
|
|
2163
|
+
or for a scoped stream a function of the scope) opens it to the sockets
|
|
2164
|
+
in that app room instead, signed in or not, and a socket that leaves the
|
|
2165
|
+
room, or is taken out, leaves the feed; a stream without `access` is
|
|
2166
|
+
closed. The answer is the seed; `useStream` then appends, keeps the
|
|
2167
|
+
latest `max` (default 500), and subscribes again after a reconnect, when
|
|
2168
|
+
the seed replaces what it held. A socket holds at most 500 feeds. A
|
|
2169
|
+
subscriber whose access is lowered is authorized again; one refused
|
|
2170
|
+
leaves the feed and gets
|
|
2171
|
+
`qd:revoked { kind: "stream", reason: "access", s, stream, scope? }`, and
|
|
2172
|
+
`useStream` shows `FORBIDDEN` until the next connect.
|
|
2173
|
+
- A service's `streams: { <name>: { seed, validate } }` computes a stream's
|
|
2174
|
+
seed per subscriber, `seed: (scope, ctx) => items` (the current state,
|
|
2175
|
+
where the items that follow are deltas; on whichever node the subscriber
|
|
2176
|
+
is on, under its principal, in the tick it joins the feed; a contract
|
|
2177
|
+
`seed: n` and a seed function cannot both be declared), and
|
|
2178
|
+
`validate: "development"` checks pushed items only while the dispatcher
|
|
2179
|
+
checks outputs (`outputValidation`, off in production), for a hot stream:
|
|
2180
|
+
unchecked, an item goes out as pushed, extra keys included.
|
|
1960
2181
|
- Channels: each message is `qd:ch [service, channel, payload]`, sent
|
|
1961
2182
|
volatile and never answered. Per socket and channel a token bucket
|
|
1962
2183
|
(`ratePerSecond`, default 30; `burst`, default twice that) drops what is
|
|
@@ -1968,10 +2189,13 @@ export function TaskRoom({
|
|
|
1968
2189
|
scope; `{ room }`: the app room, a name like `"world"` or a function of
|
|
1969
2190
|
the payload, which a call over that same socket joined) is dropped.
|
|
1970
2191
|
Nothing is logged per message; a handler's error is. The socket rate
|
|
1971
|
-
limiter does not count channels.
|
|
1972
|
-
socket
|
|
1973
|
-
|
|
1974
|
-
|
|
2192
|
+
limiter does not count channels. `{ room: { prefix: "world:" } }` takes
|
|
2193
|
+
a socket in any app room whose name starts with the prefix (a game of many
|
|
2194
|
+
worlds), and every room form gives the handler the room it matched as
|
|
2195
|
+
`ctx.room`, so the payload need not repeat it. Every requirement is the
|
|
2196
|
+
sending socket's own: a room another socket of the user joined does not
|
|
2197
|
+
count, a reconnected socket must join again, and behind a cluster the
|
|
2198
|
+
check runs on the node the socket is connected to, with no round trip.
|
|
1975
2199
|
- Presence: `isOnline`, `lastSeen` (now while online, else when the user's
|
|
1976
2200
|
last socket on this process disconnected), `count` and `users` (each user
|
|
1977
2201
|
once, anonymous sockets left out; app rooms only) come from this process's
|
|
@@ -1986,16 +2210,26 @@ export function TaskRoom({
|
|
|
1986
2210
|
`emitToUser(userId, ...)` check the payload first (`INTERNAL`, nothing
|
|
1987
2211
|
sent, when it fails), then send the validated payload as
|
|
1988
2212
|
`qd:event [service, event, payload]`; `useEvent` hears them.
|
|
2213
|
+
- Rooms belong to a socket: a reconnect (a lost network, `qd:rotate`, new
|
|
2214
|
+
credentials) is a new socket in no room, which hears none of its events
|
|
2215
|
+
and whose channel messages requiring it are dropped. `useJoin(member,
|
|
2216
|
+
input, { enabled?, onJoined? })` (from `./client`) runs the joining call
|
|
2217
|
+
(`enterBoard` above) on every `qd:hello` and when its input changes by
|
|
2218
|
+
value, never on a re-render, and shows `status` (`idle` with no socket to
|
|
2219
|
+
join with, `joining`, `joined`, `error`), `isJoined`, `data` and `error`;
|
|
2220
|
+
a refusal stands until the next hello or `retry()` (which runs the call
|
|
2221
|
+
again at once on the current socket, and does nothing while there is
|
|
2222
|
+
none or `enabled` is false), `RATE_LIMITED` is tried again after its
|
|
2223
|
+
backoff, and it never leaves the room itself.
|
|
2224
|
+
`connection.onHello(listener)` is the same hook without React.
|
|
1989
2225
|
|
|
1990
2226
|
Code that is not a handler (a game loop, a timer, a job) reaches rooms
|
|
1991
|
-
through `qd.rooms` (also `server.rooms`)
|
|
1992
|
-
leave:
|
|
2227
|
+
through `qd.rooms` (also `server.rooms`); the service's `onRoomLeave` (above)
|
|
2228
|
+
hears sockets leave:
|
|
1993
2229
|
|
|
1994
2230
|
<!-- example: apps/api/src/services/kits/rooms.ts#rooms -->
|
|
1995
2231
|
|
|
1996
2232
|
```ts
|
|
1997
|
-
import type { RoomLeaveHandler } from "@fitzzero/quickdraw-core/server";
|
|
1998
|
-
|
|
1999
2233
|
const boardRoom = (projectId: string): string => `board:${projectId}`;
|
|
2000
2234
|
|
|
2001
2235
|
// a timer or a game loop's tick, outside any handler: every socket in the room, on every node
|
|
@@ -2009,20 +2243,20 @@ export async function removeFromBoard(projectId: string, userId: string): Promis
|
|
|
2009
2243
|
await qd.rooms.leave(boardRoom(projectId), { userId });
|
|
2010
2244
|
}
|
|
2011
2245
|
|
|
2012
|
-
//
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
const projectId = room.slice("board:".length);
|
|
2018
|
-
qd.rooms.emit(room, task, "leftBoard", { projectId, userId: principal.userId });
|
|
2019
|
-
}
|
|
2020
|
-
}
|
|
2021
|
-
};
|
|
2246
|
+
// a tick loop's "is anyone watching?", at its tick rate: this node's sockets in the room,
|
|
2247
|
+
// anonymous ones too, with no promise (presence.count asks every node for users)
|
|
2248
|
+
export function hasAudience(projectId: string): boolean {
|
|
2249
|
+
return qd.rooms.size(boardRoom(projectId)) > 0;
|
|
2250
|
+
}
|
|
2022
2251
|
```
|
|
2023
2252
|
|
|
2024
2253
|
- `qd.rooms.emit` and `emitToUser` are `ctx.rooms`' own, from anywhere,
|
|
2025
2254
|
reaching every node behind a cluster adapter.
|
|
2255
|
+
- `rooms.size(room)` (on `qd.rooms`, `server.rooms` and `ctx.rooms`) counts
|
|
2256
|
+
the sockets in an app room on this node, anonymous ones included, at once:
|
|
2257
|
+
a game loop can ask it every tick. It is local by design; `presence.count`
|
|
2258
|
+
counts users on every node. A handler's `ctx.socketId` names the socket
|
|
2259
|
+
its call arrived on (`undefined` over HTTP, MCP or in process).
|
|
2026
2260
|
- `leave(room, { userId })` (on `qd.rooms`, and on `ctx.rooms` beside the
|
|
2027
2261
|
calling socket's own `leave(room)`) takes every socket of the user out of
|
|
2028
2262
|
an app room, on every node: they hear nothing more of it, a channel that
|
|
@@ -2030,8 +2264,13 @@ export const onRoomLeave: RoomLeaveHandler<AppPrincipal> = ({ principal, rooms }
|
|
|
2030
2264
|
`qd:presence { room, users: [] }`, and the room hears `left`. Behind a
|
|
2031
2265
|
cluster it is broadcast and answered, so await it before sending what the
|
|
2032
2266
|
user must not receive. Joining again is the app's to refuse.
|
|
2033
|
-
-
|
|
2034
|
-
|
|
2267
|
+
- A service's `onRoomLeave` runs once per socket that leaves app rooms, in
|
|
2268
|
+
every server the service runs in (`createTestApp` and a benchmark's
|
|
2269
|
+
server included, with nothing to wire), beside every other service's and
|
|
2270
|
+
`createServer({ onRoomLeave })`, the app's own; each runs in a unit of
|
|
2271
|
+
work of its own, and one that throws stops none of the others. It hears
|
|
2272
|
+
every app room a socket leaves, so check the room's name. It runs on
|
|
2273
|
+
the node that held the socket: its own `leave` (`reason: "leave"`), a
|
|
2035
2274
|
removal (`"removed"`), or a disconnect, which leaves every app room it was
|
|
2036
2275
|
in (`"disconnect"`; `qd:rotate` reconnects with a new socket). Each room
|
|
2037
2276
|
comes with `last`: true when no socket of that user is in the room any
|
|
@@ -2173,8 +2412,16 @@ nothing is cached:
|
|
|
2173
2412
|
any page's handshake). A handshake without `Origin` is refused unless it
|
|
2174
2413
|
is a browser's same-origin request (`Sec-Fetch-Site: same-origin`) or
|
|
2175
2414
|
`allowMissingOrigin: true` is set for native clients that keep cookies.
|
|
2176
|
-
|
|
2177
|
-
|
|
2415
|
+
An HTTP call (`/qd/...`) that sends the cookie is checked the same way
|
|
2416
|
+
and answered `FORBIDDEN` (403) from an `Origin` outside `allowedOrigins`,
|
|
2417
|
+
besides needing its JSON content type. One without `Origin` is accepted:
|
|
2418
|
+
a browser sends `Origin` with every POST, so it comes from curl or a
|
|
2419
|
+
server rendering a page with the user's forwarded cookie (refused only
|
|
2420
|
+
when `Sec-Fetch-Site` names another site). Bearer tokens need no Origin
|
|
2421
|
+
on either transport. An app's own `authenticate` learns where an HTTP
|
|
2422
|
+
call's token came from in `request.credential` (`"cookie"` or
|
|
2423
|
+
`"bearer"`), and a `QuickdrawError("FORBIDDEN")` it throws on HTTP is
|
|
2424
|
+
answered as it is.
|
|
2178
2425
|
- `socketAuth({ devCredentials })` signs a socket in by the user id its
|
|
2179
2426
|
handshake names (`auth: { userId }`, no token), as the function answers
|
|
2180
2427
|
(the principal, or `null` to refuse): for a game editor or load-test bots
|
|
@@ -2191,11 +2438,11 @@ nothing is cached:
|
|
|
2191
2438
|
session's token, for clients that keep no cookies (a game engine, a page
|
|
2192
2439
|
in a third-party iframe), at the cost of the token being readable by the
|
|
2193
2440
|
page's scripts.
|
|
2194
|
-
- `requireSession({ sessions, jwtSecret })` guards the
|
|
2195
|
-
routes: the credential is read as `/me` reads it,
|
|
2196
|
-
and the session checked in the store
|
|
2197
|
-
|
|
2198
|
-
|
|
2441
|
+
- `requireSession({ sessions, jwtSecret }, { loadPrincipal? })` guards the
|
|
2442
|
+
app's own REST routes (below): the credential is read as `/me` reads it,
|
|
2443
|
+
the JWT verified once and the session checked in the store; otherwise
|
|
2444
|
+
401 `{ error: "UNAUTHENTICATED", message }`. 4.1's `createRequireAuth`
|
|
2445
|
+
stays for token-keyed sessions.
|
|
2199
2446
|
- Rate limits: the sign-in routes share `createAuthLimiter({ max: 60 })` (60
|
|
2200
2447
|
requests per 15 minutes per IP) and the session routes
|
|
2201
2448
|
`createAuthStatusLimiter()` (120); pass `rateLimit: { signIn, session }`
|
|
@@ -2240,6 +2487,83 @@ nothing is cached:
|
|
|
2240
2487
|
session for an app's own sign-in flow (login codes, an embedded activity),
|
|
2241
2488
|
and `liveSession` reads a token back; both work with `socketAuth`.
|
|
2242
2489
|
|
|
2490
|
+
From the browser, `./client` speaks to these routes: `signInUrl(provider,
|
|
2491
|
+
{ apiUrl, returnTo })` is the start route's URL (to navigate to; `returnTo`
|
|
2492
|
+
defaults to the current page's origin), and `signOut()` and
|
|
2493
|
+
`signOutEverywhere()` post to `logout` and `logout-all`. Each POST sends the
|
|
2494
|
+
session cookie (`credentials: "include"`, so the API's CORS must allow the
|
|
2495
|
+
web app's origin with credentials) with `Content-Type: application/json`,
|
|
2496
|
+
and the token `setAuthToken` stored, if any, as a bearer token; the stored
|
|
2497
|
+
token is forgotten either way, and a refusal or an unreachable server
|
|
2498
|
+
rejects with a `QuickdrawError`. A socket keeps the user it signed in as
|
|
2499
|
+
until it connects again, so reconnect the provider's connection after
|
|
2500
|
+
signing out (with a token in `auth`, clearing it does that):
|
|
2501
|
+
|
|
2502
|
+
<!-- example: apps/web/src/components/SignIn.tsx#browser -->
|
|
2503
|
+
|
|
2504
|
+
```tsx
|
|
2505
|
+
export function SignIn() {
|
|
2506
|
+
// the kit's GET /auth/google/start: back to this page's origin with the session cookie
|
|
2507
|
+
return <a href={signInUrl("google", { apiUrl: API_URL })}>Sign in with Google</a>;
|
|
2508
|
+
}
|
|
2509
|
+
|
|
2510
|
+
export function SignOut() {
|
|
2511
|
+
const { connection } = useQuickdraw();
|
|
2512
|
+
const leave = async (): Promise<void> => {
|
|
2513
|
+
// POST /auth/logout with the cookie: the session is revoked, the cookie cleared
|
|
2514
|
+
await signOut({ apiUrl: API_URL });
|
|
2515
|
+
// the socket keeps its user until it connects again, as nobody now
|
|
2516
|
+
connection.close();
|
|
2517
|
+
connection.open();
|
|
2518
|
+
};
|
|
2519
|
+
return (
|
|
2520
|
+
<button type="button" onClick={() => void leave()}>
|
|
2521
|
+
Sign out
|
|
2522
|
+
</button>
|
|
2523
|
+
);
|
|
2524
|
+
}
|
|
2525
|
+
```
|
|
2526
|
+
|
|
2527
|
+
An app's own REST route (a service worker's renewal, a webhook) signs the
|
|
2528
|
+
user in with `requireSession` and calls the services in process, so the
|
|
2529
|
+
method's validation, access check and tracked writes are a socket call's.
|
|
2530
|
+
`sessionOf(req)` gives the route `{ userId, sessionId, principal }`, typed,
|
|
2531
|
+
with no cast of `req` (`sessionOf<AppPrincipal>(req)` when `loadPrincipal`,
|
|
2532
|
+
like `socketAuth`'s, builds the app's own principal; by default it is
|
|
2533
|
+
`{ userId, kind: "user" }`); they are also set as `req.userId`,
|
|
2534
|
+
`req.sessionId` and `req.principal`. `qd.caller(principal)` then loads the
|
|
2535
|
+
user's grants as the user's sockets get them, so a method behind
|
|
2536
|
+
`{ service: L }` passes or answers `FORBIDDEN` as it would over a socket:
|
|
2537
|
+
|
|
2538
|
+
<!-- example: apps/api/src/auth/routes.ts#rest -->
|
|
2539
|
+
|
|
2540
|
+
```ts
|
|
2541
|
+
import { httpStatus, toWire } from "@fitzzero/quickdraw-core";
|
|
2542
|
+
import { requireSession, sessionOf } from "@fitzzero/quickdraw-core/server/auth";
|
|
2543
|
+
|
|
2544
|
+
// 401 without a live session; the principal built as socketAuth builds a socket's
|
|
2545
|
+
const signedIn = requireSession(
|
|
2546
|
+
{ sessions, jwtSecret: env.JWT_SECRET },
|
|
2547
|
+
{ loadPrincipal: (userId): AppPrincipal => ({ userId, kind: "user" }) },
|
|
2548
|
+
);
|
|
2549
|
+
|
|
2550
|
+
app.get("/api/projects/:projectId/task-count", signedIn, (req, res) => {
|
|
2551
|
+
void (async () => {
|
|
2552
|
+
const { principal } = sessionOf<AppPrincipal>(req);
|
|
2553
|
+
try {
|
|
2554
|
+
// the method's validation, access check (with the user's grants) and writes, as over a socket
|
|
2555
|
+
const count = await qd.caller(principal).taskService.countOnBoard({
|
|
2556
|
+
projectId: req.params.projectId,
|
|
2557
|
+
});
|
|
2558
|
+
res.json({ count });
|
|
2559
|
+
} catch (error) {
|
|
2560
|
+
const failure = toWire(error);
|
|
2561
|
+
res.status(httpStatus(failure.code)).json(failure);
|
|
2562
|
+
}
|
|
2563
|
+
})();
|
|
2564
|
+
});
|
|
2565
|
+
```
|
|
2566
|
+
|
|
2243
2567
|
`createMemorySessionStore()` keeps sessions in the process, for development
|
|
2244
2568
|
and tests. In production, store them in the database. Sessions are not live
|
|
2245
2569
|
data, so nothing needs their writes tracked: with the tracked client, the
|
|
@@ -2320,8 +2644,14 @@ it("sends a rename to the other members' boards", async () => {
|
|
|
2320
2644
|
- `app.as(principal)` calls in process and `app.connect(principal)` over a
|
|
2321
2645
|
real socket (`{ call, socket, hello, close }`); both are keyed by service
|
|
2322
2646
|
name. `app.frames(match?)` lists every frame the server sent, with its
|
|
2323
|
-
socket and user; `frames.waitFor(match)` waits for one.
|
|
2324
|
-
`
|
|
2647
|
+
socket and user; `frames.waitFor(match)` waits for one. A query of one
|
|
2648
|
+
event takes `where`, a predicate over its frames typed by the event
|
|
2649
|
+
(`{ event: "qd:presence", where: ({ data }) => data.joined === id }`), and
|
|
2650
|
+
`streamFrames(contract, stream, where?, scope?)` and
|
|
2651
|
+
`eventFrames(contract, event, where?)` match one stream's items or one
|
|
2652
|
+
event's payloads, typed by the contract, to spread beside `socketId` or
|
|
2653
|
+
`userId`. `emitWithAck` and `waitForEvent` send raw frames and wait for
|
|
2654
|
+
events.
|
|
2325
2655
|
|
|
2326
2656
|
### Access matrices
|
|
2327
2657
|
|
|
@@ -2428,16 +2758,17 @@ one format and names the method call it happened in:
|
|
|
2428
2758
|
[quickdraw:n-plus-one] taskService.board: task.findUnique by id ran 10 times in one call, once per item (N+1); ...
|
|
2429
2759
|
```
|
|
2430
2760
|
|
|
2431
|
-
| Kind
|
|
2432
|
-
|
|
|
2433
|
-
| `n-plus-one`
|
|
2434
|
-
| `unbounded-read`
|
|
2435
|
-
| `oversized-response`
|
|
2436
|
-
| `nested-write`
|
|
2437
|
-
| `ambient-write`
|
|
2438
|
-
| `batch-read`
|
|
2439
|
-
| `batch-create-many`
|
|
2440
|
-
| `repeated-call`
|
|
2761
|
+
| Kind | Raised when |
|
|
2762
|
+
| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------- |
|
|
2763
|
+
| `n-plus-one` | a call ran 10 statements of one shape (model, operation, `where` keys), outside a `$transaction([])` |
|
|
2764
|
+
| `unbounded-read` | a call ran `findMany` with neither `take` nor ids to read (`id`, `{ in }` or `{ equals }`) |
|
|
2765
|
+
| `oversized-response` | a reply was larger than `maxResponseBytes` (default 1 MiB) |
|
|
2766
|
+
| `nested-write` | a write's `data` wrote a related row, which is not tracked |
|
|
2767
|
+
| `ambient-write` | a tracked write ran outside any unit of work |
|
|
2768
|
+
| `batch-read` | a write in an array-form `$transaction` read its rows outside the batch |
|
|
2769
|
+
| `batch-create-many` | a `createMany` in an array-form `$transaction` could not report its rows |
|
|
2770
|
+
| `repeated-call` | one connection repeated a call or was refused `RATE_LIMITED` again and again (below) |
|
|
2771
|
+
| `tiered-field-in-output` | a method's own output schema names a field the contract tiers, which only projection outputs strip; raised when the server is made |
|
|
2441
2772
|
|
|
2442
2773
|
Updates and deletes by id inside an interactive transaction are not counted
|
|
2443
2774
|
toward `n-plus-one`: that is how per-row writes are written (see tracked
|
|
@@ -2455,7 +2786,9 @@ fails: the call it happened in fails with `INTERNAL` and the error as its
|
|
|
2455
2786
|
once the reply was recorded (over a socket or HTTP the reply was already
|
|
2456
2787
|
sent, so that error is logged, not thrown). Strictness belongs to the app:
|
|
2457
2788
|
warnings outside its calls (an ambient write while seeding, another app's
|
|
2458
|
-
calls) are logged as usual, and `app.close()` ends it.
|
|
2789
|
+
calls) are logged as usual, and `app.close()` ends it. The one raised when
|
|
2790
|
+
the app is made, `tiered-field-in-output`, is thrown from `createTestApp`
|
|
2791
|
+
itself, so a strict app over such a method does not start.
|
|
2459
2792
|
|
|
2460
2793
|
A `repeated-call` warning names a client caught in a loop (a mutation fired
|
|
2461
2794
|
from an effect that its own result runs again, a refetch that triggers
|
|
@@ -2500,9 +2833,22 @@ async (Testing Library, an optional peer, is loaded lazily) and returns
|
|
|
2500
2833
|
Testing Library's result plus `connection`, `queryClient`, `disconnect()`
|
|
2501
2834
|
and `reconnect()`, which drop and restore the socket as a lost network does.
|
|
2502
2835
|
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2836
|
+
The web app's test imports the API's services across apps, as above, and
|
|
2837
|
+
needs a database: run such tests in a jsdom project of their own, with the
|
|
2838
|
+
API's global setup (the template its test databases boot from, below) and a
|
|
2839
|
+
setup file that calls `installJsdomShims()` from `./testing/client`. That
|
|
2840
|
+
adds what jsdom lacks, and keeps what it has: `scrollTo`, `scrollBy` and
|
|
2841
|
+
`scrollIntoView` on elements (they do nothing; a list that follows its newest
|
|
2842
|
+
item calls them) and `Blob.prototype.arrayBuffer` (PGlite reads a database
|
|
2843
|
+
dump through it). This repository runs the README's example app that way:
|
|
2844
|
+
`packages/core/vitest.config.ts`, project `readme`, with
|
|
2845
|
+
`test/readme/globalSetup.ts` and `test/readme/workerSetup.ts`.
|
|
2846
|
+
|
|
2847
|
+
For a component test or a story without a server, `createMockClient(contracts)`
|
|
2848
|
+
gives the typed client's shape with stubs; give it to the components in place
|
|
2849
|
+
of the app's client (a module mock of the file that exports `qd`, say). It
|
|
2850
|
+
comes from `./testing/client`, or from `@fitzzero/quickdraw-core/testing/mock`,
|
|
2851
|
+
which names no Testing Library, for a browser bundle (Storybook):
|
|
2506
2852
|
|
|
2507
2853
|
<!-- example: apps/web/src/components/TaskBoard.test.tsx#mock -->
|
|
2508
2854
|
|
|
@@ -2522,14 +2868,58 @@ each test only when the test runner has a global `afterEach` (vitest with
|
|
|
2522
2868
|
(`resetAfterEach: false` opts out); otherwise call `mock.$reset()` in an
|
|
2523
2869
|
`afterEach` of your own, as above. Optimistic updates are not shown.
|
|
2524
2870
|
|
|
2871
|
+
The members' hooks need no provider. A component that reads the connection
|
|
2872
|
+
(`useQuickdraw()`, `usePresence(room)`) renders inside the mock's own
|
|
2873
|
+
provider, `mock.$Provider`, where those real hooks read the mock's session:
|
|
2874
|
+
who it acts for, set with `createMockClient(contracts, { session })` and
|
|
2875
|
+
`mock.$session({ userId, serviceAccess, isConnected, isKnown })` (each field
|
|
2876
|
+
left out keeps its starting value, and the reset after each test puts the
|
|
2877
|
+
starting session back), and the rooms `mock.$presence(room, userIds)` sets.
|
|
2878
|
+
Its views select members for the session's user:
|
|
2879
|
+
|
|
2880
|
+
<!-- example: apps/web/src/components/TaskBoard.test.tsx#session -->
|
|
2881
|
+
|
|
2882
|
+
```tsx
|
|
2883
|
+
it("lets a signed-in user through the gate", () => {
|
|
2884
|
+
mock.$session({ userId: "ada", serviceAccess: { taskService: "Admin" } }); // useQuickdraw() shows it
|
|
2885
|
+
render(<AuthGate>Board</AuthGate>, { wrapper: mock.$Provider });
|
|
2886
|
+
expect(screen.getByText("Board")).toBeTruthy();
|
|
2887
|
+
});
|
|
2888
|
+
```
|
|
2889
|
+
|
|
2890
|
+
In Storybook, a decorator renders every story inside `qd.$Provider`, and a
|
|
2891
|
+
story's `beforeEach` sets its session (`qd.$session(...)`) beside its data.
|
|
2892
|
+
A docs page renders its stories side by side, where one mock session would
|
|
2893
|
+
show the last story's in all of them: give each its own with the
|
|
2894
|
+
provider's `session` prop, `<qd.$Provider session={{ userId: null }}>`,
|
|
2895
|
+
laid over the mock's session field by field for that subtree alone (the
|
|
2896
|
+
real `useQuickdraw()` and `usePresence`, and the mock's views and admin
|
|
2897
|
+
grants, read it there).
|
|
2898
|
+
|
|
2525
2899
|
### Test databases
|
|
2526
2900
|
|
|
2527
2901
|
`@fitzzero/quickdraw-core/testing/prisma` gives each vitest worker a database
|
|
2528
2902
|
of its own: `createPrismaTestGlobalSetup` migrates a template once per run
|
|
2529
2903
|
and clones a database per worker on PostgreSQL (`TEST_DATABASE_URL`), or
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
the
|
|
2904
|
+
builds a PGlite dump without one; `workerDatabaseUrl` and `resetDatabase`
|
|
2905
|
+
(truncates every table) do the rest. On PGlite each worker boots its own
|
|
2906
|
+
database from the dump with `openPgliteFromTemplate`, in milliseconds and
|
|
2907
|
+
under jsdom too (it reads the dump through Node's `Blob`); the app's db
|
|
2908
|
+
package gives its test client from it while tests run:
|
|
2909
|
+
|
|
2910
|
+
<!-- example: packages/db/src/testing.ts#worker -->
|
|
2911
|
+
|
|
2912
|
+
```ts
|
|
2913
|
+
// this worker's own database: the migrated template, loaded in milliseconds
|
|
2914
|
+
const pglite = await openPgliteFromTemplate(TEST_TEMPLATE);
|
|
2915
|
+
export const prisma = new PrismaClient({ adapter: new PrismaPGlite(pglite) });
|
|
2916
|
+
```
|
|
2917
|
+
|
|
2918
|
+
`TEST_TEMPLATE` is `{ migrationsDir, cacheDir, templateName }`, the same the
|
|
2919
|
+
global setup builds with (`buildPgliteTemplate(TEST_TEMPLATE)`, or
|
|
2920
|
+
`createPrismaTestGlobalSetup`), and a setup file empties the database before
|
|
2921
|
+
each test (`beforeEach(() => resetDatabase(prisma))`). Apply `trackPrisma`
|
|
2922
|
+
to the test client exactly as in production.
|
|
2533
2923
|
|
|
2534
2924
|
## Observability
|
|
2535
2925
|
|
|
@@ -2582,17 +2972,37 @@ is also a server span named `service.method`, with an error status for
|
|
|
2582
2972
|
|
|
2583
2973
|
## API docs from contracts
|
|
2584
2974
|
|
|
2585
|
-
The `quickdraw-docs` command writes Markdown API docs from the contracts
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
2975
|
+
The `quickdraw-docs` command writes Markdown API docs from the contracts:
|
|
2976
|
+
one page per service (its entity and field tiers, projections, methods
|
|
2977
|
+
with their kind, input fields and output, collections, streams, channels
|
|
2978
|
+
and events, from the schemas' JSON Schema where they have one) and a
|
|
2979
|
+
`README.md` index. It reads contracts (and, with `--services`, the
|
|
2980
|
+
services' definitions), never source code.
|
|
2590
2981
|
|
|
2591
2982
|
```bash
|
|
2592
2983
|
quickdraw-docs packages/shared/src/index.ts --out docs/api # write the pages
|
|
2593
2984
|
quickdraw-docs packages/shared/src/index.ts --out docs/api --check # exit 1 when they are stale
|
|
2985
|
+
quickdraw-docs packages/shared/src/index.ts --services apps/api/src/services/index.ts --out docs/api
|
|
2594
2986
|
```
|
|
2595
2987
|
|
|
2988
|
+
With `--services <module>` (a module exporting the services, each or in a
|
|
2989
|
+
list, as the server takes them; importing it must not start the server),
|
|
2990
|
+
each page also says who may call what, read from the services'
|
|
2991
|
+
definitions: an "Access" section with the row policy, whether a
|
|
2992
|
+
service-wide `Admin` grant passes every check, who may watch the change
|
|
2993
|
+
topic (`watchAccess`) and the field levels; each method's access form, in
|
|
2994
|
+
words, and its `rowless`; who may open a collection's scope; a channel's
|
|
2995
|
+
access; a stream's computed seed and when its items are checked. A
|
|
2996
|
+
contract the services module has no service for says so, and a service
|
|
2997
|
+
without a contract is an error. Pass the same flag to `--check`. The
|
|
2998
|
+
services module is imported with everything it imports, as the server
|
|
2999
|
+
loads it: the workspace packages it uses (`@project/db`, `@project/shared`)
|
|
3000
|
+
load from their built output when their `package.json` points there, so
|
|
3001
|
+
build them first (`bun run build`, or `turbo run build --filter` for those
|
|
3002
|
+
packages) in a script that runs the docs on a fresh checkout; the error
|
|
3003
|
+
says so when a built file is missing. The contracts module, which imports
|
|
3004
|
+
only the shared package's own sources, needs no build.
|
|
3005
|
+
|
|
2596
3006
|
The module may export each contract, or a map of them as given to
|
|
2597
3007
|
`createQuickdrawClient`. A TypeScript module loads through Node's type
|
|
2598
3008
|
stripping, or through `tsx` when the project has it installed (for
|
|
@@ -2604,7 +3014,7 @@ nothing. Run `--check` in CI next to the lint step.
|
|
|
2604
3014
|
|
|
2605
3015
|
## Lint rules and agent guidance
|
|
2606
3016
|
|
|
2607
|
-
[`@fitzzero/quickdraw-lint`](
|
|
3017
|
+
[`@fitzzero/quickdraw-lint`](https://github.com/fitzzero/quickdraw/tree/dev/packages/lint) is the oxlint plugin and base
|
|
2608
3018
|
config every 5.0 app extends: it reports untracked and foreign writes, nested
|
|
2609
3019
|
and raw SQL writes, hand-sent frames, inline auth guards, unbounded reads,
|
|
2610
3020
|
database calls and emits in loops, layering breaks, bypasses of the typed
|
|
@@ -2619,7 +3029,7 @@ takes an `id`, unless it says `rowless: true`); then lint; then a
|
|
|
2619
3029
|
development warning as it happens (`repeated-call`, `repeated-mutation` and
|
|
2620
3030
|
`repeated-invalidation` name a client loop before the rate limit does).
|
|
2621
3031
|
|
|
2622
|
-
[`@fitzzero/quickdraw-skills`](
|
|
3032
|
+
[`@fitzzero/quickdraw-skills`](https://github.com/fitzzero/quickdraw/tree/dev/packages/skills) ships agent rules and skills
|
|
2623
3033
|
for quickdraw apps and links them into `.claude/` with
|
|
2624
3034
|
`quickdraw-skills link`, so every app's agents read the same, current
|
|
2625
3035
|
guidance:
|
|
@@ -2635,11 +3045,11 @@ guidance:
|
|
|
2635
3045
|
|
|
2636
3046
|
## Migrating from 4.x
|
|
2637
3047
|
|
|
2638
|
-
[`@fitzzero/quickdraw-codemod`](
|
|
3048
|
+
[`@fitzzero/quickdraw-codemod`](https://github.com/fitzzero/quickdraw/tree/dev/packages/codemod) moves a 4.x app to 5.0:
|
|
2639
3049
|
contracts from the method maps, `defineService` from the service classes,
|
|
2640
3050
|
the typed client for the hooks, and a report of everything left to decide.
|
|
2641
|
-
[`MIGRATION.md`](
|
|
2642
|
-
the defaults that changed; [`UPGRADE-PROMPT.md`](
|
|
3051
|
+
[`MIGRATION.md`](https://github.com/fitzzero/quickdraw/blob/dev/MIGRATION.md) explains each step, the access mapping and
|
|
3052
|
+
the defaults that changed; [`UPGRADE-PROMPT.md`](https://github.com/fitzzero/quickdraw/blob/dev/UPGRADE-PROMPT.md) is the
|
|
2643
3053
|
procedure for an agent. Both ship with the codemod, at
|
|
2644
3054
|
`node_modules/@fitzzero/quickdraw-codemod/MIGRATION.md` and
|
|
2645
3055
|
`node_modules/@fitzzero/quickdraw-codemod/UPGRADE-PROMPT.md`.
|
|
@@ -2654,7 +3064,7 @@ bunx @fitzzero/quickdraw-codemod@next v5 .
|
|
|
2654
3064
|
| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
2655
3065
|
| `.` | `defineContract`, `query`, `mutation`, `nullable`, `listOf`, `via`, the kits' contract halves, inference types, `QuickdrawError`, error codes, protocol types, room and topic names |
|
|
2656
3066
|
| `./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
|
|
3067
|
+
| `./server/auth` | `createAuthRoutes`, `socketAuth`, `requireSession` and `sessionOf`, providers (`google`, `discord`, `mock`, `guest`), session stores, `issueSession`, `liveSession`, JWT, cookie and origin helpers |
|
|
2658
3068
|
| `./server/express` | Express rate limits: `createAuthLimiter`, `createAuthStatusLimiter`, `createCallLimiter`, `createPublicApiLimiter`, `createWebhookLimiter` |
|
|
2659
3069
|
| `./server/mcp` | `createMcpRegistry`, `describeTools`, `createMcpStdioServer`, `createMcpHttpRouter`, `bootstrapMcpServer` |
|
|
2660
3070
|
| `./server/otel` | `otelOnCall` |
|
|
@@ -2663,7 +3073,8 @@ bunx @fitzzero/quickdraw-codemod@next v5 .
|
|
|
2663
3073
|
| `./utils` | `createServerCaller`, cache keys (`methodKey`, `entityKey`, `collectionKey`), formatting, navigation, `parseJWTPayload` |
|
|
2664
3074
|
| `./parser` | the JSON-only Socket.IO parser |
|
|
2665
3075
|
| `./testing` | `createTestApp`, `describeAccessMatrix`, `expectBudget`, `createRecordingSink`, `DevWarningError` |
|
|
2666
|
-
| `./testing/client` | `renderWithQuickdraw`, `
|
|
3076
|
+
| `./testing/client` | `renderWithQuickdraw`, and everything in `./testing/mock` |
|
|
3077
|
+
| `./testing/mock` | `createMockClient` alone, without Testing Library: for browser bundles such as Storybook |
|
|
2667
3078
|
| `./testing/prisma` | test databases on PostgreSQL or PGlite |
|
|
2668
3079
|
|
|
2669
3080
|
The package also ships the `quickdraw-docs` command.
|
|
@@ -2672,7 +3083,7 @@ The package also ships the `quickdraw-docs` command.
|
|
|
2672
3083
|
|
|
2673
3084
|
A bun workspace with turbo: `packages/core` (this package), `packages/lint`,
|
|
2674
3085
|
`packages/skills` and `packages/codemod`. See
|
|
2675
|
-
[CONTRIBUTING.md](
|
|
3086
|
+
[CONTRIBUTING.md](https://github.com/fitzzero/quickdraw/blob/dev/CONTRIBUTING.md).
|
|
2676
3087
|
|
|
2677
3088
|
```bash
|
|
2678
3089
|
bun install
|