@fitzzero/quickdraw-core 5.0.0-rc.1 → 5.0.0-rc.3
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 +144 -16
- package/dist/{admin-DufEkep0.d.ts → admin-BiVNyWdI.d.ts} +1 -1
- package/dist/{auth-5yi_rUSM.d.ts → auth-DlFtykbn.d.ts} +2 -2
- package/dist/{caller-JNTbbD37.d.ts → caller-D4cgj-Xl.d.ts} +120 -14
- package/dist/{chunk-UJQJM7EG.js → chunk-3ONXZP2X.js} +3 -2
- package/dist/chunk-3ONXZP2X.js.map +1 -0
- package/dist/{chunk-4QNDKNDK.js → chunk-5CXPEEWF.js} +2 -2
- package/dist/{chunk-4QNDKNDK.js.map → chunk-5CXPEEWF.js.map} +1 -1
- package/dist/{chunk-5ADGYGC5.js → chunk-B4IOFSM6.js} +2 -2
- package/dist/{chunk-NRM4XAWR.js → chunk-CNECM3VC.js} +2 -2
- package/dist/{chunk-5EZNDES3.js → chunk-CWPBMY7P.js} +2 -2
- package/dist/{chunk-5EZNDES3.js.map → chunk-CWPBMY7P.js.map} +1 -1
- package/dist/{chunk-GXLJA7AH.js → chunk-DEGCLMTU.js} +6 -5
- package/dist/chunk-DEGCLMTU.js.map +1 -0
- package/dist/{chunk-U32GOJRO.js → chunk-GBM7QP5Z.js} +3 -3
- package/dist/chunk-GBM7QP5Z.js.map +1 -0
- package/dist/{chunk-EI57GEMD.js → chunk-H23KEFBA.js} +1 -1
- package/dist/{chunk-EI57GEMD.js.map → chunk-H23KEFBA.js.map} +1 -1
- package/dist/{chunk-7E7STZEO.js → chunk-HVJ4KIYX.js} +1 -1
- package/dist/{chunk-7E7STZEO.js.map → chunk-HVJ4KIYX.js.map} +1 -1
- package/dist/{chunk-ONVWF4IM.js → chunk-ISNQKSHQ.js} +3 -3
- package/dist/{chunk-EQ2LW5C5.js → chunk-TNTZBUQX.js} +224 -60
- package/dist/chunk-TNTZBUQX.js.map +1 -0
- package/dist/cli/quickdraw-docs.js +3 -2
- package/dist/cli/quickdraw-docs.js.map +1 -1
- package/dist/client/index.d.ts +4 -4
- package/dist/client/index.js +3 -3
- package/dist/{clientTypes-B9eYqDO1.d.ts → clientTypes-P_VIjnyz.d.ts} +2 -2
- package/dist/{createServer-301BCb6c.d.ts → createServer-BwhO8YlA.d.ts} +35 -6
- package/dist/{createTestApp-BPi72usy.d.ts → createTestApp-CBESgORq.d.ts} +10 -6
- package/dist/{envelope-CR2A9lrd.d.ts → envelope-CKQEC7e-.d.ts} +6 -4
- package/dist/{http-DC1RmIgu.d.ts → http-B5hNMuVY.d.ts} +17 -10
- package/dist/index.d.ts +5 -5
- package/dist/index.js +8 -4
- package/dist/index.js.map +1 -1
- package/dist/{infer-CEfvP0_I.d.ts → infer-Dlplloxi.d.ts} +78 -11
- package/dist/{metrics-B1dXpOQc.d.ts → metrics-BBUPqcsT.d.ts} +2 -2
- package/dist/prisma/index.d.ts +4 -4
- package/dist/prisma/index.js +3 -3
- package/dist/server/auth/index.d.ts +98 -12
- package/dist/server/auth/index.js +99 -12
- package/dist/server/auth/index.js.map +1 -1
- package/dist/server/index.d.ts +89 -33
- package/dist/server/index.js +90 -21
- package/dist/server/index.js.map +1 -1
- package/dist/server/mcp/index.d.ts +22 -11
- package/dist/server/mcp/index.js +11 -6
- package/dist/server/mcp/index.js.map +1 -1
- package/dist/server/otel.d.ts +3 -3
- package/dist/{sharing-B5-5Q_wT.d.ts → sharing-RrW03F9j.d.ts} +2 -2
- package/dist/testing/client.d.ts +11 -11
- package/dist/testing/client.js +3 -3
- package/dist/testing/index.d.ts +29 -15
- package/dist/testing/index.js +28 -10
- package/dist/testing/index.js.map +1 -1
- package/dist/{types-CIq6xwng.d.ts → types-B3N6xKkl.d.ts} +1 -1
- package/dist/utils/index.d.ts +1 -1
- package/package.json +2 -2
- package/dist/chunk-EQ2LW5C5.js.map +0 -1
- package/dist/chunk-GXLJA7AH.js.map +0 -1
- package/dist/chunk-U32GOJRO.js.map +0 -1
- package/dist/chunk-UJQJM7EG.js.map +0 -1
- /package/dist/{chunk-5ADGYGC5.js.map → chunk-B4IOFSM6.js.map} +0 -0
- /package/dist/{chunk-NRM4XAWR.js.map → chunk-CNECM3VC.js.map} +0 -0
- /package/dist/{chunk-ONVWF4IM.js.map → chunk-ISNQKSHQ.js.map} +0 -0
package/README.md
CHANGED
|
@@ -716,7 +716,10 @@ export const taskService = qd.defineService(task, {
|
|
|
716
716
|
(`qd:access`).
|
|
717
717
|
- Policies: `owner(field)`, `jsonAcl(field, { owner? })`,
|
|
718
718
|
`members({ model, entry, user, level, levels? })`, `inherit({ from, via })`,
|
|
719
|
-
`anyOf(...)
|
|
719
|
+
`anyOf(...)`, `resolver({ levelsFor, where? })` and `everyone(level)`
|
|
720
|
+
(every signed-in user has `level` on every row, reading nothing: public
|
|
721
|
+
profiles are `anyOf(owner("id"), everyone("Read"))`; unlike `rowless: true`
|
|
722
|
+
on a method, it covers subscriptions and lists too). Their column names are
|
|
720
723
|
checked against the Prisma client's models at compile time. A lookup is one
|
|
721
724
|
batched query per table, memoized for the call, so checking 60 ids costs
|
|
722
725
|
what checking one does. `entry` access needs a policy; a service without
|
|
@@ -766,7 +769,11 @@ tracked client as `db`; the server finds the rest on it.
|
|
|
766
769
|
(`writes: ["taskLabel"]`); the `no-foreign-write` lint rule checks it.
|
|
767
770
|
- Jobs, scripts and webhooks wrap their writes in `qd.run(fn)`, which
|
|
768
771
|
flushes before it returns. A write made outside any unit of work flushes
|
|
769
|
-
on its own on the next tick, with a development warning.
|
|
772
|
+
on its own on the next tick, with a development warning. Inside a
|
|
773
|
+
handler `qd.run` joins the handler's unit; background work the reply does
|
|
774
|
+
not wait for (a push sent after a message, pruning what it reports dead)
|
|
775
|
+
runs in `qd.run(fn, { detached: true })`, a unit of its own that flushes
|
|
776
|
+
when `fn` settles (catch what the promise rejects with: nothing awaits it).
|
|
770
777
|
|
|
771
778
|
<!-- example: apps/api/src/jobs/overdue.ts#run -->
|
|
772
779
|
|
|
@@ -828,14 +835,16 @@ export const taskService = qd.defineService(task, {
|
|
|
828
835
|
// a write to a subtask sends its parent again
|
|
829
836
|
affects: [{ service: task, id: "parentTaskId" }],
|
|
830
837
|
project: {
|
|
831
|
-
// a relation count: read with select, built by a pure, synchronous map
|
|
838
|
+
// a relation count: read with select, built by a pure, synchronous map. Read the relation's
|
|
839
|
+
// ids, which Prisma fetches for the rows read only; its _count aggregates the whole TaskLabel
|
|
840
|
+
// table (a GROUP BY over every row) on every snapshot and flush
|
|
832
841
|
card: {
|
|
833
|
-
select: { title: true, status: true,
|
|
834
|
-
map: (row: { id: string; title: string; status: string;
|
|
842
|
+
select: { title: true, status: true, labels: { select: { id: true } } },
|
|
843
|
+
map: (row: { id: string; title: string; status: string; labels: { id: string }[] }) => ({
|
|
835
844
|
id: row.id,
|
|
836
845
|
title: row.title,
|
|
837
846
|
status: row.status,
|
|
838
|
-
labelCount: row.
|
|
847
|
+
labelCount: row.labels.length,
|
|
839
848
|
}),
|
|
840
849
|
},
|
|
841
850
|
},
|
|
@@ -848,13 +857,19 @@ export const taskService = qd.defineService(task, {
|
|
|
848
857
|
handler: ({ input, db }) =>
|
|
849
858
|
db.task.findUniqueOrThrow({
|
|
850
859
|
where: { id: input.id },
|
|
851
|
-
select: { id: true, title: true, status: true,
|
|
860
|
+
select: { id: true, title: true, status: true, labels: { select: { id: true } } },
|
|
852
861
|
}),
|
|
853
862
|
},
|
|
854
863
|
},
|
|
855
864
|
});
|
|
856
865
|
```
|
|
857
866
|
|
|
867
|
+
- A count over a relation reads the relation's ids (`labels: { select: { id: true } }`)
|
|
868
|
+
and counts them in `map`: Prisma reads them for the rows read only (a
|
|
869
|
+
second SQL query, `WHERE "taskId" IN (...)`). Its `_count` compiles to a
|
|
870
|
+
`GROUP BY` over the whole relation table, joined in, on every snapshot,
|
|
871
|
+
page and flush that reads the projection. For a relation too large to read
|
|
872
|
+
(a chat's members), keep a counter column that the writes maintain.
|
|
858
873
|
- A projection's keys come from its schema's JSON Schema (Zod 4.2 or later),
|
|
859
874
|
or from `project: { <name>: { keys } }`; a service whose projection has
|
|
860
875
|
neither fails when it is defined. A handler returning a projection returns
|
|
@@ -959,6 +974,11 @@ export const taskService = qd.defineService(task, {
|
|
|
959
974
|
ends in `id`; `limit` (default 100) and `maxLimit` (default 500) size its
|
|
960
975
|
pages; `where` is an equality filter on membership; `access` is the level
|
|
961
976
|
needed on the anchor (default `Read`).
|
|
977
|
+
- A junction write adds or removes its entry in the one scope it links.
|
|
978
|
+
When the item reads the junction (a chat's `memberCount`), declare
|
|
979
|
+
`via({ model, entry, scope, refreshEntry: true })`: every junction write
|
|
980
|
+
then also sends the entry again, `updated`, to each scope that still
|
|
981
|
+
holds it, for one more read of the item per flush.
|
|
962
982
|
- `qd:col:sub { s, c, scope }` authorizes the scope through its anchor's
|
|
963
983
|
policy, then answers a page and joins the scope's room; flushes send `qd:c`
|
|
964
984
|
deltas to it (`added`, `updated`, `patched`, `removed`, or `reset` for more
|
|
@@ -1105,7 +1125,9 @@ createMcpStdioServer({ registry, name: "my-app", version: "1.0.0" });
|
|
|
1105
1125
|
|
|
1106
1126
|
- **stdio** speaks JSON-RPC (MCP protocol version 2024-11-05). One process is
|
|
1107
1127
|
one session: its queries share one concurrency lane, and
|
|
1108
|
-
`notifications/cancelled` cancels a call.
|
|
1128
|
+
`notifications/cancelled` cancels a call. When stdin ends, the calls still
|
|
1129
|
+
running finish and their replies are written before `closed` resolves
|
|
1130
|
+
(`server.close()` cancels them instead). Start its module through
|
|
1109
1131
|
`bootstrapMcpServer(new URL("./mcp-server.js", import.meta.url))`, which
|
|
1110
1132
|
sends console output to stderr so only the protocol reaches stdout.
|
|
1111
1133
|
- **HTTP**: `GET /mcp/tools` and `POST /mcp/invoke`, which takes
|
|
@@ -1115,6 +1137,11 @@ createMcpStdioServer({ registry, name: "my-app", version: "1.0.0" });
|
|
|
1115
1137
|
`"public"` methods, and custom tools that declare `access: "public"`; any
|
|
1116
1138
|
other tool answers `UNAUTHENTICATED` before it runs. A failed call reaches
|
|
1117
1139
|
the agent as a tool error carrying the code.
|
|
1140
|
+
- The tool list is the registry's, the same for every caller: `tools/list`
|
|
1141
|
+
and `GET /mcp/tools` are not filtered by the principal, so an agent sees
|
|
1142
|
+
tools it may not call (and its call is refused). Keep a tool out of the
|
|
1143
|
+
list with `exclude` or `include`, per registry, and serve agents of
|
|
1144
|
+
different reach from separate registries.
|
|
1118
1145
|
|
|
1119
1146
|
## The client
|
|
1120
1147
|
|
|
@@ -1153,6 +1180,7 @@ export function TaskDetail({ id }: { readonly id: string }) {
|
|
|
1153
1180
|
if (isRemoved) {
|
|
1154
1181
|
return <p>This task was deleted.</p>;
|
|
1155
1182
|
}
|
|
1183
|
+
// notes reach Admins only (the contract's fields), so its type is optional: guard it
|
|
1156
1184
|
return (
|
|
1157
1185
|
<div>
|
|
1158
1186
|
<h1>{task?.title}</h1>
|
|
@@ -1166,6 +1194,10 @@ export function TaskDetail({ id }: { readonly id: string }) {
|
|
|
1166
1194
|
}
|
|
1167
1195
|
```
|
|
1168
1196
|
|
|
1197
|
+
- A field the contract's `fields` map tiers (`notes` here) is optional in
|
|
1198
|
+
every row type a reader gets (`useEntity`, collection items, `"entity"`
|
|
1199
|
+
outputs, `EntityOf`, `ItemOf`), because a reader below its level receives
|
|
1200
|
+
the row without it. Handlers still return the full row.
|
|
1169
1201
|
- A mutation whose input has `id` and whose output is `"entity"` is
|
|
1170
1202
|
optimistic by default: its input's fields show over the cached row and its
|
|
1171
1203
|
collection items from the moment it is sent, are dropped if it fails, and
|
|
@@ -1784,6 +1816,15 @@ export function AdminTasks() {
|
|
|
1784
1816
|
configurations written for 4.x still type); `id` and the
|
|
1785
1817
|
timestamps come first and are not editable; `acl`, `serviceAccess` and
|
|
1786
1818
|
`service_access` are hidden.
|
|
1819
|
+
- Grants: on a users service, `admin.handlers(user, { grants: true })`
|
|
1820
|
+
shows and writes the entity's `serviceAccess` (a `json` field the entity
|
|
1821
|
+
schema checks), so the admin screen edits grants through `adminUpdate`.
|
|
1822
|
+
Only a caller whose own service-wide grant on that service is `Admin`
|
|
1823
|
+
reads or writes it, whatever `access` gives the method: anyone else gets
|
|
1824
|
+
rows without it and `FORBIDDEN` for a write, filter or sort naming it.
|
|
1825
|
+
The write is tracked, so with `auth.serviceAccessSource` naming the column
|
|
1826
|
+
the user's open sockets get the new grants at once, on every node. Such
|
|
1827
|
+
an Admin can grant any service, themself included.
|
|
1787
1828
|
- `adminSubscribers({ id })` counts the sockets subscribed to a row per
|
|
1788
1829
|
access level (`{ id, count, levels, complete }`; behind a Redis adapter
|
|
1789
1830
|
the counts are this server's and `complete` is `false`), and
|
|
@@ -1830,7 +1871,11 @@ export const task = defineContract("taskService", {
|
|
|
1830
1871
|
requires: { room: (cursor) => `board:${cursor.projectId}` },
|
|
1831
1872
|
},
|
|
1832
1873
|
},
|
|
1833
|
-
events: {
|
|
1874
|
+
events: {
|
|
1875
|
+
cursorMoved: { payload: cursorSchema },
|
|
1876
|
+
// a user's last socket left a board: `onRoomLeave` sends it
|
|
1877
|
+
leftBoard: { payload: z.object({ projectId: z.string(), userId: z.string() }) },
|
|
1878
|
+
},
|
|
1834
1879
|
});
|
|
1835
1880
|
```
|
|
1836
1881
|
|
|
@@ -1934,14 +1979,69 @@ export function TaskRoom({
|
|
|
1934
1979
|
`ctx.rooms.join(room)` and `leave` put the calling socket in an app room
|
|
1935
1980
|
(calls without a socket get `false`; names starting with `qd:` or `user:`
|
|
1936
1981
|
are refused with `VALIDATION`; at most 100 per socket; a method that shares
|
|
1937
|
-
its runs, `share`, may not join or leave
|
|
1938
|
-
sockets get `qd:presence` frames: the list on
|
|
1939
|
-
who leaves. `usePresence(room)` shows them.
|
|
1982
|
+
its runs, `share`, may not join or leave with its caller's socket:
|
|
1983
|
+
`INTERNAL`), and the room's sockets get `qd:presence` frames: the list on
|
|
1984
|
+
joining, then who joins and who leaves. `usePresence(room)` shows them.
|
|
1940
1985
|
- Events: `ctx.rooms.emit(room, contract, event, payload)` and
|
|
1941
1986
|
`emitToUser(userId, ...)` check the payload first (`INTERNAL`, nothing
|
|
1942
1987
|
sent, when it fails), then send the validated payload as
|
|
1943
1988
|
`qd:event [service, event, payload]`; `useEvent` hears them.
|
|
1944
1989
|
|
|
1990
|
+
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:
|
|
1993
|
+
|
|
1994
|
+
<!-- example: apps/api/src/services/kits/rooms.ts#rooms -->
|
|
1995
|
+
|
|
1996
|
+
```ts
|
|
1997
|
+
import type { RoomLeaveHandler } from "@fitzzero/quickdraw-core/server";
|
|
1998
|
+
|
|
1999
|
+
const boardRoom = (projectId: string): string => `board:${projectId}`;
|
|
2000
|
+
|
|
2001
|
+
// a timer or a game loop's tick, outside any handler: every socket in the room, on every node
|
|
2002
|
+
export function showCursor(projectId: string, taskId: string, x: number): void {
|
|
2003
|
+
qd.rooms.emit(boardRoom(projectId), task, "cursorMoved", { projectId, taskId, x });
|
|
2004
|
+
}
|
|
2005
|
+
|
|
2006
|
+
// a member removed from the project: their sockets leave its board on every node, so they hear
|
|
2007
|
+
// nothing more of it and its cursor channel drops their messages (also ctx.rooms.leave)
|
|
2008
|
+
export async function removeFromBoard(projectId: string, userId: string): Promise<void> {
|
|
2009
|
+
await qd.rooms.leave(boardRoom(projectId), { userId });
|
|
2010
|
+
}
|
|
2011
|
+
|
|
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
|
+
};
|
|
2022
|
+
```
|
|
2023
|
+
|
|
2024
|
+
- `qd.rooms.emit` and `emitToUser` are `ctx.rooms`' own, from anywhere,
|
|
2025
|
+
reaching every node behind a cluster adapter.
|
|
2026
|
+
- `leave(room, { userId })` (on `qd.rooms`, and on `ctx.rooms` beside the
|
|
2027
|
+
calling socket's own `leave(room)`) takes every socket of the user out of
|
|
2028
|
+
an app room, on every node: they hear nothing more of it, a channel that
|
|
2029
|
+
`requires` the room drops their messages, each gets
|
|
2030
|
+
`qd:presence { room, users: [] }`, and the room hears `left`. Behind a
|
|
2031
|
+
cluster it is broadcast and answered, so await it before sending what the
|
|
2032
|
+
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
|
|
2035
|
+
removal (`"removed"`), or a disconnect, which leaves every app room it was
|
|
2036
|
+
in (`"disconnect"`; `qd:rotate` reconnects with a new socket). Each room
|
|
2037
|
+
comes with `last`: true when no socket of that user is in the room any
|
|
2038
|
+
more, on any node, which is what a game's `playerLeft` waits for. It runs
|
|
2039
|
+
in a unit of work of its own (never the unit of the handler that left), a
|
|
2040
|
+
throw is logged, and `close()` waits for it. Behind a cluster `last` is
|
|
2041
|
+
decided by asking every node once the socket left, so two sockets of one
|
|
2042
|
+
user leaving two nodes at the same moment may both see the other (a
|
|
2043
|
+
removal never misses: each node reports its own last socket).
|
|
2044
|
+
|
|
1945
2045
|
### Auth routes kit
|
|
1946
2046
|
|
|
1947
2047
|
Sign-in for Google, Discord, a development mock and guests, as one Express
|
|
@@ -1976,7 +2076,8 @@ app.set("trust proxy", 1);
|
|
|
1976
2076
|
app.use(
|
|
1977
2077
|
createAuthRoutes({
|
|
1978
2078
|
providers: [
|
|
1979
|
-
|
|
2079
|
+
// nothing without its credentials: the routes skip it
|
|
2080
|
+
google.optional({ clientId: env.GOOGLE_CLIENT_ID, clientSecret: env.GOOGLE_CLIENT_SECRET }),
|
|
1980
2081
|
discord({ clientId: env.DISCORD_CLIENT_ID, clientSecret: env.DISCORD_CLIENT_SECRET }),
|
|
1981
2082
|
// served only while isMockOAuthEnabled()
|
|
1982
2083
|
mock({ listUsers: listSeededUsers }),
|
|
@@ -2025,7 +2126,7 @@ nothing is cached:
|
|
|
2025
2126
|
| -------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
|
|
2026
2127
|
| `GET /{provider}/start?returnTo=<url>` | 302 to the provider |
|
|
2027
2128
|
| `GET /{provider}/callback` | 302 to `{origin}{successPath}` with the session cookie, or to `{origin}{errorPath}?error=state`, `denied` or `failed` |
|
|
2028
|
-
| `POST /guest` | `createUser(body)`, then `{ userId }` with the session cookie
|
|
2129
|
+
| `POST /guest` | `createUser(body)`, then `{ userId, name? }` with the session cookie (and `token` with `guest({ token: true })`) |
|
|
2029
2130
|
| `GET /me` | `{ userId }`, or 401 |
|
|
2030
2131
|
| `POST /logout` | 204: revokes the session, clears the cookie |
|
|
2031
2132
|
| `POST /logout-all` | 204: revokes every session of the user; 401 without a live session |
|
|
@@ -2074,6 +2175,27 @@ nothing is cached:
|
|
|
2074
2175
|
`allowMissingOrigin: true` is set for native clients that keep cookies.
|
|
2075
2176
|
Bearer tokens need no Origin, and HTTP calls are guarded by their JSON
|
|
2076
2177
|
content type instead.
|
|
2178
|
+
- `socketAuth({ devCredentials })` signs a socket in by the user id its
|
|
2179
|
+
handshake names (`auth: { userId }`, no token), as the function answers
|
|
2180
|
+
(the principal, or `null` to refuse): for a game editor or load-test bots
|
|
2181
|
+
in development. `socketAuth` throws when it is given while `NODE_ENV` is
|
|
2182
|
+
`production`, and refuses such a handshake there anyway; pass it only
|
|
2183
|
+
behind the app's own flag.
|
|
2184
|
+
- `google.optional(...)` and `discord.optional(...)` build nothing when
|
|
2185
|
+
neither credential is set (only one is a misconfiguration, refused), and
|
|
2186
|
+
`providers` skips `undefined`, `null` and `false` entries, so an
|
|
2187
|
+
environment without a provider's app leaves it out in place.
|
|
2188
|
+
- A guest's `createUser` may return `{ userId, name }` when the name it gave
|
|
2189
|
+
differs from the one asked (a numbered one after a collision), and the
|
|
2190
|
+
route answers it; `guest({ createUser, token: true })` also answers the
|
|
2191
|
+
session's token, for clients that keep no cookies (a game engine, a page
|
|
2192
|
+
in a third-party iframe), at the cost of the token being readable by the
|
|
2193
|
+
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.
|
|
2077
2199
|
- Rate limits: the sign-in routes share `createAuthLimiter({ max: 60 })` (60
|
|
2078
2200
|
requests per 15 minutes per IP) and the session routes
|
|
2079
2201
|
`createAuthStatusLimiter()` (120); pass `rateLimit: { signIn, session }`
|
|
@@ -2231,7 +2353,11 @@ it("lets the owner rename, members read, and nobody else in", async () => {
|
|
|
2231
2353
|
|
|
2232
2354
|
`"deny"` (the default for everyone `allow` does not name) means
|
|
2233
2355
|
`UNAUTHENTICATED` without a principal and `FORBIDDEN` with one. Mutations run
|
|
2234
|
-
for real, once per allowed principal: give inputs that can run again
|
|
2356
|
+
for real, once per allowed principal: give inputs that can run again, or
|
|
2357
|
+
`input` as a function of the cell (`({ name, principal }) => input`, sync or
|
|
2358
|
+
async), called before each cell's call, that makes a row of its own (a task
|
|
2359
|
+
to delete, an unused name), so no cell depends on the order of the
|
|
2360
|
+
principals.
|
|
2235
2361
|
|
|
2236
2362
|
### Performance budgets
|
|
2237
2363
|
|
|
@@ -2514,7 +2640,9 @@ contracts from the method maps, `defineService` from the service classes,
|
|
|
2514
2640
|
the typed client for the hooks, and a report of everything left to decide.
|
|
2515
2641
|
[`MIGRATION.md`](../../MIGRATION.md) explains each step, the access mapping and
|
|
2516
2642
|
the defaults that changed; [`UPGRADE-PROMPT.md`](../../UPGRADE-PROMPT.md) is the
|
|
2517
|
-
procedure for an agent.
|
|
2643
|
+
procedure for an agent. Both ship with the codemod, at
|
|
2644
|
+
`node_modules/@fitzzero/quickdraw-codemod/MIGRATION.md` and
|
|
2645
|
+
`node_modules/@fitzzero/quickdraw-codemod/UPGRADE-PROMPT.md`.
|
|
2518
2646
|
|
|
2519
2647
|
```bash
|
|
2520
2648
|
bunx @fitzzero/quickdraw-codemod@next v5 .
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { e as StandardSchemaWithJSON, x as SortDirection, d as StandardSchemaV1, I as InferInput, c as InferOutput, L as RowSchema, Q as QueryDef, P as ProjectionList, p as MutationDef, A as AccessLevel, G as AnyContract,
|
|
1
|
+
import { e as StandardSchemaWithJSON, x as SortDirection, d as StandardSchemaV1, I as InferInput, c as InferOutput, L as RowSchema, Q as QueryDef, P as ProjectionList, p as MutationDef, A as AccessLevel, G as AnyContract, al as MethodName } from './infer-Dlplloxi.js';
|
|
2
2
|
|
|
3
3
|
/** A schema a kit generates: validation, and its JSON Schema. */
|
|
4
4
|
type KitSchema<Input, Output = Input> = StandardSchemaWithJSON<Input, Output>;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { IncomingHttpHeaders, IncomingMessage } from 'node:http';
|
|
2
2
|
import { Socket } from 'socket.io';
|
|
3
|
-
import { A as AccessLevel } from './infer-
|
|
4
|
-
import { P as Principal, M as MaybePromise } from './types-
|
|
3
|
+
import { A as AccessLevel } from './infer-Dlplloxi.js';
|
|
4
|
+
import { P as Principal, M as MaybePromise } from './types-B3N6xKkl.js';
|
|
5
5
|
|
|
6
6
|
/** A principal's service-wide grants by service name: `{ taskService: "Admin" }`. */
|
|
7
7
|
type ServiceGrants = Readonly<Record<string, AccessLevel>>;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { ad as Version, a5 as Revision } from './envelope-
|
|
1
|
+
import { ad as Version, a5 as Revision } from './envelope-CKQEC7e-.js';
|
|
2
2
|
import { L as Logger } from './logger-BY28acW-.js';
|
|
3
|
-
import { A as AccessLevel, G as AnyContract, w as CollectionWhere, O as OrderBy, k as MethodKind, d as StandardSchemaV1, ac as EventName, ad as EventPayloadOf, a6 as ChannelName, a7 as ChannelPayloadOf,
|
|
4
|
-
import { P as Principal, Q as QuickdrawTypes, M as MaybePromise, c as PrincipalOf, a as McpContext, T as Transport, C as ContextExtensionOf, b as McpContextOf, D as DbOf } from './types-
|
|
3
|
+
import { A as AccessLevel, G as AnyContract, w as CollectionWhere, O as OrderBy, k as MethodKind, d as StandardSchemaV1, ac as EventName, ad as EventPayloadOf, a6 as ChannelName, a7 as ChannelPayloadOf, av as StreamName, ai as IsScopedStream, au as StreamItemOf, aa as ContractMap, al as MethodName, ah as InputOf, an as OutputOf } from './infer-Dlplloxi.js';
|
|
4
|
+
import { P as Principal, Q as QuickdrawTypes, M as MaybePromise, c as PrincipalOf, a as McpContext, T as Transport, C as ContextExtensionOf, b as McpContextOf, D as DbOf } from './types-B3N6xKkl.js';
|
|
5
5
|
|
|
6
6
|
/** What a development warning is about. */
|
|
7
7
|
type DevWarningKind = "n-plus-one" | "unbounded-read" | "oversized-response" | "nested-write" | "ambient-write" | "batch-read" | "batch-create-many" | "repeated-call";
|
|
@@ -194,7 +194,7 @@ interface ForeignColumns {
|
|
|
194
194
|
readonly columns: string;
|
|
195
195
|
}
|
|
196
196
|
/** The builder a policy came from. */
|
|
197
|
-
type PolicyKind = "owner" | "jsonAcl" | "members" | "inherit" | "anyOf" | "resolver";
|
|
197
|
+
type PolicyKind = "owner" | "jsonAcl" | "members" | "inherit" | "anyOf" | "resolver" | "everyone";
|
|
198
198
|
/**
|
|
199
199
|
* A service's access policy, from one of the builders. `Columns` names the
|
|
200
200
|
* columns of the service's own model it reads and `Foreign` the other models
|
|
@@ -299,6 +299,8 @@ type CollectionScope = {
|
|
|
299
299
|
readonly entry: string;
|
|
300
300
|
/** The junction column holding the scope value. */
|
|
301
301
|
readonly scope: string;
|
|
302
|
+
/** A junction write sends its entry again to every scope that still holds it (`via`'s `refreshEntry`). */
|
|
303
|
+
readonly refreshEntry: boolean;
|
|
302
304
|
};
|
|
303
305
|
/** One collection of a service: its contract declaration and the service's option, checked. */
|
|
304
306
|
interface ServiceCollection {
|
|
@@ -574,12 +576,55 @@ interface Presence {
|
|
|
574
576
|
/** The ids of the users with a socket in `room`, each once. */
|
|
575
577
|
users(room: string): Promise<string[]>;
|
|
576
578
|
}
|
|
579
|
+
/** Whose sockets `rooms.leave(room, target)` takes out of a room: every socket of one user. */
|
|
580
|
+
interface RoomTarget {
|
|
581
|
+
readonly userId: string;
|
|
582
|
+
}
|
|
583
|
+
/**
|
|
584
|
+
* App rooms from code that is not a handler (a game loop, a job, a webhook):
|
|
585
|
+
* `qd.rooms`, `server.rooms` and `dispatcher.rooms`, the half of `ctx.rooms`
|
|
586
|
+
* that needs no calling socket. Behind a cluster adapter each reaches every
|
|
587
|
+
* node.
|
|
588
|
+
*
|
|
589
|
+
* @example
|
|
590
|
+
* // a game loop's tick, outside any handler
|
|
591
|
+
* qd.rooms.emit(WORLD_ROOM, gameContract, "death", { id: playerId });
|
|
592
|
+
* // a moderator removed a member: their sockets stop hearing the room
|
|
593
|
+
* await qd.rooms.leave(chatRoom(chatId), { userId });
|
|
594
|
+
*/
|
|
595
|
+
interface ServerRooms {
|
|
596
|
+
/**
|
|
597
|
+
* Sends the contract's event to every socket in `room` as `qd:event`, on
|
|
598
|
+
* every node. The payload is checked against the event's schema first: a
|
|
599
|
+
* payload that fails it throws `INTERNAL` and nothing is sent. Without a
|
|
600
|
+
* server it does nothing more.
|
|
601
|
+
*/
|
|
602
|
+
emit<C extends AnyContract, E extends EventName<C>>(room: string, contract: C, event: E, payload: EventPayloadOf<C, E>): void;
|
|
603
|
+
/** `emit` to every socket of one user (their `user:{userId}` room), on every node. */
|
|
604
|
+
emitToUser<C extends AnyContract, E extends EventName<C>>(userId: string, contract: C, event: E, payload: EventPayloadOf<C, E>): void;
|
|
605
|
+
/**
|
|
606
|
+
* Takes every socket of `target.userId` out of the app room `room`, on
|
|
607
|
+
* every node: they stop receiving the room's events and `qd:presence`
|
|
608
|
+
* frames, and a channel that `requires: { room }` drops their messages.
|
|
609
|
+
* Each socket taken out gets `qd:presence { room, users: [] }`, as after
|
|
610
|
+
* its own leave, and `onRoomLeave` hears of it with reason `"removed"`.
|
|
611
|
+
* Behind a cluster adapter the request is broadcast and answered: it
|
|
612
|
+
* resolves once every node took the user's sockets out (at most
|
|
613
|
+
* `cluster.timeoutMs`), so await it before emitting what the user must not
|
|
614
|
+
* receive. Room names are checked as `join` checks them (`VALIDATION`). A
|
|
615
|
+
* user with no socket in the room is nothing to do; joining again is the
|
|
616
|
+
* app's to refuse.
|
|
617
|
+
*/
|
|
618
|
+
leave(room: string, target: RoomTarget): Promise<void>;
|
|
619
|
+
}
|
|
577
620
|
/**
|
|
578
621
|
* `ctx.rooms` (RFC 0003 sections 3, 12.5 and 15): app-defined rooms the
|
|
579
622
|
* calling socket joins and leaves, and typed room events. It replaces 4.1's
|
|
580
623
|
* `emitToRoom` and `emitToUserRoom` (`legacy-src/server/BaseService.ts:378-441`).
|
|
624
|
+
* Everything that needs no calling socket is also on `qd.rooms`
|
|
625
|
+
* ({@link ServerRooms}).
|
|
581
626
|
*/
|
|
582
|
-
interface ContextRooms {
|
|
627
|
+
interface ContextRooms extends ServerRooms {
|
|
583
628
|
/**
|
|
584
629
|
* Puts the calling socket in an app-defined room (a lobby), so it receives
|
|
585
630
|
* the room's events and `qd:presence` frames. Returns `false`, doing
|
|
@@ -592,16 +637,49 @@ interface ContextRooms {
|
|
|
592
637
|
join(room: string): boolean;
|
|
593
638
|
/** Takes the calling socket out of an app room; `false` when it was not in it, or the call has no socket. */
|
|
594
639
|
leave(room: string): boolean;
|
|
640
|
+
/** See {@link ServerRooms.leave}: every socket of the user, on every node, from any call. */
|
|
641
|
+
leave(room: string, target: RoomTarget): Promise<void>;
|
|
642
|
+
}
|
|
643
|
+
/** Why a socket left app rooms, as `onRoomLeave` hears it. */
|
|
644
|
+
type RoomLeaveReason =
|
|
645
|
+
/** The socket's own `ctx.rooms.leave(room)`. */
|
|
646
|
+
"leave"
|
|
647
|
+
/** `rooms.leave(room, { userId })` took its user out. */
|
|
648
|
+
| "removed"
|
|
649
|
+
/** The socket disconnected (a closed tab, a lost network, `qd:rotate`, the server closing): every app room it was in. */
|
|
650
|
+
| "disconnect";
|
|
651
|
+
/** One app room a socket left. */
|
|
652
|
+
interface RoomLeft {
|
|
653
|
+
readonly room: string;
|
|
595
654
|
/**
|
|
596
|
-
*
|
|
597
|
-
*
|
|
598
|
-
*
|
|
599
|
-
*
|
|
655
|
+
* True when no socket of the user is in the room any more, on any node:
|
|
656
|
+
* the user is gone from it, not just one of their sockets (another tab
|
|
657
|
+
* keeps it false). Always true for an anonymous socket. Behind a cluster
|
|
658
|
+
* adapter it is decided by asking every node once this socket left, as
|
|
659
|
+
* the room's `left` presence frame is; when they cannot be asked it is
|
|
660
|
+
* true. For a removal every node reports its own last socket of the user
|
|
661
|
+
* as last, so it is never missed and may come from more than one node.
|
|
600
662
|
*/
|
|
601
|
-
|
|
602
|
-
/** `emit` to every socket of one user (their `user:{userId}` room). */
|
|
603
|
-
emitToUser<C extends AnyContract, E extends EventName<C>>(userId: string, contract: C, event: E, payload: EventPayloadOf<C, E>): void;
|
|
663
|
+
readonly last: boolean;
|
|
604
664
|
}
|
|
665
|
+
/** What `onRoomLeave` receives: one socket leaving one or more app rooms. */
|
|
666
|
+
interface RoomLeave<P = Principal> {
|
|
667
|
+
/** The socket's principal; `null` for an anonymous socket (a spectator). */
|
|
668
|
+
readonly principal: P | null;
|
|
669
|
+
/** The socket that left. */
|
|
670
|
+
readonly socketId: string;
|
|
671
|
+
readonly reason: RoomLeaveReason;
|
|
672
|
+
/** The app rooms it left: one for a leave or a removal, every one it was in for a disconnect. */
|
|
673
|
+
readonly rooms: readonly RoomLeft[];
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
676
|
+
* `createServer`'s `onRoomLeave`: called once per socket that leaves app
|
|
677
|
+
* rooms, on the node that holds the socket, in a unit of work of its own
|
|
678
|
+
* (detached: never the unit of the handler whose `ctx.rooms.leave` caused
|
|
679
|
+
* it), after the room heard the socket go. `ctx` is a `qd.run` context. An
|
|
680
|
+
* error it throws is logged; a server's `close()` waits for it.
|
|
681
|
+
*/
|
|
682
|
+
type RoomLeaveHandler<P = Principal> = (leave: RoomLeave<P>, ctx: RunContext) => void | PromiseLike<void>;
|
|
605
683
|
/** The arguments of a stream's `push`: `(scope, item)` for a scoped stream, `(item)` for a global one. */
|
|
606
684
|
type StreamPushArgs<C extends AnyContract, K extends StreamName<C>> = IsScopedStream<C, K> extends true ? [scope: string, item: StreamItemOf<C, K>] : [item: StreamItemOf<C, K>];
|
|
607
685
|
/** The arguments of a stream's `pushMany`: `(scope, items)` for a scoped stream, `(items)` for a global one. */
|
|
@@ -826,6 +904,26 @@ interface RunContext {
|
|
|
826
904
|
readonly log: Logger;
|
|
827
905
|
readonly principal: null;
|
|
828
906
|
}
|
|
907
|
+
/** Options of `qd.run(fn, options)` and `dispatcher.run(fn, options)`. */
|
|
908
|
+
interface RunOptions {
|
|
909
|
+
/**
|
|
910
|
+
* Run `fn` in a unit of work of its own even inside a method call or a
|
|
911
|
+
* transaction: for background work a handler starts and does not await
|
|
912
|
+
* (a push sent after the reply, pruning what it reports dead). Its writes
|
|
913
|
+
* flush once `fn` settles, on their own, instead of joining the handler's
|
|
914
|
+
* unit, which may have flushed long before (they would then flush as
|
|
915
|
+
* ambient writes, with a development warning). A failed flush is logged.
|
|
916
|
+
* Default `false`: inside an open unit of work or transaction, `fn` joins
|
|
917
|
+
* it.
|
|
918
|
+
*
|
|
919
|
+
* @example
|
|
920
|
+
* // in a handler, not awaited: the reply does not wait for the push
|
|
921
|
+
* void qd.run(() => sendPushes(db, message), { detached: true }).catch((error) => {
|
|
922
|
+
* ctx.log.error("Pushing the message failed", { error: String(error) });
|
|
923
|
+
* });
|
|
924
|
+
*/
|
|
925
|
+
readonly detached?: boolean;
|
|
926
|
+
}
|
|
829
927
|
|
|
830
928
|
/**
|
|
831
929
|
* The `fields` of a write whose changed fields are unknown, such as a
|
|
@@ -881,6 +979,14 @@ interface UnitOfWorkScope {
|
|
|
881
979
|
* dispatcher's own calls throw. Without them, the attached dispatcher's.
|
|
882
980
|
*/
|
|
883
981
|
readonly warnings?: DevWarnings;
|
|
982
|
+
/**
|
|
983
|
+
* A unit of its own even where another unit or a transaction is open (a
|
|
984
|
+
* handler's background work, `qd.run(fn, { detached: true })`): its writes
|
|
985
|
+
* flush when it does, never with the unit around it, and its statements
|
|
986
|
+
* are not counted there. Default `false`: a unit begun inside an open
|
|
987
|
+
* one joins it.
|
|
988
|
+
*/
|
|
989
|
+
readonly detached?: boolean;
|
|
884
990
|
}
|
|
885
991
|
/**
|
|
886
992
|
* The writes of one handler run. The dispatcher calls `run` once, with the
|
|
@@ -924,7 +1030,7 @@ interface UnitOfWorkFactory {
|
|
|
924
1030
|
}
|
|
925
1031
|
|
|
926
1032
|
/** What a sink learns about a flush besides its writes: where they came from, and the flush's revision. */
|
|
927
|
-
interface FlushInfo extends Omit<UnitOfWorkScope, "sink" | "warnings"> {
|
|
1033
|
+
interface FlushInfo extends Omit<UnitOfWorkScope, "sink" | "warnings" | "detached"> {
|
|
928
1034
|
/**
|
|
929
1035
|
* The flush's revision (RFC 0003 section 5.3, step 2), taken once for the
|
|
930
1036
|
* whole batch before any sink runs, so before any row is read.
|
|
@@ -972,4 +1078,4 @@ type CallerFor<T extends QuickdrawTypes> = T extends {
|
|
|
972
1078
|
readonly contracts: infer Contracts extends ContractMap;
|
|
973
1079
|
} ? Caller<Contracts[keyof Contracts]> : Caller<AnyContract>;
|
|
974
1080
|
|
|
975
|
-
export {
|
|
1081
|
+
export { type Projection as $, type AnyService as A, type PublicAccess as B, type CountArgs as C, DevWarningError as D, type HandlerArgs as E, type FindManyArgs as F, type CustomAccess as G, type HandlerContext as H, type ShareMode as I, type ChannelOptions as J, type ChannelsRequired as K, type BaseContext as L, type ModelName as M, type CallerFor as N, type AccessPolicy as O, type Presence as P, type AnyAccessPolicy as Q, type RowLevels as R, type StorageAdapter as S, type PolicyColumns as T, type UnitOfWorkFactory as U, type PolicyForeign as V, type WriteRecord as W, type PolicyTools as X, type RowAccess as Y, type AffectsLink as Z, type ProjectedOutput as _, type StorageRow as a, type TierGroup as a0, type Tiers as a1, DEFAULT_BULK_THRESHOLD as a2, type CollectionScope as a3, type ServiceCollection as a4, type ContextRooms as a5, type ContextServices as a6, type TouchOptions as a7, type ChannelAccess as a8, type ChannelContext as a9, type ScopeAccess as aA, type ServiceAccess as aB, ANY_FIELD as aC, type UnitOfWork as aD, type UnitOfWorkScope as aE, formatDevWarning as aF, N_PLUS_ONE_STATEMENTS as aG, type ChannelHandler as aa, type ChannelImplementation as ab, type RoomLeave as ac, type RoomLeaveReason as ad, type RoomLeft as ae, type RoomTarget as af, type ServiceChannel as ag, type ServiceStream as ah, type StreamPushArgs as ai, type StreamPushManyArgs as aj, type CallOptions as ak, type MethodCaller as al, type ServiceCaller as am, type ForeignColumns as an, type ForeignColumnsOf as ao, type MembershipRead as ap, type ParentLink as aq, type PolicyKind as ar, type PolicyReads as as, type RowLevel as at, type AccessForm as au, type AccessRequest as av, type AuthenticatedAccess as aw, type EntryAccess as ax, type IdKeyOf as ay, type IdSelector as az, type StorageWhere as b, type StatementCount as c, type FlushSink as d, type FlushInfo as e, type Service as f, type DevWarning as g, type DevWarningKind as h, type Caller as i, type ServiceMethod as j, type AccessFilter as k, type RunContext as l, type RunOptions as m, type ServerRooms as n, type StreamHandle as o, type AnyContext as p, type AccessEngine as q, type AppRooms as r, storageOf as s, type StreamSubscriptions as t, type RoomLeaveHandler as u, type PolicyFor as v, type RowForms as w, type AccessFor as x, type ModelColumn as y, type WatchAccess as z };
|
|
@@ -6,7 +6,8 @@ function via(junction) {
|
|
|
6
6
|
kind: "via",
|
|
7
7
|
model: junction.model,
|
|
8
8
|
entry: junction.entry,
|
|
9
|
-
scope: junction.scope
|
|
9
|
+
scope: junction.scope,
|
|
10
|
+
...junction.refreshEntry === void 0 ? {} : { refreshEntry: junction.refreshEntry }
|
|
10
11
|
});
|
|
11
12
|
}
|
|
12
13
|
|
|
@@ -27,4 +28,4 @@ export {
|
|
|
27
28
|
CHANNEL_DEFAULT_RATE,
|
|
28
29
|
isScopedStream
|
|
29
30
|
};
|
|
30
|
-
//# sourceMappingURL=chunk-
|
|
31
|
+
//# sourceMappingURL=chunk-3ONXZP2X.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/contract/collections.ts","../src/contract/realtime.ts"],"sourcesContent":["// Collection declarations for a contract (RFC 0003 section 7.1). A collection\n// is the rows of one service grouped by a scope value. Membership is declared\n// (a scope column or a junction table, plus an equality filter), never\n// computed by a function, so the framework knows which writes can move a row.\n// This replaces 4.1's `defineCollection({ resolveScopeId, checkScopeAccess,\n// snapshot, toItem })`.\n\nimport type { AccessLevel } from \"./access\";\n\n/** Page size of a collection snapshot when the collection sets no `limit`. */\nexport const DEFAULT_COLLECTION_LIMIT = 100;\n\n/** Largest page a client may ask for when the collection sets no `maxLimit`. */\nexport const DEFAULT_COLLECTION_MAX_LIMIT = 500;\n\n/**\n * A scope that comes from a junction table instead of a column on the row:\n * the rows of `model` link an entry (`entry` holds the row's id) to a scope\n * value (`scope`). For example, a chat appears in each member's list through\n * `via({ model: \"chatMember\", entry: \"chatId\", scope: \"userId\" })`.\n */\nexport interface ViaScope<\n Model extends string = string,\n Entry extends string = string,\n Scope extends string = string,\n> {\n readonly kind: \"via\";\n readonly model: Model;\n readonly entry: Entry;\n readonly scope: Scope;\n /**\n * The item is computed from the junction (a member count): every junction\n * write sends its entry again, whole, to each scope that still holds it.\n * Without it a junction write only adds or removes the entry in the scope\n * it links, so a count over the junction goes stale everywhere else.\n */\n readonly refreshEntry?: boolean;\n}\n\n/** What `via(...)` takes: the junction model, its entry and scope columns, and `refreshEntry`. */\nexport interface ViaOptions<\n Model extends string = string,\n Entry extends string = string,\n Scope extends string = string,\n> {\n /** The junction model, as the database client names it: `\"chatMember\"`. */\n readonly model: Model;\n /** The junction column holding the entry's id: `\"chatId\"`. */\n readonly entry: Entry;\n /** The junction column holding the scope value: `\"userId\"`. */\n readonly scope: Scope;\n /**\n * Set when the item reads the junction, as a `memberCount` mapped from a\n * relation count does: a junction create, update or delete then sends the\n * entry again, as `updated`, to every scope that still holds it (one read\n * of the entry's item per flush). Default `false`: a junction write adds\n * or removes the entry for the scope it links, and nothing else.\n */\n readonly refreshEntry?: boolean;\n}\n\n/**\n * Declares a collection scope that comes from a junction table.\n *\n * @example\n * myChats: {\n * scope: via({ model: \"chatMember\", entry: \"chatId\", scope: \"userId\", refreshEntry: true }),\n * item: \"listItem\",\n * order: [[\"id\", \"asc\"]],\n * }\n */\nexport function via<\n const Model extends string,\n const Entry extends string,\n const Scope extends string,\n>(junction: ViaOptions<Model, Entry, Scope>): ViaScope<Model, Entry, Scope> {\n return Object.freeze({\n kind: \"via\",\n model: junction.model,\n entry: junction.entry,\n scope: junction.scope,\n ...(junction.refreshEntry === undefined ? {} : { refreshEntry: junction.refreshEntry }),\n });\n}\n\nexport type SortDirection = \"asc\" | \"desc\";\n\n/**\n * Sort columns for the keyset cursor, ending in `id` so every row has a\n * unique position: `[[\"ordinal\", \"asc\"], [\"id\", \"asc\"]]`.\n */\nexport type OrderBy<Column extends string = string> = readonly [\n ...(readonly [Column, SortDirection])[],\n readonly [\"id\", SortDirection],\n];\n\n/** Who a view is evaluated for, on the client. */\nexport interface Viewer {\n readonly userId: string;\n}\n\n/**\n * A view: a named pure predicate evaluated on the client over the index rows\n * of a scope (RFC 0003 section 7.5). `row` holds only `id` and the\n * collection's `index` fields, which the type enforces.\n */\nexport type ViewPredicate<Row> = (row: Row, who: Viewer) => boolean;\n\n/** The equality filter that decides membership, for example `{ archived: false }`. */\nexport interface CollectionWhere {\n readonly [column: string]: string | number | boolean | null;\n}\n\n/** Any collection of any contract, as `defineContract` stores it. */\nexport interface CollectionDef {\n /** A string column of the entity, or `via(...)` for a junction table. */\n readonly scope: string | ViaScope;\n /** The projection each item is sent as: `\"entity\"` or a named projection. */\n readonly item: string;\n /** Sort columns for the keyset cursor; the last one is `id`. */\n readonly order: OrderBy;\n /** Equality filter that decides membership. */\n readonly where?: CollectionWhere | undefined;\n /** Page size. Default {@link DEFAULT_COLLECTION_LIMIT}. */\n readonly limit?: number | undefined;\n /** Largest page a client may ask for. Default {@link DEFAULT_COLLECTION_MAX_LIMIT}. */\n readonly maxLimit?: number | undefined;\n /** Item fields sent for every member of the scope (RFC 0003 section 7.4). */\n readonly index?: readonly string[] | undefined;\n /** Named predicates over index rows. Requires `index`. */\n readonly views?: { readonly [view: string]: ViewPredicate<never> } | undefined;\n /** Level required on the scope's anchor row. Default `\"Read\"`, through the anchor's policy. */\n readonly access?: AccessLevel | undefined;\n}\n","// Streams, channels and typed room events in a contract (RFC 0003 section\n// 12.5). A stream is server-to-client append-only data a subscriber receives\n// with a seed of recent items; a channel is fire-and-forget client-to-server\n// input (cursors, typing) behind a per-socket token bucket; an event is a\n// typed custom frame a handler sends to a room. 4.1 declared channels on the\n// service class (`legacy-src/server/BaseService.ts:915-942`) and typed room\n// events through the augmentable `QuickdrawEventMap`; here all three live in\n// the contract, so the client is typed from it.\n//\n// Methods, collections, streams, channels and events share one namespace:\n// the client exposes each as `qd.<service>.<name>`.\n\nimport type { AccessLevel } from \"./access\";\nimport type { AnyContract } from \"./defineContract\";\nimport type { StandardSchemaV1 } from \"./standardSchema\";\n\n/** The `scope` of a stream with one feed for the whole service: the default. */\nexport const GLOBAL_STREAM = \"global\";\n\n/** The most items a stream may keep per scope as the seed for new subscribers. */\nexport const STREAM_MAX_SEED = 1000;\n\n/** A channel's sustained rate when it declares none: messages per second per socket. */\nexport const CHANNEL_DEFAULT_RATE = 30;\n\n/**\n * Who may subscribe to a stream (RFC 0003 section 4.1's forms, as data). A\n * scoped stream's scope value is the row an `entry` or `scope` form is about:\n * `{ entry: L }` needs level `L` on the row of this service whose id is the\n * scope, `{ scope: L, of }` level `L` on that row of `of`'s service. A\n * stream that declares none is closed: no client may subscribe.\n */\nexport type StreamAccess =\n | \"public\"\n | \"authenticated\"\n | {\n readonly service: AccessLevel;\n readonly entry?: undefined;\n readonly scope?: undefined;\n readonly of?: undefined;\n }\n | {\n readonly entry: AccessLevel;\n readonly service?: AccessLevel;\n readonly scope?: undefined;\n readonly of?: undefined;\n }\n | {\n readonly scope: AccessLevel;\n readonly of: AnyContract;\n readonly service?: undefined;\n readonly entry?: undefined;\n };\n\n/** A server-to-client stream of append-only items (RFC 0003 section 12.5). */\nexport interface StreamDef<Item extends StandardSchemaV1 = StandardSchemaV1> {\n /** One item's schema. Pushed items are checked against it before they are sent. */\n readonly item: Item;\n /**\n * What a scope value is (`\"taskId\"`): the stream has one feed per scope.\n * `\"global\"`, the default, makes one feed for the whole service.\n */\n readonly scope?: string;\n /**\n * How many of the latest items the server keeps per scope, in memory on\n * each process, and sends a new subscriber first. Default 0; at most 1,000.\n */\n readonly seed?: number;\n /** Send items volatile: dropped for a client whose connection is backed up. Default `false`. */\n readonly volatile?: boolean;\n /** Who may subscribe. Without it the stream is closed. */\n readonly access?: StreamAccess;\n}\n\n/**\n * Where a channel requirement finds its row id or scope value in the parsed\n * payload: the name of a key holding a string, or a function of the payload.\n */\nexport type PayloadSelector = string | ((payload: never) => string | null | undefined);\n\n/**\n * The app room a channel requirement names: the room itself (`\"world\"`, a\n * game's one world), or a function of the parsed payload that returns it\n * (`(payload) => \\`lobby:${payload.lobbyId}\\``). Unlike a\n * {@link PayloadSelector}, a string here is the room's name, not a payload\n * key. A name starting with `qd:` or `user:` is never an app room: a literal\n * one is refused when the contract is defined, and a computed one drops the\n * message.\n */\nexport type RoomSelector = string | ((payload: never) => string | null | undefined);\n\n/**\n * What a channel message requires of the socket that sends it: a live\n * subscription (`qd:sub`) to the row of this service `entity` names, or\n * (`qd:col:sub`) to the scope of `collection` that `scope` names; or that\n * the socket is in the app room `room` names, which a method called over\n * that same socket joined with `ctx.rooms.join` (a room another socket of\n * the user joined does not count, and a reconnected socket is in none until\n * it joins again). Each is checked in memory against the sending socket's\n * own records, on the node it is connected to. A message whose payload names\n * none is dropped.\n */\nexport type ChannelRequires =\n | {\n readonly entity: PayloadSelector;\n readonly collection?: undefined;\n readonly scope?: undefined;\n readonly room?: undefined;\n }\n | {\n readonly collection: string;\n readonly scope: PayloadSelector;\n readonly entity?: undefined;\n readonly room?: undefined;\n }\n | {\n readonly room: RoomSelector;\n readonly entity?: undefined;\n readonly collection?: undefined;\n readonly scope?: undefined;\n };\n\n/** A client-to-server fire-and-forget channel (RFC 0003 section 12.5). */\nexport interface ChannelDef<Payload extends StandardSchemaV1 = StandardSchemaV1> {\n /** One message's schema. A message that fails it is dropped. */\n readonly payload: Payload;\n /** Messages one socket may send per second, sustained. Default 30. */\n readonly ratePerSecond?: number;\n /** How many messages one socket may send at once. Default twice `ratePerSecond`. */\n readonly burst?: number;\n /** What the sender must already be subscribed to, or the app room it must be in. */\n readonly requires?: ChannelRequires;\n}\n\n/** A typed custom room event, delivered as `qd:event` (RFC 0003 section 8.3). */\nexport interface EventDef<Payload extends StandardSchemaV1 = StandardSchemaV1> {\n readonly payload: Payload;\n}\n\n/** True when a stream has one feed per scope value; false for a global stream. */\nexport function isScopedStream(def: Pick<StreamDef, \"scope\">): boolean {\n return def.scope !== undefined && def.scope !== GLOBAL_STREAM;\n}\n"],"mappings":";AAUO,IAAM,2BAA2B;AAGjC,IAAM,+BAA+B;AA0DrC,SAAS,IAId,UAA0E;AAC1E,SAAO,OAAO,OAAO;AAAA,IACnB,MAAM;AAAA,IACN,OAAO,SAAS;AAAA,IAChB,OAAO,SAAS;AAAA,IAChB,OAAO,SAAS;AAAA,IAChB,GAAI,SAAS,iBAAiB,SAAY,CAAC,IAAI,EAAE,cAAc,SAAS,aAAa;AAAA,EACvF,CAAC;AACH;;;AClEO,IAAM,gBAAgB;AAGtB,IAAM,kBAAkB;AAGxB,IAAM,uBAAuB;AAqH7B,SAAS,eAAe,KAAwC;AACrE,SAAO,IAAI,UAAU,UAAa,IAAI,UAAU;AAClD;","names":[]}
|
|
@@ -3,7 +3,7 @@ import {
|
|
|
3
3
|
} from "./chunk-V3KNCTXJ.js";
|
|
4
4
|
|
|
5
5
|
// src/version.ts
|
|
6
|
-
var QUICKDRAW_VERSION = "5.0.0-rc.
|
|
6
|
+
var QUICKDRAW_VERSION = "5.0.0-rc.3";
|
|
7
7
|
|
|
8
8
|
// src/contract/names.ts
|
|
9
9
|
function entityRoom(service, id, level) {
|
|
@@ -114,4 +114,4 @@ export {
|
|
|
114
114
|
MAX_SUBSCRIBE_IDS,
|
|
115
115
|
MAX_SCOPE_LENGTH
|
|
116
116
|
};
|
|
117
|
-
//# sourceMappingURL=chunk-
|
|
117
|
+
//# sourceMappingURL=chunk-5CXPEEWF.js.map
|