@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 +21 -0
- package/README.md +66 -0
- package/bin/cli.mjs +329 -0
- package/package.json +37 -0
- package/rules/quickdraw-access.md +136 -0
- package/rules/quickdraw-client.md +140 -0
- package/rules/quickdraw-services.md +179 -0
- package/rules/quickdraw-testing.md +124 -0
- package/skills/quickdraw-migrate-v5/SKILL.md +81 -0
- package/skills/quickdraw-new-service/SKILL.md +161 -0
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.
|