@fitzzero/quickdraw-skills 5.0.0 → 5.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -8
- package/package.json +1 -1
- package/rules/quickdraw-access.md +61 -3
- package/rules/quickdraw-services.md +21 -5
- package/rules/quickdraw-testing.md +47 -5
- package/skills/quickdraw-api-docs/SKILL.md +107 -0
- package/skills/quickdraw-api-docs/docs-api.yml +104 -0
- package/skills/quickdraw-migrate-v5/SKILL.md +21 -8
- package/skills/quickdraw-new-service/SKILL.md +21 -4
package/README.md
CHANGED
|
@@ -24,14 +24,15 @@ whatever the version.
|
|
|
24
24
|
|
|
25
25
|
## What it links
|
|
26
26
|
|
|
27
|
-
| Link | What it holds
|
|
28
|
-
| -------------------------------------- |
|
|
29
|
-
| `.claude/rules/quickdraw-services.md` | contracts, `defineService`, tracked writes, derived frames, `affects`, `ctx.touch`, kits
|
|
30
|
-
| `.claude/rules/quickdraw-access.md` | access forms, row policies, service grants, failing closed, one policy on every surface
|
|
31
|
-
| `.claude/rules/quickdraw-client.md` | the typed client, live entities and collections, views, optimistic mutations, `watch`
|
|
32
|
-
| `.claude/rules/quickdraw-testing.md` | `createTestApp`, access matrices, budgets, strict warnings, component tests
|
|
33
|
-
| `.claude/skills/quickdraw-new-service` | adding a service end to end: contract, service, registration, client, tests
|
|
34
|
-
| `.claude/skills/quickdraw-migrate-v5` | moving a 4.x app to 5.0 with `@fitzzero/quickdraw-codemod` and the migration guide
|
|
27
|
+
| Link | What it holds |
|
|
28
|
+
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
29
|
+
| `.claude/rules/quickdraw-services.md` | contracts, `defineService`, tracked writes, derived frames, `affects`, `ctx.touch`, kits |
|
|
30
|
+
| `.claude/rules/quickdraw-access.md` | access forms, row policies, service grants, failing closed, one policy on every surface |
|
|
31
|
+
| `.claude/rules/quickdraw-client.md` | the typed client, live entities and collections, views, optimistic mutations, `watch` |
|
|
32
|
+
| `.claude/rules/quickdraw-testing.md` | `createTestApp`, access matrices, budgets, strict warnings, component tests |
|
|
33
|
+
| `.claude/skills/quickdraw-new-service` | adding a service end to end: contract, service, registration, client, tests |
|
|
34
|
+
| `.claude/skills/quickdraw-migrate-v5` | moving a 4.x app to 5.0 with `@fitzzero/quickdraw-codemod` and the migration guide |
|
|
35
|
+
| `.claude/skills/quickdraw-api-docs` | describes on every contract member, and the API docs kept current: committed and checked, or regenerated on each push by the workflow file it ships (`docs-api.yml`, which an app copies into `.github/workflows`) |
|
|
35
36
|
|
|
36
37
|
Each rule's `paths` frontmatter follows the quickdraw template's layout
|
|
37
38
|
(`apps/api`, `apps/web`, `packages/shared`), so Claude Code loads it while
|
package/package.json
CHANGED
|
@@ -36,6 +36,45 @@ Every method's `{ access, handler }` names one form:
|
|
|
36
36
|
- Never guard inline (`if (!ctx.principal) throw ...`): declare the form. The
|
|
37
37
|
`no-inline-auth-guard` lint rule reports it.
|
|
38
38
|
|
|
39
|
+
## Which kinds of principal may call
|
|
40
|
+
|
|
41
|
+
An app whose principals come in kinds (`principal.kind`: a user, an agent's
|
|
42
|
+
token, a runner) declares which kinds may call with `kinds`, beside `access`,
|
|
43
|
+
never inside a form:
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
export const qd = initQuickdraw<{ db: typeof db; principal: AppPrincipal }>({
|
|
47
|
+
kinds: ["user", "agent"], // every service, unless it narrows them
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
export const tokenService = qd.defineService(token, {
|
|
51
|
+
kinds: ["user", "agent"], // its methods, subscriptions and channels
|
|
52
|
+
methods: {
|
|
53
|
+
mint: { access: "authenticated", kinds: ["user"], handler: mint }, // this method alone
|
|
54
|
+
heartbeat: { access: "authenticated", handler: beat }, // the service's kinds
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
- Each level only narrows the one above it: a method's list within its
|
|
60
|
+
service's, a service's within the app's. The types refuse a name that is
|
|
61
|
+
no kind of the app's principal, and `defineService` refuses a wider or
|
|
62
|
+
empty list when the service is defined.
|
|
63
|
+
- A principal of another kind, or without a `kind`, gets `FORBIDDEN` before
|
|
64
|
+
the form is asked. No grant passes the check, a service-wide `Admin` grant
|
|
65
|
+
included: a token acting with its user's grants is what it stops.
|
|
66
|
+
- An anonymous caller is left to the form (`UNAUTHENTICATED` wherever it
|
|
67
|
+
needs a principal). So a `"public"` method takes no `kinds` of its own: a
|
|
68
|
+
caller of a refused kind would call it signed out. It keeps its service's
|
|
69
|
+
list, which holds for signed-in callers.
|
|
70
|
+
- A service's list also refuses other kinds on `qd:sub`, `qd:col:sub`,
|
|
71
|
+
`qd:watch` and `qd:stream:sub`, and its channels drop their messages.
|
|
72
|
+
- Never check the kind in a handler (`if (ctx.principal.kind !== "user")
|
|
73
|
+
throw ...`; `no-inline-auth-guard` reports it) or in a `custom` form:
|
|
74
|
+
`kinds` combines with any form (`{ entry: "Moderate" }` and
|
|
75
|
+
`kinds: ["user"]`), where `custom` would have to check the row by hand.
|
|
76
|
+
A check on a claim, such as a token's `scope`, stays `custom`.
|
|
77
|
+
|
|
39
78
|
## A method that takes a row id
|
|
40
79
|
|
|
41
80
|
On a service with a policy, a method whose input has `id` reads or writes
|
|
@@ -54,7 +93,8 @@ defined. Fix the form, not the error:
|
|
|
54
93
|
row themselves and need neither.
|
|
55
94
|
- The check reads the input's keys from its JSON Schema: an `id` in any
|
|
56
95
|
branch of a union counts, as does one beside a `Date` or a `Set`. An input
|
|
57
|
-
without JSON Schema (a Zod 3 schema)
|
|
96
|
+
without JSON Schema (a Zod 3 schema) or one that names no keys (a
|
|
97
|
+
`todoSchema` without `keys`), a bare string that is the id itself
|
|
58
98
|
and a row named by another key (`taskId`, `ids`) are not checked, so the
|
|
59
99
|
form is all yours there (`{ entry: L, id: "taskId" }` names another key).
|
|
60
100
|
|
|
@@ -88,7 +128,7 @@ compile time.
|
|
|
88
128
|
| `members({ model, entry, user, level, levels? })` | a membership table row |
|
|
89
129
|
| `inherit({ from: projectContract, via: "projectId" })` | the level on the parent row in another service |
|
|
90
130
|
| `anyOf(policyA, policyB)` | the highest level any of them gives |
|
|
91
|
-
| `resolver({ levelsFor, where
|
|
131
|
+
| `resolver({ levelsFor, where?, reads })` | your code, one batched read for all ids |
|
|
92
132
|
| `everyone("Read")` | every signed-in user, on every row; no read |
|
|
93
133
|
|
|
94
134
|
- `entry` forms need a policy and `scope` forms a `model`; a service without
|
|
@@ -100,6 +140,17 @@ compile time.
|
|
|
100
140
|
`anyOf(owner("id"), everyone("Read"))`, never a hand-written `resolver`
|
|
101
141
|
answering `Read` for every id: `rowless: true` covers one method, not
|
|
102
142
|
subscriptions.
|
|
143
|
+
- A `resolver` declares what its levels depend on, or nothing re-checks it
|
|
144
|
+
and a removed member keeps their live rows:
|
|
145
|
+
`reads: { columns: ["visibility"], memberships: [{ model: "teamMember", entry: "projectId", user: "userId", level: "role" }] }`
|
|
146
|
+
(columns of the service's model; membership tables as `members` takes
|
|
147
|
+
them, `entry` holding this service's row id). Tracked writes to them then
|
|
148
|
+
revoke and re-check as for the other policies, and `tools.rows(ids)` reads
|
|
149
|
+
the declared columns. `reads: "none"` only when nothing a tracked write
|
|
150
|
+
changes can change a level (the principal's grants alone). Without either
|
|
151
|
+
the server warns `[quickdraw:resolver-without-reads]` and a strict test
|
|
152
|
+
app fails to start. A level from another service's row is `inherit`'s
|
|
153
|
+
job, alone or in `anyOf`.
|
|
103
154
|
- Lookups are batched per call: checking 60 ids costs what one does.
|
|
104
155
|
`createServer({ access: { cacheMs } })` keeps them across calls; tracked
|
|
105
156
|
writes to the columns and tables a policy reads evict them.
|
|
@@ -153,4 +204,11 @@ channel `requires`.
|
|
|
153
204
|
|
|
154
205
|
Pin every service's matrix with `describeAccessMatrix` from
|
|
155
206
|
`@fitzzero/quickdraw-core/testing` (see quickdraw-testing.md): each method
|
|
156
|
-
as owner, member, stranger and anonymous, through the real dispatcher
|
|
207
|
+
as owner, member, stranger and anonymous, through the real dispatcher, and
|
|
208
|
+
on a service with `kinds`, as a principal of each kind it refuses (each
|
|
209
|
+
cell records its principal's `kind`). Before changing a form, a policy or
|
|
210
|
+
the kinds of principal, record the whole table with `snapshotAccessMatrix`
|
|
211
|
+
(every method, entity subscribe and collection scope, in
|
|
212
|
+
`__access__/<test file>.json`): each cell the change moves then fails,
|
|
213
|
+
saying whether it opens or closes access, until
|
|
214
|
+
`QD_UPDATE_ACCESS_SNAPSHOT=1` accepts it.
|
|
@@ -17,6 +17,7 @@ without server code. Everything else is typed from it.
|
|
|
17
17
|
|
|
18
18
|
```ts
|
|
19
19
|
export const task = defineContract("taskService", {
|
|
20
|
+
describe: "Tasks on a project's board.", // what the service is for
|
|
20
21
|
entity: taskSchema, // the full row; every row schema has `id: string`
|
|
21
22
|
projections: { card: cardSchema }, // named lean shapes; "entity" is implicit
|
|
22
23
|
fields: { notes: "Admin" }, // the level a caller needs to receive a field
|
|
@@ -26,15 +27,23 @@ export const task = defineContract("taskService", {
|
|
|
26
27
|
input: z.object({ projectId: z.string() }),
|
|
27
28
|
output: z.number(),
|
|
28
29
|
watch: { collection: "board", scope: (input) => input.projectId },
|
|
30
|
+
describe: "Counts the tasks on a project's board.",
|
|
29
31
|
}),
|
|
30
32
|
},
|
|
31
|
-
collections: {
|
|
33
|
+
collections: {
|
|
34
|
+
board: { describe: "A project's tasks.", scope: "projectId", item: "card", order: byOrdinal },
|
|
35
|
+
},
|
|
32
36
|
});
|
|
33
37
|
```
|
|
34
38
|
|
|
35
39
|
- Every method is `query` or `mutation`, with `input` and `output`. `output`
|
|
36
40
|
is a schema or a projection: `"entity"`, `"card"`, `nullable("entity")`,
|
|
37
41
|
`listOf("card")`. Only a query may `watch`.
|
|
42
|
+
- Give the contract and every member a `describe`: a sentence or two on what
|
|
43
|
+
it is for (each method, collection, stream, channel and event). The MCP
|
|
44
|
+
bridge uses a method's as its tool's description, and `quickdraw-docs`
|
|
45
|
+
leads each section with it. Lint's `require-describe` warns on each one
|
|
46
|
+
missing; kits' contract halves write their own.
|
|
38
47
|
- Schemas are any Standard Schema. Use Zod 4.2 or later: MCP tools, the admin
|
|
39
48
|
kit, projection keys and `quickdraw-docs` read their JSON Schema.
|
|
40
49
|
- Methods, collections, streams, channels and events share one namespace
|
|
@@ -65,12 +74,14 @@ export const taskService = qd.defineService(task, {
|
|
|
65
74
|
|
|
66
75
|
- `qd` comes from one `initQuickdraw<{ db: typeof db; principal: AppPrincipal }>()`
|
|
67
76
|
for the whole app (`context: (base) => ({...})` adds app fields to every
|
|
68
|
-
`ctx
|
|
77
|
+
`ctx`; `kinds: ["user", "agent"]` the kinds of principal every service
|
|
78
|
+
admits). `db` is `trackPrisma(new PrismaClient({ adapter }))` from
|
|
69
79
|
`@fitzzero/quickdraw-core/prisma`, applied as the last extension.
|
|
70
80
|
- `methods` implements exactly the contract's methods; each is
|
|
71
|
-
`{ access, handler }`, plus `
|
|
72
|
-
quickdraw-access.md), and for queries `share` (`"caller"` or
|
|
73
|
-
`ttlMs` and `version`.
|
|
81
|
+
`{ access, handler }`, plus `kinds` and `rowless` (see
|
|
82
|
+
quickdraw-access.md), `timeoutMs`, and for queries `share` (`"caller"` or
|
|
83
|
+
`"all"`), `ttlMs` and `version`. A service takes `kinds` too: the kinds of
|
|
84
|
+
principal its methods and subscriptions admit.
|
|
74
85
|
- A handler receives `{ input, ctx, db }`: the parsed input, the context
|
|
75
86
|
(`principal`, `signal`, `log`, `requestId`, `transport`, `touch`, `rooms`,
|
|
76
87
|
`presence`, `mcp`, `services`) and the tracked client.
|
|
@@ -186,6 +197,11 @@ Contract halves come from `@fitzzero/quickdraw-core`, handlers from
|
|
|
186
197
|
- `sharing.contract({ mode: "acl" | "members" })` with `sharing.handlers(project)`,
|
|
187
198
|
on a service whose policy has a `jsonAcl` or `members`.
|
|
188
199
|
- `admin.contract({ entity })` with `admin.handlers(task)` (`{ service: "Admin" }` by default).
|
|
200
|
+
Name what an admin may change with `editable: ["title", "status"]` (on
|
|
201
|
+
`admin.contract` too, so the input schemas and MCP tools name only those):
|
|
202
|
+
every other field is read-only, and a write naming one is `VALIDATION`.
|
|
203
|
+
Without it the kit writes every field it shows but `id` and the
|
|
204
|
+
timestamps, owner and foreign-key columns included.
|
|
189
205
|
On a users service, `admin.handlers(user, { grants: true })` lets the admin
|
|
190
206
|
screen edit `serviceAccess` through `adminUpdate` (service-wide Admins
|
|
191
207
|
only); never hand-write a `setServiceAccess` method for it. `adminMeta`
|
|
@@ -4,6 +4,7 @@ paths:
|
|
|
4
4
|
- "apps/**/*.test.tsx"
|
|
5
5
|
- "apps/**/__tests__/**"
|
|
6
6
|
- "apps/**/__budgets__/**"
|
|
7
|
+
- "apps/**/__access__/**"
|
|
7
8
|
---
|
|
8
9
|
|
|
9
10
|
# quickdraw 5.0: testing
|
|
@@ -30,7 +31,7 @@ The template runs two lanes, and a file's name picks its lane:
|
|
|
30
31
|
|
|
31
32
|
A test that boots the app but is named `<name>.test.ts` runs in the unit
|
|
32
33
|
lane and fails at its first query. Budgets go to `__budgets__/` beside the
|
|
33
|
-
test file that measures them
|
|
34
|
+
test file that measures them, and access snapshots to `__access__/`.
|
|
34
35
|
|
|
35
36
|
## The test app
|
|
36
37
|
|
|
@@ -95,6 +96,45 @@ await describeAccessMatrix(app, {
|
|
|
95
96
|
(`input: async () => ({ id: await newTask() })`), which makes a fresh row
|
|
96
97
|
for every cell; never order the principals so the allowed one goes last.
|
|
97
98
|
|
|
99
|
+
## Pin the whole matrix with a snapshot
|
|
100
|
+
|
|
101
|
+
Before a migration, a policy refactor or a new kind of principal, record
|
|
102
|
+
every cell once and let each change fail until someone accepts it:
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
const report = await snapshotAccessMatrix(app, {
|
|
106
|
+
principals: (seeded) => ({ owner: seeded.ada, member: seeded.bo, stranger: seeded.ed }),
|
|
107
|
+
reset: async () => {
|
|
108
|
+
await resetDatabase(prisma); // from @fitzzero/quickdraw-core/testing/prisma
|
|
109
|
+
return await seed(); // the app's seed: users, a project, a task in it and one outside it
|
|
110
|
+
},
|
|
111
|
+
rows: (seeded) => ({
|
|
112
|
+
projectService: seeded.projectId,
|
|
113
|
+
taskService: { own: seeded.taskId, other: seeded.otherTaskId },
|
|
114
|
+
}),
|
|
115
|
+
});
|
|
116
|
+
expect(report.inconclusive).toEqual([]);
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
- It runs every method of the app's services (or `services`), each entity
|
|
120
|
+
subscribe and each collection scope, as every principal and anonymously,
|
|
121
|
+
and writes `__access__/<test file>.json` beside the test on the first run:
|
|
122
|
+
commit it. Each cell is the exact outcome (`"ok"` or the error code).
|
|
123
|
+
- `reset` runs first and after every mutation access let through, so no cell
|
|
124
|
+
sees another's writes. `principals` may be a function of what `reset`
|
|
125
|
+
returned, for ids the seed makes. `rows` names every service a form,
|
|
126
|
+
subscribe or anchor reads (a row id, or variants such as `{ own, other }`).
|
|
127
|
+
- Inputs are made from each input's JSON Schema with the row where the form
|
|
128
|
+
reads it; give the ones that cannot be made with
|
|
129
|
+
`inputs: (ref, board) => ...` (`ref.service`, `ref.method`, `ref.row`).
|
|
130
|
+
Cells never called are `VALIDATION` and listed in `report.inconclusive`:
|
|
131
|
+
keep it empty.
|
|
132
|
+
- A changed cell fails, saying whether it opens or closes access. When the
|
|
133
|
+
change is meant, rerun with `QD_UPDATE_ACCESS_SNAPSHOT=1` and commit the
|
|
134
|
+
file; never set it in CI, which also fails a missing file and added or
|
|
135
|
+
removed cells. Leave out methods whose outcome varies with
|
|
136
|
+
`exclude: ["<service>.<method>"]`. One snapshot per test file.
|
|
137
|
+
|
|
98
138
|
## Hot methods get a budget
|
|
99
139
|
|
|
100
140
|
```ts
|
|
@@ -122,9 +162,10 @@ test that caused it (an oversized reply fails an in-process `app.as(...)`
|
|
|
122
162
|
call; over a socket or HTTP the reply was already sent, so it is logged, not
|
|
123
163
|
thrown). Warnings outside its calls (an `ambient-write` while seeding) are
|
|
124
164
|
logged, and `app.close()` ends it. `tiered-field-in-output` (a method's own
|
|
125
|
-
output schema declares a tiered field, at any depth)
|
|
126
|
-
`
|
|
127
|
-
|
|
165
|
+
output schema declares a tiered field, at any depth) and
|
|
166
|
+
`resolver-without-reads` (a `resolver` policy declares no `reads`) are
|
|
167
|
+
thrown by `createTestApp` itself: answer `"entity"` from that method, or
|
|
168
|
+
declare the resolver's `reads`, rather than turning strictness off.
|
|
128
169
|
Turn it on for service suites.
|
|
129
170
|
`createRecordingSink()` passed as `flushSink` records what each flush wrote.
|
|
130
171
|
|
|
@@ -166,7 +207,8 @@ Turn it on for service suites.
|
|
|
166
207
|
|
|
167
208
|
## What a new service's tests cover
|
|
168
209
|
|
|
169
|
-
1. The access matrix of every method
|
|
210
|
+
1. The access matrix of every method (`describeAccessMatrix`), or its
|
|
211
|
+
snapshot (`snapshotAccessMatrix`).
|
|
170
212
|
2. Live behavior: a write by one user reaches another's entity or
|
|
171
213
|
collection (`frames.waitFor`, or a rendered component).
|
|
172
214
|
3. A budget for each method a page calls on load, or in a loop.
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: quickdraw-api-docs
|
|
3
|
+
description: Keep a quickdraw 5.0 app's API docs (the quickdraw-docs pages, which MCP clients and agents read) current - write a describe on every contract member, then either commit the pages and check them in pull-request CI, or install the docs-on-push workflow that regenerates them on every push to the base branch. Use when asked to "set up API docs", "document the MCP tools", "keep docs/api up to date", when generated docs conflict in merges, or when lint reports quickdraw/require-describe.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Keep the API docs current
|
|
7
|
+
|
|
8
|
+
`quickdraw-docs` writes one Markdown page per service from its contract, and
|
|
9
|
+
an index. A page leads with the contract's `describe`; each method shows its
|
|
10
|
+
`describe`, its kind, its default MCP tool name (`{service}_{method}`; an
|
|
11
|
+
app's registry can rename tools, and `bind` can take arguments out of a
|
|
12
|
+
tool's schema, which the docs cannot see) and, for a query, the MCP
|
|
13
|
+
read-only hint; with `--services`, also who may call it, and each page
|
|
14
|
+
says which other services it depends on (with a graph on the index). The
|
|
15
|
+
command and its options are in the README's "API docs from contracts"
|
|
16
|
+
section (`node_modules/@fitzzero/quickdraw-core/README.md`).
|
|
17
|
+
|
|
18
|
+
## 1. Write the describes
|
|
19
|
+
|
|
20
|
+
The docs and the MCP tools are only as useful as the prose in the contract.
|
|
21
|
+
Give every member a `describe` of a sentence or two that says what it is
|
|
22
|
+
for: the contract, each `query` and `mutation`, each collection, stream,
|
|
23
|
+
channel and event. The MCP bridge uses a method's describe as its tool's
|
|
24
|
+
description; without one an agent sees only `taskService.rename (mutation)`.
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
export const taskContract = defineContract("taskService", {
|
|
28
|
+
describe: "Tasks on a project's board.",
|
|
29
|
+
methods: {
|
|
30
|
+
rename: mutation({ input: renameInput, output: "entity", describe: "Renames a task." }),
|
|
31
|
+
},
|
|
32
|
+
collections: {
|
|
33
|
+
board: {
|
|
34
|
+
describe: "A project's tasks, in board order.",
|
|
35
|
+
scope: "projectId",
|
|
36
|
+
item: "entity",
|
|
37
|
+
order: [["id", "asc"]],
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Lint's `quickdraw/require-describe` (a warning in the base config) lists
|
|
44
|
+
each member without one, and each static describe under 3 words. The 4.x
|
|
45
|
+
codemod writes none, so a migrated app starts with one warning per member.
|
|
46
|
+
Once the list is empty, set the rule to `"error"` in the app's
|
|
47
|
+
`.oxlintrc.json` so new members cannot land without one. Kits' contract
|
|
48
|
+
halves (`...crud.contract(...)`) write their own.
|
|
49
|
+
|
|
50
|
+
## 2. Pick where the pages live
|
|
51
|
+
|
|
52
|
+
| The repo | Do this |
|
|
53
|
+
| ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
|
|
54
|
+
| Few pull requests at a time | Commit the pages (`--out docs/api`) and run `quickdraw-docs ... --check` in pull-request CI |
|
|
55
|
+
| Many pull requests at a time, or agents that each change methods | Install the docs-on-push workflow below, and never commit the pages by hand |
|
|
56
|
+
|
|
57
|
+
A committed generated file changes in every pull request that touches a
|
|
58
|
+
contract, so two such pull requests conflict on it even when their
|
|
59
|
+
contracts do not. The workflow moves the pages out of pull requests: they
|
|
60
|
+
are regenerated after each push to the base branch.
|
|
61
|
+
|
|
62
|
+
## 3. Install the docs-on-push workflow
|
|
63
|
+
|
|
64
|
+
1. Copy `node_modules/@fitzzero/quickdraw-skills/skills/quickdraw-api-docs/docs-api.yml`
|
|
65
|
+
to `.github/workflows/docs-api.yml`. GitHub reads only committed
|
|
66
|
+
workflows, so `quickdraw-skills link` cannot install it.
|
|
67
|
+
2. Set it to the app:
|
|
68
|
+
- `on.push.branches`: the base branch pull requests merge into.
|
|
69
|
+
- `on.push.paths`: where the contracts and services live, and the
|
|
70
|
+
workflow itself.
|
|
71
|
+
- `env`: `CONTRACTS` (the module exporting the contracts), `SERVICES`
|
|
72
|
+
(the module exporting the services) and `OUT` (the pages' folder).
|
|
73
|
+
- The build step: the packages the services module imports, by the
|
|
74
|
+
names in their `package.json` (the template's are `@project/db`, with
|
|
75
|
+
its Prisma client, and `@project/shared`).
|
|
76
|
+
3. Remove any `quickdraw-docs --check` step from pull-request CI, and remove
|
|
77
|
+
`OUT` from `.gitignore` if it is there.
|
|
78
|
+
4. Merge it, then run it once from the Actions tab (`workflow_dispatch`): the
|
|
79
|
+
first run commits the pages.
|
|
80
|
+
|
|
81
|
+
### Rules
|
|
82
|
+
|
|
83
|
+
- **Never commit the pages by hand once the workflow is installed.** They
|
|
84
|
+
belong to the workflow; a hand edit conflicts with its next commit and is
|
|
85
|
+
overwritten by it. Change the contract instead.
|
|
86
|
+
- **The services module imports without secrets.** The `generate` job has
|
|
87
|
+
none: importing the module must not read a required environment
|
|
88
|
+
variable, connect to the database or start the server. Move such work
|
|
89
|
+
into the function that starts the server.
|
|
90
|
+
- **Keep the two jobs.** The job that can write (`commit`, with
|
|
91
|
+
`contents: write`) runs no dependency code: no install, no build, only
|
|
92
|
+
git. The job that runs dependency code (`generate`) has a read-only token. Never
|
|
93
|
+
trigger it with `pull_request_target`, and never give `generate` a secret.
|
|
94
|
+
- **Keep `GITHUB_TOKEN` for the push.** A push made with it starts no
|
|
95
|
+
workflow run, so the docs commit never starts CI or this workflow again.
|
|
96
|
+
On a protected branch that `GITHUB_TOKEN` cannot push to, mint a GitHub
|
|
97
|
+
App token in the `commit` job only (`actions/create-github-app-token`),
|
|
98
|
+
give it to that job's checkout, and add `[skip ci]` to the commit
|
|
99
|
+
message: a push made with an app token does start workflows.
|
|
100
|
+
|
|
101
|
+
## 4. Check it
|
|
102
|
+
|
|
103
|
+
After the merge, the Actions tab shows an "API docs" run for each push that
|
|
104
|
+
touched a contract or a service. A run commits only when a page changed,
|
|
105
|
+
as `github-actions[bot]`. A failing `generate` step prints what
|
|
106
|
+
`quickdraw-docs` refused: usually a module that needs a build or a secret
|
|
107
|
+
at import.
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# API docs on push: regenerates the quickdraw-docs pages from the contracts on
|
|
2
|
+
# every push to the base branch, and commits them when they changed. Copied
|
|
3
|
+
# into an app by the quickdraw-api-docs skill
|
|
4
|
+
# (node_modules/@fitzzero/quickdraw-skills/skills/quickdraw-api-docs/SKILL.md):
|
|
5
|
+
# set the branch, the paths, the env block and the build step to the app's own.
|
|
6
|
+
#
|
|
7
|
+
# Two jobs, so the job that can write to the repository runs no dependency
|
|
8
|
+
# code: `generate` installs, builds and runs quickdraw-docs with a read-only
|
|
9
|
+
# token and no secrets, and hands the pages over as an artifact; `commit`
|
|
10
|
+
# only downloads them and runs git.
|
|
11
|
+
|
|
12
|
+
name: API docs
|
|
13
|
+
|
|
14
|
+
on:
|
|
15
|
+
push:
|
|
16
|
+
branches: [dev]
|
|
17
|
+
paths:
|
|
18
|
+
- "packages/shared/src/**"
|
|
19
|
+
- "apps/api/src/services/**"
|
|
20
|
+
- ".github/workflows/docs-api.yml"
|
|
21
|
+
workflow_dispatch:
|
|
22
|
+
|
|
23
|
+
# The latest push wins: an older run would commit pages a newer one replaces.
|
|
24
|
+
concurrency:
|
|
25
|
+
group: docs-api-${{ github.ref_name }}
|
|
26
|
+
cancel-in-progress: true
|
|
27
|
+
|
|
28
|
+
permissions:
|
|
29
|
+
contents: read
|
|
30
|
+
|
|
31
|
+
env:
|
|
32
|
+
# The module exporting the contracts, and the one exporting the services
|
|
33
|
+
# (importing it must not start the server, and must not need a secret).
|
|
34
|
+
CONTRACTS: packages/shared/src/index.ts
|
|
35
|
+
SERVICES: apps/api/src/services/index.ts
|
|
36
|
+
OUT: docs/api
|
|
37
|
+
|
|
38
|
+
jobs:
|
|
39
|
+
generate:
|
|
40
|
+
runs-on: ubuntu-latest
|
|
41
|
+
timeout-minutes: 15
|
|
42
|
+
permissions:
|
|
43
|
+
contents: read
|
|
44
|
+
steps:
|
|
45
|
+
- uses: actions/checkout@v7
|
|
46
|
+
with:
|
|
47
|
+
persist-credentials: false
|
|
48
|
+
|
|
49
|
+
- uses: actions/setup-node@v7
|
|
50
|
+
with:
|
|
51
|
+
node-version-file: .nvmrc
|
|
52
|
+
|
|
53
|
+
- uses: oven-sh/setup-bun@v2
|
|
54
|
+
with:
|
|
55
|
+
bun-version-file: package.json
|
|
56
|
+
|
|
57
|
+
- run: bun install --frozen-lockfile
|
|
58
|
+
|
|
59
|
+
# What the services module imports loads from its built output: the db
|
|
60
|
+
# package (with its Prisma client) and the shared package.
|
|
61
|
+
- name: Build what the services module imports
|
|
62
|
+
run: bunx turbo run build --filter=@project/db --filter=@project/shared
|
|
63
|
+
|
|
64
|
+
- name: Generate the API docs
|
|
65
|
+
run: bunx quickdraw-docs "$CONTRACTS" --services "$SERVICES" --out "$OUT"
|
|
66
|
+
|
|
67
|
+
- uses: actions/upload-artifact@v4
|
|
68
|
+
with:
|
|
69
|
+
name: docs-api
|
|
70
|
+
path: ${{ env.OUT }}
|
|
71
|
+
if-no-files-found: error
|
|
72
|
+
retention-days: 1
|
|
73
|
+
|
|
74
|
+
commit:
|
|
75
|
+
needs: generate
|
|
76
|
+
runs-on: ubuntu-latest
|
|
77
|
+
timeout-minutes: 5
|
|
78
|
+
permissions:
|
|
79
|
+
contents: write
|
|
80
|
+
steps:
|
|
81
|
+
# GITHUB_TOKEN on purpose: a push made with it starts no workflow run, so
|
|
82
|
+
# the docs commit never starts this workflow or CI again.
|
|
83
|
+
- uses: actions/checkout@v7
|
|
84
|
+
|
|
85
|
+
# The whole folder as generate left it: a page of a removed service goes.
|
|
86
|
+
- name: Replace the pages with the generated ones
|
|
87
|
+
run: rm -rf "$OUT"
|
|
88
|
+
|
|
89
|
+
- uses: actions/download-artifact@v4
|
|
90
|
+
with:
|
|
91
|
+
name: docs-api
|
|
92
|
+
path: ${{ env.OUT }}
|
|
93
|
+
|
|
94
|
+
- name: Commit the pages when they changed
|
|
95
|
+
run: |
|
|
96
|
+
git add --all -- "$OUT"
|
|
97
|
+
if git diff --cached --quiet; then
|
|
98
|
+
echo "The API docs are current."
|
|
99
|
+
exit 0
|
|
100
|
+
fi
|
|
101
|
+
git config user.name "github-actions[bot]"
|
|
102
|
+
git config user.email "41898283+github-actions[bot]@users.noreply.github.com"
|
|
103
|
+
git commit -m "docs(api): regenerate from the contracts"
|
|
104
|
+
git push origin "HEAD:$GITHUB_REF_NAME"
|
|
@@ -53,17 +53,28 @@ guide's sections as the report sends you to them.
|
|
|
53
53
|
It expects the template's layout (`packages/shared`, `apps/api`,
|
|
54
54
|
`apps/web`, `packages/db`); `--shared`, `--api`, `--web` and
|
|
55
55
|
`--db-package` move each part. It formats its output with the app's
|
|
56
|
-
formatter.
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
56
|
+
formatter. On an app of more than 1,500 source files it starts itself
|
|
57
|
+
again with a larger heap (75% of the memory, at most 16384 MiB) and says
|
|
58
|
+
so; pass `--heap <MiB>` when other work shares the memory, smaller after
|
|
59
|
+
a SIGKILL, larger after running out of heap. Commit its output
|
|
60
|
+
untouched, then run `quickdraw-lint baseline -c .oxlintrc.json` and
|
|
61
|
+
commit the baseline, so lint passes and reports only new violations. Add
|
|
62
|
+
each file the report lists under "Carve-outs" to the template's carve-out
|
|
63
|
+
script
|
|
60
64
|
(`scripts/strip-game.mjs`'s delete list).
|
|
61
65
|
|
|
62
66
|
3. **Read `quickdraw-migration-report.md`.** Every item is a
|
|
63
67
|
`// quickdraw-migrate: review [kind] ...` marker above the code it is
|
|
64
|
-
about.
|
|
68
|
+
about. First the "Services" section (`[service]`), when it has one: a
|
|
69
|
+
contract whose class implements none of its method map was read from
|
|
70
|
+
the wrong class, so settle these before anything else. Then work
|
|
71
|
+
through it in this order, one commit per step:
|
|
65
72
|
1. **Contracts** (`[contract]`): real schemas for the entity and each
|
|
66
|
-
`todoSchema`; check each method's kind (chosen from its name).
|
|
73
|
+
`todoSchema`; check each method's kind (chosen from its name). The
|
|
74
|
+
codemod writes no `describe` (4.x had no per-method prose): write one
|
|
75
|
+
for the contract and each method, collection, stream, channel and
|
|
76
|
+
event, which lint's `require-describe` lists. MCP tools and the API
|
|
77
|
+
docs read them.
|
|
67
78
|
2. **Access** (`[access]`, `[access-override]`): decide the
|
|
68
79
|
`"authenticated"` forms and the `rowless: true` flags (keep one only
|
|
69
80
|
for a lookup open to anyone; else an `entry` form); port
|
|
@@ -85,7 +96,8 @@ guide's sections as the report sends you to them.
|
|
|
85
96
|
in handlers (5.0 answers it with a generic `INTERNAL`: throw
|
|
86
97
|
`QuickdrawError(code, message)` where the caller should see the message),
|
|
87
98
|
channels, the server set-up (`qd.createServer`, with `legacyWire: true`
|
|
88
|
-
while 4.x clients remain
|
|
99
|
+
while 4.x clients remain, or `legacyWire: { aliases: { old: "new" } }`
|
|
100
|
+
when you renamed a service they still call).
|
|
89
101
|
A hand-built sign-in (unmarked: the codemod leaves it) moves onto the
|
|
90
102
|
auth routes kit last, with its `Session` table migration (MIGRATION.md,
|
|
91
103
|
"Hand-built auth to the auth routes kit").
|
|
@@ -95,7 +107,8 @@ guide's sections as the report sends you to them.
|
|
|
95
107
|
left. Run `quickdraw-lint baseline` again when `no-unused-baseline` warns.
|
|
96
108
|
5. **Check**: `quickdraw-lint check` (`no-v4-api` lists every 4.x API left,
|
|
97
109
|
with its replacement; `no-todo-schema` every placeholder; `prefer-kit`
|
|
98
|
-
every hand-written method a kit implements
|
|
110
|
+
every hand-written method a kit implements; `require-describe` every
|
|
111
|
+
member without a `describe`), the typecheck, the tests,
|
|
99
112
|
and the running app (a change from a second session arrives live).
|
|
100
113
|
|
|
101
114
|
Done when the report says nothing is left to review, lint is clean without
|
|
@@ -20,6 +20,9 @@ and the Prisma model the rows live in. Decide:
|
|
|
20
20
|
no `entity`, `model` or row policy).
|
|
21
21
|
- **Who may see a row**: an owner column, a JSON access list, a membership
|
|
22
22
|
table, or the parent row's access (`inherit`). This is the row policy.
|
|
23
|
+
- **Which kinds of principal may call**: when the app's principals come in
|
|
24
|
+
kinds (a user, an agent's token, a runner), which of them the service and
|
|
25
|
+
each method admit. These are `kinds` lists, beside `access`.
|
|
23
26
|
- **What a client lists**: which scopes (a project's tasks, a user's chats).
|
|
24
27
|
Each is a collection, not a list method.
|
|
25
28
|
|
|
@@ -62,6 +65,7 @@ import { z } from "zod";
|
|
|
62
65
|
const label = z.object({ id: z.string(), projectId: z.string(), name: z.string() });
|
|
63
66
|
|
|
64
67
|
export const labelContract = defineContract("labelService", {
|
|
68
|
+
describe: "Labels a project puts on its tasks.",
|
|
65
69
|
entity: label,
|
|
66
70
|
methods: {
|
|
67
71
|
...crud.contract({ entity: label, get: true, create: { input: label.omit({ id: true }) } }),
|
|
@@ -73,6 +77,7 @@ export const labelContract = defineContract("labelService", {
|
|
|
73
77
|
},
|
|
74
78
|
collections: {
|
|
75
79
|
byProject: {
|
|
80
|
+
describe: "A project's labels, by name.",
|
|
76
81
|
scope: "projectId",
|
|
77
82
|
item: "entity",
|
|
78
83
|
order: [
|
|
@@ -86,8 +91,10 @@ export const labelContract = defineContract("labelService", {
|
|
|
86
91
|
|
|
87
92
|
- Service names end in `Service` and never change once deployed: they are
|
|
88
93
|
stored in users' grants and sent on the wire.
|
|
89
|
-
- Use Zod 4.2 or later for every schema
|
|
90
|
-
|
|
94
|
+
- Use Zod 4.2 or later for every schema. Give the contract and each of its
|
|
95
|
+
methods, collections, streams, channels and events a `describe` sentence:
|
|
96
|
+
MCP clients and the generated API docs read it, and lint's
|
|
97
|
+
`require-describe` warns on each one missing.
|
|
91
98
|
- Export the contract from `packages/shared/src/contracts/index.ts`, next to
|
|
92
99
|
the others, and add it to the `contracts` map there: that map types the
|
|
93
100
|
web client and `qd.caller`.
|
|
@@ -136,6 +143,11 @@ export const labelService = qd.defineService(labelContract, {
|
|
|
136
143
|
`defineService` refuses `"public"`, `"authenticated"` or `{ service: L }`
|
|
137
144
|
below `Admin` there unless the method says `rowless: true` (every caller
|
|
138
145
|
the form admits may reach any row, on purpose).
|
|
146
|
+
- A method or service that only some kinds of principal may call says so
|
|
147
|
+
with `kinds` beside `access` (`kinds: ["user"]`), within the app's
|
|
148
|
+
`initQuickdraw({ kinds })`: never with a check of `ctx.principal.kind` in
|
|
149
|
+
the handler or in a `custom` form. Other kinds get `FORBIDDEN`, whatever
|
|
150
|
+
their grants (see quickdraw-access.md).
|
|
139
151
|
- Reach for a kit before hand-writing CRUD, search, sharing or admin; lint's
|
|
140
152
|
`prefer-kit` warns on a hand-written `get`, `list`, `create` or
|
|
141
153
|
`getLabel`-style method in a service that uses no kit.
|
|
@@ -196,7 +208,11 @@ Components rendered against the server go in
|
|
|
196
208
|
`apps/web/src/__tests__/<name>.int.test.tsx`.
|
|
197
209
|
|
|
198
210
|
- `describeAccessMatrix` over every method, as an owner, a member, a
|
|
199
|
-
stranger and anonymously
|
|
211
|
+
stranger and anonymously, plus a principal of each kind the service or a
|
|
212
|
+
method refuses; or `snapshotAccessMatrix`, which records every method,
|
|
213
|
+
entity subscribe and collection scope of the service in
|
|
214
|
+
`__access__/<test file>.json` (commit it, and keep its report's
|
|
215
|
+
`inconclusive` empty).
|
|
200
216
|
- One live test: a write by one user reaches another user's collection or
|
|
201
217
|
entity (`app.frames.waitFor`, or a component through
|
|
202
218
|
`renderWithQuickdraw`).
|
|
@@ -211,5 +227,6 @@ Components rendered against the server go in
|
|
|
211
227
|
Run the app's lint (the quickdraw rules catch untracked writes, foreign
|
|
212
228
|
writes, unbounded reads and raw socket use), typecheck and the unit and
|
|
213
229
|
integration tests. If the app generates API docs with `quickdraw-docs`
|
|
214
|
-
(with `--services`, its pages also say who may call each method
|
|
230
|
+
(with `--services`, its pages also say who may call each method and which
|
|
231
|
+
services each depends on),
|
|
215
232
|
regenerate them and commit the result.
|