@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fitzzero/quickdraw-skills",
3
- "version": "5.0.0",
3
+ "version": "5.1.0",
4
4
  "description": "Agent rules and skills for quickdraw 5.0 apps, linked into .claude/ by the quickdraw-skills command",
5
5
  "keywords": [
6
6
  "agents",
@@ -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), a bare string that is the id itself
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? })` | your code, one batched read for all ids |
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: { board: { scope: "projectId", item: "card", order: byOrdinal } }, // ends in "id"
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`). `db` is `trackPrisma(new PrismaClient({ adapter }))` from
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 `timeoutMs`, `rowless` (see
72
- quickdraw-access.md), and for queries `share` (`"caller"` or `"all"`),
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) is thrown by
126
- `createTestApp` itself: answer `"entity"` from that method rather than
127
- turning strictness off.
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. Commit its output untouched, then run
57
- `quickdraw-lint baseline -c .oxlintrc.json` and commit the baseline, so
58
- lint passes and reports only new violations. Add each file the report
59
- lists under "Carve-outs" to the template's carve-out script
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. Work through it in this order, one commit per step:
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), the typecheck, the tests,
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; give each method a `describe`
90
- sentence when agents or MCP clients will call it.
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.