@fitzzero/quickdraw-skills 5.0.0-rc.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026-present fitzzero
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,66 @@
1
+ # @fitzzero/quickdraw-skills
2
+
3
+ Agent rules and skills for quickdraw 5.0 apps, and the `quickdraw-skills link`
4
+ command that wires them into an app's `.claude/` directory. Every app reads
5
+ the same guidance, written against the API as built, and it updates with
6
+ the package instead of drifting per app.
7
+
8
+ ```bash
9
+ bun add -d @fitzzero/quickdraw-skills@next
10
+ ```
11
+
12
+ Until 5.0.0 is released, 5.0 is published under the `next` dist-tag,
13
+ which the command names.
14
+
15
+ ```jsonc
16
+ // package.json
17
+ {
18
+ "scripts": {
19
+ "prepare": "quickdraw-skills link",
20
+ },
21
+ }
22
+ ```
23
+
24
+ Add the package to the repo's root `package.json`, so it installs at
25
+ `node_modules/@fitzzero/quickdraw-skills` and the links stay the same
26
+ whatever the version.
27
+
28
+ ## What it links
29
+
30
+ | Link | What it holds |
31
+ | -------------------------------------- | ---------------------------------------------------------------------------------------- |
32
+ | `.claude/rules/quickdraw-services.md` | contracts, `defineService`, tracked writes, derived frames, `affects`, `ctx.touch`, kits |
33
+ | `.claude/rules/quickdraw-access.md` | access forms, row policies, service grants, failing closed, one policy on every surface |
34
+ | `.claude/rules/quickdraw-client.md` | the typed client, live entities and collections, views, optimistic mutations, `watch` |
35
+ | `.claude/rules/quickdraw-testing.md` | `createTestApp`, access matrices, budgets, strict warnings, component tests |
36
+ | `.claude/skills/quickdraw-new-service` | adding a service end to end: contract, service, registration, client, tests |
37
+ | `.claude/skills/quickdraw-migrate-v5` | moving a 4.x app to 5.0 with `@fitzzero/quickdraw-codemod` and the migration guide |
38
+
39
+ Each rule's `paths` frontmatter follows the quickdraw template's layout
40
+ (`apps/api`, `apps/web`, `packages/shared`), so Claude Code loads it while
41
+ working on matching files.
42
+
43
+ ## The command
44
+
45
+ - `quickdraw-skills link` writes each link as a relative symlink into
46
+ `node_modules/@fitzzero/quickdraw-skills` (or into the package wherever it
47
+ is installed, when it is not at the repo root) in the nearest directory
48
+ above the working directory that holds `.git`. It replaces or prunes only
49
+ links that point into this package (through
50
+ `@fitzzero/quickdraw-skills/`, or landing in its directory): a real file
51
+ or directory, or another package's link, is left alone with a warning,
52
+ even under one of this package's names. That is how an app keeps its own
53
+ version of a rule (with different `paths`, say): replace the link with a
54
+ copy.
55
+ - It writes links only into real directories of the repo. When `.claude`,
56
+ `.claude/skills` or `.claude/rules` is a symlink (to a shared or global
57
+ directory, say) or resolves outside the repo, that kind is left alone
58
+ with a warning, since relative links written through it would land
59
+ elsewhere and resolve there.
60
+ - `quickdraw-skills link --check` changes nothing and exits 1 when a link is
61
+ missing, stale (points elsewhere) or dangling, or when a link to a rule or
62
+ skill the package no longer ships is left; run it in CI.
63
+
64
+ Commit the links. They are dead on a fresh clone, come alive at the first
65
+ install, and always show the installed version's text. Claude Code skips a
66
+ broken link.
package/bin/cli.mjs ADDED
@@ -0,0 +1,329 @@
1
+ #!/usr/bin/env node
2
+ // quickdraw-skills: links this package's agent rules and skills into a
3
+ // consumer repo's `.claude/` as relative symlinks, the way
4
+ // `@rallycry/conveyor-skills` links its skills:
5
+ //
6
+ // .claude/skills/<name> -> node_modules/@fitzzero/quickdraw-skills/skills/<name>
7
+ // .claude/rules/<name>.md -> node_modules/@fitzzero/quickdraw-skills/rules/<name>.md
8
+ //
9
+ // Claude Code follows symlinks there and skips broken ones, so the links are
10
+ // safe to commit: they are dead on a fresh clone and come alive at the first
11
+ // install, and their content always matches the installed version.
12
+ //
13
+ // Usage:
14
+ // quickdraw-skills link create or refresh the links, prune stale ones (the default)
15
+ // quickdraw-skills link --check change nothing; exit 1 when a link is missing, stale or dangling
16
+ //
17
+ // Only links this package owns (their target has the path segments
18
+ // `@fitzzero/quickdraw-skills/skills/` or `@fitzzero/quickdraw-skills/rules/`,
19
+ // or lands in this package's directory) are ever replaced or pruned. A real
20
+ // file or directory, and another package's link, are left alone with a
21
+ // warning, even when they take the name of one of this package's rules or
22
+ // skills: that is how an app keeps its own version of one.
23
+ //
24
+ // Links are only written into real directories of the repo: when `.claude`
25
+ // or `.claude/<kind>` is a symlink, or resolves outside the repo, that kind is
26
+ // left alone with a warning, since relative links written through it would
27
+ // land elsewhere (a shared or global directory) and resolve against it.
28
+
29
+ import {
30
+ existsSync,
31
+ lstatSync,
32
+ mkdirSync,
33
+ readdirSync,
34
+ readlinkSync,
35
+ realpathSync,
36
+ rmSync,
37
+ symlinkSync,
38
+ } from "node:fs";
39
+ import { dirname, join, relative, resolve, sep } from "node:path";
40
+ import process from "node:process";
41
+ import { fileURLToPath } from "node:url";
42
+
43
+ const PACKAGE = "@fitzzero/quickdraw-skills";
44
+
45
+ const USAGE = `Usage: quickdraw-skills link [--check]
46
+
47
+ link link this package's rules into .claude/rules and its skills
48
+ into .claude/skills of the repo around the working directory,
49
+ and prune links to rules or skills it no longer ships
50
+ link --check change nothing; exit 1 when a link is missing, stale or dangling
51
+ `;
52
+
53
+ /** The two kinds of entry this package links: skill directories and rule files. */
54
+ const KINDS = [
55
+ { name: "skills", isEntry: (entry) => entry.isDirectory() },
56
+ { name: "rules", isEntry: (entry) => entry.isFile() && entry.name.endsWith(".md") },
57
+ ];
58
+
59
+ function warn(message) {
60
+ process.stderr.write(`quickdraw-skills: warning: ${message}\n`);
61
+ }
62
+
63
+ function fail(message) {
64
+ process.stderr.write(`quickdraw-skills: ${message}\n`);
65
+ process.exit(1);
66
+ }
67
+
68
+ /** The nearest directory above `start` that holds `.git`, else the nearest that holds `package.json`. */
69
+ function findRepoRoot(start) {
70
+ for (const marker of [".git", "package.json"]) {
71
+ let dir = start;
72
+ for (;;) {
73
+ if (existsSync(join(dir, marker))) {
74
+ return dir;
75
+ }
76
+ const parent = dirname(dir);
77
+ if (parent === dir) {
78
+ break;
79
+ }
80
+ dir = parent;
81
+ }
82
+ }
83
+ return undefined;
84
+ }
85
+
86
+ function isSymlink(path) {
87
+ try {
88
+ return lstatSync(path).isSymbolicLink();
89
+ } catch {
90
+ return false;
91
+ }
92
+ }
93
+
94
+ /** True when `path` exists without following a symlink: a file, a directory, or a link. */
95
+ function occupied(path) {
96
+ try {
97
+ lstatSync(path);
98
+ return true;
99
+ } catch {
100
+ return false;
101
+ }
102
+ }
103
+
104
+ /** The target of the symlink at `path`, with the platform's separator. */
105
+ function targetOf(path) {
106
+ return readlinkSync(path).split("/").join(sep);
107
+ }
108
+
109
+ function parseArgs(args) {
110
+ const known = new Set(["link", "--check", "--help", "-h"]);
111
+ const unknown = args.find((arg) => !known.has(arg));
112
+ if (unknown !== undefined) {
113
+ process.stderr.write(`quickdraw-skills: unknown argument "${unknown}"\n\n${USAGE}`);
114
+ process.exit(2);
115
+ }
116
+ if (args.includes("--help") || args.includes("-h")) {
117
+ process.stdout.write(USAGE);
118
+ process.exit(0);
119
+ }
120
+ return { check: args.includes("--check") };
121
+ }
122
+
123
+ const { check } = parseArgs(process.argv.slice(2));
124
+
125
+ // This package's own directory, through any install symlink (bun and pnpm may
126
+ // install it as a link into their store).
127
+ const packageDir = realpathSync(dirname(dirname(fileURLToPath(import.meta.url))));
128
+ const repoRoot = findRepoRoot(process.cwd());
129
+ if (repoRoot === undefined) {
130
+ fail("found no repo root (a .git or package.json) above the working directory");
131
+ }
132
+
133
+ /**
134
+ * What one kind links: its entries, where the links go, and the directory
135
+ * they point into. Links prefer the stable `node_modules/@fitzzero/quickdraw-skills`
136
+ * path, so committed links never embed a package store's hashed path; without
137
+ * it (the package not installed at the repo root) they point at the package
138
+ * where it is.
139
+ */
140
+ function planKind(kind) {
141
+ const sourceDir = join(packageDir, kind.name);
142
+ if (!existsSync(sourceDir)) {
143
+ fail(`no ${kind.name} directory at ${sourceDir} (a broken install?)`);
144
+ }
145
+ const stableDir = join(repoRoot, "node_modules", ...PACKAGE.split("/"), kind.name);
146
+ const names = readdirSync(sourceDir, { withFileTypes: true })
147
+ .filter((entry) => kind.isEntry(entry))
148
+ .map((entry) => entry.name)
149
+ .sort();
150
+ return {
151
+ kind: kind.name,
152
+ names,
153
+ sourceDir,
154
+ linkDir: join(repoRoot, ".claude", kind.name),
155
+ targetDir: existsSync(stableDir) ? stableDir : sourceDir,
156
+ };
157
+ }
158
+
159
+ /** True when the path `target` has the segments `@fitzzero/quickdraw-skills/<kind>/` in it. */
160
+ function namesPackage(target, kind) {
161
+ const segments = target.split(sep);
162
+ const [scope, name] = PACKAGE.split("/");
163
+ return segments.some(
164
+ (segment, index) =>
165
+ segment === scope &&
166
+ segments[index + 1] === name &&
167
+ segments[index + 2] === kind &&
168
+ index + 3 < segments.length,
169
+ );
170
+ }
171
+
172
+ /** True when `path` lies inside `dir`. */
173
+ function inside(path, dir) {
174
+ return path.startsWith(`${dir}${sep}`);
175
+ }
176
+
177
+ /**
178
+ * True when the symlink at `linkPath` belongs to this package: its target
179
+ * names the installed package, or it lands in this package's directory (as
180
+ * written, or through any symlink on the way).
181
+ */
182
+ function ownedBy(plan, linkPath) {
183
+ const target = targetOf(linkPath);
184
+ if (namesPackage(target, plan.kind)) {
185
+ return true;
186
+ }
187
+ const landing = resolve(dirname(linkPath), target);
188
+ if (inside(landing, plan.sourceDir)) {
189
+ return true;
190
+ }
191
+ try {
192
+ return inside(realpathSync(landing), plan.sourceDir);
193
+ } catch {
194
+ return false;
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Why this package's links cannot go into `.claude/<kind>`, or `undefined`:
200
+ * `.claude` or `.claude/<kind>` is a symlink, or resolves outside the repo.
201
+ * A directory that does not exist yet is created inside the repo.
202
+ */
203
+ function refusal(plan) {
204
+ const realRoot = realpathSync(repoRoot);
205
+ for (const [dir, shown] of [
206
+ [dirname(plan.linkDir), ".claude"],
207
+ [plan.linkDir, `.claude/${plan.kind}`],
208
+ ]) {
209
+ if (!occupied(dir)) {
210
+ return undefined;
211
+ }
212
+ if (isSymlink(dir)) {
213
+ return `${shown} is a symlink (to ${targetOf(dir)})`;
214
+ }
215
+ const real = realpathSync(dir);
216
+ if (!inside(real, realRoot)) {
217
+ return `${shown} resolves outside the repo (to ${real})`;
218
+ }
219
+ }
220
+ return undefined;
221
+ }
222
+
223
+ /** What `--check` and `link` found and did, for the summary. */
224
+ const tally = { problems: 0, linked: 0, pruned: 0 };
225
+
226
+ function problem(message) {
227
+ warn(message);
228
+ tally.problems += 1;
229
+ }
230
+
231
+ /** Leaves a real file or another package's link at `linkPath` alone, with a warning. */
232
+ function leaveAlone(plan, name, linkPath) {
233
+ const shown = `.claude/${plan.kind}/${name}`;
234
+ if (isSymlink(linkPath)) {
235
+ warn(`${shown} is a link owned by something else (${targetOf(linkPath)}): left alone`);
236
+ } else {
237
+ warn(`${shown} exists and is not a link: left alone, so this package's ${name} is not linked`);
238
+ }
239
+ }
240
+
241
+ function linkOne(plan, name) {
242
+ const linkPath = join(plan.linkDir, name);
243
+ const desired = relative(plan.linkDir, join(plan.targetDir, name));
244
+ const isLink = isSymlink(linkPath);
245
+ if (isLink && targetOf(linkPath) === desired) {
246
+ if (!existsSync(linkPath)) {
247
+ problem(`link .claude/${plan.kind}/${name} is dangling (${desired})`);
248
+ }
249
+ return;
250
+ }
251
+ if (occupied(linkPath) && (!isLink || !ownedBy(plan, linkPath))) {
252
+ leaveAlone(plan, name, linkPath);
253
+ return;
254
+ }
255
+ if (check) {
256
+ problem(`link .claude/${plan.kind}/${name} is ${isLink ? "stale" : "missing"}`);
257
+ return;
258
+ }
259
+ try {
260
+ rmSync(linkPath, { force: true });
261
+ symlinkSync(desired, linkPath);
262
+ tally.linked += 1;
263
+ process.stdout.write(`linked .claude/${plan.kind}/${name} -> ${desired}\n`);
264
+ } catch (error) {
265
+ // A filesystem without symlinks (Windows without developer mode) gets a
266
+ // warning, not a failed install.
267
+ warn(`could not link ${name}: ${error instanceof Error ? error.message : String(error)}`);
268
+ }
269
+ }
270
+
271
+ /** Removes (or, with `--check`, reports) this package's links to entries it no longer ships. */
272
+ function pruneKind(plan) {
273
+ if (!existsSync(plan.linkDir)) {
274
+ return;
275
+ }
276
+ for (const entry of readdirSync(plan.linkDir)) {
277
+ const linkPath = join(plan.linkDir, entry);
278
+ if (plan.names.includes(entry) || !isSymlink(linkPath) || !ownedBy(plan, linkPath)) {
279
+ continue;
280
+ }
281
+ if (check) {
282
+ problem(
283
+ `link .claude/${plan.kind}/${entry} points to a ${plan.kind} entry this package no longer ships`,
284
+ );
285
+ continue;
286
+ }
287
+ try {
288
+ rmSync(linkPath);
289
+ tally.pruned += 1;
290
+ process.stdout.write(`pruned .claude/${plan.kind}/${entry}\n`);
291
+ } catch (error) {
292
+ warn(`could not prune ${entry}: ${error instanceof Error ? error.message : String(error)}`);
293
+ }
294
+ }
295
+ }
296
+
297
+ const plans = [];
298
+ const leftAlone = [];
299
+ for (const plan of KINDS.map(planKind)) {
300
+ const refused = refusal(plan);
301
+ if (refused !== undefined) {
302
+ warn(
303
+ `${refused}: left alone, since links written through it would not resolve in this repo; make it a real directory to link this package's ${plan.kind}`,
304
+ );
305
+ leftAlone.push(`.claude/${plan.kind}`);
306
+ continue;
307
+ }
308
+ plans.push(plan);
309
+ if (!check) {
310
+ mkdirSync(plan.linkDir, { recursive: true });
311
+ }
312
+ for (const name of plan.names) {
313
+ linkOne(plan, name);
314
+ }
315
+ pruneKind(plan);
316
+ }
317
+
318
+ const counts = plans.map((plan) => `${plan.names.length} ${plan.kind}`).join(" and ") || "nothing";
319
+ const aside = leftAlone.length === 0 ? "" : `; ${leftAlone.join(" and ")} left alone`;
320
+ if (check) {
321
+ if (tally.problems > 0) {
322
+ fail(`${tally.problems} link(s) out of date: run 'quickdraw-skills link'`);
323
+ }
324
+ process.stdout.write(`quickdraw-skills: ${counts} linked and up to date${aside}\n`);
325
+ } else {
326
+ process.stdout.write(
327
+ `quickdraw-skills: ${counts} in .claude (${tally.linked} updated, ${tally.pruned} pruned)${aside}\n`,
328
+ );
329
+ }
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@fitzzero/quickdraw-skills",
3
+ "version": "5.0.0-rc.1",
4
+ "description": "Agent rules and skills for quickdraw 5.0 apps, linked into .claude/ by the quickdraw-skills command",
5
+ "keywords": [
6
+ "agents",
7
+ "claude-code",
8
+ "quickdraw",
9
+ "skills"
10
+ ],
11
+ "license": "MIT",
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/fitzzero/quickdraw.git",
15
+ "directory": "packages/skills"
16
+ },
17
+ "bin": {
18
+ "quickdraw-skills": "./bin/cli.mjs"
19
+ },
20
+ "files": [
21
+ "bin",
22
+ "skills",
23
+ "rules",
24
+ "README.md"
25
+ ],
26
+ "type": "module",
27
+ "publishConfig": {
28
+ "access": "public"
29
+ },
30
+ "scripts": {
31
+ "lint": "oxlint -c ../../.oxlintrc.json bin test",
32
+ "test": "node --test 'test/*.test.mjs'"
33
+ },
34
+ "engines": {
35
+ "node": ">=24"
36
+ }
37
+ }
@@ -0,0 +1,136 @@
1
+ ---
2
+ paths:
3
+ - "apps/api/**"
4
+ - "packages/shared/**"
5
+ ---
6
+
7
+ # quickdraw 5.0: access control
8
+
9
+ > From `@fitzzero/quickdraw-skills` (`quickdraw-skills link`). `paths` follow
10
+ > the quickdraw template: contracts in `packages/shared`, the server in
11
+ > `apps/api`. Another layout replaces this link with a copy and edits them.
12
+
13
+ Access is declared, never coded inline, and everything fails closed: a
14
+ method without `access` does not compile, and a missing row, a missing id, an
15
+ unknown level or a malformed access list denies.
16
+
17
+ ## Who may call a method
18
+
19
+ Every method's `{ access, handler }` names one form:
20
+
21
+ | Form | Passes when |
22
+ | --------------------------------- | ------------------------------------------------------------------------------------------- |
23
+ | `"public"` | always, with or without a principal |
24
+ | `"authenticated"` | a principal exists |
25
+ | `{ service: L }` | the principal's service-wide grant is at least `L` |
26
+ | `{ entry: L, id? }` | the policy gives the principal at least `L` on the row (`id` defaults to `input.id`) |
27
+ | `{ service: L1, entry: L2, id? }` | either of the two |
28
+ | `{ scope: L, of: contract, id }` | at least `L` on a row of another service, whose id is `input[id]` (create and list methods) |
29
+ | `custom((ctx, input) => boolean)` | the function resolves `true` (a principal is required first) |
30
+
31
+ - Levels are `"Read" < "Moderate" < "Admin"` (`AccessLevel` also has
32
+ `"Public"`). `id` is an input key (type-checked) or a function of the input.
33
+ - Without a principal every form but `"public"` answers `UNAUTHENTICATED`; a
34
+ principal that fails gets `FORBIDDEN`. In a `"public"` handler
35
+ `ctx.principal` may be `null`; under every other form it is not.
36
+ - Never guard inline (`if (!ctx.principal) throw ...`): declare the form. The
37
+ `no-inline-auth-guard` lint rule reports it.
38
+
39
+ ## A method that takes a row id
40
+
41
+ On a service with a policy, a method whose input has `id` reads or writes
42
+ the row that id names. Under a form that checks no row (`"public"`,
43
+ `"authenticated"`, `{ service: L }` below `Admin`), anyone the form admits
44
+ reaches any row by its id, so `defineService` refuses it when the service is
45
+ defined. Fix the form, not the error:
46
+
47
+ - Give it `{ entry: L }` (`"Read"` for a read, `"Moderate"` for a change),
48
+ or `{ service: L, entry: L }` to keep a service grant: the policy decides.
49
+ - Only when every caller the form admits may reach any row on purpose (a
50
+ public profile, a lookup by an id that tells nothing) say so with
51
+ `rowless: true` on the method, or `rowless: ["get"]` in a kit's options
52
+ (`crud.handlers`, `admin.handlers`, `sharing.handlers`).
53
+ - The read/write kit's `update`, `delete`, `reorder` and `create` check the
54
+ row themselves and need neither.
55
+ - The check reads the input's keys from its JSON Schema: an `id` in any
56
+ branch of a union counts, as does one beside a `Date` or a `Set`. An input
57
+ without JSON Schema (a Zod 3 schema), a bare string that is the id itself
58
+ and a row named by another key (`taskId`, `ids`) are not checked, so the
59
+ form is all yours there (`{ entry: L, id: "taskId" }` names another key).
60
+
61
+ ## Service-wide grants
62
+
63
+ `principal.serviceAccess` holds grants by service name,
64
+ `{ taskService: "Admin" }`: returned by `authenticate`, or loaded by
65
+ `createServer({ auth: { loadServiceAccess } })`. With
66
+ `auth.serviceAccessSource: { model: "user", column: "serviceAccess" }`, a
67
+ tracked write to that column refreshes the user's open sockets.
68
+
69
+ - A service-wide `Admin` grant passes every check on its service
70
+ (`adminBypass: false` on the service turns that off).
71
+ - A grant below `Admin` counts only where the form names `service`: a `Read`
72
+ grant does not read every row through an `entry` form.
73
+
74
+ ## Row policies
75
+
76
+ The service's `access` option says how a principal's level on one of its rows
77
+ is found. Column and model names are checked against the Prisma client at
78
+ compile time.
79
+
80
+ | Policy | Level from |
81
+ | ------------------------------------------------------ | ----------------------------------------------- |
82
+ | `owner("ownerId")` | `Admin` when the column is the user's id |
83
+ | `jsonAcl("acl", { owner: "ownerId" })` | a `[{ userId, level }]` JSON column, plus owner |
84
+ | `members({ model, entry, user, level, levels? })` | a membership table row |
85
+ | `inherit({ from: projectContract, via: "projectId" })` | the level on the parent row in another service |
86
+ | `anyOf(policyA, policyB)` | the highest level any of them gives |
87
+ | `resolver({ levelsFor, where? })` | your code, one batched read for all ids |
88
+
89
+ - `entry` forms need a policy and `scope` forms a `model`; a service without
90
+ a model may use only `"public"`, `"authenticated"`, `{ service }` and
91
+ `custom`.
92
+ - `inherit` uses the parent's policy only: grants on the parent's service do
93
+ not flow down.
94
+ - Lookups are batched per call: checking 60 ids costs what one does.
95
+ `createServer({ access: { cacheMs } })` keeps them across calls; tracked
96
+ writes to the columns and tables a policy reads evict them.
97
+
98
+ ## One policy, every surface
99
+
100
+ The same policy decides method calls, entity subscriptions (`useEntity`),
101
+ collection scopes (through the collection's `anchor`), the rows the kits'
102
+ `list`, `getMany`, `search` and bulk methods return or write, streams and
103
+ channel `requires`.
104
+
105
+ - Everyone who may open a collection scope sees every item in it, stripped
106
+ at the collection's `access` level (default `Read`). Per-row policies and
107
+ field tiers do not apply inside a collection: give the item service
108
+ `inherit` from the anchor, or use separate scopes.
109
+ - A hand-written method that reads many rows authorizes the parent
110
+ (`{ scope: "Read", of: project, id: "projectId" }`) and filters by it, or
111
+ uses the read/write kit's `list` or a collection, which filter by policy.
112
+ - `fields: { notes: "Admin" }` in the contract strips a field from callers
113
+ below that level on the row, in replies and in live frames.
114
+ - The service's change topic (`qd:watch` on `"service"`) is closed unless
115
+ the service declares `watchAccess` (`"public"`, `"authenticated"` or
116
+ `{ service: L }`). A stream without `access` in its contract is closed.
117
+ A channel takes `{ access: { service: L }, handler }` on the service and
118
+ `requires: { entity } | { collection, scope } | { room }` in the contract;
119
+ `{ room }` admits only a socket a method joined to that app room, so the
120
+ method's access decides who may send.
121
+
122
+ ## Changing access
123
+
124
+ - Change access lists and memberships with the sharing kit
125
+ (`sharing.contract({ mode: "acl" | "members" })`), which refuses granting
126
+ above the caller's own level and removing the last `Admin`.
127
+ - Write policy columns and membership rows through the tracked `db`: the
128
+ flush then revokes what a demoted user held (`qd:revoked`), moves them to
129
+ their new field tier, and updates `via` collections. A write the tracked
130
+ client cannot see needs `ctx.touch`, or the revocation never happens.
131
+
132
+ ## Testing
133
+
134
+ Pin every service's matrix with `describeAccessMatrix` from
135
+ `@fitzzero/quickdraw-core/testing` (see quickdraw-testing.md): each method
136
+ as owner, member, stranger and anonymous, through the real dispatcher.
@@ -0,0 +1,140 @@
1
+ ---
2
+ paths:
3
+ - "apps/web/**"
4
+ - "packages/shared/**"
5
+ ---
6
+
7
+ # quickdraw 5.0: the typed client
8
+
9
+ > From `@fitzzero/quickdraw-skills` (`quickdraw-skills link`). `paths` follow
10
+ > the quickdraw template: the web app in `apps/web`, contracts in
11
+ > `packages/shared`. Another layout replaces this link with a copy and edits
12
+ > them.
13
+
14
+ ## One client, one provider
15
+
16
+ ```tsx
17
+ // apps/web/src/lib/quickdraw.ts: the keys of this map become qd.<key>
18
+ export const qd = createQuickdrawClient({ task, project });
19
+
20
+ // apps/web/src/app/providers.tsx, a "use client" module
21
+ export function Providers({ children }: { readonly children: React.ReactNode }) {
22
+ return (
23
+ <QuickdrawProvider client={qd} url={API_URL}>
24
+ {children}
25
+ </QuickdrawProvider>
26
+ );
27
+ }
28
+ ```
29
+
30
+ - `createQuickdrawClient` and `QuickdrawProvider` come from
31
+ `@fitzzero/quickdraw-core/client`; the contracts from the shared package.
32
+ Every member is typed from them: no wrapper hooks, no codegen.
33
+ - `auth` is a token (sent as `auth.token`) or handshake fields; leave it out
34
+ for cookie sessions. Changing it reconnects, and a hello naming another
35
+ user empties everything quickdraw cached.
36
+ - `useQuickdraw()` gives
37
+ `{ connection, status, isConnected, userId, serviceAccess, hello, refusal, isRateLimited }`.
38
+
39
+ ## Reading
40
+
41
+ | Need | Use |
42
+ | ---------------------------- | ------------------------------------------------------------------------- |
43
+ | one row, live | `qd.task.useEntity(id)` → `{ data, isLoading, isRemoved, error }` |
44
+ | several rows, live | `qd.task.useEntities(ids)` → `{ data, byId, isLoading, error, errors }` |
45
+ | a live list of one scope | `qd.task.board.useCollection(projectId, { view, load: "all", limit })` |
46
+ | anything else a method reads | `qd.task.stats.useQuery(input, options)` (TanStack's result) |
47
+ | search as the user types | `qd.task.search.useSearch(q, { scope, debounceMs })` (search kit methods) |
48
+ | a feed of appended items | `qd.task.logs.useStream(taskId, { max })` → `{ items, isLoading, error }` |
49
+
50
+ - Prefer live data: `useEntity` and `useCollection` stay current from the
51
+ server's frames, resume by revision after a reconnect, and cost no refetch.
52
+ - `useCollection` returns `items`, `index`, `byId`, `totalCount`,
53
+ `hasMore`, `isLoading`, `isLoadingMore`, `error`, `loadMore`, `loadItems`,
54
+ `refresh`, `clamped` and `indexTruncated`. `view` names a view the
55
+ contract declares (filtered on the client over the index); `load: "all"`
56
+ keeps every page loaded. A `null` scope or id holds nothing;
57
+ `enabled: false` subscribes to nothing.
58
+ - A query whose result follows writes declares `watch` in its contract; the
59
+ client then joins that change topic and refetches when it changes.
60
+ - Errors are `QuickdrawError` instances: switch on `error.code`
61
+ (`FORBIDDEN`, `NOT_FOUND`, `VALIDATION`, `RATE_LIMITED`, ...).
62
+
63
+ ## Writing
64
+
65
+ ```tsx
66
+ const rename = qd.task.rename.useMutation();
67
+ rename.mutate({ id, title }); // returns nothing: never await it
68
+ await rename.mutateAsync({ id, title }); // resolves with the output, rejects with QuickdrawError
69
+ ```
70
+
71
+ - A mutation whose input has `id` and whose output is `"entity"` is
72
+ optimistic by default: its input's fields show over the cached row and its
73
+ collection items at once, are dropped if the call fails, and give way to
74
+ the server's frame. `useMutation({ optimistic: false })` turns that off;
75
+ `optimistic: (input, cache) => cache.patchEntity(input.id, { ... })`
76
+ (`removeEntity(id)`, `patchItem(collection, id, fields)`) writes your own.
77
+ - Never refetch or invalidate after a mutation by hand: the frames update
78
+ live rows and collections, and watched queries refetch themselves. For
79
+ anything else use `qd.invalidate(qd.task.stats, input?)`, never
80
+ `queryClient.invalidateQueries` on a quickdraw key.
81
+ - Fire a mutation from an event handler, never from render or from an
82
+ effect that its own result runs again: that loops, and every round
83
+ writes. In development the client warns
84
+ `[quickdraw:repeated-mutation]` (one `useMutation` issuing more than 5
85
+ within a second, naming its component) and
86
+ `[quickdraw:repeated-invalidation]` (`qd.invalidate` asking for one query
87
+ key more than 20 times within a second; a busy watched topic is not
88
+ counted, the coordinator coalesces it), and the server
89
+ `[quickdraw:repeated-call]`.
90
+ An effect that must mutate runs once per change: give it the inputs as
91
+ dependencies and compare them with what it last sent.
92
+ - Outside React, through the mounted provider's connection:
93
+ `qd.task.get.call(input)`, `qd.task.rename.call(input)`,
94
+ `qd.task.get.prefetch(queryClient, input)`; `qd.task.get.key(input)` is
95
+ the cache key, `["qd", "taskService", "m", "get", input]`.
96
+
97
+ ## Realtime
98
+
99
+ - Channels: `const { send, isReady } = qd.task.cursor.useChannel();` sends
100
+ fire-and-forget messages (dropped over the channel's rate). A channel that
101
+ `requires` a room takes messages only from a socket a method joined to it:
102
+ the client that sends must make the joining call itself.
103
+ - Events: `qd.task.cursorMoved.useEvent((payload) => ...)` hears the
104
+ contract's events sent to a room the socket is in.
105
+ - Presence: `usePresence(room)` returns the user ids in an app room, after a
106
+ method joined the socket to it (`ctx.rooms.join`).
107
+ - Admin screens: `qd.task.admin.adminList.useQuery(input)` and the other
108
+ admin kit members; `useAdminServices(qd)` lists the services whose
109
+ `adminMeta` answers the user.
110
+
111
+ ## Server components and other runtimes
112
+
113
+ - A React server component or route handler cannot call `./client` (it
114
+ starts with `"use client"`): use
115
+ `createServerCaller(contracts, { url, headers })` from
116
+ `@fitzzero/quickdraw-core/utils`, whose
117
+ `caller.task.get.prefetch(queryClient, input)` fills the keys the hooks
118
+ read, for `dehydrate` and `HydrationBoundary`.
119
+ - React Native uses the same client and provider (no DOM is needed).
120
+ React-free code (a Node script, a worker) makes a connection with
121
+ `createQuickdrawConnection({ url, auth })`, calls `open()`, and calls
122
+ `callData(connection, { service: "taskService", method: "get", input })`
123
+ from `./client`; `liveDataOf(connection, queryClient)` holds live rows.
124
+ - A client in another language (a Godot game, a native app) speaks the wire
125
+ itself: `docs/protocol-v5.md` in the quickdraw repository is the
126
+ specification, `examples/godot` a GDScript client written from it, and
127
+ `docs/clients.md` compares the ways in.
128
+
129
+ ## Do not
130
+
131
+ - Do not use the socket (`socket.emit`, `socket.on`) or hand-written
132
+ TanStack hooks around quickdraw calls; go through `qd.<service>`. For a
133
+ hook the typed client has none of (infinite scroll over `list` with
134
+ `useInfiniteQuery`, `useSuspenseQuery`, `useQueries`, `queryOptions`), key
135
+ it with the member's `key(input)` (a suffix may follow it) and fetch with
136
+ its `call(input)`.
137
+ - Do not copy server data into React state to keep it current: read it from
138
+ the hooks, which share one cache.
139
+ - The lint rules `no-raw-socket`, `no-untyped-client`, `no-manual-refetch`
140
+ and `no-await-void-mutate` report these.
@@ -0,0 +1,179 @@
1
+ ---
2
+ paths:
3
+ - "apps/api/**"
4
+ - "packages/shared/**"
5
+ ---
6
+
7
+ # quickdraw 5.0: contracts and services
8
+
9
+ > From `@fitzzero/quickdraw-skills` (`quickdraw-skills link`). `paths` follow
10
+ > the quickdraw template: contracts in `packages/shared`, the server in
11
+ > `apps/api`. Another layout replaces this link with a copy and edits them.
12
+
13
+ ## The contract comes first
14
+
15
+ One contract per service, in the shared package, so the client imports it
16
+ without server code. Everything else is typed from it.
17
+
18
+ ```ts
19
+ export const task = defineContract("taskService", {
20
+ entity: taskSchema, // the full row; every row schema has `id: string`
21
+ projections: { card: cardSchema }, // named lean shapes; "entity" is implicit
22
+ fields: { notes: "Admin" }, // the level a caller needs to receive a field
23
+ methods: {
24
+ rename: mutation({ input: renameSchema, output: "entity", describe: "Renames a task." }),
25
+ count: query({
26
+ input: z.object({ projectId: z.string() }),
27
+ output: z.number(),
28
+ watch: { collection: "board", scope: (input) => input.projectId },
29
+ }),
30
+ },
31
+ collections: { board: { scope: "projectId", item: "card", order: byOrdinal } }, // ends in "id"
32
+ });
33
+ ```
34
+
35
+ - Every method is `query` or `mutation`, with `input` and `output`. `output`
36
+ is a schema or a projection: `"entity"`, `"card"`, `nullable("entity")`,
37
+ `listOf("card")`. Only a query may `watch`.
38
+ - Schemas are any Standard Schema. Use Zod 4.2 or later: MCP tools, the admin
39
+ kit, projection keys and `quickdraw-docs` read their JSON Schema.
40
+ - Methods, collections, streams, channels and events share one namespace
41
+ (`qd.<service>.<name>` on the client). `subscribe`, `unsubscribe`, `call`,
42
+ `then`, `useEntity`, `useEntities`, `admin` and `$`-names are reserved.
43
+ A contract without `entity` is an RPC-only service.
44
+
45
+ ## The service implements it
46
+
47
+ ```ts
48
+ export const taskService = qd.defineService(task, {
49
+ model: "task", // the Prisma delegate the rows live in
50
+ access: inherit({ from: project, via: "projectId" }), // see quickdraw-access.md
51
+ collections: { board: { anchor: project } },
52
+ methods: {
53
+ count: {
54
+ access: { scope: "Read", of: project, id: "projectId" },
55
+ handler: ({ input, db }) => db.task.count({ where: { projectId: input.projectId } }),
56
+ },
57
+ rename: {
58
+ access: { entry: "Moderate" },
59
+ handler: ({ input, db }) =>
60
+ db.task.update({ where: { id: input.id }, data: { title: input.title } }),
61
+ },
62
+ },
63
+ });
64
+ ```
65
+
66
+ - `qd` comes from one `initQuickdraw<{ db: typeof db; principal: AppPrincipal }>()`
67
+ for the whole app (`context: (base) => ({...})` adds app fields to every
68
+ `ctx`). `db` is `trackPrisma(new PrismaClient({ adapter }))` from
69
+ `@fitzzero/quickdraw-core/prisma`, applied as the last extension.
70
+ - `methods` implements exactly the contract's methods; each is
71
+ `{ access, handler }`, plus `timeoutMs`, `rowless` (see
72
+ quickdraw-access.md), and for queries `share` (`"caller"` or `"all"`),
73
+ `ttlMs` and `version`.
74
+ - A handler receives `{ input, ctx, db }`: the parsed input, the context
75
+ (`principal`, `signal`, `log`, `requestId`, `transport`, `touch`, `rooms`,
76
+ `presence`, `mcp`, `services`) and the tracked client.
77
+ - For a projection output, return the database row (a `Date` where the wire
78
+ has a string, extra columns allowed): the framework selects and projects
79
+ it. Never build the wire shape by hand. A relation or computed field is
80
+ `project: { card: { select, map } }` on the service, `map` synchronous.
81
+ - Fail with `throw new QuickdrawError(code, message, data?)`: `NOT_FOUND`,
82
+ `CONFLICT`, `VALIDATION`, `FORBIDDEN`, and so on. Anything else thrown
83
+ reaches the caller as `INTERNAL`, except Prisma's unique violation
84
+ (`CONFLICT`) and missing row (`NOT_FOUND`, as from `findUniqueOrThrow`).
85
+ - To use another service's method, call it through `ctx.services`, by
86
+ service name: `await ctx.services.projectService.get({ id })`. It runs as
87
+ the same principal with transport `"internal"`, checks that method's
88
+ access, joins this call's unit of work (its writes flush with this call's)
89
+ and is cancelled with `ctx.signal`; it is typed by the app's `contracts`
90
+ (`initQuickdraw<{ ...; contracts }>()`). Never write another service's
91
+ model directly to skip its access checks.
92
+
93
+ ## Writes are tracked; frames are derived
94
+
95
+ Entity frames, collection deltas and change topics are computed from the
96
+ writes made through `db`, after the response is sent:
97
+
98
+ - Write through the handler's `db` (it may return `db.task.update(...)`
99
+ unawaited). In a job, script or webhook, import the tracked client and
100
+ wrap the work in `qd.run(async (ctx) => ...)`, which flushes before it
101
+ returns. Never write through the untracked client.
102
+ - Never emit by hand: no `io.emit`, `socket.emit` or `qd:` event names.
103
+ - List the other models a service writes: `writes: ["taskLabel"]`.
104
+ - Nested writes (`data: { labels: { create: [...] } }`) are not tracked:
105
+ write each model through its own delegate, inside an interactive
106
+ `db.$transaction(async (tx) => ...)` when they must commit together.
107
+ - Raw SQL writes and database cascades are not seen: record them with
108
+ `ctx.touch("task", ids)` (`{ removed: true }` for deleted rows); `qd.run`
109
+ passes `fn` the same `touch`.
110
+ - A write to one row that changes how another service's row looks (a
111
+ parent's counts) declares `affects: [{ service: task, id: "parentTaskId" }]`.
112
+
113
+ ## Collections
114
+
115
+ A contract collection has `scope` (a string column, or
116
+ `via({ model, entry, scope })` for a junction table), `item`, `order` (ending
117
+ in `id`), and optionally `where`, `limit`, `maxLimit`, `index` (small fields
118
+ sent for the whole scope), `views` (`(row, who) => boolean` over index rows)
119
+ and `access`. The service says who may open a scope: `{ anchor: project }`
120
+ (the level on the row the scope value names) or `{ scopeAccess: "self" }`
121
+ (the subscriber's own user id). Everyone in a scope sees every item, so
122
+ derive the item service's access from the anchor (`inherit`).
123
+
124
+ ## Kits instead of hand-written CRUD
125
+
126
+ Before writing `get`, `list`, `create`, `update`, `delete`, `search`,
127
+ sharing or admin methods by hand, use the kit: it checks access on every row
128
+ it touches, pages, filters by declared fields and stays live. Lint's
129
+ `prefer-kit` warns on a hand-written method a kit implements (`get`,
130
+ `list`, `create`, `getTask`, `listTasks`, `createTask`, `share`,
131
+ `adminList`, ...) in a service that spreads no kit; when one must stay
132
+ hand-written, say why right above it:
133
+ `// quickdraw: hand-written because it answers null for a missing task`.
134
+ Contract halves come from `@fitzzero/quickdraw-core`, handlers from
135
+ `@fitzzero/quickdraw-core/server`, spread into `methods`:
136
+
137
+ - `crud.contract({ entity, get: true, list: { item, filter, sort }, create: { input }, update: { input }, delete: true })`
138
+ (also `getMany`, `reorder`, `bulkUpdate`, `bulkDelete`) with
139
+ `crud.handlers(task, { access: { get: { entry: "Read" }, ... }, prepare })`.
140
+ - `search.contract({ entity, fields, item?, scope? })` with `search.handlers(task, { access })`.
141
+ - `sharing.contract({ mode: "acl" | "members" })` with `sharing.handlers(project)`,
142
+ on a service whose policy has a `jsonAcl` or `members`.
143
+ - `admin.contract({ entity })` with `admin.handlers(task)` (`{ service: "Admin" }` by default).
144
+
145
+ ## Realtime
146
+
147
+ - Streams: `streams: { logs: { item, scope: "taskId", seed: 50, access } }`;
148
+ push with `qd.stream(task, "logs").push(taskId, item)`, and several items
149
+ at once with `pushMany(taskId, items)`, never `push` in a loop.
150
+ - Channels: `channels: { cursor: { payload, ratePerSecond, requires } }` in
151
+ the contract, `channels: { cursor: (payload, ctx) => ... }` on the service.
152
+ `requires` is what the sending socket must hold: `{ entity: "taskId" }` (a
153
+ subscription to the row the payload's key names), `{ collection, scope }`,
154
+ or an app room a method joined that socket to: `{ room: "world" }` names
155
+ the room itself (not a payload key), ``{ room: (p) => `lobby:${p.lobbyId}` }``
156
+ computes it. 4.x's `requireRoom` becomes `{ room }`.
157
+ - Events: `events: { moved: { payload } }`, sent with
158
+ `ctx.rooms.emit(room, task, "moved", payload)` to an app room
159
+ (`ctx.rooms.join(room)` in a method puts the caller's socket in one).
160
+
161
+ ## Performance
162
+
163
+ Bound every `findMany` with `take`; batch per-item reads with `in:` filters;
164
+ filter in `where`, not after loading. Write many rows in one statement when
165
+ they all get the same data (`updateMany`, `createMany`). When each row's
166
+ data differs, loop over the rows inside an interactive
167
+ `db.$transaction(async (tx) => ...)` and await one `tx.task.update(...)` by
168
+ id per row, not an array-form `$transaction([...])`, which cannot read a
169
+ moved row inside its batch. `share: "caller"` for hot queries;
170
+ `versionColumn: "updatedAt"` answers "not modified" cheaply. The quickdraw
171
+ lint rules enforce most of this file (`no-untracked-write`,
172
+ `no-foreign-write`, `no-nested-write`, `no-raw-sql-write`, `no-manual-emit`,
173
+ `no-unbounded-read`, `no-db-call-in-loop`, `no-load-then-filter`,
174
+ `prefer-kit`): fix the code, not the rule. In development the server also
175
+ names a client loop: `[quickdraw:repeated-call]` when one connection calls
176
+ a method with the same input more than 10 times within a second, or is
177
+ refused `RATE_LIMITED` more than 30 times within a minute. Find the client
178
+ code that repeats it (quickdraw-client.md); never raise the rate limit to
179
+ quiet it.
@@ -0,0 +1,124 @@
1
+ ---
2
+ paths:
3
+ - "apps/**/*.test.ts"
4
+ - "apps/**/*.test.tsx"
5
+ - "apps/**/__tests__/**"
6
+ - "apps/**/__budgets__/**"
7
+ ---
8
+
9
+ # quickdraw 5.0: testing
10
+
11
+ > From `@fitzzero/quickdraw-skills` (`quickdraw-skills link`). `paths` follow
12
+ > the quickdraw template: tests beside the code in `apps/api` and `apps/web`.
13
+ > Another layout replaces this link with a copy and edits them.
14
+
15
+ Test through the real server: the same dispatcher, access engine, tracked
16
+ writes and frames production runs. Helpers come from
17
+ `@fitzzero/quickdraw-core/testing`, `@fitzzero/quickdraw-core/testing/client`
18
+ and `@fitzzero/quickdraw-core/testing/prisma`.
19
+
20
+ ## The test app
21
+
22
+ ```ts
23
+ const app = await createTestApp({ services: [projectService, taskService], db });
24
+ const task = await app.as(ada).taskService.create({ projectId, title: "Write the docs" });
25
+ const { call, close } = await app.connect(bo); // a real protocol 5 socket
26
+ await call.taskService.get({ id: task.id });
27
+ await app.frames.waitFor({ event: "qd:e", userId: bo.userId });
28
+ await app.close();
29
+ ```
30
+
31
+ - `createTestApp` takes `createServer`'s options plus `strictWarnings`. A
32
+ socket acts as the principal it connects as, the rate limiter is off, and
33
+ the server listens on a free port of 127.0.0.1 (`app.url`).
34
+ - `app.as(principal)` calls in process; `app.connect(principal)` resolves
35
+ `{ call, socket, hello, close }` once the server said hello. Pass `null`
36
+ for an anonymous caller. Both callers are keyed by service name
37
+ (`taskService`), where the web client uses the contract map's keys.
38
+ - `app.frames(match?)` lists every frame the server sent, each with its
39
+ `event`, `data`, `socketId`, `userId` and `at`;
40
+ `frames.waitFor(match, timeoutMs?)` waits for one, `frames.clear()`
41
+ forgets them. Assert on frames, not internals.
42
+ - The app's dispatcher becomes current for the services' `qd`, so
43
+ `qd.run`, `qd.stream(...).push` and `qd.presence` reach it.
44
+ - `db` is the tracked client over a test database, made exactly as in
45
+ production (`trackPrisma(new PrismaClient({ adapter }))`). The
46
+ `./testing/prisma` helpers give each worker a database:
47
+ `createPrismaTestGlobalSetup`, `workerDatabaseUrl` and `resetDatabase`
48
+ (PostgreSQL, or PGlite when no `TEST_DATABASE_URL` is set).
49
+ - Seed rows with the untracked client (`prisma`), or inside `qd.run` once an
50
+ app runs: a tracked write outside any unit of work flushes on its own with
51
+ an `ambient-write` warning.
52
+
53
+ ## Every service gets an access matrix
54
+
55
+ ```ts
56
+ await describeAccessMatrix(app, {
57
+ service: taskService,
58
+ principals: { owner: ada, member: bo, stranger: ed },
59
+ cases: [
60
+ { method: "get", input: { id }, allow: ["owner", "member"] },
61
+ { method: "remove", input: { id }, expect: { owner: "allow", member: "FORBIDDEN" } },
62
+ ],
63
+ });
64
+ ```
65
+
66
+ - Each case runs as each principal and anonymously (`"deny"` means
67
+ `UNAUTHENTICATED` without a principal, `FORBIDDEN` with one). It rejects
68
+ listing every cell that differs; `via: "socket"` runs it over sockets.
69
+ - Mutations run for real, once per allowed principal: give inputs that can
70
+ run again, or a fresh row per case.
71
+
72
+ ## Hot methods get a budget
73
+
74
+ ```ts
75
+ await expectBudget(() => app.as(ada).taskService.list({ limit: 20 }), { name: "list a page" });
76
+ ```
77
+
78
+ - It records SQL statements and bytes (never time) per call and for the
79
+ whole step, in `__budgets__/<test file>.json` beside the test: commit it.
80
+ A missing entry is written, a cheaper step rewrites its entry (under CI it
81
+ fails: rerun locally and commit), a costlier one fails naming each number
82
+ that grew. Statements must match exactly; bytes may move 5%.
83
+ - Accept a deliberate rise with `QD_ALLOW_BUDGET_GROWTH=1` (every budget) or
84
+ `QD_ALLOW_BUDGET_GROWTH="list a page"` (the ones named), then commit the
85
+ file. Give each step of a test file its own name. Await everything the
86
+ step should cost inside `run`, measure one step at a time, and run a step
87
+ once first when it may pay a one-time read.
88
+
89
+ ## Development warnings
90
+
91
+ `createTestApp({ strictWarnings: true })` (under vitest or jest) throws each
92
+ development warning raised in that app's method calls (`n-plus-one`,
93
+ `unbounded-read`, `oversized-response`, `nested-write`, `batch-read`,
94
+ `batch-create-many`) as a `DevWarningError` where it happens, failing the
95
+ test that caused it (an oversized reply fails an in-process `app.as(...)`
96
+ call; over a socket or HTTP the reply was already sent, so it is logged, not
97
+ thrown). Warnings outside its calls (an `ambient-write` while seeding) are
98
+ logged, and `app.close()` ends it. Turn it on for service suites.
99
+ `createRecordingSink()` passed as `flushSink` records what each flush wrote.
100
+
101
+ ## Components
102
+
103
+ - Against the real server:
104
+ `const view = await renderWithQuickdraw(<Board projectId={id} />, { app, as: ada, client: qd })`
105
+ from `./testing/client` returns Testing Library's result plus
106
+ `connection`, `queryClient`, `disconnect()` and `reconnect()`. Change
107
+ data with `app.as(...)` and wait for the screen (`findByText`).
108
+ - Without a server: `createMockClient({ task, project })` has the typed
109
+ client's shape with stubs: `qd.task.get.mockResolvedValue(row)`,
110
+ `mockRejectedValue(error)`, `mockImplementation(fn)`, `calls`;
111
+ `qd.task.useEntity.mockRow(row)` (and `mockRemoved`, `mockError`);
112
+ `qd.task.board.mockScope(scope, items)`; `mockItems` for a stream, `sent`
113
+ for a channel, `mockEmit` for an event. Everything set is forgotten
114
+ after each test only when the runner has a global `afterEach` (vitest
115
+ with `globals: true`, or jest); otherwise add
116
+ `afterEach(() => mock.$reset())`. It shows no optimistic updates.
117
+
118
+ ## What a new service's tests cover
119
+
120
+ 1. The access matrix of every method.
121
+ 2. Live behavior: a write by one user reaches another's entity or
122
+ collection (`frames.waitFor`, or a rendered component).
123
+ 3. A budget for each method a page calls on load, or in a loop.
124
+ 4. Errors callers rely on (`NOT_FOUND`, `CONFLICT`, `VALIDATION`) by code.
@@ -0,0 +1,81 @@
1
+ ---
2
+ name: quickdraw-migrate-v5
3
+ description: Move a quickdraw 4.x app to quickdraw 5.0 (BaseService classes, ServiceRegistry and useService hooks to contracts, defineService and the typed client) with @fitzzero/quickdraw-codemod and the migration guide. Use when asked to "upgrade quickdraw", "migrate to quickdraw 5", when the repo has a quickdraw-migration-report.md, or when lint reports quickdraw/no-v4-api.
4
+ ---
5
+
6
+ # Migrate a quickdraw 4.x app to 5.0
7
+
8
+ The codemod does the mechanical part; you work through what it marks. The
9
+ details of every step are in the migration guide:
10
+ `node_modules/@fitzzero/quickdraw-codemod/MIGRATION.md` (or `MIGRATION.md` at
11
+ the root of the quickdraw repository). Read its sections as the report sends
12
+ you to them.
13
+
14
+ ## Rules
15
+
16
+ - **Never change who may call a method silently.** The codemod's access forms
17
+ admit exactly the callers 4.x admitted: `"Public"` became `"public"`, a
18
+ method with a row id `{ service: L, entry: L, id }` (never `{ entry: L }`:
19
+ 4.x let a service grant pass the row check), `"Read"` without a row id
20
+ `"authenticated"` (marked: 4.x let every signed-in user call it), and
21
+ `"Moderate"` or `"Admin"` without one `{ service: L }`. Change a form only
22
+ as a decision the user agrees to.
23
+ - **Keep every service name**: stored grants (`User.serviceAccess`, JSON
24
+ access lists) name them.
25
+ - **One step per commit**, lint, typecheck and tests green after each.
26
+
27
+ ## Procedure
28
+
29
+ 1. **Prepare.** A clean tree on a new branch. Node 24, Prisma 7, Zod 3.25 or
30
+ later (4.2 or later where JSON Schema is read: MCP tools, the admin kit,
31
+ projection keys). Upgrade `@fitzzero/quickdraw-core` to 5.0 (`@next` until
32
+ 5.0.0 ships) in every package that imports it, add
33
+ `@fitzzero/quickdraw-lint`, `@fitzzero/quickdraw-skills` and `oxlint` as
34
+ dev dependencies, extend `oxlint.base.jsonc`, add `quickdraw-skills link`
35
+ to `prepare`, and add `zod` to the shared package if it lacks it.
36
+ 2. **Run the codemod** from the repository root, first with `--dry-run`:
37
+
38
+ ```bash
39
+ bunx @fitzzero/quickdraw-codemod@next v5 . --dry-run
40
+ bunx @fitzzero/quickdraw-codemod@next v5 .
41
+ ```
42
+
43
+ It expects the template's layout (`packages/shared`, `apps/api`,
44
+ `apps/web`, `packages/db`); `--shared`, `--api`, `--web` and
45
+ `--db-package` move each part. Commit its output untouched.
46
+
47
+ 3. **Read `quickdraw-migration-report.md`.** Every item is a
48
+ `// quickdraw-migrate: review [kind] ...` marker above the code it is
49
+ about. Work through it in this order, one commit per step:
50
+ 1. **Contracts** (`[contract]`): real schemas for the entity and each
51
+ `todoSchema`; check each method's kind (chosen from its name).
52
+ 2. **Access** (`[access]`, `[access-override]`): decide the
53
+ `"authenticated"` forms and the `rowless: true` flags (keep one only
54
+ for a lookup open to anyone; else an `entry` form); port
55
+ `checkAccess`/`checkEntryACL` overrides into the service's policy
56
+ (`owner`, `jsonAcl`, `members`, `inherit`, `anyOf`, `resolver`).
57
+ 3. **Emits** (`[emit]`, `[write]`, `[raw-sql]`, `[lifecycle]`,
58
+ `[collection]`, `[projection]`, `[admin]`, `[kit]`): write through
59
+ `db`, declare the collections (boards: `index`, `views`,
60
+ `useCollection`; never a fat query with `watch`), delete the hand
61
+ emits, turn `toDto` and protected fields into projections and
62
+ `fields`, use the admin kit, and move each method a kit implements to
63
+ its kit (or keep it with `// quickdraw: hand-written because ...`).
64
+ 4. **Client** (`[client]`): declare the collections the web app reads,
65
+ settle dropped hook options (`invalidateOn` becomes `watch`), replace
66
+ the 4.x `QuickdrawProvider` props.
67
+
68
+ Then `[this]`, `[context]`, `[channel]`, `[server]` and `[v4-api]`:
69
+ instance state, the 4.x context, channels, the server set-up
70
+ (`qd.createServer`, with `legacyWire: true` while 4.x clients remain).
71
+
72
+ 4. **Delete each marker when its item is done**, and run the codemod again:
73
+ it changes no code a second time and rewrites the report from the markers
74
+ left.
75
+ 5. **Check**: `oxlint` (`no-v4-api` lists every 4.x API left, with its
76
+ replacement; `no-todo-schema` every placeholder; `prefer-kit` every
77
+ hand-written method a kit implements), the typecheck, the tests, and the
78
+ running app (a change from a second session arrives live).
79
+
80
+ Done when the report says nothing is left to review and lint, the
81
+ typecheck and the tests are clean.
@@ -0,0 +1,161 @@
1
+ ---
2
+ name: quickdraw-new-service
3
+ description: Add a service to a quickdraw 5.0 app end to end - its contract in the shared package, the service on the server, registration in createServer, the typed client's hooks in the web app, and its tests (access matrix, live behavior, budgets). Use when asked to "add a service", "add a model to the API", "expose X to the client", or to add methods, collections or a kit to an existing service.
4
+ ---
5
+
6
+ # Add a quickdraw service
7
+
8
+ Five steps, in this order, because each one is typed by the one before it.
9
+ Paths follow the quickdraw template (`packages/shared`, `apps/api`,
10
+ `apps/web`); use the app's own if they differ. The rules
11
+ `quickdraw-services.md`, `quickdraw-access.md`, `quickdraw-client.md` and
12
+ `quickdraw-testing.md` (linked into `.claude/rules/`) hold the details.
13
+
14
+ Before you start, read one existing service and its contract in this app,
15
+ and the Prisma model the rows live in. Decide:
16
+
17
+ - **Where its rows live**: a Prisma model, or none (an RPC-only service has
18
+ no `entity`, `model` or row policy).
19
+ - **Who may see a row**: an owner column, a JSON access list, a membership
20
+ table, or the parent row's access (`inherit`). This is the row policy.
21
+ - **What a client lists**: which scopes (a project's tasks, a user's chats).
22
+ Each is a collection, not a list method.
23
+
24
+ ## 1. The contract (`packages/shared/src/contracts/<name>.ts`)
25
+
26
+ <!-- example: packages/shared/src/contracts/label.ts -->
27
+
28
+ ```ts
29
+ import { crud, defineContract, mutation } from "@fitzzero/quickdraw-core";
30
+ import { z } from "zod";
31
+
32
+ const label = z.object({ id: z.string(), projectId: z.string(), name: z.string() });
33
+
34
+ export const labelContract = defineContract("labelService", {
35
+ entity: label,
36
+ methods: {
37
+ ...crud.contract({ entity: label, get: true, create: { input: label.omit({ id: true }) } }),
38
+ rename: mutation({
39
+ input: z.object({ id: z.string(), name: z.string() }),
40
+ output: "entity",
41
+ describe: "Renames a label.",
42
+ }),
43
+ },
44
+ collections: {
45
+ byProject: {
46
+ scope: "projectId",
47
+ item: "entity",
48
+ order: [
49
+ ["name", "asc"],
50
+ ["id", "asc"],
51
+ ],
52
+ },
53
+ },
54
+ });
55
+ ```
56
+
57
+ - Service names end in `Service` and never change once deployed: they are
58
+ stored in users' grants and sent on the wire.
59
+ - Use Zod 4.2 or later for every schema; give each method a `describe`
60
+ sentence when agents or MCP clients will call it.
61
+ - Export the contract from the shared package's index, next to the others.
62
+
63
+ ## 2. The service (`apps/api/src/services/<name>.ts`)
64
+
65
+ <!-- example: apps/api/src/services/label.ts -->
66
+
67
+ ```ts
68
+ import { crud, inherit } from "@fitzzero/quickdraw-core/server";
69
+ import { labelContract, projectContract } from "@project/shared";
70
+ import { qd } from "../quickdraw";
71
+
72
+ export const labelService = qd.defineService(labelContract, {
73
+ model: "label",
74
+ access: inherit({ from: projectContract, via: "projectId" }),
75
+ collections: { byProject: { anchor: projectContract } },
76
+ methods: {
77
+ ...crud.handlers(labelContract, {
78
+ access: {
79
+ get: { entry: "Read" },
80
+ create: { scope: "Moderate", of: projectContract, id: "projectId" },
81
+ },
82
+ }),
83
+ rename: {
84
+ access: { entry: "Moderate" },
85
+ handler: ({ input, db }) =>
86
+ db.label.update({ where: { id: input.id }, data: { name: input.name } }),
87
+ },
88
+ },
89
+ });
90
+ ```
91
+
92
+ - `qd` is the app's one `initQuickdraw` instance; import it, never create a
93
+ second one.
94
+ - Every method declares `access`; write through the handler's `db`; list any
95
+ other model the handlers write in `writes`; return rows for projection
96
+ outputs. Throw `QuickdrawError` with a code for expected failures.
97
+ - A method whose input has `id` names a row: give it `{ entry: L }`.
98
+ `defineService` refuses `"public"`, `"authenticated"` or `{ service: L }`
99
+ below `Admin` there unless the method says `rowless: true` (every caller
100
+ the form admits may reach any row, on purpose).
101
+ - Reach for a kit before hand-writing CRUD, search, sharing or admin; lint's
102
+ `prefer-kit` warns on a hand-written `get`, `list`, `create` or
103
+ `getLabel`-style method in a service that uses no kit.
104
+
105
+ ## 3. Register it (`apps/api/src/index.ts`)
106
+
107
+ Add the service to `qd.createServer({ services: [...] })`. If the app has an
108
+ MCP registry (`createMcpRegistry({ services, dispatcher })`), add it there
109
+ too, or exclude the methods agents must not call.
110
+
111
+ ## 4. Use it from the web app
112
+
113
+ Add the contract to the map given to `createQuickdrawClient` (the key you
114
+ pick becomes `qd.<key>`), then use the hooks:
115
+
116
+ <!-- example: apps/web/src/components/Labels.tsx -->
117
+
118
+ ```tsx
119
+ "use client";
120
+
121
+ import { qd } from "../lib/quickdraw";
122
+
123
+ export function Labels({ projectId }: { readonly projectId: string }) {
124
+ const { items } = qd.label.byProject.useCollection(projectId);
125
+ const rename = qd.label.rename.useMutation();
126
+ return (
127
+ <ul>
128
+ {items.map((label) => (
129
+ <li key={label.id}>
130
+ <button type="button" onClick={() => rename.mutate({ id: label.id, name: "urgent" })}>
131
+ {label.name}
132
+ </button>
133
+ </li>
134
+ ))}
135
+ </ul>
136
+ );
137
+ }
138
+ ```
139
+
140
+ Read with `useEntity` and `useCollection` before `useQuery`; never refetch
141
+ or invalidate after a mutation by hand.
142
+
143
+ ## 5. Test it (`apps/api/src/services/<name>.test.ts`)
144
+
145
+ - `describeAccessMatrix` over every method, as an owner, a member, a
146
+ stranger and anonymously.
147
+ - One live test: a write by one user reaches another user's collection or
148
+ entity (`app.frames.waitFor`, or a component through
149
+ `renderWithQuickdraw`).
150
+ - `expectBudget` for each method a page calls on load; commit the
151
+ `__budgets__` file it writes.
152
+ - `createTestApp({ ..., strictWarnings: true })`, so N+1 reads, unbounded
153
+ reads and nested writes in the service's calls fail the test that caused
154
+ them. Seed rows with the untracked client (`prisma`).
155
+
156
+ ## Finish
157
+
158
+ Run the app's lint (the quickdraw rules catch untracked writes, foreign
159
+ writes, unbounded reads and raw socket use), typecheck and tests. If the app
160
+ generates API docs with `quickdraw-docs`, regenerate them and commit the
161
+ result.