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.
- package/README.md +58 -0
- package/bin/index.js +458 -0
- package/bin/sync-templates.js +42 -0
- package/package.json +36 -0
- package/templates/_shared/.github/workflows/client-gen.yml +65 -0
- package/templates/_shared/.github/workflows/deploy.yml +70 -0
- package/templates/_shared/.github/workflows/migrate.yml +66 -0
- package/templates/_shared/.github/workflows/test.yml +49 -0
- package/templates/_shared/agent.md +480 -0
- package/templates/_shared/tests/smoke.test.js +67 -0
- package/templates/b2b-saas/README.md +57 -0
- package/templates/b2b-saas/schema/versions/v1/billingEvent.js +46 -0
- package/templates/b2b-saas/schema/versions/v1/invite.js +30 -0
- package/templates/b2b-saas/schema/versions/v1/org.js +23 -0
- package/templates/b2b-saas/schema/versions/v1/workspace.js +13 -0
- package/templates/b2b-saas/seed.js +98 -0
- package/templates/blank/README.md +42 -0
- package/templates/blank/schema/versions/v1/note.js +10 -0
- package/templates/blank/seed.js +86 -0
- package/templates/content/README.md +58 -0
- package/templates/content/schema/versions/v1/article.js +77 -0
- package/templates/content/schema/versions/v1/category.js +30 -0
- package/templates/content/seed.js +97 -0
- package/templates/crm/README.md +59 -0
- package/templates/crm/schema/versions/v1/account.js +22 -0
- package/templates/crm/schema/versions/v1/activity.js +20 -0
- package/templates/crm/schema/versions/v1/contact.js +28 -0
- package/templates/crm/schema/versions/v1/deal.js +72 -0
- package/templates/crm/seed.js +124 -0
- package/templates/ticketing/README.md +46 -0
- package/templates/ticketing/schema/versions/v1/comment.js +26 -0
- package/templates/ticketing/schema/versions/v1/ticket.js +65 -0
- 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/>
|