@terreno/agent-plugin 0.0.0-stage → 58.0.0-beta.2
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/.claude-plugin/plugin.json +32 -0
- package/CHANGELOG.md +2213 -0
- package/LICENSE +21 -0
- package/README.md +57 -2
- package/agents/pre-commit.md +65 -0
- package/agents/ui-verifier.md +93 -0
- package/package.json +32 -4
- package/references/async-review-bots.md +76 -0
- package/references/documentation-contract.md +61 -0
- package/references/execution-state.schema.json +77 -0
- package/references/github-attention-contract.md +165 -0
- package/references/independent-review.md +31 -0
- package/references/lifecycle-contract.md +211 -0
- package/references/loop-engineering.md +125 -0
- package/references/pick-roast-loop.md +112 -0
- package/references/pr-deployments.md +69 -0
- package/references/product-ci.md +164 -0
- package/references/push-ownership.md +91 -0
- package/references/stage-result.schema.json +82 -0
- package/references/subagent-briefing.md +43 -0
- package/references/testing-plan.md +163 -0
- package/references/user-guide.md +171 -0
- package/skills/1-grow/SKILL.md +145 -0
- package/skills/1-grow/references/grilling.md +243 -0
- package/skills/2-pick/SKILL.md +170 -0
- package/skills/2-pick/references/mocking.md +34 -0
- package/skills/2-pick/references/testing.md +43 -0
- package/skills/3-roast/SKILL.md +168 -0
- package/skills/4-brew/SKILL.md +201 -0
- package/skills/5-taste/SKILL.md +311 -0
- package/skills/ai-prompt-governance/SKILL.md +100 -0
- package/skills/attach-pr-media/SKILL.md +89 -0
- package/skills/attach-pr-media/scripts/upload-pr-media.sh +151 -0
- package/skills/backend-test-env/SKILL.md +64 -0
- package/skills/backfill-regression/SKILL.md +101 -0
- package/skills/backfill-user-guide/SKILL.md +96 -0
- package/skills/bootstrap-mcp-server/SKILL.md +244 -0
- package/skills/building-admin-interfaces/SKILL.md +86 -0
- package/skills/building-admin-interfaces/agents/openai.yaml +4 -0
- package/skills/building-admin-interfaces/references/nav-and-shell.md +42 -0
- package/skills/building-admin-interfaces/references/patterns-and-tools.md +74 -0
- package/skills/building-admin-interfaces/references/screens-and-routes.md +50 -0
- package/skills/building-terreno-apps/SKILL.md +294 -0
- package/skills/building-terreno-apps/agents/openai.yaml +4 -0
- package/skills/building-terreno-apps/references/navigation.md +108 -0
- package/skills/building-terreno-apps/references/route-structure.md +104 -0
- package/skills/building-terreno-apps/references/screen-patterns.md +174 -0
- package/skills/debug-syncdb-with-mcp/SKILL.md +75 -0
- package/skills/deploy-gcp/SKILL.md +73 -0
- package/skills/deploy-gcp/references/troubleshooting.md +29 -0
- package/skills/generate-release-announcements/SKILL.md +145 -0
- package/skills/generate-sdk/SKILL.md +165 -0
- package/skills/mongoose-schema-safety/SKILL.md +164 -0
- package/skills/pick-roast-loop/SKILL.md +152 -0
- package/skills/planning-loop/SKILL.md +161 -0
- package/skills/prd/SKILL.md +138 -0
- package/skills/set-up-social-login/SKILL.md +76 -0
- package/skills/store-assets/SKILL.md +221 -0
- package/skills/store-assets/examples/screenshots.yaml +31 -0
- package/skills/store-assets/examples/storeAssets.config.json +44 -0
- package/skills/store-assets/scripts/banner.ts +165 -0
- package/skills/store-assets/scripts/config.ts +255 -0
- package/skills/store-assets/scripts/devClient.ts +635 -0
- package/skills/store-assets/scripts/devices.ts +286 -0
- package/skills/store-assets/scripts/icon.ts +81 -0
- package/skills/store-assets/scripts/maestro/goHome.yaml +21 -0
- package/skills/store-assets/scripts/maestro/login.yaml +27 -0
- package/skills/store-assets/scripts/maestro/openApp.yaml +26 -0
- package/skills/store-assets/scripts/screenshots.ts +848 -0
- package/skills/taste-sweep/SKILL.md +172 -0
- package/skills/terreno-backend-api/SKILL.md +355 -0
- package/skills/terreno-backend-api/agents/openai.yaml +4 -0
- package/skills/terreno-backend-api/references/custom-routes.md +132 -0
- package/skills/terreno-backend-api/references/model-router.md +112 -0
- package/skills/terreno-backend-api/references/server-setup.md +116 -0
- package/skills/terreno-backend-tests/SKILL.md +176 -0
- package/skills/terreno-data-fetching/SKILL.md +288 -0
- package/skills/terreno-data-fetching/agents/openai.yaml +4 -0
- package/skills/terreno-data-fetching/references/auth-and-tokens.md +89 -0
- package/skills/terreno-data-fetching/references/realtime.md +50 -0
- package/skills/terreno-data-fetching/references/sdk-customization.md +55 -0
- package/skills/terreno-frontend-tests/SKILL.md +190 -0
- package/skills/terreno-mobile-tests/SKILL.md +127 -0
- package/skills/terreno-test-setup/SKILL.md +81 -0
- package/skills/terreno-test-setup/references/backend.md +131 -0
- package/skills/terreno-test-setup/references/frontend.md +169 -0
- package/skills/terreno-test-setup/references/mobile.md +134 -0
- package/skills/terreno-ui/SKILL.md +252 -0
- package/skills/terreno-ui/agents/openai.yaml +4 -0
- package/skills/terreno-ui/references/data-display.md +87 -0
- package/skills/terreno-ui/references/feedback.md +122 -0
- package/skills/terreno-ui/references/forms.md +119 -0
- package/skills/terreno-ui/references/layout.md +124 -0
- package/skills/terreno-ui/references/theming.md +87 -0
- package/skills/update-docs/SKILL.md +82 -0
- package/skills/upgrading-terreno/SKILL.md +93 -0
- package/skills/upgrading-terreno/references/ordering.md +40 -0
- package/skills/verify-ui-changes/SKILL.md +605 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# modelRouter — Terreno
|
|
2
|
+
|
|
3
|
+
## Registration signatures
|
|
4
|
+
|
|
5
|
+
```typescript
|
|
6
|
+
// TerrenoApp (recommended):
|
|
7
|
+
const todoRouter = modelRouter("/todos", Todo, options);
|
|
8
|
+
app.register(todoRouter);
|
|
9
|
+
|
|
10
|
+
// setupServer (legacy):
|
|
11
|
+
router.use("/todos", modelRouter(Todo, options));
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Full options reference
|
|
15
|
+
|
|
16
|
+
```typescript
|
|
17
|
+
modelRouter("/todos", Todo, {
|
|
18
|
+
permissions: {
|
|
19
|
+
create: [Permissions.IsAuthenticated],
|
|
20
|
+
list: [Permissions.IsAuthenticated],
|
|
21
|
+
read: [Permissions.IsOwner],
|
|
22
|
+
update: [Permissions.IsOwner],
|
|
23
|
+
delete: [], // disabled
|
|
24
|
+
},
|
|
25
|
+
queryFields: ["completed", "ownerId"],
|
|
26
|
+
queryFilter: OwnerQueryFilter,
|
|
27
|
+
sort: "-created",
|
|
28
|
+
defaultLimit: 100,
|
|
29
|
+
maxLimit: 500,
|
|
30
|
+
populatePaths: [{path: "ownerId", fields: ["name", "email"]}],
|
|
31
|
+
preCreate: (body, req) => ({...body, ownerId: req.user?._id}),
|
|
32
|
+
preUpdate: (body, req) => body,
|
|
33
|
+
postCreate: (doc, req) => {},
|
|
34
|
+
postUpdate: (doc, body, req, prev) => {},
|
|
35
|
+
postDelete: (req, doc) => {},
|
|
36
|
+
responseHandler: (value, method, req, options) => value,
|
|
37
|
+
validation: {
|
|
38
|
+
validateCreate: true,
|
|
39
|
+
validateUpdate: true,
|
|
40
|
+
validateQuery: true,
|
|
41
|
+
excludeFromCreate: ["ownerId"],
|
|
42
|
+
excludeFromUpdate: ["ownerId"],
|
|
43
|
+
},
|
|
44
|
+
sync: {scope: {type: "owner"}},
|
|
45
|
+
collectionActions: {
|
|
46
|
+
bulkComplete: {
|
|
47
|
+
method: "POST",
|
|
48
|
+
permissions: [Permissions.IsAuthenticated],
|
|
49
|
+
body: z.object({ids: z.array(z.string()).min(1)}).strict(),
|
|
50
|
+
response: z.object({matched: z.number(), modified: z.number()}).strict(),
|
|
51
|
+
summary: "Mark multiple todos complete",
|
|
52
|
+
handler: async ({body, user}) => { /* ... */ },
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
instanceActions: {
|
|
56
|
+
markComplete: {
|
|
57
|
+
method: "POST",
|
|
58
|
+
permissions: [Permissions.IsOwner],
|
|
59
|
+
summary: "Mark a single todo complete",
|
|
60
|
+
handler: async ({doc}) => { /* ... */ },
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
collectionActions: { /* named GET/POST on /todos/{name} */ },
|
|
64
|
+
instanceActions: { /* named GET/POST on /todos/:id/{name} */ },
|
|
65
|
+
// Last resort only — prefer collectionActions / instanceActions.
|
|
66
|
+
endpoints: (router) => {
|
|
67
|
+
// Custom routes registered before CRUD
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Query features
|
|
73
|
+
|
|
74
|
+
- Pagination: `?limit=20&page=2`
|
|
75
|
+
- Sort: `?sort=-created`
|
|
76
|
+
- Filters: `?completed=true` (field must be in `queryFields`)
|
|
77
|
+
- Complex: `?$and=[{...}]`, `?$or=[{...}]`
|
|
78
|
+
|
|
79
|
+
## Lifecycle hooks
|
|
80
|
+
|
|
81
|
+
| Hook | When | Use for |
|
|
82
|
+
|------|------|---------|
|
|
83
|
+
| `preCreate` | Before insert | Set `ownerId`, defaults |
|
|
84
|
+
| `preUpdate` | Before patch | Strip immutable fields |
|
|
85
|
+
| `postCreate` | After insert | Side effects, notifications |
|
|
86
|
+
| `postUpdate` | After patch | Audit, cache invalidation |
|
|
87
|
+
| `postDelete` | After delete | Cleanup |
|
|
88
|
+
|
|
89
|
+
## User casting in callbacks
|
|
90
|
+
|
|
91
|
+
```typescript
|
|
92
|
+
const user = u as unknown as UserDocument;
|
|
93
|
+
// or in route handlers:
|
|
94
|
+
(req.user as unknown as UserDocument)?._id
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Never use `as any as UserDocument`.
|
|
98
|
+
|
|
99
|
+
## Plugins on schemas
|
|
100
|
+
|
|
101
|
+
| Plugin | Adds |
|
|
102
|
+
|--------|------|
|
|
103
|
+
| `createdUpdatedPlugin` | `created`, `updated` timestamps |
|
|
104
|
+
| `isDeletedPlugin` | Soft delete (`deleted` field) |
|
|
105
|
+
| `findExactlyOne` | Static that throws on 0 or >1 match |
|
|
106
|
+
| `findOneOrNone` | Static that returns null on 0, throws on >1 |
|
|
107
|
+
|
|
108
|
+
## OpenAPI
|
|
109
|
+
|
|
110
|
+
`setupServer` / `TerrenoApp` auto-generates `/openapi.json`. Field `description` on schema paths flows to the spec via `mongoose-to-swagger`.
|
|
111
|
+
|
|
112
|
+
After any model or router change, regenerate the frontend SDK.
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# Server Setup — Terreno
|
|
2
|
+
|
|
3
|
+
## TerrenoApp (recommended)
|
|
4
|
+
|
|
5
|
+
```typescript
|
|
6
|
+
import {TerrenoApp} from "@terreno/api";
|
|
7
|
+
import {User} from "./models/user";
|
|
8
|
+
import {todoRouter} from "./api/todos";
|
|
9
|
+
|
|
10
|
+
const app = new TerrenoApp({
|
|
11
|
+
userModel: User,
|
|
12
|
+
loggingOptions: {logRequests: true, logSlowRequests: true},
|
|
13
|
+
})
|
|
14
|
+
.register(todoRouter)
|
|
15
|
+
.start();
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Methods: `.register()`, `.addMiddleware()`, `.build()`, `.start()`.
|
|
19
|
+
|
|
20
|
+
## setupServer (legacy)
|
|
21
|
+
|
|
22
|
+
```typescript
|
|
23
|
+
import {setupServer} from "@terreno/api";
|
|
24
|
+
|
|
25
|
+
setupServer({
|
|
26
|
+
userModel: User,
|
|
27
|
+
addRoutes: (router, options) => {
|
|
28
|
+
router.use("/todos", modelRouter(Todo, {...options, ...}));
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Required environment variables
|
|
34
|
+
|
|
35
|
+
| Variable | Purpose |
|
|
36
|
+
|----------|---------|
|
|
37
|
+
| `MONGO_URI` | MongoDB connection (replica set for change streams) |
|
|
38
|
+
| `TOKEN_SECRET` | JWT signing secret |
|
|
39
|
+
| `TOKEN_ISSUER` | JWT issuer claim |
|
|
40
|
+
| `REFRESH_TOKEN_SECRET` | Refresh token secret |
|
|
41
|
+
| `SESSION_SECRET` | Express session secret |
|
|
42
|
+
| `PORT` | Server port (default 4000) |
|
|
43
|
+
|
|
44
|
+
Optional: `ENABLE_SWAGGER=true`, `USE_SENTRY_LOGGING=true`, GitHub OAuth vars, Better Auth vars.
|
|
45
|
+
|
|
46
|
+
## MongoDB for local dev
|
|
47
|
+
|
|
48
|
+
The dev server needs a real replica set (change streams). See `AGENTS.md` for the single-node replica set setup with `mongodb-memory-server` cache binary.
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
MONGO_URI="mongodb://127.0.0.1:27017/terreno-example?replicaSet=rs0" \
|
|
52
|
+
TOKEN_SECRET=dev-token-secret TOKEN_ISSUER=terreno-dev \
|
|
53
|
+
REFRESH_TOKEN_SECRET=dev-refresh-secret SESSION_SECRET=dev-session-secret \
|
|
54
|
+
PORT=4000 bun run backend:dev
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Seed users: `cd example-backend && bun run seed` → `test@example.com` / `testpassword123`.
|
|
58
|
+
|
|
59
|
+
## Health check
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
curl localhost:4000/health
|
|
63
|
+
# {"healthy":true,...}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## OpenAPI / Swagger
|
|
67
|
+
|
|
68
|
+
- Spec: `http://localhost:4000/openapi.json`
|
|
69
|
+
- Swagger UI: `http://localhost:4000/swagger` (when `ENABLE_SWAGGER=true`)
|
|
70
|
+
|
|
71
|
+
## Logging
|
|
72
|
+
|
|
73
|
+
```typescript
|
|
74
|
+
import {createScopedLogger, logger} from "@terreno/api";
|
|
75
|
+
|
|
76
|
+
logger.info("Server started", {port: 4000});
|
|
77
|
+
|
|
78
|
+
const log = createScopedLogger({
|
|
79
|
+
prefix: "[Billing]",
|
|
80
|
+
labels: {invoiceId: id.toString()},
|
|
81
|
+
});
|
|
82
|
+
log.info("Processing payment");
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Never use `console.log` for permanent server logs.
|
|
86
|
+
|
|
87
|
+
## Testing
|
|
88
|
+
|
|
89
|
+
```typescript
|
|
90
|
+
import {describe, it, expect} from "bun:test";
|
|
91
|
+
import request from "supertest";
|
|
92
|
+
import {getBaseServer, authAsUser, setupDb} from "@terreno/api/tests";
|
|
93
|
+
|
|
94
|
+
describe("todos", () => {
|
|
95
|
+
it("lists todos for authenticated user", async () => {
|
|
96
|
+
const app = await getBaseServer();
|
|
97
|
+
const agent = await authAsUser(app, "notAdmin");
|
|
98
|
+
const res = await agent.get("/todos");
|
|
99
|
+
expect(res.status).toBe(200);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
See `backend-test-env` skill when tests mutate `process.env`.
|
|
105
|
+
|
|
106
|
+
## Plugins
|
|
107
|
+
|
|
108
|
+
Register `TerrenoPlugin` implementations:
|
|
109
|
+
|
|
110
|
+
```typescript
|
|
111
|
+
import {BetterAuthApp, type BetterAuthConfig} from "@terreno/api";
|
|
112
|
+
import {AdminApp} from "@terreno/admin-backend";
|
|
113
|
+
|
|
114
|
+
app.register(new BetterAuthApp({config: betterAuthConfig, userModel: User}));
|
|
115
|
+
admin.register(app);
|
|
116
|
+
```
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: terreno-backend-tests
|
|
3
|
+
description: >-
|
|
4
|
+
Write and fix Bun tests for a Terreno backend: modelRouter routes and actions, permissions,
|
|
5
|
+
Mongoose models and hooks, services that call SendGrid / Twilio / Expo push / Cloud Tasks /
|
|
6
|
+
GCS / AI models, env-dependent code, time, and async side effects. Covers supertest +
|
|
7
|
+
@terreno/test helpers, auth tokens, test data, service mocks, and flake-free waiting. Use
|
|
8
|
+
when adding or changing backend behavior or when a backend test fails. If the app has no
|
|
9
|
+
test preload yet, run terreno-test-setup first.
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
# Writing Terreno backend tests
|
|
13
|
+
|
|
14
|
+
Tests use `bun:test`, run against a real in-memory MongoDB, and call the app through HTTP with
|
|
15
|
+
supertest. Never mock `@terreno/api` internals or your own models — exercise the real stack.
|
|
16
|
+
|
|
17
|
+
## 1. Pick the seam
|
|
18
|
+
|
|
19
|
+
| Behavior | Test through |
|
|
20
|
+
|----------|--------------|
|
|
21
|
+
| CRUD, actions, permissions, query filters, response shape | HTTP route (`supertest`) — default |
|
|
22
|
+
| Model statics, methods, hooks, validation | the model directly |
|
|
23
|
+
| A service with no route | its exported function |
|
|
24
|
+
| Third-party side effect (email, SMS, task, upload, AI call) | the route or service, asserting on the preload's mock handle |
|
|
25
|
+
|
|
26
|
+
Done when: the test file sits next to the code (`todos.ts` → `todos.test.ts`) and names the
|
|
27
|
+
seam in its `describe`.
|
|
28
|
+
|
|
29
|
+
## 2. Build the app per file
|
|
30
|
+
|
|
31
|
+
```typescript
|
|
32
|
+
import {beforeEach, describe, expect, it} from "bun:test";
|
|
33
|
+
import {generateTokens, TerrenoApp} from "@terreno/api";
|
|
34
|
+
import {authWithToken, expectAPIError} from "@terreno/test";
|
|
35
|
+
import supertest from "supertest";
|
|
36
|
+
import {Todo} from "../models/todo";
|
|
37
|
+
import {User} from "../models/user";
|
|
38
|
+
import {todoRouter} from "./todos";
|
|
39
|
+
|
|
40
|
+
const app = new TerrenoApp({skipListen: true, userModel: User as never})
|
|
41
|
+
.register(todoRouter)
|
|
42
|
+
.build();
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Register only the routers and plugins under test. Do not call the app's `start()`: it connects
|
|
46
|
+
to the dev database and registers everything.
|
|
47
|
+
|
|
48
|
+
## 3. Authenticate
|
|
49
|
+
|
|
50
|
+
```typescript
|
|
51
|
+
const createUser = async (email: string, admin = false) =>
|
|
52
|
+
User.register({admin, email, name: email} as never, "password12345");
|
|
53
|
+
|
|
54
|
+
const agentFor = async (user: unknown) =>
|
|
55
|
+
authWithToken(app, {token: (await generateTokens(user)).token});
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
- `authWithToken` + `generateTokens` mints the same JWT `/auth/login` issues, without the
|
|
59
|
+
~200ms password hash. Use it everywhere except tests of the login flow itself.
|
|
60
|
+
- `authAsUser(app, {email, password})` really logs in; keep it for login, lockout, and
|
|
61
|
+
password tests.
|
|
62
|
+
- `supertest(app)` with no token is the anonymous client.
|
|
63
|
+
|
|
64
|
+
Permission failures by layer — assert the exact status:
|
|
65
|
+
|
|
66
|
+
| Case | Status |
|
|
67
|
+
|------|--------|
|
|
68
|
+
| No or invalid token on a protected route | 401 |
|
|
69
|
+
| Method-level permission fails (`list`, `create`, or a disabled `[]` method) | 405 |
|
|
70
|
+
| Object-level permission fails (`read`, `update`, `delete` on a loaded document, e.g. `IsOwner`) | 403 |
|
|
71
|
+
| `list` where `queryFilter` excludes another user's documents | 200, those documents absent |
|
|
72
|
+
| Id that does not exist | 404 |
|
|
73
|
+
|
|
74
|
+
Write one test per role that matters: owner, other user, admin, anonymous.
|
|
75
|
+
|
|
76
|
+
## 4. Create and clean data
|
|
77
|
+
|
|
78
|
+
- Create exactly what the test needs, in the test or a `beforeEach`, with small factory
|
|
79
|
+
functions (`createUser`, `createTodo({ownerId, ...overrides})`) local to the file or in
|
|
80
|
+
`src/tests/factories.ts`.
|
|
81
|
+
- The default preload does not wipe the database. Start each test clean for the collections
|
|
82
|
+
it reads: `beforeEach(async () => { await Todo.deleteMany({}); await User.deleteMany({}); })`.
|
|
83
|
+
(Apps on `registerBackendPreload({useTransactions: true})` roll back automatically; skip
|
|
84
|
+
the deletes there.)
|
|
85
|
+
- Dates are Luxon: `DateTime.fromISO("2024-06-01T10:00:00Z")`, compared with `.toISO()` or
|
|
86
|
+
`.toMillis()`.
|
|
87
|
+
- Expected values are literals from the example, never recomputed with production code.
|
|
88
|
+
|
|
89
|
+
## 5. Assert
|
|
90
|
+
|
|
91
|
+
```typescript
|
|
92
|
+
it("lets an owner list only their todos", async () => {
|
|
93
|
+
const owner = await createUser("owner@example.com");
|
|
94
|
+
const other = await createUser("other@example.com");
|
|
95
|
+
await Todo.create({ownerId: owner._id, title: "Mine"});
|
|
96
|
+
await Todo.create({ownerId: other._id, title: "Theirs"});
|
|
97
|
+
|
|
98
|
+
const res = await (await agentFor(owner)).get("/todos").expect(200);
|
|
99
|
+
|
|
100
|
+
expect(res.body.data.map((todo: {title: string}) => todo.title)).toEqual(["Mine"]);
|
|
101
|
+
expect(res.body.total).toBe(1);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("rejects a todo without a title", async () => {
|
|
105
|
+
const res = await (await agentFor(await createUser("a@example.com"))).post("/todos").send({});
|
|
106
|
+
expectAPIError(res, {status: 400, title: /validation/i});
|
|
107
|
+
});
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
- List responses are `{data, limit, more, page, total}`; single documents are `{data}`.
|
|
111
|
+
- `expectAPIError(res, {status, title?, detail?, code?, fields?})` matches the JSONAPI error
|
|
112
|
+
body; `fields` partially matches `meta.fields`.
|
|
113
|
+
- On routes that return user or PHI data, pin the response shape so a new model field cannot
|
|
114
|
+
leak: `assertStrictResponseProperties(res.body, ["data._id", "data.name", "limit", "more",
|
|
115
|
+
"page", "total"])`.
|
|
116
|
+
- After a write, re-read through the API (or `Model.findById`) to prove persistence.
|
|
117
|
+
|
|
118
|
+
## 6. Services and side effects
|
|
119
|
+
|
|
120
|
+
Mocks are registered once in `src/tests/bunSetup.ts` and exported (see `terreno-test-setup`).
|
|
121
|
+
Import the handle and assert what the app sent:
|
|
122
|
+
|
|
123
|
+
```typescript
|
|
124
|
+
import {cloudTasks, gcs, sendGrid} from "../tests/bunSetup";
|
|
125
|
+
|
|
126
|
+
expect(sendGrid.getSentMessages()).toEqual([
|
|
127
|
+
expect.objectContaining({subject: "Welcome", to: "new@example.com"}),
|
|
128
|
+
]);
|
|
129
|
+
|
|
130
|
+
await cloudTasks.drainTasks({app, headers: {authorization: "Bearer task-secret"}});
|
|
131
|
+
|
|
132
|
+
gcs.putObject({bucket: "uploads", contents: "hi", name: "a.txt"});
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
- Cloud Tasks: assert `getEnqueuedTasks()` (decoded bodies), then `drainTasks({app})` to run
|
|
136
|
+
the task handlers through HTTP; it also drains tasks the handlers enqueue.
|
|
137
|
+
- Make a provider fail with the mock's standard API, e.g.
|
|
138
|
+
`sendGrid.send.mockRejectedValueOnce(new Error("503"))`, and assert the app's handling.
|
|
139
|
+
- AI: `new AIService({model: createMockLanguageModel({responses: [{text: "Hi"}, {toolCalls:
|
|
140
|
+
[...]}]})})`; assert prompts via `model.doGenerate.mock.calls` / `doStream.mock.calls`.
|
|
141
|
+
- Never `mock.module` inside a test file: it replaces the module for every later file in the
|
|
142
|
+
run. Use the preload, dependency injection, or `spyOn(object, "method")` (restored
|
|
143
|
+
automatically after each test).
|
|
144
|
+
|
|
145
|
+
## 7. Env, time, and async
|
|
146
|
+
|
|
147
|
+
- Env: set only the keys the test needs (`process.env.FEATURE_X = "true"`); express "unset" as
|
|
148
|
+
`Reflect.deleteProperty(process.env, "KEY")`. The preload re-applies the canonical env after
|
|
149
|
+
every test. Never snapshot and restore all of `process.env`.
|
|
150
|
+
- Time: `freezeTime("2024-06-01T10:30:00Z")` from `@terreno/test` freezes `Date.now()`,
|
|
151
|
+
`new Date()`, and Luxon for the current test and restores afterwards.
|
|
152
|
+
- Async side effects (post-save hooks, fire-and-forget writes, jobs): wait for the outcome, not
|
|
153
|
+
a duration — `await waitForDocuments(AuditEvent, {action: "create"}, 1)`,
|
|
154
|
+
`await waitUntil(() => sendGrid.send.mock.calls.length > 0, {label: "welcome email"})`, or
|
|
155
|
+
`await waitForCalls(mockFn, 2)`. Never `await new Promise((r) => setTimeout(r, n))`.
|
|
156
|
+
|
|
157
|
+
## 8. Run
|
|
158
|
+
|
|
159
|
+
```bash
|
|
160
|
+
bun test src/api/todos.test.ts # one file
|
|
161
|
+
bun test -t "lists only their todos" # by name
|
|
162
|
+
bun test --only-failures # rerun what failed last time
|
|
163
|
+
bun test --coverage # coverage report
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
A test that passes alone and fails in the full run is leaking state: a module mock in a test
|
|
167
|
+
file, a collection not cleaned, or an env key not reset. Fix the leak; do not reorder files.
|
|
168
|
+
|
|
169
|
+
## Checklist
|
|
170
|
+
|
|
171
|
+
- [ ] Happy path plus each rejected role/case asserts an exact status
|
|
172
|
+
- [ ] Data created in the test; collections it reads cleaned in `beforeEach`
|
|
173
|
+
- [ ] Third-party calls asserted on the preload mock handles; no `mock.module` in the file
|
|
174
|
+
- [ ] No sleeps; async outcomes awaited with `waitUntil` / `waitForDocuments`
|
|
175
|
+
- [ ] Time-dependent behavior uses `freezeTime`; env changes are targeted
|
|
176
|
+
- [ ] `bun test` passes for the whole backend, not just the file
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: terreno-data-fetching
|
|
3
|
+
description: >-
|
|
4
|
+
Use when implementing or debugging ANY network request, API call, or data
|
|
5
|
+
fetching in a Terreno app. Covers syncdb collection CRUD, generated request
|
|
6
|
+
hooks (store/apiSdk.ts) for non-synced routes, session auth, socket pushes,
|
|
7
|
+
feature flags, admin's host-bound fetch client, and SDK regeneration. Replaces direct fetch, axios, React Query, and SWR in
|
|
8
|
+
application code.
|
|
9
|
+
Lifecycle composition: Grow for data-flow shape, Pick for implementation, Roast
|
|
10
|
+
for independent network/cache behavior proof.
|
|
11
|
+
---
|
|
12
|
+
# Terreno Data Fetching
|
|
13
|
+
|
|
14
|
+
**You MUST use this skill for ANY networking work in Terreno apps.** Everything goes
|
|
15
|
+
through `@terreno/syncdb`: `useQuery` / `useMutate` for synced collection CRUD, and
|
|
16
|
+
generated hooks on the `@terreno/syncdb/api` request client for every other route.
|
|
17
|
+
Never call `axios`, raw `fetch`, React Query, or SWR from application screens.
|
|
18
|
+
`@terreno/rtk` and Redux were removed in Terreno 58; upgrade old code with
|
|
19
|
+
`docs/how-to/migrate-rtk-to-syncdb.md`.
|
|
20
|
+
|
|
21
|
+
**Related skills:** `generate-sdk` (regenerate hooks after backend changes), `terreno-backend-api` (create backend routes), `building-terreno-apps` (screen integration).
|
|
22
|
+
|
|
23
|
+
## Documentation
|
|
24
|
+
|
|
25
|
+
1. Read `docs/reference/syncdb.md`, `docs/how-to/migrate-rtk-to-syncdb.md`, `docs/how-to/use-syncdb-correctly.md`, and auth/realtime explanation pages before changing fetch, cache, or session behavior.
|
|
26
|
+
2. Implement against that design.
|
|
27
|
+
3. Update those pages in the same slice with `update-docs`.
|
|
28
|
+
4. Ship without matching docs is a failed slice.
|
|
29
|
+
|
|
30
|
+
## References
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
references/
|
|
34
|
+
auth-and-tokens.md SessionProvider/useSession, login/logout, token refresh, JWT
|
|
35
|
+
realtime.md Socket events, feature flags, sync status
|
|
36
|
+
sdk-customization.md Generated apiSdk.ts, ad-hoc useApiQuery/useApiMutation, cache tags
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## When to Use
|
|
40
|
+
|
|
41
|
+
- Implementing API requests in screens or hooks
|
|
42
|
+
- Setting up authentication (login, logout, token refresh)
|
|
43
|
+
- Debugging network failures or stale data
|
|
44
|
+
- Connecting realtime updates (sockets, feature flags)
|
|
45
|
+
- Configuring API URLs and environment variables
|
|
46
|
+
- After any backend route or model change (regenerate SDK)
|
|
47
|
+
|
|
48
|
+
## Core Rules
|
|
49
|
+
|
|
50
|
+
1. **Use syncdb hooks** (`useQuery`, `useEntity`, `useWindowQuery`, `useMutate`) for synced collection CRUD.
|
|
51
|
+
2. **Use generated hooks** from `store/apiSdk.ts` (`terreno-syncdb-codegen --api-out`) for every
|
|
52
|
+
non-synced route: `/auth/me`, modelRouter actions, AI, consents, announcements.
|
|
53
|
+
3. **Never edit `store/apiSdk.ts` or `store/syncDbSdk.ts`** — regenerate with `generate-sdk`.
|
|
54
|
+
4. **Ad-hoc routes not in OpenAPI** use `useApiQuery` / `useLazyApiQuery` / `useApiMutation`
|
|
55
|
+
from `@terreno/syncdb/react`. Prefer documenting the route in OpenAPI and regenerating.
|
|
56
|
+
5. **Never use `axios` or raw `fetch`** in app code. Exceptions: SSE streams (`/gpt/prompt`)
|
|
57
|
+
authenticated with `authProvider.getToken()`, and framework-owned admin requests
|
|
58
|
+
(`@terreno/admin-frontend` binds `adminRequest` from `AdminProvider` `credentials` /
|
|
59
|
+
`getAuthHeaders` / `apiOrigin`).
|
|
60
|
+
6. **Admin uses its own fetch client.** String-`_id` models with `adminBroadcast` use
|
|
61
|
+
windowed syncdb; ObjectId/API-only CRUD uses `useAdminModelApi`; admin RPC uses
|
|
62
|
+
`useAdminRpc`. Admin components take no `api` prop (removed in Terreno 58).
|
|
63
|
+
|
|
64
|
+
## Quick Start
|
|
65
|
+
|
|
66
|
+
### Synced collection read
|
|
67
|
+
|
|
68
|
+
```tsx
|
|
69
|
+
import {useQuery} from "@terreno/syncdb/react";
|
|
70
|
+
|
|
71
|
+
const TodosScreen: React.FC = () => {
|
|
72
|
+
const todos = useQuery<Todo>("todos", {
|
|
73
|
+
filter: (todo) => !todo.completed,
|
|
74
|
+
});
|
|
75
|
+
};
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### Synced collection write
|
|
79
|
+
|
|
80
|
+
```tsx
|
|
81
|
+
import {generateMutationId} from "@terreno/syncdb";
|
|
82
|
+
import {useMutate} from "@terreno/syncdb/react";
|
|
83
|
+
|
|
84
|
+
const {create, update} = useMutate("todos");
|
|
85
|
+
|
|
86
|
+
const handleCreate = useCallback((): void => {
|
|
87
|
+
create({data: {_id: generateMutationId(), title: "New todo", completed: false}});
|
|
88
|
+
}, [create]);
|
|
89
|
+
|
|
90
|
+
const handleToggle = useCallback((id: string, completed: boolean): void => {
|
|
91
|
+
update({id, data: {completed: !completed}});
|
|
92
|
+
}, [update]);
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### Non-synced route
|
|
96
|
+
|
|
97
|
+
```tsx
|
|
98
|
+
import {useGetAuthMeQuery, usePatchAuthMeMutation} from "@/store/apiSdk";
|
|
99
|
+
|
|
100
|
+
const {data: me, isLoading} = useGetAuthMeQuery(undefined, {skip: !userId});
|
|
101
|
+
const [updateMe, {isLoading: isSaving}] = usePatchAuthMeMutation();
|
|
102
|
+
await updateMe({name}).unwrap();
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Auth
|
|
106
|
+
|
|
107
|
+
```tsx
|
|
108
|
+
import {useSession} from "@terreno/syncdb/auth";
|
|
109
|
+
|
|
110
|
+
const {userId, isAuthenticated, isLoading, logout} = useSession();
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
See `./references/auth-and-tokens.md` for the full auth flow.
|
|
114
|
+
|
|
115
|
+
## Client Setup
|
|
116
|
+
|
|
117
|
+
One `authProvider` feeds the sync client and the request client, so both send the same
|
|
118
|
+
token and refresh on a 401 the same way. Reference: `example-frontend/store/syncdb.ts`,
|
|
119
|
+
`store/apiClient.ts`, `app/_layout.tsx`.
|
|
120
|
+
|
|
121
|
+
```tsx
|
|
122
|
+
// store/syncdb.ts
|
|
123
|
+
export const authProvider = betterAuthAdapter(bridgeBetterAuthReactClient(betterAuthClient));
|
|
124
|
+
export const syncDb = createSyncDb({authProvider, baseUrl, collections: [...SYNC_COLLECTIONS], name: "myapp", wipeOnSignOut: true});
|
|
125
|
+
|
|
126
|
+
// store/apiClient.ts
|
|
127
|
+
export const apiClient = createApiClient({authProvider, baseUrl, credentials: "include", headers: appHeaders});
|
|
128
|
+
|
|
129
|
+
// app/_layout.tsx — no Redux store
|
|
130
|
+
<SessionProvider authClient={betterAuthClient} syncDbs={[syncDb]} onLogout={() => apiClient.reset()}>
|
|
131
|
+
<ApiClientProvider client={apiClient}>
|
|
132
|
+
<SyncDbProvider client={syncDb}>{children}</SyncDbProvider>
|
|
133
|
+
</ApiClientProvider>
|
|
134
|
+
</SessionProvider>
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## Environment Variables
|
|
138
|
+
|
|
139
|
+
| Variable | Purpose |
|
|
140
|
+
|----------|---------|
|
|
141
|
+
| `EXPO_PUBLIC_API_URL` | Backend URL for web dev (e.g. `http://localhost:4000`) |
|
|
142
|
+
| `EXPO_PUBLIC_DEV_API_PORT` | Dev API port when using Expo host URI (default `4000`) |
|
|
143
|
+
| `expo.extra.BASE_URL` | Production/staging API URL in `app.json` |
|
|
144
|
+
|
|
145
|
+
Base URL resolution priority (`baseUrl` from `@terreno/syncdb/config`):
|
|
146
|
+
|
|
147
|
+
1. `expo.extra.BASE_URL`
|
|
148
|
+
2. `EXPO_PUBLIC_API_URL`
|
|
149
|
+
3. Expo dev server host + dev API port
|
|
150
|
+
4. `http://localhost:4000`
|
|
151
|
+
|
|
152
|
+
Never put server secrets in `EXPO_PUBLIC_*` variables.
|
|
153
|
+
|
|
154
|
+
## Error Handling
|
|
155
|
+
|
|
156
|
+
Request hooks expose `ApiError` (`@terreno/syncdb/api`) on the result:
|
|
157
|
+
|
|
158
|
+
```tsx
|
|
159
|
+
const {error, isError} = useGetAuthMeQuery();
|
|
160
|
+
|
|
161
|
+
if (isError) {
|
|
162
|
+
// error.status, error.data.title, error.data.detail; status 0 = network error or timeout
|
|
163
|
+
console.error("Query failed", error);
|
|
164
|
+
}
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
For mutations, use `.unwrap()`:
|
|
168
|
+
|
|
169
|
+
```tsx
|
|
170
|
+
try {
|
|
171
|
+
await updateMe({name}).unwrap();
|
|
172
|
+
} catch (error) {
|
|
173
|
+
if (isApiError(error)) {
|
|
174
|
+
console.error("Mutation failed", error.data.title);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Report every request failure once with `createApiClient({onError})` (skip Sentry when
|
|
180
|
+
`error.data.disableExternalErrorTracking` is `true`). Synced writes never throw: rejected
|
|
181
|
+
writes surface in `useConflicts()` and `useSyncStatus().failedCount`.
|
|
182
|
+
|
|
183
|
+
## SDK Regeneration
|
|
184
|
+
|
|
185
|
+
After a backend API change, run the `generate-sdk` skill.
|
|
186
|
+
|
|
187
|
+
```bash
|
|
188
|
+
# Backend running on :4000, then:
|
|
189
|
+
cd example-frontend && bun run sdk # store/apiSdk.ts (non-synced routes)
|
|
190
|
+
cd example-frontend && bun run sync-sdk # store/syncDbSdk.ts (synced collections)
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Triggers: custom routes, modelRouter actions, non-synced `modelRouter` routes, and OpenAPI
|
|
194
|
+
edits (`sdk`); synced schema or `sync` config changes (`sync-sdk`).
|
|
195
|
+
|
|
196
|
+
## Decision Tree
|
|
197
|
+
|
|
198
|
+
```
|
|
199
|
+
Need data in a Terreno app?
|
|
200
|
+
|-- Synced collection CRUD?
|
|
201
|
+
| \-- @terreno/syncdb hooks
|
|
202
|
+
|
|
|
203
|
+
|-- Non-synced API route?
|
|
204
|
+
| \-- useGet*/usePost* from store/apiSdk.ts
|
|
205
|
+
|
|
|
206
|
+
|-- Built-in admin?
|
|
207
|
+
| |-- String id + adminBroadcast -> windowed syncdb
|
|
208
|
+
| |-- Framework RPC -> AdminProvider adminRequest (useAdminRpc)
|
|
209
|
+
| \-- ObjectId / API-only CRUD -> useAdminModelApi
|
|
210
|
+
|
|
|
211
|
+
|-- Login/logout?
|
|
212
|
+
| \-- references/auth-and-tokens.md
|
|
213
|
+
|
|
|
214
|
+
|-- Server push / feature flags?
|
|
215
|
+
| \-- references/realtime.md (useSocketEvent, @terreno/syncdb/flags)
|
|
216
|
+
|
|
|
217
|
+
|-- Custom endpoint not in SDK?
|
|
218
|
+
| |-- Backend exists? -> generate-sdk first
|
|
219
|
+
| \-- Route outside OpenAPI? -> references/sdk-customization.md (useApiQuery)
|
|
220
|
+
|
|
|
221
|
+
|-- Backend doesn't exist?
|
|
222
|
+
| \-- terreno-backend-api, then generate-sdk
|
|
223
|
+
|
|
|
224
|
+
\-- Using axios/direct fetch/React Query?
|
|
225
|
+
\-- STOP — choose syncdb or a generated hook
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
## Common Mistakes
|
|
229
|
+
|
|
230
|
+
**Wrong: axios**
|
|
231
|
+
|
|
232
|
+
```tsx
|
|
233
|
+
const result = await axios.get("/todos");
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
**Right: syncdb for a synced collection**
|
|
237
|
+
|
|
238
|
+
```tsx
|
|
239
|
+
const todos = useQuery<Todo>("todos");
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
**Wrong: manual token in fetch**
|
|
243
|
+
|
|
244
|
+
```tsx
|
|
245
|
+
fetch(url, {headers: {Authorization: `Bearer ${token}`}});
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
**Right: a generated hook handles auth for a non-synced route**
|
|
249
|
+
|
|
250
|
+
```tsx
|
|
251
|
+
const {data: profile} = useGetAuthMeQuery();
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
**Wrong: editing store/apiSdk.ts**
|
|
255
|
+
|
|
256
|
+
```tsx
|
|
257
|
+
// Adding a new endpoint by hand
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
**Right: regenerate**
|
|
261
|
+
|
|
262
|
+
```bash
|
|
263
|
+
cd example-frontend && bun run sdk
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
**Wrong: refusing a synced write with 403 from a router hook**
|
|
267
|
+
|
|
268
|
+
```typescript
|
|
269
|
+
throw new APIError({status: 403, title: "Not a member"}); // in preCreate — halts the client outbox
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
**Right: 422, so syncdb nacks it as `validation`**
|
|
273
|
+
|
|
274
|
+
```typescript
|
|
275
|
+
throw new APIError({status: 422, title: "Not a member"});
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
**Wrong: rendering the empty state from `useQuery` before the stream bootstraps.** An empty array before bootstrap looks the same as an empty collection. Gate on the stream's `bootstrapped` cell. See `docs/how-to/use-syncdb-correctly.md` for this and the other pitfalls (cold-start cost per stream, dropped ack warnings, phantom creates, unresolved conflicts).
|
|
279
|
+
|
|
280
|
+
## Example Invocations
|
|
281
|
+
|
|
282
|
+
- "How do I fetch todos?" → `useQuery("todos")` from `@terreno/syncdb/react`
|
|
283
|
+
- "How do I handle login?" → `betterAuthClient.signIn.email` then `useSession().refresh()`
|
|
284
|
+
- "Should I use React Query?" → No — syncdb for collections, generated `store/apiSdk.ts` hooks for non-synced routes
|
|
285
|
+
- "API calls return 401" → Check the shared `authProvider`; see auth-and-tokens.md
|
|
286
|
+
- "Old code imports `@terreno/rtk`" → `docs/how-to/migrate-rtk-to-syncdb.md`
|
|
287
|
+
- "Hooks are missing after backend change" → Run `generate-sdk` skill
|
|
288
|
+
- "How do I add a custom endpoint?" → Backend route + OpenAPI, then regenerate the SDK
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
interface:
|
|
2
|
+
display_name: "Terreno Data Fetching"
|
|
3
|
+
short_description: "syncdb collection hooks, generated request hooks, session auth, and socket events for Terreno apps — never axios or raw fetch"
|
|
4
|
+
default_prompt: "Use $terreno-data-fetching to implement API calls with @terreno/syncdb collection hooks, generated store/apiSdk.ts hooks, and session auth in a Terreno app."
|