@terreno/agent-plugin 0.0.0-stage → 57.21.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/.claude-plugin/plugin.json +32 -0
- package/CHANGELOG.md +2124 -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 +83 -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 +73 -0
- package/skills/building-admin-interfaces/references/screens-and-routes.md +50 -0
- package/skills/building-terreno-apps/SKILL.md +280 -0
- package/skills/building-terreno-apps/agents/openai.yaml +4 -0
- package/skills/building-terreno-apps/references/navigation.md +102 -0
- package/skills/building-terreno-apps/references/route-structure.md +91 -0
- package/skills/building-terreno-apps/references/screen-patterns.md +191 -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 +156 -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 +116 -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 +276 -0
- package/skills/terreno-data-fetching/agents/openai.yaml +4 -0
- package/skills/terreno-data-fetching/references/auth-and-tokens.md +93 -0
- package/skills/terreno-data-fetching/references/realtime.md +45 -0
- package/skills/terreno-data-fetching/references/sdk-customization.md +84 -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 +85 -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 +86 -0
- package/skills/upgrading-terreno/references/ordering.md +40 -0
- package/skills/verify-ui-changes/SKILL.md +605 -0
|
@@ -0,0 +1,116 @@
|
|
|
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
|
+
realtime: { // deprecated; removed in Terreno 58 — use `sync` instead
|
|
45
|
+
methods: ["create", "update", "delete"],
|
|
46
|
+
roomStrategy: "owner",
|
|
47
|
+
},
|
|
48
|
+
sync: {scope: {type: "owner"}},
|
|
49
|
+
collectionActions: {
|
|
50
|
+
bulkComplete: {
|
|
51
|
+
method: "POST",
|
|
52
|
+
permissions: [Permissions.IsAuthenticated],
|
|
53
|
+
body: z.object({ids: z.array(z.string()).min(1)}).strict(),
|
|
54
|
+
response: z.object({matched: z.number(), modified: z.number()}).strict(),
|
|
55
|
+
summary: "Mark multiple todos complete",
|
|
56
|
+
handler: async ({body, user}) => { /* ... */ },
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
instanceActions: {
|
|
60
|
+
markComplete: {
|
|
61
|
+
method: "POST",
|
|
62
|
+
permissions: [Permissions.IsOwner],
|
|
63
|
+
summary: "Mark a single todo complete",
|
|
64
|
+
handler: async ({doc}) => { /* ... */ },
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
collectionActions: { /* named GET/POST on /todos/{name} */ },
|
|
68
|
+
instanceActions: { /* named GET/POST on /todos/:id/{name} */ },
|
|
69
|
+
// Last resort only — prefer collectionActions / instanceActions.
|
|
70
|
+
endpoints: (router) => {
|
|
71
|
+
// Custom routes registered before CRUD
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Query features
|
|
77
|
+
|
|
78
|
+
- Pagination: `?limit=20&page=2`
|
|
79
|
+
- Sort: `?sort=-created`
|
|
80
|
+
- Filters: `?completed=true` (field must be in `queryFields`)
|
|
81
|
+
- Complex: `?$and=[{...}]`, `?$or=[{...}]`
|
|
82
|
+
|
|
83
|
+
## Lifecycle hooks
|
|
84
|
+
|
|
85
|
+
| Hook | When | Use for |
|
|
86
|
+
|------|------|---------|
|
|
87
|
+
| `preCreate` | Before insert | Set `ownerId`, defaults |
|
|
88
|
+
| `preUpdate` | Before patch | Strip immutable fields |
|
|
89
|
+
| `postCreate` | After insert | Side effects, notifications |
|
|
90
|
+
| `postUpdate` | After patch | Audit, cache invalidation |
|
|
91
|
+
| `postDelete` | After delete | Cleanup |
|
|
92
|
+
|
|
93
|
+
## User casting in callbacks
|
|
94
|
+
|
|
95
|
+
```typescript
|
|
96
|
+
const user = u as unknown as UserDocument;
|
|
97
|
+
// or in route handlers:
|
|
98
|
+
(req.user as unknown as UserDocument)?._id
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Never use `as any as UserDocument`.
|
|
102
|
+
|
|
103
|
+
## Plugins on schemas
|
|
104
|
+
|
|
105
|
+
| Plugin | Adds |
|
|
106
|
+
|--------|------|
|
|
107
|
+
| `createdUpdatedPlugin` | `created`, `updated` timestamps |
|
|
108
|
+
| `isDeletedPlugin` | Soft delete (`deleted` field) |
|
|
109
|
+
| `findExactlyOne` | Static that throws on 0 or >1 match |
|
|
110
|
+
| `findOneOrNone` | Static that returns null on 0, throws on >1 |
|
|
111
|
+
|
|
112
|
+
## OpenAPI
|
|
113
|
+
|
|
114
|
+
`setupServer` / `TerrenoApp` auto-generates `/openapi.json`. Field `description` on schema paths flows to the spec via `mongoose-to-swagger`.
|
|
115
|
+
|
|
116
|
+
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,276 @@
|
|
|
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, RTK Query generated
|
|
6
|
+
hooks for non-synced routes, admin's host-bound fetch client, auth, caching,
|
|
7
|
+
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.** Use syncdb
|
|
15
|
+
for synced collection CRUD and generated RTK Query hooks for non-synced routes.
|
|
16
|
+
Never call `axios`, raw `fetch`, React Query, or SWR from application screens.
|
|
17
|
+
|
|
18
|
+
**Related skills:** `generate-sdk` (regenerate hooks after backend changes), `terreno-backend-api` (create backend routes), `building-terreno-apps` (screen integration).
|
|
19
|
+
|
|
20
|
+
## Documentation
|
|
21
|
+
|
|
22
|
+
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.
|
|
23
|
+
2. Implement against that design.
|
|
24
|
+
3. Update those pages in the same slice with `update-docs`.
|
|
25
|
+
4. Ship without matching docs is a failed slice.
|
|
26
|
+
|
|
27
|
+
## References
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
references/
|
|
31
|
+
auth-and-tokens.md generateAuthSlice, login/logout, token refresh
|
|
32
|
+
realtime.md Sockets, feature flags, server status
|
|
33
|
+
sdk-customization.md injectEndpoints, cache tags, hand-maintained sdk.ts
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## When to Use
|
|
37
|
+
|
|
38
|
+
- Implementing API requests in screens or hooks
|
|
39
|
+
- Setting up authentication (login, logout, token refresh)
|
|
40
|
+
- Debugging network failures or stale data
|
|
41
|
+
- Connecting realtime updates (sockets, feature flags)
|
|
42
|
+
- Configuring API URLs and environment variables
|
|
43
|
+
- After any backend route or model change (regenerate SDK)
|
|
44
|
+
|
|
45
|
+
## Core Rules
|
|
46
|
+
|
|
47
|
+
1. **Use syncdb hooks** (`useQuery`, `useEntity`, `useMutate`) for synced collection CRUD.
|
|
48
|
+
2. **Use generated hooks** from `store/openApiSdk.ts` (or `store/sdk.ts`) for non-synced routes.
|
|
49
|
+
3. **Never edit `openApiSdk.ts` manually** — regenerate with `generate-sdk`.
|
|
50
|
+
4. **Never use `axios` or raw `fetch`** in app code. Framework-owned admin RPC
|
|
51
|
+
is the exception: `@terreno/admin-frontend` binds `adminRequest` from host
|
|
52
|
+
`credentials` / `getAuthHeaders`.
|
|
53
|
+
5. **Do not add new admin `injectEndpoints`.** String-`_id` models with
|
|
54
|
+
`adminBroadcast` use windowed syncdb; ObjectId compatibility CRUD remains RTK
|
|
55
|
+
until Terreno 58 removes `useAdminApi` and the required `api` prop.
|
|
56
|
+
|
|
57
|
+
## Quick Start
|
|
58
|
+
|
|
59
|
+
### Synced collection read
|
|
60
|
+
|
|
61
|
+
```tsx
|
|
62
|
+
import {useQuery} from "@terreno/syncdb/react";
|
|
63
|
+
|
|
64
|
+
const TodosScreen: React.FC = () => {
|
|
65
|
+
const todos = useQuery<Todo>("todos", {
|
|
66
|
+
filter: (todo) => !todo.completed,
|
|
67
|
+
});
|
|
68
|
+
};
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Synced collection write
|
|
72
|
+
|
|
73
|
+
```tsx
|
|
74
|
+
import {useMutate} from "@terreno/syncdb/react";
|
|
75
|
+
|
|
76
|
+
const {create, update} = useMutate("todos");
|
|
77
|
+
|
|
78
|
+
const handleCreate = useCallback((): void => {
|
|
79
|
+
create({data: {title: "New todo", completed: false}});
|
|
80
|
+
}, [create]);
|
|
81
|
+
|
|
82
|
+
const handleToggle = useCallback((id: string, completed: boolean): void => {
|
|
83
|
+
update({id, data: {completed: !completed}});
|
|
84
|
+
}, [update]);
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Auth
|
|
88
|
+
|
|
89
|
+
```tsx
|
|
90
|
+
import {generateAuthSlice} from "@terreno/rtk";
|
|
91
|
+
import {terrenoApi} from "@/store/sdk";
|
|
92
|
+
|
|
93
|
+
const {authReducer, logout, middleware} = generateAuthSlice(terrenoApi);
|
|
94
|
+
|
|
95
|
+
// In a component:
|
|
96
|
+
import {useEmailLoginMutation} from "@/store/sdk";
|
|
97
|
+
import {useSelectCurrentUserId} from "@terreno/rtk";
|
|
98
|
+
|
|
99
|
+
const [login] = useEmailLoginMutation();
|
|
100
|
+
const userId = useSelectCurrentUserId();
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
See `./references/auth-and-tokens.md` for the full auth flow.
|
|
104
|
+
|
|
105
|
+
## Redux Store Setup
|
|
106
|
+
|
|
107
|
+
```tsx
|
|
108
|
+
import {configureStore} from "@reduxjs/toolkit";
|
|
109
|
+
import {generateAuthSlice} from "@terreno/rtk";
|
|
110
|
+
import {terrenoApi} from "./sdk";
|
|
111
|
+
|
|
112
|
+
const authSlice = generateAuthSlice(terrenoApi);
|
|
113
|
+
|
|
114
|
+
const store = configureStore({
|
|
115
|
+
reducer: {
|
|
116
|
+
auth: authSlice.authReducer,
|
|
117
|
+
[terrenoApi.reducerPath]: terrenoApi.reducer,
|
|
118
|
+
},
|
|
119
|
+
middleware: (getDefault) =>
|
|
120
|
+
getDefault().concat(terrenoApi.middleware, ...authSlice.middleware),
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
export const {logout} = authSlice;
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
See `example-frontend/store/index.ts` for persist and Sentry integration.
|
|
127
|
+
|
|
128
|
+
## Environment Variables
|
|
129
|
+
|
|
130
|
+
| Variable | Purpose |
|
|
131
|
+
|----------|---------|
|
|
132
|
+
| `EXPO_PUBLIC_API_URL` | Backend URL for web dev (e.g. `http://localhost:4000`) |
|
|
133
|
+
| `EXPO_PUBLIC_DEV_API_PORT` | Dev API port when using Expo host URI (default `4000`) |
|
|
134
|
+
| `expo.extra.BASE_URL` | Production/staging API URL in `app.json` |
|
|
135
|
+
|
|
136
|
+
Base URL resolution priority (from `@terreno/rtk`):
|
|
137
|
+
|
|
138
|
+
1. `expo.extra.BASE_URL`
|
|
139
|
+
2. `EXPO_PUBLIC_API_URL`
|
|
140
|
+
3. Expo dev server host + dev API port
|
|
141
|
+
4. `http://localhost:4000`
|
|
142
|
+
|
|
143
|
+
Never put server secrets in `EXPO_PUBLIC_*` variables.
|
|
144
|
+
|
|
145
|
+
## Error Handling
|
|
146
|
+
|
|
147
|
+
RTK Query exposes errors on the hook result:
|
|
148
|
+
|
|
149
|
+
```tsx
|
|
150
|
+
const {error, isError} = useGetMeQuery();
|
|
151
|
+
|
|
152
|
+
if (isError) {
|
|
153
|
+
// error is FetchBaseQueryError | SerializedError
|
|
154
|
+
console.error("Query failed", error);
|
|
155
|
+
}
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
For mutations, use `.unwrap()`:
|
|
159
|
+
|
|
160
|
+
```tsx
|
|
161
|
+
try {
|
|
162
|
+
await updateMe({name}).unwrap();
|
|
163
|
+
} catch (err) {
|
|
164
|
+
// Handle APIError-shaped responses from @terreno/api
|
|
165
|
+
console.error("Mutation failed", err);
|
|
166
|
+
}
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Use `isNetworkFetchError` from `@terreno/rtk` to detect connectivity issues in error UI.
|
|
170
|
+
|
|
171
|
+
## SDK Regeneration
|
|
172
|
+
|
|
173
|
+
After a non-synced backend API change, run the `generate-sdk` skill. Synced
|
|
174
|
+
collection CRUD does not belong in the OpenAPI SDK.
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
# Backend running on :4000, then:
|
|
178
|
+
cd example-frontend && bun run sdk
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Triggers: custom routes, non-synced `modelRouter` routes, and OpenAPI builder
|
|
182
|
+
edits. Regenerate syncdb collection types/hooks with its codegen when a synced
|
|
183
|
+
schema changes.
|
|
184
|
+
|
|
185
|
+
## Decision Tree
|
|
186
|
+
|
|
187
|
+
```
|
|
188
|
+
Need data in a Terreno app?
|
|
189
|
+
|-- Synced collection CRUD?
|
|
190
|
+
| \-- @terreno/syncdb hooks
|
|
191
|
+
|
|
|
192
|
+
|-- Non-synced API route?
|
|
193
|
+
| \-- useGet*/usePost* from generated SDK
|
|
194
|
+
|
|
|
195
|
+
|-- Built-in admin?
|
|
196
|
+
| |-- String id + adminBroadcast -> windowed syncdb
|
|
197
|
+
| |-- Framework RPC -> host-bound adminRequest
|
|
198
|
+
| \-- ObjectId compatibility CRUD -> useAdminApi until Terreno 58
|
|
199
|
+
|
|
|
200
|
+
|-- Login/logout?
|
|
201
|
+
| \-- references/auth-and-tokens.md
|
|
202
|
+
|
|
|
203
|
+
|-- Realtime updates?
|
|
204
|
+
| \-- references/realtime.md (useSocketConnection)
|
|
205
|
+
|
|
|
206
|
+
|-- Custom endpoint not in SDK?
|
|
207
|
+
| |-- Backend exists? -> generate-sdk first
|
|
208
|
+
| \-- App-only extension? -> references/sdk-customization.md
|
|
209
|
+
|
|
|
210
|
+
|-- Backend doesn't exist?
|
|
211
|
+
| \-- terreno-backend-api, then generate-sdk
|
|
212
|
+
|
|
|
213
|
+
\-- Using axios/direct fetch/React Query?
|
|
214
|
+
\-- STOP — choose syncdb or a generated hook
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
## Common Mistakes
|
|
218
|
+
|
|
219
|
+
**Wrong: axios**
|
|
220
|
+
|
|
221
|
+
```tsx
|
|
222
|
+
const result = await axios.get("/todos");
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
**Right: syncdb for a synced collection**
|
|
226
|
+
|
|
227
|
+
```tsx
|
|
228
|
+
const todos = useQuery<Todo>("todos");
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
**Wrong: manual token in fetch**
|
|
232
|
+
|
|
233
|
+
```tsx
|
|
234
|
+
fetch(url, {headers: {Authorization: `Bearer ${token}`}});
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
**Right: a generated hook handles auth for a non-synced route**
|
|
238
|
+
|
|
239
|
+
```tsx
|
|
240
|
+
const {data: profile} = useGetMeQuery();
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
**Wrong: editing openApiSdk.ts**
|
|
244
|
+
|
|
245
|
+
```tsx
|
|
246
|
+
// Adding a new endpoint by hand
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
**Right: regenerate**
|
|
250
|
+
|
|
251
|
+
```bash
|
|
252
|
+
cd example-frontend && bun run sdk
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
**Wrong: refusing a synced write with 403 from a router hook**
|
|
256
|
+
|
|
257
|
+
```typescript
|
|
258
|
+
throw new APIError({status: 403, title: "Not a member"}); // in preCreate — halts the client outbox
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
**Right: 422, so syncdb nacks it as `validation`**
|
|
262
|
+
|
|
263
|
+
```typescript
|
|
264
|
+
throw new APIError({status: 422, title: "Not a member"});
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
**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).
|
|
268
|
+
|
|
269
|
+
## Example Invocations
|
|
270
|
+
|
|
271
|
+
- "How do I fetch todos?" → `useQuery("todos")` from `@terreno/syncdb/react`
|
|
272
|
+
- "How do I handle login?" → `useEmailLoginMutation` + `generateAuthSlice`
|
|
273
|
+
- "Should I use React Query?" → No — syncdb for collections, generated RTK hooks for non-synced routes
|
|
274
|
+
- "API calls return 401" → Check token refresh; see auth-and-tokens.md
|
|
275
|
+
- "Hooks are missing after backend change" → Run `generate-sdk` skill
|
|
276
|
+
- "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: "RTK Query generated hooks, auth tokens, and realtime sockets for Terreno apps — never axios or raw fetch"
|
|
4
|
+
default_prompt: "Use $terreno-data-fetching to implement API calls with generated RTK Query hooks, auth, and caching in a Terreno app."
|