create-davepi-app 0.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.
Files changed (33) hide show
  1. package/README.md +58 -0
  2. package/bin/index.js +458 -0
  3. package/bin/sync-templates.js +42 -0
  4. package/package.json +36 -0
  5. package/templates/_shared/.github/workflows/client-gen.yml +65 -0
  6. package/templates/_shared/.github/workflows/deploy.yml +70 -0
  7. package/templates/_shared/.github/workflows/migrate.yml +66 -0
  8. package/templates/_shared/.github/workflows/test.yml +49 -0
  9. package/templates/_shared/agent.md +480 -0
  10. package/templates/_shared/tests/smoke.test.js +67 -0
  11. package/templates/b2b-saas/README.md +57 -0
  12. package/templates/b2b-saas/schema/versions/v1/billingEvent.js +46 -0
  13. package/templates/b2b-saas/schema/versions/v1/invite.js +30 -0
  14. package/templates/b2b-saas/schema/versions/v1/org.js +23 -0
  15. package/templates/b2b-saas/schema/versions/v1/workspace.js +13 -0
  16. package/templates/b2b-saas/seed.js +98 -0
  17. package/templates/blank/README.md +42 -0
  18. package/templates/blank/schema/versions/v1/note.js +10 -0
  19. package/templates/blank/seed.js +86 -0
  20. package/templates/content/README.md +58 -0
  21. package/templates/content/schema/versions/v1/article.js +77 -0
  22. package/templates/content/schema/versions/v1/category.js +30 -0
  23. package/templates/content/seed.js +97 -0
  24. package/templates/crm/README.md +59 -0
  25. package/templates/crm/schema/versions/v1/account.js +22 -0
  26. package/templates/crm/schema/versions/v1/activity.js +20 -0
  27. package/templates/crm/schema/versions/v1/contact.js +28 -0
  28. package/templates/crm/schema/versions/v1/deal.js +72 -0
  29. package/templates/crm/seed.js +124 -0
  30. package/templates/ticketing/README.md +46 -0
  31. package/templates/ticketing/schema/versions/v1/comment.js +26 -0
  32. package/templates/ticketing/schema/versions/v1/ticket.js +65 -0
  33. package/templates/ticketing/seed.js +97 -0
@@ -0,0 +1,70 @@
1
+ name: Deploy
2
+
3
+ # Deploys the app on push to main. This template targets Fly.io
4
+ # because it's the simplest "node app with Mongo Atlas" deploy and
5
+ # the `superfly/flyctl-actions` action handles the auth, build, and
6
+ # release in two steps.
7
+ #
8
+ # Pick whichever target matches your hosting and replace this whole
9
+ # file. Starter snippets for the common alternatives are in the
10
+ # `# Alternates:` comment block below.
11
+ #
12
+ # Required setup (Fly.io):
13
+ # 1. fly launch — once locally, commits a
14
+ # fly.toml at the repo root.
15
+ # 2. fly secrets set MONGO_URI=... TOKEN_KEY=...
16
+ # 3. Generate an API token: fly auth token
17
+ # 4. Add it as the FLY_API_TOKEN repo secret.
18
+ #
19
+ # Alternates (delete this comment and replace the job below):
20
+ #
21
+ # Render
22
+ # uses: johnbeynon/render-deploy-action@v0.0.8
23
+ # with:
24
+ # service-id: ${{ secrets.RENDER_SERVICE_ID }}
25
+ # api-key: ${{ secrets.RENDER_API_KEY }}
26
+ #
27
+ # Railway
28
+ # run: npx @railway/cli@latest up
29
+ # env:
30
+ # RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}
31
+ #
32
+ # Docker Hub (build + push, deploy separately)
33
+ # uses: docker/build-push-action@v6
34
+ # with:
35
+ # push: true
36
+ # tags: yourdockeruser/yourapp:${{ github.sha }}
37
+ #
38
+ # See https://docs.davepi.dev/operations/deployment/ for the full
39
+ # per-target guides.
40
+
41
+ on:
42
+ push:
43
+ branches: [main]
44
+ workflow_dispatch:
45
+
46
+ permissions:
47
+ contents: read
48
+
49
+ jobs:
50
+ deploy:
51
+ runs-on: ubuntu-latest
52
+ # Gate behind a GitHub Environment so a required-reviewers
53
+ # rule can hold deploys at a manual approval step. Without
54
+ # this, every push to main goes straight to production.
55
+ environment: production
56
+ concurrency:
57
+ group: deploy-production
58
+ cancel-in-progress: false
59
+ steps:
60
+ - uses: actions/checkout@v4
61
+ # Pinned to the action's stable major-version tag rather than
62
+ # `@master` so a breaking upstream change can't silently land
63
+ # in your deploy pipeline. For strict supply-chain setups,
64
+ # swap this for a full commit SHA (looks like `@<40-char-sha>`)
65
+ # — that's fully immutable and protects against tag re-points
66
+ # by a compromised upstream.
67
+ - uses: superfly/flyctl-actions/setup-flyctl@1.5
68
+ - run: flyctl deploy --remote-only
69
+ env:
70
+ FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
@@ -0,0 +1,66 @@
1
+ name: Migrate
2
+
3
+ # Applies pending data migrations against a production-grade
4
+ # database. Two-stage:
5
+ #
6
+ # 1. dry-run: always runs, prints what WOULD be applied.
7
+ # 2. apply: gated on the `migrate-prod` GitHub Environment
8
+ # (configure required reviewers there so a human
9
+ # approves before the real run).
10
+ #
11
+ # Triggered by:
12
+ # - workflow_dispatch (manual, from the Actions tab)
13
+ # - tags matching `v*` (so a release tag drives migrations)
14
+ #
15
+ # Required secrets:
16
+ # MONGO_URI Connection string for the target database.
17
+ # TOKEN_KEY The same secret the app uses; some migrations
18
+ # may need it to mint admin tokens.
19
+
20
+ on:
21
+ workflow_dispatch:
22
+ push:
23
+ tags:
24
+ - 'v*'
25
+
26
+ permissions:
27
+ contents: read
28
+
29
+ jobs:
30
+ dry-run:
31
+ runs-on: ubuntu-latest
32
+ env:
33
+ MONGO_URI: ${{ secrets.MONGO_URI }}
34
+ TOKEN_KEY: ${{ secrets.TOKEN_KEY }}
35
+ NODE_ENV: production
36
+ steps:
37
+ - uses: actions/checkout@v4
38
+ - uses: actions/setup-node@v4
39
+ with:
40
+ node-version: '20.x'
41
+ cache: npm
42
+ - run: npm ci
43
+ - name: Preview pending migrations (no writes)
44
+ run: npx davepi migrate --dry
45
+
46
+ apply:
47
+ needs: dry-run
48
+ runs-on: ubuntu-latest
49
+ # The `migrate-prod` environment must exist on GitHub with
50
+ # required-reviewers configured. Without that, this step
51
+ # would auto-apply on tag push — which is exactly the
52
+ # foot-gun this gate prevents.
53
+ environment: migrate-prod
54
+ env:
55
+ MONGO_URI: ${{ secrets.MONGO_URI }}
56
+ TOKEN_KEY: ${{ secrets.TOKEN_KEY }}
57
+ NODE_ENV: production
58
+ steps:
59
+ - uses: actions/checkout@v4
60
+ - uses: actions/setup-node@v4
61
+ with:
62
+ node-version: '20.x'
63
+ cache: npm
64
+ - run: npm ci
65
+ - name: Apply pending migrations
66
+ run: npx davepi migrate
@@ -0,0 +1,49 @@
1
+ name: Test
2
+
3
+ # Runs `npm test` on every push to main and on every pull request.
4
+ # Matrix-tests against the two Node versions dAvePi officially
5
+ # supports: 20.x (current LTS) and 22.x (active LTS).
6
+ #
7
+ # A MongoDB service container is provided because most non-trivial
8
+ # tests boot the framework, which connects to Mongo on startup. If
9
+ # your project's tests don't need Mongo, you can drop the service.
10
+
11
+ on:
12
+ push:
13
+ branches: [main]
14
+ pull_request:
15
+ branches: [main]
16
+
17
+ permissions:
18
+ contents: read
19
+
20
+ jobs:
21
+ test:
22
+ runs-on: ubuntu-latest
23
+ strategy:
24
+ fail-fast: false
25
+ matrix:
26
+ node-version: ['20.x', '22.x']
27
+ services:
28
+ mongo:
29
+ image: mongo:7
30
+ ports:
31
+ - 27017:27017
32
+ options: >-
33
+ --health-cmd "mongosh --quiet --eval 'db.adminCommand({ ping: 1 }).ok'"
34
+ --health-interval 5s
35
+ --health-timeout 5s
36
+ --health-retries 10
37
+ env:
38
+ MONGO_URI: mongodb://127.0.0.1:27017/test
39
+ TOKEN_KEY: ci-only-not-a-real-secret
40
+ NODE_ENV: test
41
+ PAGE_SIZE: 20
42
+ steps:
43
+ - uses: actions/checkout@v4
44
+ - uses: actions/setup-node@v4
45
+ with:
46
+ node-version: ${{ matrix.node-version }}
47
+ cache: npm
48
+ - run: npm ci
49
+ - run: npm test
@@ -0,0 +1,480 @@
1
+ # Agent guide for this dAvePi project
2
+
3
+ You are working inside a project built on **dAvePi**, a schema-driven backend
4
+ that auto-generates REST + GraphQL + MCP from a single schema file per
5
+ resource. Drop a file in `schema/versions/v1/<resource>.js` and the
6
+ framework mounts every surface for it without further wiring. Hot reload
7
+ is enabled in dev — saving a schema file rebuilds the surface in
8
+ 50–150ms; no restart.
9
+
10
+ This guide is the canonical agent contract for working in this project.
11
+ Read it before adding code. The full framework reference lives at
12
+ <https://docs.davepi.dev>.
13
+
14
+ ## How to think about this project
15
+
16
+ | Question | Answer |
17
+ |----------|--------|
18
+ | Where does business data live? | `schema/versions/v1/*.js`. One file per resource. |
19
+ | Where do custom routes go? | `index.js` after `require('davepi')`, using `app.locals.schemaLoader` for cross-cutting helpers. Avoid this when an auto-generated route works. |
20
+ | How do I add a field? | Edit the schema file. Hot reload picks it up. No migration needed unless you're backfilling. |
21
+ | How do I expose data to an agent? | Already done — every schema becomes an MCP tool set automatically. Run `npx davepi mcp` (or use this project's `.mcp.json`). |
22
+ | Where's the source of truth for "what this project exposes"? | `GET /_describe` on the running server. Read it before planning anything non-trivial. |
23
+
24
+ ## To add a resource
25
+
26
+ Create `schema/versions/v1/<resource>.js`:
27
+
28
+ ```js
29
+ module.exports = {
30
+ path: 'task', // URL segment + GraphQL prefix + MCP tool prefix
31
+ collection: 'task', // Mongo collection name (conventionally matches path)
32
+ fields: [
33
+ { name: 'userId', type: String, required: true }, // tenant column — MUST be on every schema
34
+ { name: 'title', type: String, required: true, searchable: true },
35
+ { name: 'done', type: Boolean, default: false },
36
+ ],
37
+ };
38
+ ```
39
+
40
+ Save the file. The framework now serves:
41
+
42
+ - REST: `GET / POST / PUT / DELETE /api/v1/task` plus `/:id`, `/:id/restore`, `/:id/history`
43
+ - GraphQL: `taskMany`, `taskById`, `taskCreateOne`, `taskUpdateById`, `taskRemoveById`, etc.
44
+ - MCP: `list_task`, `get_task`, `create_task`, `update_task`, `delete_task`, plus restore / history / search where applicable
45
+ - Swagger: every route documented at `/api-docs`
46
+ - Capability manifest: described at `/_describe`
47
+
48
+ ## Schema field reference
49
+
50
+ ```js
51
+ {
52
+ name: 'amount', // string, required, camelCase
53
+ type: Number, // String, Number, Boolean, Date, [String], 'File'
54
+ required: true, // Mongoose validation
55
+ default: 0, // value or function
56
+ enum: ['low', 'med', 'high'], // restricts to a set; surfaces as literal union in TS client
57
+ min: 0, // numeric / date bound
58
+ max: 100,
59
+ minLength: 1, // string bound
60
+ maxLength: 200,
61
+ match: /^[A-Z]/, // string regex
62
+ trim: true, // string normalizers
63
+ lowercase: true,
64
+ searchable: true, // joins the schema's full-text index; enables ?q= and search_<path>
65
+ index: true, // single-field index. For per-tenant uniqueness, use compositeIndex (see below)
66
+ acl: { read: ['admin', 'hr'] }, // field-level ACL — see "ACL" below
67
+ description: 'Deal value in cents.', // surfaces in Swagger / _describe / TS client doc comment
68
+ }
69
+ ```
70
+
71
+ ### Computed fields
72
+
73
+ ```js
74
+ {
75
+ name: 'fullName',
76
+ type: String,
77
+ computed: (record, ctx) => `${record.firstName} ${record.lastName}`,
78
+ }
79
+ ```
80
+
81
+ Read-only on every surface. Runs at response time. Don't query / sort by
82
+ computed fields — they don't exist in Mongo. If you need filtering, mirror
83
+ the value as a stored field updated on writes, or use an aggregation.
84
+
85
+ ### State machines
86
+
87
+ ```js
88
+ {
89
+ name: 'status',
90
+ type: String,
91
+ stateMachine: {
92
+ initial: 'open', // stamped on POST; clients can't pick a non-initial state
93
+ states: ['open', 'in_progress', 'closed'],
94
+ transitions: {
95
+ open: ['in_progress', 'closed'],
96
+ in_progress: ['open', 'closed'],
97
+ closed: ['open'],
98
+ },
99
+ onEnter: { // optional side effects per state arrival
100
+ closed: async (record, ctx) => { /* ... */ },
101
+ },
102
+ },
103
+ }
104
+ ```
105
+
106
+ Invalid transitions reject with `400 INVALID_TRANSITION` carrying
107
+ `current / attempted / allowed` — agents read `details.allowed` and
108
+ self-correct. Every read includes `availableTransitions[<field>]` so
109
+ clients render the right buttons without re-parsing the schema.
110
+
111
+ How to transition a record:
112
+
113
+ | Surface | Call |
114
+ |---------|------|
115
+ | REST | `PUT /api/v1/<path>/:id` with `{ <field>: '<to>' }`. (No dedicated transition route.) |
116
+ | GraphQL | `<path>Transition<Field>(_id, to: <to>)` — `to` is typed as the schema's generated enum. The standard `<path>UpdateById` also validates. |
117
+ | MCP | `update_<path>` with `{ id, record: { <field>: '<to>' } }`. (No dedicated transition tool.) |
118
+ | Typed client | `api.<path>.transition<Field>(id, to)` (convenience wrapper around the REST PUT). |
119
+
120
+ ### File fields
121
+
122
+ ```js
123
+ {
124
+ name: 'logo',
125
+ type: 'File',
126
+ file: {
127
+ maxBytes: 5 * 1024 * 1024,
128
+ accept: ['image/png', 'image/jpeg'],
129
+ storage: 's3', // or 'local'
130
+ visibility: 'private', // or 'public'
131
+ },
132
+ }
133
+ ```
134
+
135
+ Uploads go through dedicated multipart routes — never JSON. A `FileMeta`
136
+ sub-doc is what's stored in Mongo; the blob lives in your storage backend.
137
+
138
+ ## Top-level schema options
139
+
140
+ ```js
141
+ module.exports = {
142
+ path: 'order',
143
+ collection: 'order',
144
+ fields: [/* ... */],
145
+
146
+ relations: { // see "Relations" below
147
+ account: { belongsTo: 'account', fk: 'parentAccountId' },
148
+ items: { hasMany: 'orderItem', fk: 'orderId' },
149
+ },
150
+
151
+ aggregations: [/* see "Aggregations" below */],
152
+
153
+ compositeIndex: [ // unique indexes — ALWAYS lead with userId
154
+ { userId: 1, slug: 1 },
155
+ ],
156
+
157
+ softDelete: true, // default true. false = hard-delete on DELETE
158
+ audit: true, // default true. false = skip audit log
159
+
160
+ acl: { // optional — opt operators in to cross-tenant reads / deletes
161
+ list: ['admin', 'support'],
162
+ delete: ['admin'],
163
+ },
164
+
165
+ webhooks: { // optional — outbound notifications
166
+ events: ['created', 'updated', 'deleted', 'transitioned'],
167
+ endpoints: [{ url: '...', secret: '...' }],
168
+ },
169
+
170
+ softDelete: { retentionDays: 30 }, // optional — auto-purge tombstoned rows. Audit log + webhook_delivery don't auto-purge; prune manually.
171
+ };
172
+ ```
173
+
174
+ ## When to use which feature
175
+
176
+ | You want to... | Use |
177
+ |----------------|-----|
178
+ | Store a new piece of data | A field on the schema. |
179
+ | Show data derived from other fields | `computed`. Don't denormalise unless you need to filter / sort on it. |
180
+ | Show a list of related child records | `hasMany` relation, accessed via `?__include=<rel>`. Re-fetches with tenant scope re-applied. |
181
+ | Show a single related parent record | `belongsTo` relation. Same access pattern. |
182
+ | Group / count / sum across a tenant's records | Declarative `aggregations[]` entry. The framework prepends `$match: { userId }` automatically. |
183
+ | Track a finite-state field (status, stage, phase, etc.) | `stateMachine` config. Don't hand-roll an `enum` + checks. |
184
+ | Store an upload | `type: 'File'`. Don't base64 into a String field. |
185
+ | Hide a field from non-privileged users | `field.acl.read = ['role']`. Stripped from REST / GraphQL / MCP / audit / webhook payloads. |
186
+ | Allow operators to see across tenants | `schema.acl.list = ['role']`. Owner-only is the baseline. |
187
+ | Notify an external system on writes | `webhooks` block. HMAC-SHA256 signed, retries with exponential backoff. |
188
+
189
+ ## Conventions you must follow
190
+
191
+ - **`userId` is required on every schema.** The framework stamps it from
192
+ the JWT on every write and filters every read. Never set it manually.
193
+ - **`accountId` is auto-stamped too.** If your schema needs a foreign key
194
+ to a parent account, name it `parentAccountId` (or `orgId` etc.) —
195
+ anything other than `accountId`.
196
+ - **Don't write custom CRUD routes.** The auto-generated REST / GraphQL /
197
+ MCP surfaces cover create / list / get / update / delete / restore /
198
+ history / search / aggregations / file uploads / state-machine
199
+ transitions. Custom routes are for things the schema vocabulary can't
200
+ express.
201
+ - **Include `userId` first in every `compositeIndex`.** A `unique: true`
202
+ index on `slug` alone creates a global uniqueness constraint that
203
+ crosses tenants. Use `{ userId: 1, slug: 1 }` instead.
204
+ - **Computed is computed.** A field with `computed: () => ...` is never
205
+ writable. Don't add it to POST / PUT bodies — the server strips it.
206
+ - **State machines need `initial`.** Without it, the framework can't pick
207
+ a default starting state on POST and creates fail with a validation
208
+ error.
209
+
210
+ ## The MCP tool surface
211
+
212
+ The MCP server exposes one tool set per schema. For schema `path: 'task'`:
213
+
214
+ | Tool | When | Description |
215
+ |------|------|-------------|
216
+ | `list_task` | always | Paginated list. `filter` / `sort` / `q` / `include` / `includeDeleted`. |
217
+ | `get_task` | always | One record by `_id`. |
218
+ | `create_task` | always | Create. Accepts optional `idempotencyKey`. |
219
+ | `update_task` | always | Partial update by `_id`. |
220
+ | `delete_task` | always | Soft-delete (or hard if `softDelete: false`). |
221
+ | `restore_task` | softDelete | Clear `deletedAt`. |
222
+ | `history_task` | audit | Audit log for one record. |
223
+ | `search_task` | any field has `searchable: true` | Full-text search. |
224
+ | `list_task_<rel>` | per `hasMany` | Children of a parent `_id`. |
225
+ | `get_task_<rel>` | per `hasOne` / `belongsTo` | Populated relation. |
226
+ | `aggregate_task_<name>` | per declared aggregation | Run the named pipeline. |
227
+ | _(state-machine transitions)_ | per state-machine field | Use `update_<path>` with `{ id, record: { <field>: <to> } }`. The framework validates against `transitions[current]` and rejects undeclared moves with `INVALID_TRANSITION`. |
228
+ | `upload_task_<file>` / `fetch_task_<file>` / `delete_task_<file>` | per file field | Blob lifecycle. |
229
+
230
+ ## Capability discovery: read `_describe` first
231
+
232
+ ```http
233
+ GET /_describe
234
+ ```
235
+
236
+ Returns a JSON manifest: every schema, every field, every relation,
237
+ every aggregation, every state machine, every available endpoint and
238
+ MCP tool. **Read this before planning a non-trivial change.** It's the
239
+ fastest way to know what the project already has and what's safe to
240
+ build on.
241
+
242
+ ## Idempotency: retry safely
243
+
244
+ Every auto-generated `POST` route accepts an `Idempotency-Key` header.
245
+ Same key + same body = original response replayed (with
246
+ `Idempotency-Replay: true`). Same key + different body = `409
247
+ IDEMPOTENCY_CONFLICT`.
248
+
249
+ ```http
250
+ POST /api/v1/task
251
+ Idempotency-Key: 9f3c-...
252
+ Content-Type: application/json
253
+
254
+ { "title": "..." }
255
+ ```
256
+
257
+ Every `create_<path>` MCP tool accepts an optional `idempotencyKey`
258
+ argument that does the same thing.
259
+
260
+ **Use a UUID per logical operation, NOT per retry.** Same operation
261
+ retried = same key. New operation = new key.
262
+
263
+ ## Aggregations
264
+
265
+ ```js
266
+ aggregations: [
267
+ {
268
+ name: 'pipelineByStage',
269
+ description: 'Total amount and count grouped by deal stage.',
270
+ pipeline: [
271
+ { $group: { _id: '$stage', total: { $sum: '$amount' }, count: { $sum: 1 } } },
272
+ { $sort: { total: -1 } },
273
+ ],
274
+ cache: { ttlSeconds: 30 }, // optional in-process cache, per-tenant
275
+ params: [ // optional typed inputs
276
+ { name: 'since', type: 'date', match: { createdAt: { $gte: '$since' } } },
277
+ ],
278
+ },
279
+ ],
280
+ ```
281
+
282
+ The framework prepends `$match: { userId }` as the first stage of every
283
+ aggregation. Even `unsafe: true` aggregations (which require an
284
+ `acl.list` role) still operate within the tenant scope.
285
+
286
+ ## Errors agents should know about
287
+
288
+ | Code | HTTP | Recoverable | Meaning |
289
+ |------|------|-------------|---------|
290
+ | `VALIDATION` | 400 | yes | Mongoose / framework validation failed. `details.fields` carries the per-field reasons. |
291
+ | `INVALID_ID` | 400 | yes | A path param looks like an ObjectId but isn't valid. |
292
+ | `INVALID_TRANSITION` | 400 | yes | State-machine value not declared in `transitions[current]`. Read `details.allowed`. |
293
+ | `UNAUTHORIZED` | 401 | usually | Bearer token missing / invalid / expired. |
294
+ | `FORBIDDEN` | 403 | no | Token valid, role insufficient. |
295
+ | `NOT_FOUND` | 404 | no | Resource doesn't exist for this tenant. (Cross-tenant reads also return 404 — we don't disclose existence.) |
296
+ | `DUPLICATE` | 409 | sometimes | Mongo unique-index violation. |
297
+ | `IDEMPOTENCY_CONFLICT` | 409 | no | Same key reused with a different body. Pick a new key. |
298
+ | `IDEMPOTENCY_IN_PROGRESS` | 409 | yes | Concurrent retry hit the same key. Wait briefly and retry. |
299
+ | `RATE_LIMITED` | 429 | yes | Retry after `Retry-After` header. |
300
+
301
+ Agents should branch on `code`, not the human-readable `message`.
302
+
303
+ ## Common mistakes to avoid
304
+
305
+ - **Manually wiring `userId`.** `req.user.user_id` is stamped automatically.
306
+ Setting it on the wire either gets stripped (POST) or returns 404
307
+ (GET / PUT / DELETE for another user's record).
308
+ - **Using `accountId` as a custom foreign key.** It's auto-stamped from the
309
+ JWT and your client value is overwritten. Use `parentAccountId`,
310
+ `organizationId`, etc.
311
+ - **Treating computed fields as writable.** They're stripped from input
312
+ shapes everywhere. If you need to write a value, store it in a regular
313
+ field.
314
+ - **State machine without `initial`.** POST will fail because the framework
315
+ can't decide a starting state.
316
+ - **Hand-rolling pagination on a list endpoint.** The auto-generated route
317
+ already supports `__page`, `__sort`, `__perPage`, `__include`, `q`,
318
+ `__includeDeleted`, plus mongo-querystring filters.
319
+ - **A `unique: true` index without `userId` in the key.** Creates a
320
+ global constraint that crosses tenants. Use a `compositeIndex: [{ userId: 1, ... }]`.
321
+ - **Custom routes that re-implement CRUD.** If you find yourself writing
322
+ `Foo.findOne({ _id, userId })`, the auto-generated route already does
323
+ this — call it instead.
324
+ - **Skipping `_describe`.** The fastest way to plan a change is to read
325
+ what already exists.
326
+
327
+ ## Worked example: a CRM resource set
328
+
329
+ The `crm` template ships this; reproduce it from scratch in any project:
330
+
331
+ ### `schema/versions/v1/account.js`
332
+
333
+ ```js
334
+ module.exports = {
335
+ path: 'account',
336
+ collection: 'account',
337
+ fields: [
338
+ { name: 'userId', type: String, required: true },
339
+ { name: 'name', type: String, required: true, searchable: true },
340
+ { name: 'industry', type: String },
341
+ { name: 'description', type: String, searchable: true },
342
+ ],
343
+ relations: {
344
+ contacts: { hasMany: 'contact', fk: 'parentAccountId' },
345
+ deals: { hasMany: 'deal', fk: 'parentAccountId' },
346
+ },
347
+ };
348
+ ```
349
+
350
+ ### `schema/versions/v1/contact.js`
351
+
352
+ ```js
353
+ module.exports = {
354
+ path: 'contact',
355
+ collection: 'contact',
356
+ fields: [
357
+ { name: 'userId', type: String, required: true },
358
+ { name: 'parentAccountId', type: String, required: true },
359
+ { name: 'name', type: String, required: true, searchable: true },
360
+ { name: 'email', type: String },
361
+ { name: 'phone', type: String },
362
+ ],
363
+ relations: {
364
+ account: { belongsTo: 'account', fk: 'parentAccountId' },
365
+ },
366
+ };
367
+ ```
368
+
369
+ ### `schema/versions/v1/deal.js`
370
+
371
+ ```js
372
+ module.exports = {
373
+ path: 'deal',
374
+ collection: 'deal',
375
+ fields: [
376
+ { name: 'userId', type: String, required: true },
377
+ { name: 'parentAccountId', type: String, required: true },
378
+ { name: 'title', type: String, required: true, searchable: true },
379
+ { name: 'amount', type: Number, required: true },
380
+ { name: 'closedAt', type: Date },
381
+ {
382
+ name: 'stage',
383
+ type: String,
384
+ stateMachine: {
385
+ initial: 'lead',
386
+ states: ['lead', 'qualified', 'proposal', 'negotiation', 'won', 'lost'],
387
+ transitions: {
388
+ lead: ['qualified', 'lost'],
389
+ qualified: ['proposal', 'lost'],
390
+ proposal: ['negotiation', 'won', 'lost'],
391
+ negotiation: ['won', 'lost'],
392
+ won: [],
393
+ lost: ['lead'],
394
+ },
395
+ },
396
+ },
397
+ ],
398
+ relations: {
399
+ account: { belongsTo: 'account', fk: 'parentAccountId' },
400
+ },
401
+ aggregations: [
402
+ {
403
+ name: 'pipelineByStage',
404
+ description: 'Total amount and count grouped by deal stage.',
405
+ pipeline: [
406
+ { $group: { _id: '$stage', total: { $sum: '$amount' }, count: { $sum: 1 } } },
407
+ { $sort: { total: -1 } },
408
+ ],
409
+ cache: { ttlSeconds: 30 },
410
+ },
411
+ ],
412
+ };
413
+ ```
414
+
415
+ That's the whole CRM. Save the files; the framework mounts:
416
+
417
+ - REST routes for each resource plus `/api/v1/deal/aggregations/pipelineByStage`. Stage transitions go through the standard `PUT /api/v1/deal/:id` with `{ stage: 'qualified' }` — the framework validates against `transitions[current]`.
418
+ - GraphQL types and resolvers (`accountMany`, `dealUpdateById`, `dealTransitionStage`, etc.).
419
+ - MCP tools (`create_account`, `update_deal`, `list_account_contacts`, `aggregate_deal_pipelineByStage`, ...). Transitions ride on `update_deal` with `{ id, record: { stage: '<to>' } }`.
420
+ - Swagger docs.
421
+ - A `_describe` manifest entry for each.
422
+
423
+ ## Prompt templates
424
+
425
+ Copy and adapt these — they encode the conventions above so the model
426
+ makes the same decisions you would.
427
+
428
+ ### Add a resource
429
+
430
+ > Add a `<resource>` resource with these fields: <list of name + type +
431
+ > required>. Tenant column is `userId` (required, auto-stamped). If a
432
+ > foreign key to an existing schema is needed, name it
433
+ > `parent<Schema>Id` (don't use `accountId` for custom FKs). Don't write
434
+ > a custom route — the auto-generated CRUD covers it.
435
+
436
+ ### Add a state machine
437
+
438
+ > Add a `<field>` state-machine field on `<resource>` with states
439
+ > `[<states>]`, initial `<state>`, and transitions <list>. Don't write
440
+ > validation logic — the framework rejects undeclared transitions with
441
+ > `INVALID_TRANSITION` automatically.
442
+
443
+ ### Add a relation
444
+
445
+ > On `<parent>`, add a `<name>` `hasMany`/`hasOne`/`belongsTo` relation
446
+ > to `<target>` with foreign key `<fk>`. Use `__include=<name>` to
447
+ > populate it on reads. Don't manually populate — the relations engine
448
+ > batches the query and re-applies tenant scope.
449
+
450
+ ### Add an aggregation
451
+
452
+ > On `<resource>`, add an aggregation `<name>` that <description>.
453
+ > Pipeline: <stages>. The framework prepends `$match: { userId }`
454
+ > automatically — don't add it.
455
+
456
+ ### Add a computed field
457
+
458
+ > Add a computed field `<name>` on `<resource>` of type `<type>` that
459
+ > returns `<expression>`. Don't store it. The framework runs the
460
+ > function on every read and includes it in responses, GraphQL output,
461
+ > MCP results, and the typed client.
462
+
463
+ ## Useful commands
464
+
465
+ - `npm start` — boot the server (dev, hot-reload).
466
+ - `npm test` — run the schema-shape smoke tests under `tests/`. Add your own integration tests alongside.
467
+ - `npm run seed` — register a demo user and POST sample records (template-dependent).
468
+ - `npx davepi gen-client --out client/davepi.ts` — regenerate the typed TS client.
469
+ - `npx davepi migrate up` — apply pending data migrations.
470
+ - `npx davepi mcp` — run the MCP server over stdio (used by `.mcp.json`).
471
+ - `curl -s http://localhost:{{PORT}}/_describe | jq` — inspect what the project exposes.
472
+
473
+ ## Where to look next
474
+
475
+ - Full framework reference: <https://docs.davepi.dev>
476
+ - Schema field options: <https://docs.davepi.dev/reference/fields/>
477
+ - Per-feature deep dives: <https://docs.davepi.dev/features/>
478
+ - Idempotency contract: <https://docs.davepi.dev/features/idempotency/>
479
+ - MCP server reference: <https://docs.davepi.dev/surfaces/mcp/>
480
+ - TypeScript client: <https://docs.davepi.dev/surfaces/client/>