@fitzzero/quickdraw-core 5.0.0 → 5.1.0
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 +343 -49
- package/dist/{admin-BiU4LRZS.d.ts → admin-DKwkIh5i.d.ts} +25 -10
- package/dist/{adminSchemas-k5a1Ov7b.d.ts → adminSchemas-2DUA_hZ8.d.ts} +15 -9
- package/dist/{auth-CMyRkfjZ.d.ts → auth-BhWqdyb-.d.ts} +2 -2
- package/dist/{caller-DHzK3oiI.d.ts → caller-CXTFswBO.d.ts} +28 -6
- package/dist/{chunk-2XJFNVHW.js → chunk-2PWMUVC7.js} +1 -1
- package/dist/{chunk-2XJFNVHW.js.map → chunk-2PWMUVC7.js.map} +1 -1
- package/dist/{chunk-5IC5VZ7T.js → chunk-2UELNHDD.js} +3 -3
- package/dist/{chunk-XFDOGTMC.js → chunk-3674EIRT.js} +4 -4
- package/dist/{chunk-DNCAI3KY.js → chunk-4DIQHASK.js} +1 -1
- package/dist/chunk-4DIQHASK.js.map +1 -0
- package/dist/{chunk-LLLYILEP.js → chunk-A6DJBGOG.js} +411 -43
- package/dist/chunk-A6DJBGOG.js.map +1 -0
- package/dist/{chunk-B2UQ27GH.js → chunk-CDKG4TFA.js} +25 -5
- package/dist/{chunk-B2UQ27GH.js.map → chunk-CDKG4TFA.js.map} +1 -1
- package/dist/{chunk-IRGAGRVG.js → chunk-JZBCLNDU.js} +4 -4
- package/dist/{chunk-XUSN5WKU.js → chunk-KNDDCVQJ.js} +4 -4
- package/dist/{chunk-5YRQ26LF.js → chunk-RT6OILOI.js} +1 -1
- package/dist/chunk-RT6OILOI.js.map +1 -0
- package/dist/{chunk-7QIY2DDX.js → chunk-VJTFXHOK.js} +2 -2
- package/dist/{chunk-7QIY2DDX.js.map → chunk-VJTFXHOK.js.map} +1 -1
- package/dist/chunk-XNLWBZIG.js +9 -0
- package/dist/chunk-XNLWBZIG.js.map +1 -0
- package/dist/cli/quickdraw-docs.js +241 -27
- package/dist/cli/quickdraw-docs.js.map +1 -1
- package/dist/client/index.d.ts +7 -7
- package/dist/client/index.js +5 -5
- package/dist/{clientTypes-B3o_99Xq.d.ts → clientTypes-Bc-G8n-U.d.ts} +3 -3
- package/dist/{connection-DuN8GvcD.d.ts → connection-Cr1BU07W.d.ts} +2 -2
- package/dist/{createServer-DUacdfKl.d.ts → createServer-CP9eDHZZ.d.ts} +30 -9
- package/dist/{createTestApp-DFLXoTKm.d.ts → createTestApp-UKVGEmOf.d.ts} +6 -6
- package/dist/{envelope-nf5eGB-a.d.ts → envelope-Cp3YcVxC.d.ts} +1 -1
- package/dist/{http--uoooIH5.d.ts → http-Djj2Inuu.d.ts} +6 -6
- package/dist/index.d.ts +6 -6
- package/dist/index.js +27 -7
- package/dist/index.js.map +1 -1
- package/dist/{infer-Dw21kmLw.d.ts → infer-Teo1xpsd.d.ts} +17 -0
- package/dist/{metrics-C0WvB7-I.d.ts → metrics-BdBu5o8G.d.ts} +2 -2
- package/dist/prisma/index.d.ts +4 -4
- package/dist/prisma/index.js +1 -1
- package/dist/server/auth/index.d.ts +14 -5
- package/dist/server/auth/index.js +0 -5
- package/dist/server/auth/index.js.map +1 -1
- package/dist/server/index.d.ts +120 -33
- package/dist/server/index.js +146 -193
- package/dist/server/index.js.map +1 -1
- package/dist/server/mcp/index.d.ts +51 -11
- package/dist/server/mcp/index.js +129 -8
- package/dist/server/mcp/index.js.map +1 -1
- package/dist/server/otel.d.ts +3 -3
- package/dist/{sharing-n4ss-ONM.d.ts → sharing-DVa8W-yR.d.ts} +3 -3
- package/dist/testing/client.d.ts +13 -13
- package/dist/testing/client.js +6 -6
- package/dist/testing/index.d.ts +156 -12
- package/dist/testing/index.js +1037 -22
- package/dist/testing/index.js.map +1 -1
- package/dist/testing/mock.d.ts +5 -5
- package/dist/testing/mock.js +5 -5
- package/dist/{types-Bwg3kIEc.d.ts → types-Cu0vmx7c.d.ts} +8 -2
- package/dist/utils/index.d.ts +1 -1
- package/package.json +1 -1
- package/dist/chunk-5YRQ26LF.js.map +0 -1
- package/dist/chunk-DNCAI3KY.js.map +0 -1
- package/dist/chunk-LLLYILEP.js.map +0 -1
- /package/dist/{chunk-5IC5VZ7T.js.map → chunk-2UELNHDD.js.map} +0 -0
- /package/dist/{chunk-XFDOGTMC.js.map → chunk-3674EIRT.js.map} +0 -0
- /package/dist/{chunk-IRGAGRVG.js.map → chunk-JZBCLNDU.js.map} +0 -0
- /package/dist/{chunk-XUSN5WKU.js.map → chunk-KNDDCVQJ.js.map} +0 -0
package/README.md
CHANGED
|
@@ -77,6 +77,8 @@ import { z } from "zod";
|
|
|
77
77
|
import { cardSchema, taskSchema } from "../schemas";
|
|
78
78
|
|
|
79
79
|
export const taskContract = defineContract("taskService", {
|
|
80
|
+
// what the service is for: MCP tools and the generated docs show it
|
|
81
|
+
describe: "Tasks on a project's board.",
|
|
80
82
|
// the full row; it must contain `id: string`
|
|
81
83
|
entity: taskSchema,
|
|
82
84
|
// lean shapes of the row
|
|
@@ -103,11 +105,13 @@ export const taskContract = defineContract("taskService", {
|
|
|
103
105
|
output: z.number(),
|
|
104
106
|
// fetched again whenever the project's board changes
|
|
105
107
|
watch: { collection: "board", scope: (input) => input.projectId },
|
|
108
|
+
describe: "Counts the tasks on a project's board.",
|
|
106
109
|
}),
|
|
107
110
|
},
|
|
108
111
|
collections: {
|
|
109
112
|
// every task of a project, live, in board order
|
|
110
113
|
board: {
|
|
114
|
+
describe: "A project's tasks, in board order.",
|
|
111
115
|
scope: "projectId",
|
|
112
116
|
item: "card",
|
|
113
117
|
order: [
|
|
@@ -482,25 +486,49 @@ any server code (design: `docs/rfcs/0003-v5.md`, section 2).
|
|
|
482
486
|
|
|
483
487
|
```ts
|
|
484
488
|
export const labelContract = defineContract("labelService", {
|
|
489
|
+
describe: "Labels a project puts on its tasks.",
|
|
485
490
|
entity: labelSchema,
|
|
486
491
|
projections: { chip: z.object({ id: z.string(), name: z.string() }) },
|
|
487
492
|
methods: {
|
|
488
|
-
find: query({
|
|
489
|
-
|
|
490
|
-
|
|
493
|
+
find: query({
|
|
494
|
+
input: z.object({ name: z.string() }),
|
|
495
|
+
output: nullable("entity"),
|
|
496
|
+
describe: "Finds a label by its name, or null.",
|
|
497
|
+
}),
|
|
498
|
+
chips: query({
|
|
499
|
+
input: z.object({ projectId: z.string() }),
|
|
500
|
+
output: listOf("chip"),
|
|
501
|
+
describe: "Lists a project's labels as chips.",
|
|
502
|
+
}),
|
|
503
|
+
usage: query({
|
|
504
|
+
input: z.undefined(),
|
|
505
|
+
output: z.record(z.string(), z.number()),
|
|
506
|
+
describe: "Counts the tasks of each label.",
|
|
507
|
+
}),
|
|
491
508
|
},
|
|
492
509
|
});
|
|
493
510
|
|
|
494
511
|
// No entity: an RPC-only service, with no projections, field tiers or collections.
|
|
495
512
|
export const healthContract = defineContract("healthService", {
|
|
496
|
-
|
|
513
|
+
describe: "Tells a caller the server is up.",
|
|
514
|
+
methods: {
|
|
515
|
+
ping: query({
|
|
516
|
+
input: z.undefined(),
|
|
517
|
+
output: z.literal("pong"),
|
|
518
|
+
describe: "Answers pong while the server runs.",
|
|
519
|
+
}),
|
|
520
|
+
},
|
|
497
521
|
});
|
|
498
522
|
```
|
|
499
523
|
|
|
500
524
|
- Every method is a `query` or a `mutation`, with an `input` and an
|
|
501
525
|
`output`. The kind decides request sharing, cancellation, the concurrency
|
|
502
|
-
cap, the MCP read-only hint and which client hook exists.
|
|
503
|
-
|
|
526
|
+
cap, the MCP read-only hint and which client hook exists.
|
|
527
|
+
- Every member takes a `describe`, a sentence or two for people and agents:
|
|
528
|
+
the contract itself (what the service is for), each method (its MCP tool's
|
|
529
|
+
description), collection, stream, channel and event. `quickdraw-docs`
|
|
530
|
+
leads each section of the API docs with it. It is optional in the types;
|
|
531
|
+
lint's `require-describe` (a warning) lists each member without one.
|
|
504
532
|
- `output` is a schema, or a projection: `"entity"`, a named projection,
|
|
505
533
|
`nullable("entity")` or `listOf("card")`. A handler returns database rows
|
|
506
534
|
for a projection output, and the framework projects them.
|
|
@@ -693,10 +721,20 @@ export const taskService = qd.defineService(task, {
|
|
|
693
721
|
handler: async ({ db }) => (await db.task.updateMany({ data: { status: "archived" } })).count,
|
|
694
722
|
},
|
|
695
723
|
claim: {
|
|
696
|
-
access:
|
|
724
|
+
access: { entry: "Moderate" },
|
|
725
|
+
// users only: an agent acting for one is refused, whatever its grants
|
|
726
|
+
kinds: ["user"],
|
|
697
727
|
handler: ({ input, ctx, db }) =>
|
|
698
728
|
db.task.update({ where: { id: input.id }, data: { assigneeId: ctx.principal.userId } }),
|
|
699
729
|
},
|
|
730
|
+
triage: {
|
|
731
|
+
// a check on a token's claims stays a custom form
|
|
732
|
+
access: custom(
|
|
733
|
+
(ctx, input) => ctx.principal.claims?.scope === "triage" && input.status !== "archived",
|
|
734
|
+
),
|
|
735
|
+
handler: ({ input, db }) =>
|
|
736
|
+
db.task.update({ where: { id: input.id }, data: { status: input.status } }),
|
|
737
|
+
},
|
|
700
738
|
},
|
|
701
739
|
});
|
|
702
740
|
```
|
|
@@ -708,6 +746,20 @@ export const taskService = qd.defineService(task, {
|
|
|
708
746
|
`custom(fn)`. Without a principal every form but `"public"` answers
|
|
709
747
|
`UNAUTHENTICATED`; a principal that fails gets `FORBIDDEN`. The levels,
|
|
710
748
|
lowest first, are `Public`, `Read`, `Moderate` and `Admin`.
|
|
749
|
+
- `kinds`, beside `access`, says which kinds of principal
|
|
750
|
+
(`principal.kind`) may call: `initQuickdraw({ kinds: ["user", "agent"] })`
|
|
751
|
+
for every service the app defines, `defineService(contract, { kinds })`
|
|
752
|
+
for one service, and a method's own `kinds` (`claim` above). Each level
|
|
753
|
+
only narrows the one above it: a wider or empty list is refused when the
|
|
754
|
+
service is defined. A principal of another kind, or without a `kind`,
|
|
755
|
+
gets `FORBIDDEN` before the form is asked, and no grant passes the check,
|
|
756
|
+
a service-wide `Admin` grant included, since a token acting with someone's
|
|
757
|
+
grants is what it stops. An anonymous caller is left to the form, so a
|
|
758
|
+
`"public"` method takes no `kinds` of its own: a refused caller would call
|
|
759
|
+
it signed out. A service's list also holds for its entity, collection,
|
|
760
|
+
topic and stream subscriptions, and its channels drop the messages of
|
|
761
|
+
other kinds. A check on a claim, such as a token's `scope`, stays a
|
|
762
|
+
`custom` form (`triage` above).
|
|
711
763
|
- On a service with a policy, a method whose input has `id` under a form
|
|
712
764
|
that checks no row (`"public"`, `"authenticated"`, `{ service: L }` below
|
|
713
765
|
`Admin`) would let anyone that form admits reach any row by its id, so
|
|
@@ -719,7 +771,8 @@ export const taskService = qd.defineService(task, {
|
|
|
719
771
|
`rowless: ["get"]` in the kit's options. The input's keys come from its
|
|
720
772
|
JSON Schema: an `id` in any branch of a union counts, and so does one
|
|
721
773
|
beside a value JSON Schema cannot write (a `Date`, a `Set`). Not checked:
|
|
722
|
-
an input without JSON Schema (Zod 3)
|
|
774
|
+
an input without JSON Schema (Zod 3) or whose JSON Schema names no keys (a
|
|
775
|
+
`todoSchema` without `keys`), one that is no object (a bare string
|
|
723
776
|
that is the id itself) and a row named by another key (`ids`, `taskId`, a
|
|
724
777
|
nested `where.id`).
|
|
725
778
|
- A service-wide `Admin` grant passes every check on its service
|
|
@@ -734,16 +787,23 @@ export const taskService = qd.defineService(task, {
|
|
|
734
787
|
(`qd:access`), and in-process callers load the grants again.
|
|
735
788
|
- Policies: `owner(field)`, `jsonAcl(field, { owner? })`,
|
|
736
789
|
`members({ model, entry, user, level, levels? })`, `inherit({ from, via })`,
|
|
737
|
-
`anyOf(...)`, `resolver({ levelsFor, where
|
|
790
|
+
`anyOf(...)`, `resolver({ levelsFor, where?, reads })` and `everyone(level)`
|
|
738
791
|
(every signed-in user has `level` on every row, reading nothing: public
|
|
739
792
|
profiles are `anyOf(owner("id"), everyone("Read"))`; unlike `rowless: true`
|
|
740
|
-
on a method, it covers subscriptions and lists too).
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
793
|
+
on a method, it covers subscriptions and lists too). A `resolver` is app
|
|
794
|
+
code, so it says what its levels depend on: `reads: { columns, memberships }`
|
|
795
|
+
names columns of the service's model and membership tables as `members`
|
|
796
|
+
takes them (`entry` holding this service's row id), and tracked writes to
|
|
797
|
+
them re-check what it decided, as for the other policies; `reads: "none"`
|
|
798
|
+
says nothing a tracked write changes can change a level (the principal's
|
|
799
|
+
grants alone, say). A resolver with neither is never re-checked, and the
|
|
800
|
+
server warns `[quickdraw:resolver-without-reads]` when it starts. Their
|
|
801
|
+
column and table names are checked against the Prisma client's models at
|
|
802
|
+
compile time. A lookup is one batched query per table, memoized for the
|
|
803
|
+
call, so checking 60 ids costs what checking one does. `entry` access
|
|
804
|
+
needs a policy; a service without `model` may only use `"public"`,
|
|
805
|
+
`"authenticated"`, `{ service }` and `custom`. `inherit` uses the
|
|
806
|
+
parent's policy only: grants on the parent's service do not flow down.
|
|
747
807
|
- One policy decides every surface: method calls, entity subscriptions,
|
|
748
808
|
collection scopes, the kits' lists and searches, streams and channels.
|
|
749
809
|
- When a tracked write lowers or removes someone's access, their sockets
|
|
@@ -970,11 +1030,13 @@ authorizes a scope:
|
|
|
970
1030
|
|
|
971
1031
|
```ts
|
|
972
1032
|
export const task = defineContract("taskService", {
|
|
1033
|
+
describe: "Tasks on a project's board.",
|
|
973
1034
|
entity: taskSchema,
|
|
974
1035
|
projections: { card: cardSchema },
|
|
975
1036
|
methods: { ...crud.contract({ entity: taskSchema, get: true }) },
|
|
976
1037
|
collections: {
|
|
977
1038
|
byProject: {
|
|
1039
|
+
describe: "A project's open tasks, in board order.",
|
|
978
1040
|
// a column holding the scope value
|
|
979
1041
|
scope: "projectId",
|
|
980
1042
|
// the projection each item is sent as
|
|
@@ -991,7 +1053,12 @@ export const task = defineContract("taskService", {
|
|
|
991
1053
|
views: { mine: (row, who) => row.assigneeId === who.userId },
|
|
992
1054
|
},
|
|
993
1055
|
// each user's own
|
|
994
|
-
assigned: {
|
|
1056
|
+
assigned: {
|
|
1057
|
+
describe: "The tasks assigned to a user.",
|
|
1058
|
+
scope: "assigneeId",
|
|
1059
|
+
item: "card",
|
|
1060
|
+
order: [["id", "asc"]],
|
|
1061
|
+
},
|
|
995
1062
|
},
|
|
996
1063
|
});
|
|
997
1064
|
```
|
|
@@ -1134,18 +1201,36 @@ as a 4.x client instead of being refused with `PROTOCOL_MISMATCH`. The shim
|
|
|
1134
1201
|
serves request/response calls only: `socket.emit("taskService:get", payload, ack)`
|
|
1135
1202
|
runs through the 5.0 pipeline and is answered in the 4.x `ServiceResponse`
|
|
1136
1203
|
shape, `{ success: true, data }` or `{ success: false, error, code }`, with
|
|
1137
|
-
the HTTP status of the error code as `code`.
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1204
|
+
the HTTP status of the error code as `code`. A call through the shim runs
|
|
1205
|
+
with the `ctx.socketId` and `ctx.rooms` of the socket it arrived on, so a
|
|
1206
|
+
handler can put a 4.x client in an app room; it leaves its rooms when it
|
|
1207
|
+
disconnects, and `onRoomLeave` and presence hear it as they hear a 5.0
|
|
1208
|
+
socket. 4.x subscriptions, collections and channels are not served, and a
|
|
1209
|
+
contract's events reach only 5.0 sockets: delivering events to 4.x
|
|
1210
|
+
listeners stays the app's own raw emit to the room until those clients
|
|
1211
|
+
speak protocol 5. Each service, method and principal kind that calls
|
|
1212
|
+
through the shim is logged once at `warn`, so the remaining 4.x clients can
|
|
1213
|
+
be found.
|
|
1214
|
+
|
|
1215
|
+
A service renamed during the migration keeps its old name for 4.x clients
|
|
1216
|
+
through aliases: with `legacyWire: { aliases: { taskService: "cardService" } }`,
|
|
1217
|
+
`socket.emit("taskService:get", payload, ack)` runs `cardService.get`, with
|
|
1218
|
+
that method's validation, access and `onCall` (grants are read under
|
|
1219
|
+
`cardService`, never under the old name). Its log line names the alias, once
|
|
1220
|
+
per old name, method and principal kind, so the app can see when the old name
|
|
1221
|
+
is no longer called. Each alias must name a registered service and must not be
|
|
1222
|
+
one itself, or `createServer` throws. Only 4.x events are aliased: a protocol-5,
|
|
1223
|
+
HTTP or MCP call to `taskService` finds no service.
|
|
1141
1224
|
|
|
1142
1225
|
### MCP bridge
|
|
1143
1226
|
|
|
1144
1227
|
`@fitzzero/quickdraw-core/server/mcp` serves the services to AI agents as MCP
|
|
1145
1228
|
tools generated from their contracts at startup: one tool per method, named
|
|
1146
|
-
`{service}_{method}`, described by the method's `describe` text
|
|
1229
|
+
`{service}_{method}`, described by the method's `describe` text (without
|
|
1230
|
+
one, by `service.method (kind)`, which tells an agent nothing), with the
|
|
1147
1231
|
input schema's JSON Schema as its arguments and `readOnlyHint` on every
|
|
1148
|
-
query.
|
|
1232
|
+
query. The API docs print each method's default tool name and its read-only
|
|
1233
|
+
hint. Every tool call goes through the dispatcher with transport `"mcp"`,
|
|
1149
1234
|
so input validation, access checks and limits apply exactly as on a socket.
|
|
1150
1235
|
A method whose input cannot describe itself as JSON Schema stops the
|
|
1151
1236
|
registry at startup, naming the method, unless it is excluded.
|
|
@@ -1210,6 +1295,68 @@ createMcpStdioServer({ registry, name: "my-app", version: "1.0.0" });
|
|
|
1210
1295
|
list with `exclude` or `include`, per registry, and serve agents of
|
|
1211
1296
|
different reach from separate registries.
|
|
1212
1297
|
|
|
1298
|
+
`bind` fills chosen arguments from who is calling instead of from the agent,
|
|
1299
|
+
so an agent bound to one project cannot name another in its calls. Here an
|
|
1300
|
+
agent's token names its project as a verified claim (`principal.claims`):
|
|
1301
|
+
|
|
1302
|
+
<!-- example: apps/api/src/mcp.ts#bind -->
|
|
1303
|
+
|
|
1304
|
+
```ts
|
|
1305
|
+
// agents bound to one project: an agent's token names it as a verified claim
|
|
1306
|
+
const projectAgents = createMcpRegistry({
|
|
1307
|
+
services: [taskService],
|
|
1308
|
+
dispatcher: server.dispatcher,
|
|
1309
|
+
// agent tokens only: anyone else is anonymous, and every tool here refuses them
|
|
1310
|
+
principal: (request) =>
|
|
1311
|
+
verifyAgentToken(request.transport === "http" ? request.token : process.env.AGENT_TOKEN),
|
|
1312
|
+
// filled from the claim on every call, and left out of every tool's arguments
|
|
1313
|
+
bind: { projectId: ({ principal }) => principal.claims?.projectId },
|
|
1314
|
+
include: ["taskService.create", "taskService.countOnBoard"],
|
|
1315
|
+
// the tools' public names: create_task and count_tasks
|
|
1316
|
+
name: (_service, method) => (method === "create" ? "create_task" : "count_tasks"),
|
|
1317
|
+
});
|
|
1318
|
+
|
|
1319
|
+
// beside the first registry, at a path of its own: GET /agents/tools, POST /agents/invoke
|
|
1320
|
+
app.use(createMcpHttpRouter({ registry: projectAgents, path: "/agents" }));
|
|
1321
|
+
```
|
|
1322
|
+
|
|
1323
|
+
- Each bound argument is left out of the `inputSchema` of every method's tool
|
|
1324
|
+
whose object input has it: `create_task` lists `id` and `title`, and
|
|
1325
|
+
`count_tasks` takes no arguments. Every call of such a tool gets the
|
|
1326
|
+
binder's value before its input is validated, through `tools/call` and both
|
|
1327
|
+
shapes of `POST /mcp/invoke` alike.
|
|
1328
|
+
- A call fails closed, before the method runs: `UNAUTHENTICATED` for an
|
|
1329
|
+
anonymous caller, and `FORBIDDEN` when the binder returns `undefined` or
|
|
1330
|
+
`null`, or when the agent sent the argument with another value. A binder
|
|
1331
|
+
that throws fails the call with `INTERNAL`, or with the code of the
|
|
1332
|
+
`QuickdrawError` it threw.
|
|
1333
|
+
- `createMcpRegistry` throws at startup when a bound argument is in no
|
|
1334
|
+
selected method's object input (a misspelled name would leave the real
|
|
1335
|
+
argument to the agent), and when it is a property of an input that is not
|
|
1336
|
+
an object, which it names: leave that method out, or make its input an
|
|
1337
|
+
object.
|
|
1338
|
+
- Custom tools are never bound, and neither are the calls their `caller`
|
|
1339
|
+
makes: a custom tool reads `principal` itself. `describeTools` and the API
|
|
1340
|
+
docs list the inputs as the contracts declare them, with nothing bound.
|
|
1341
|
+
- Binding is not access control. The same principal reaches the method over a
|
|
1342
|
+
socket or HTTP with nothing bound, so the method's access must still
|
|
1343
|
+
confine it to its project.
|
|
1344
|
+
|
|
1345
|
+
Two more needs are met by the options above, with nothing added:
|
|
1346
|
+
|
|
1347
|
+
- **A tool set per kind of caller.** Make one registry per kind, each with its
|
|
1348
|
+
own `include`, `bind` and `principal`, and serve each at its own `path` or
|
|
1349
|
+
from its own stdio entry, as above. A registry's `principal` hook refuses
|
|
1350
|
+
the other kinds: throwing fails their calls with `UNAUTHENTICATED`.
|
|
1351
|
+
- **Frozen public tools.** `name` gives a method's tool any public name, as
|
|
1352
|
+
`create_task` above. When agents depend on tool names and argument shapes
|
|
1353
|
+
that must never change, give that surface a contract of its own: its
|
|
1354
|
+
methods' inputs and `describe` texts are the frozen shapes, its handlers
|
|
1355
|
+
call the app's services through `ctx.services`, and a registry with
|
|
1356
|
+
`name: (_service, method) => method` serves it. The bridge never reshapes a
|
|
1357
|
+
tool's arguments on the way in: such a tool would be invisible to the API
|
|
1358
|
+
docs and to lint, which read the contracts.
|
|
1359
|
+
|
|
1213
1360
|
## The client
|
|
1214
1361
|
|
|
1215
1362
|
`createQuickdrawClient(contracts)` builds one typed client from the
|
|
@@ -1517,6 +1664,7 @@ const newTaskSchema = z.object({ projectId: z.string(), title: z.string() });
|
|
|
1517
1664
|
const taskPatch = z.object({ title: z.string(), status: z.string() }).partial();
|
|
1518
1665
|
|
|
1519
1666
|
export const task = defineContract("taskService", {
|
|
1667
|
+
describe: "Tasks on a project's board.",
|
|
1520
1668
|
entity: taskSchema,
|
|
1521
1669
|
projections: { card: cardSchema },
|
|
1522
1670
|
methods: {
|
|
@@ -1534,7 +1682,11 @@ export const task = defineContract("taskService", {
|
|
|
1534
1682
|
bulkUpdate: { input: taskPatch },
|
|
1535
1683
|
bulkDelete: true,
|
|
1536
1684
|
}),
|
|
1537
|
-
archive: mutation({
|
|
1685
|
+
archive: mutation({
|
|
1686
|
+
input: z.object({ id: z.string() }),
|
|
1687
|
+
output: "entity",
|
|
1688
|
+
describe: "Archives a task, which leaves its board.",
|
|
1689
|
+
}),
|
|
1538
1690
|
},
|
|
1539
1691
|
});
|
|
1540
1692
|
```
|
|
@@ -1679,6 +1831,7 @@ import { defineContract, search } from "@fitzzero/quickdraw-core";
|
|
|
1679
1831
|
import { cardSchema, taskSchema } from "../schemas";
|
|
1680
1832
|
|
|
1681
1833
|
export const task = defineContract("taskService", {
|
|
1834
|
+
describe: "Tasks on a project's board.",
|
|
1682
1835
|
entity: taskSchema,
|
|
1683
1836
|
projections: { card: cardSchema },
|
|
1684
1837
|
methods: {
|
|
@@ -1693,6 +1846,7 @@ export const task = defineContract("taskService", {
|
|
|
1693
1846
|
},
|
|
1694
1847
|
collections: {
|
|
1695
1848
|
byProject: {
|
|
1849
|
+
describe: "A project's tasks, in board order.",
|
|
1696
1850
|
scope: "projectId",
|
|
1697
1851
|
item: "card",
|
|
1698
1852
|
order: [
|
|
@@ -1818,6 +1972,7 @@ import { z } from "zod";
|
|
|
1818
1972
|
const projectSchema = z.object({ id: z.string(), name: z.string() });
|
|
1819
1973
|
|
|
1820
1974
|
export const project = defineContract("projectService", {
|
|
1975
|
+
describe: "Projects and the people they are shared with.",
|
|
1821
1976
|
entity: projectSchema,
|
|
1822
1977
|
methods: {
|
|
1823
1978
|
// the JSON access list jsonAcl reads: share, unshare, setLevel, listShares
|
|
@@ -1831,6 +1986,7 @@ export const project = defineContract("projectService", {
|
|
|
1831
1986
|
collections: {
|
|
1832
1987
|
// each user's projects: an invite adds the project, a remove or a leave takes it out
|
|
1833
1988
|
mine: {
|
|
1989
|
+
describe: "The projects a user is a member of.",
|
|
1834
1990
|
scope: via({ model: "projectMember", entry: "projectId", scope: "userId" }),
|
|
1835
1991
|
item: "entity",
|
|
1836
1992
|
order: [["id", "asc"]],
|
|
@@ -1924,6 +2080,7 @@ import { admin, defineContract } from "@fitzzero/quickdraw-core";
|
|
|
1924
2080
|
import { taskSchema } from "../schemas";
|
|
1925
2081
|
|
|
1926
2082
|
export const task = defineContract("taskService", {
|
|
2083
|
+
describe: "Tasks on a project's board.",
|
|
1927
2084
|
// Zod 4.2 or later: the fields come from its JSON Schema
|
|
1928
2085
|
entity: taskSchema,
|
|
1929
2086
|
methods: {
|
|
@@ -1948,6 +2105,8 @@ export const taskService = qd.defineService(task, {
|
|
|
1948
2105
|
displayName: "Tasks",
|
|
1949
2106
|
// never shown, returned or written
|
|
1950
2107
|
hiddenFields: ["notes"],
|
|
2108
|
+
// the only fields adminCreate and adminUpdate write; the rest are read-only
|
|
2109
|
+
editable: ["title", "status"],
|
|
1951
2110
|
fieldOverrides: { assigneeId: { type: "relation", relationService: "userService" } },
|
|
1952
2111
|
}),
|
|
1953
2112
|
},
|
|
@@ -2005,12 +2164,24 @@ export function AdminTasks() {
|
|
|
2005
2164
|
`adminUpdate({ id, data })` write the entity's fields through the tracked
|
|
2006
2165
|
client, so subscribers and collections get the same frames as for any
|
|
2007
2166
|
other write; `adminDelete({ id })` returns `null`. `id`, `createdAt` and
|
|
2008
|
-
`updatedAt` are never writable, nor are hidden fields
|
|
2009
|
-
made read-only (`VALIDATION`); without a
|
|
2010
|
-
are the columns the policy reads, and a
|
|
2011
|
-
with the row level on it (`FORBIDDEN`,
|
|
2012
|
-
value is checked by the entity schema
|
|
2013
|
-
refuses is `VALIDATION`. A missing row
|
|
2167
|
+
`updatedAt` are never writable, nor are hidden fields, those an override
|
|
2168
|
+
made read-only or those `editable` leaves out (`VALIDATION`); without a
|
|
2169
|
+
service-wide `Admin` grant, nor are the columns the policy reads, and a
|
|
2170
|
+
row moves to another parent only with the row level on it (`FORBIDDEN`,
|
|
2171
|
+
as for the read/write kit); each value is checked by the entity schema
|
|
2172
|
+
itself, and a value the database refuses is `VALIDATION`. A missing row
|
|
2173
|
+
is `NOT_FOUND`.
|
|
2174
|
+
- `editable: ["title", "status"]` names the only fields `adminCreate` and
|
|
2175
|
+
`adminUpdate` write: `adminMeta` reports every other field
|
|
2176
|
+
`editable: false`, so a generic form leaves it read-only, and a write
|
|
2177
|
+
naming one is `VALIDATION` ("is not editable"). Each must be a field the
|
|
2178
|
+
kit shows, never `id` or a timestamp, and a `fieldOverrides` entry may
|
|
2179
|
+
not say otherwise. `admin.contract({ entity, editable })` takes the same
|
|
2180
|
+
list, so the writes' input checks, types and JSON Schema (and the MCP
|
|
2181
|
+
tools made from it) name only those fields; given to both halves, the
|
|
2182
|
+
two lists must name the same fields. Without either list the kit writes
|
|
2183
|
+
what it wrote in 5.0.0: every field it shows but `id` and the timestamps,
|
|
2184
|
+
less those an override made read-only.
|
|
2014
2185
|
- `adminMeta()` returns `{ serviceName, displayName, fields }`, one
|
|
2015
2186
|
`{ name, type, label, required, editable, showInTable, sortable, filterable, enumValues?, relationService?, kind?, showInForm? }`
|
|
2016
2187
|
per field: `type` is
|
|
@@ -2093,28 +2264,48 @@ const cursorSchema = z.object({ projectId: z.string(), taskId: z.string(), x: z.
|
|
|
2093
2264
|
const logLineSchema = z.object({ line: z.string() });
|
|
2094
2265
|
|
|
2095
2266
|
export const task = defineContract("taskService", {
|
|
2267
|
+
describe: "Tasks on a project's board.",
|
|
2096
2268
|
entity: taskSchema,
|
|
2097
2269
|
methods: {
|
|
2098
|
-
enterBoard: mutation({
|
|
2270
|
+
enterBoard: mutation({
|
|
2271
|
+
input: z.object({ projectId: z.string() }),
|
|
2272
|
+
output: z.boolean(),
|
|
2273
|
+
describe: "Joins the caller's socket to a project's board room.",
|
|
2274
|
+
}),
|
|
2099
2275
|
},
|
|
2100
2276
|
streams: {
|
|
2101
2277
|
// one feed per task; a subscriber needs Read on the task, and first gets the latest 50 lines
|
|
2102
|
-
logs: {
|
|
2278
|
+
logs: {
|
|
2279
|
+
item: logLineSchema,
|
|
2280
|
+
scope: "taskId",
|
|
2281
|
+
seed: 50,
|
|
2282
|
+
access: { entry: "Read" },
|
|
2283
|
+
describe: "A task's log lines, as its job writes them.",
|
|
2284
|
+
},
|
|
2103
2285
|
// one feed for everyone
|
|
2104
|
-
load: {
|
|
2286
|
+
load: {
|
|
2287
|
+
item: z.number(),
|
|
2288
|
+
volatile: true,
|
|
2289
|
+
access: "authenticated",
|
|
2290
|
+
describe: "The server's load, sampled every second.",
|
|
2291
|
+
},
|
|
2105
2292
|
},
|
|
2106
2293
|
channels: {
|
|
2107
2294
|
// 20 a second per socket; only from a socket in the board's room, which enterBoard joined
|
|
2108
2295
|
cursor: {
|
|
2296
|
+
describe: "Where a user's cursor is on a task card.",
|
|
2109
2297
|
payload: cursorSchema,
|
|
2110
2298
|
ratePerSecond: 20,
|
|
2111
2299
|
requires: { room: (cursor) => `board:${cursor.projectId}` },
|
|
2112
2300
|
},
|
|
2113
2301
|
},
|
|
2114
2302
|
events: {
|
|
2115
|
-
cursorMoved: { payload: cursorSchema },
|
|
2303
|
+
cursorMoved: { payload: cursorSchema, describe: "Another user's cursor moved." },
|
|
2116
2304
|
// a user's last socket left a board: `onRoomLeave` sends it
|
|
2117
|
-
leftBoard: {
|
|
2305
|
+
leftBoard: {
|
|
2306
|
+
payload: z.object({ projectId: z.string(), userId: z.string() }),
|
|
2307
|
+
describe: "A user left a project's board.",
|
|
2308
|
+
},
|
|
2118
2309
|
},
|
|
2119
2310
|
});
|
|
2120
2311
|
```
|
|
@@ -2774,7 +2965,83 @@ for real, once per allowed principal: give inputs that can run again, or
|
|
|
2774
2965
|
`MatrixCell` `{ name, principal }`, sync or async), called before each
|
|
2775
2966
|
cell's call, that makes a row of its own (a task
|
|
2776
2967
|
to delete, an unused name), so no cell depends on the order of the
|
|
2777
|
-
principals.
|
|
2968
|
+
principals. Each cell of the report records its principal's `kind`, and a
|
|
2969
|
+
cell that differs is listed with it: on a service or method with `kinds`,
|
|
2970
|
+
give the matrix a principal of each kind it refuses.
|
|
2971
|
+
|
|
2972
|
+
### Access snapshots
|
|
2973
|
+
|
|
2974
|
+
`snapshotAccessMatrix(app, { principals, reset, rows })` records the whole
|
|
2975
|
+
matrix instead of chosen cases: every method of the app's services (or of
|
|
2976
|
+
`services`), each entity subscribe (`qd:sub`) and each collection scope
|
|
2977
|
+
(`qd:col:sub`), as every principal and anonymously. The first run writes the
|
|
2978
|
+
outcomes to `__access__/<test file>.json` beside the test, and every later
|
|
2979
|
+
run compares its matrix with that file, so each changed cell is a decision
|
|
2980
|
+
someone made. Commit the file. Use it to pin a service's table, and before
|
|
2981
|
+
a migration, a policy refactor or a new kind of principal:
|
|
2982
|
+
|
|
2983
|
+
<!-- example: apps/api/src/services/task.test.ts#snapshot -->
|
|
2984
|
+
|
|
2985
|
+
```ts
|
|
2986
|
+
it("pins who may call what, for every method, subscribe and scope", async () => {
|
|
2987
|
+
const app = await createTestApp({ services: [projectService, taskService], db });
|
|
2988
|
+
const report = await snapshotAccessMatrix(app, {
|
|
2989
|
+
principals: { owner: ada, member: bo, stranger: ed },
|
|
2990
|
+
reset: async () => {
|
|
2991
|
+
await resetDatabase(prisma); // at the start, and after each write access let through
|
|
2992
|
+
return await seed();
|
|
2993
|
+
},
|
|
2994
|
+
rows: (board) => ({ projectService: board.projectId, taskService: board.taskId }),
|
|
2995
|
+
});
|
|
2996
|
+
expect(report.inconclusive).toEqual([]); // every cell ran, with an input its schema accepts
|
|
2997
|
+
await app.close();
|
|
2998
|
+
});
|
|
2999
|
+
```
|
|
3000
|
+
|
|
3001
|
+
- `reset()` empties the database and seeds it (`resetDatabase` from
|
|
3002
|
+
`./testing/prisma`, then the app's seed, with the untracked client), and
|
|
3003
|
+
resolves with what the seed made: the fixture. It runs first, and again
|
|
3004
|
+
after every mutation the pipeline did not refuse, so no cell sees the
|
|
3005
|
+
writes of another. `principals` may be a function of the fixture, for a
|
|
3006
|
+
seed that makes new ids; its names stay the same.
|
|
3007
|
+
- `rows(fixture)` names the row each service's cells are about: a row id,
|
|
3008
|
+
or named variants (`{ own: p1, other: p2 }`), each a row of the matrix of
|
|
3009
|
+
its own. It names every service an access form, an entity subscribe or a
|
|
3010
|
+
collection anchor of the matrix reads; a missing one is a `TypeError`
|
|
3011
|
+
before any cell runs. A `"self"` scope is subscribed with the caller's own
|
|
3012
|
+
user id.
|
|
3013
|
+
- Inputs are made from each method's input schema (Zod 4.2 or later
|
|
3014
|
+
describes itself as JSON Schema): the row goes where the access form reads
|
|
3015
|
+
it (`id`, the key an `entry` or `scope` form names, or where a function
|
|
3016
|
+
`id` finds it; for any other form, the input's `id`), and every other
|
|
3017
|
+
required value is as small as the schema allows. `inputs(ref, fixture)`
|
|
3018
|
+
gives a method's input instead (`ref` names the service, method, kind,
|
|
3019
|
+
variant, row and principal of the cell); `undefined` keeps the made one.
|
|
3020
|
+
A cell whose input fails the schema, or names no row where an `entry` or
|
|
3021
|
+
`scope` form reads it, is never called: it is `VALIDATION`, and the
|
|
3022
|
+
report lists it in `inconclusive`.
|
|
3023
|
+
- Each cell is the exact outcome: `"ok"`, or the error code
|
|
3024
|
+
(`UNAUTHENTICATED`, `FORBIDDEN`, `NOT_FOUND`, ...). Each principal's
|
|
3025
|
+
`kind` is recorded beside the cells.
|
|
3026
|
+
- A cell whose outcome changed fails, naming the cell, both outcomes and
|
|
3027
|
+
whether the change opens or closes access (a changed `kind` fails too):
|
|
3028
|
+
|
|
3029
|
+
```text
|
|
3030
|
+
snapshotAccessMatrix: 1 cell differs from the access snapshot (src/services/__access__/task.test.ts.json):
|
|
3031
|
+
taskService.rename as member: FORBIDDEN → ok (opens access)
|
|
3032
|
+
```
|
|
3033
|
+
|
|
3034
|
+
When each change is meant, run the tests with
|
|
3035
|
+
`QD_UPDATE_ACCESS_SNAPSHOT=1` and commit the file.
|
|
3036
|
+
|
|
3037
|
+
- Cells added or removed (a new method, principal or exclusion) rewrite the
|
|
3038
|
+
file in a local run. Under CI (`CI=1` or `CI=true`) they fail, and so do a
|
|
3039
|
+
missing file and `QD_UPDATE_ACCESS_SNAPSHOT`: a snapshot that writes
|
|
3040
|
+
itself pins nothing.
|
|
3041
|
+
- Leave out a method whose outcome differs from run to run with
|
|
3042
|
+
`exclude: ["taskService.roll"]`; the file lists it. Take one snapshot per
|
|
3043
|
+
test file (one test file per service keeps merges small), or pass
|
|
3044
|
+
`{ file }`.
|
|
2778
3045
|
|
|
2779
3046
|
### Performance budgets
|
|
2780
3047
|
|
|
@@ -2856,6 +3123,7 @@ one format and names the method call it happened in:
|
|
|
2856
3123
|
| `batch-create-many` | a `createMany` in an array-form `$transaction` could not report its rows |
|
|
2857
3124
|
| `repeated-call` | one connection repeated a call or was refused `RATE_LIMITED` again and again (below) |
|
|
2858
3125
|
| `tiered-field-in-output` | a method's own output schema declares a field the contract tiers, which only projection outputs strip; raised when the server is made (for an output without JSON Schema, when a reply carries it) |
|
|
3126
|
+
| `resolver-without-reads` | a service's policy is, or combines in `anyOf`, a `resolver` without `reads`, so no tracked write re-checks it; raised when the server is made |
|
|
2859
3127
|
|
|
2860
3128
|
Updates and deletes by id inside an interactive transaction are not counted
|
|
2861
3129
|
toward `n-plus-one`: that is how per-row writes are written (see tracked
|
|
@@ -2873,9 +3141,10 @@ fails: the call it happened in fails with `INTERNAL` and the error as its
|
|
|
2873
3141
|
once the reply was recorded (over a socket or HTTP the reply was already
|
|
2874
3142
|
sent, so that error is logged, not thrown). Strictness belongs to the app:
|
|
2875
3143
|
warnings outside its calls (an ambient write while seeding, another app's
|
|
2876
|
-
calls) are logged as usual, and `app.close()` ends it. The
|
|
2877
|
-
the app is made, `tiered-field-in-output
|
|
2878
|
-
itself, so a strict app over such a method
|
|
3144
|
+
calls) are logged as usual, and `app.close()` ends it. The ones raised when
|
|
3145
|
+
the app is made, `tiered-field-in-output` and `resolver-without-reads`, are
|
|
3146
|
+
thrown from `createTestApp` itself, so a strict app over such a method or
|
|
3147
|
+
policy does not start.
|
|
2879
3148
|
|
|
2880
3149
|
A `repeated-call` warning names a client caught in a loop (a mutation fired
|
|
2881
3150
|
from an effect that its own result runs again, a refetch that triggers
|
|
@@ -3061,10 +3330,13 @@ is also a server span named `service.method`, with an error status for
|
|
|
3061
3330
|
## API docs from contracts
|
|
3062
3331
|
|
|
3063
3332
|
The `quickdraw-docs` command writes Markdown API docs from the contracts:
|
|
3064
|
-
one page per service (its entity and field tiers,
|
|
3065
|
-
with their kind,
|
|
3066
|
-
|
|
3067
|
-
|
|
3333
|
+
one page per service (its `describe`, its entity and field tiers,
|
|
3334
|
+
projections, methods with their `describe`, kind, default MCP tool name
|
|
3335
|
+
(`{service}_{method}`: a registry's `name`, `include` and `exclude` options
|
|
3336
|
+
are not visible to the command), the MCP read-only hint on a query, input
|
|
3337
|
+
fields and output, collections, streams, channels and events with their
|
|
3338
|
+
`describe`, from the schemas' JSON Schema where they have one) and a
|
|
3339
|
+
`README.md` index that lists each service's `describe`. It reads contracts (and, with `--services`, the
|
|
3068
3340
|
services' definitions), never source code.
|
|
3069
3341
|
|
|
3070
3342
|
```bash
|
|
@@ -3080,9 +3352,18 @@ definitions: an "Access" section with the row policy, whether a
|
|
|
3080
3352
|
service-wide `Admin` grant passes every check, who may watch the change
|
|
3081
3353
|
topic (`watchAccess`) and the field levels; each method's access form, in
|
|
3082
3354
|
words, and its `rowless`; who may open a collection's scope; a channel's
|
|
3083
|
-
access; a stream's computed seed and when its items are checked.
|
|
3084
|
-
|
|
3085
|
-
|
|
3355
|
+
access; a stream's computed seed and when its items are checked. Each
|
|
3356
|
+
page also gets a "Depends on" section: the other services its
|
|
3357
|
+
declarations name, and which declaration names each one. A service depends
|
|
3358
|
+
on another when its row policy `inherit`s from it, it `writes` the model
|
|
3359
|
+
that service's rows live in, it `affects` that service's rows, one of its
|
|
3360
|
+
methods, channels or streams takes a `{ scope, of }` form of it, or one of
|
|
3361
|
+
its collections is anchored on that service's rows. A `writes` model that no
|
|
3362
|
+
service's rows live in is listed as a model. The index adds a Mermaid
|
|
3363
|
+
`graph LR` of the same edges, each labelled with how. Only declarations are
|
|
3364
|
+
read: a handler that reads another service's rows without declaring it adds
|
|
3365
|
+
nothing. A contract the services module has no service for says so, and a
|
|
3366
|
+
service without a contract is an error. Pass the same flag to `--check`. The
|
|
3086
3367
|
services module is imported with everything it imports, as the server
|
|
3087
3368
|
loads it: the workspace packages it uses (`@project/db`, `@project/shared`)
|
|
3088
3369
|
load from their built output when their `package.json` points there, so
|
|
@@ -3098,7 +3379,19 @@ extensionless imports and `tsconfig` paths, which Node's loader refuses); a
|
|
|
3098
3379
|
module whose own code throws runs once and its error is reported as is. Pages are only ever replaced
|
|
3099
3380
|
or removed when they start with the generator's marker, and they are laid
|
|
3100
3381
|
out as oxfmt and Prettier format Markdown, so formatting them changes
|
|
3101
|
-
nothing.
|
|
3382
|
+
nothing.
|
|
3383
|
+
|
|
3384
|
+
Where the pages live depends on how busy the repository is:
|
|
3385
|
+
|
|
3386
|
+
- **Few pull requests at a time:** commit the pages and run `--check` in
|
|
3387
|
+
CI next to the lint step.
|
|
3388
|
+
- **Many at a time** (several agents, each changing methods): a committed
|
|
3389
|
+
generated file is a merge conflict in every pair of pull requests that
|
|
3390
|
+
touch contracts. Regenerate the pages on each push to the base branch
|
|
3391
|
+
instead, with the workflow the `quickdraw-api-docs` skill of
|
|
3392
|
+
[`@fitzzero/quickdraw-skills`](https://github.com/fitzzero/quickdraw/tree/main/packages/skills) ships (a job with a
|
|
3393
|
+
read-only token generates them, a second job commits them), and never
|
|
3394
|
+
commit them by hand.
|
|
3102
3395
|
|
|
3103
3396
|
## Lint rules and agent guidance
|
|
3104
3397
|
|
|
@@ -3106,7 +3399,8 @@ nothing. Run `--check` in CI next to the lint step.
|
|
|
3106
3399
|
config every 5.0 app extends: it reports untracked and foreign writes, nested
|
|
3107
3400
|
and raw SQL writes, hand-sent frames, inline auth guards, unbounded reads,
|
|
3108
3401
|
database calls and emits in loops, layering breaks, bypasses of the typed
|
|
3109
|
-
client, hand-written copies of kit methods (`prefer-kit`, a warning),
|
|
3402
|
+
client, hand-written copies of kit methods (`prefer-kit`, a warning),
|
|
3403
|
+
contract members without a `describe` (`require-describe`, a warning), and
|
|
3110
3404
|
every removed 4.x API with its replacement. Each rule supports a baseline,
|
|
3111
3405
|
so an app can adopt it before fixing old code.
|
|
3112
3406
|
|
|
@@ -3160,7 +3454,7 @@ bunx @fitzzero/quickdraw-codemod v5 .
|
|
|
3160
3454
|
| `./client` | `createQuickdrawClient`, `QuickdrawProvider`, `useQuickdraw`, `usePresence`, `useAdminServices`, `createQuickdrawConnection`, `call`, `callData`, `liveDataOf`, the coordinator; everything in `./utils` |
|
|
3161
3455
|
| `./utils` | `createServerCaller`, cache keys (`methodKey`, `entityKey`, `collectionKey`), formatting, navigation, `parseJWTPayload` |
|
|
3162
3456
|
| `./parser` | the JSON-only Socket.IO parser |
|
|
3163
|
-
| `./testing` | `createTestApp`, `describeAccessMatrix`, `expectBudget`, `createRecordingSink`, `DevWarningError`
|
|
3457
|
+
| `./testing` | `createTestApp`, `describeAccessMatrix`, `snapshotAccessMatrix`, `expectBudget`, `createRecordingSink`, `DevWarningError` |
|
|
3164
3458
|
| `./testing/client` | `renderWithQuickdraw`, and everything in `./testing/mock` |
|
|
3165
3459
|
| `./testing/mock` | `createMockClient` alone, without Testing Library: for browser bundles such as Storybook |
|
|
3166
3460
|
| `./testing/prisma` | test databases on PostgreSQL or PGlite |
|