@terreno/mcp 57.19.2 → 57.20.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/CHANGELOG.md +393 -0
- package/README.md +32 -269
- package/dist/bootstrap.d.ts +12 -0
- package/dist/bootstrap.d.ts.map +1 -1
- package/dist/bootstrap.js +77 -122
- package/dist/bootstrap.js.map +1 -1
- package/dist/docs/guidelines/ui/guidelines/core.md +1 -1
- package/dist/docs/resources/overview.md +4 -2
- package/dist/docs/resources/patterns.md +237 -176
- package/dist/docs/resources/ui.md +48 -17
- package/dist/docs/templates/bootstrap/frontend/app/(tabs)/profile.template.tsx +0 -26
- package/dist/docs/templates/bootstrap/frontend/app/_layout.template.tsx +3 -2
- package/dist/docs/templates/bootstrap/frontend/app/login.template.tsx +1 -1
- package/dist/docs/upgrades/57.20.0.md +238 -0
- package/dist/docs/versioned/next/approval-rubric.md +30 -12
- package/dist/docs/versioned/next/explanation/README.md +5 -0
- package/dist/docs/versioned/next/explanation/account-linking.md +169 -0
- package/dist/docs/versioned/next/explanation/admin-interface.md +1 -1
- package/dist/docs/versioned/next/explanation/agent-guidelines.md +173 -0
- package/dist/docs/versioned/next/explanation/ai-development-loop.md +196 -0
- package/dist/docs/versioned/next/explanation/authentication.md +45 -1
- package/dist/docs/versioned/next/explanation/consent-forms.md +105 -0
- package/dist/docs/versioned/next/explanation/example-coverage.md +1 -0
- package/dist/docs/versioned/next/explanation/local-first-data.md +1 -1
- package/dist/docs/versioned/next/explanation/organizations.md +37 -0
- package/dist/docs/versioned/next/explanation/planning-plugins.md +4 -0
- package/dist/docs/versioned/next/explanation/responsive-design.md +143 -0
- package/dist/docs/versioned/next/explanation/roadmap-seed-issues.md +11 -0
- package/dist/docs/versioned/next/explanation/schema-metadata.md +5 -0
- package/dist/docs/versioned/next/how-to/README.md +7 -2
- package/dist/docs/versioned/next/how-to/audit-log.md +22 -3
- package/dist/docs/versioned/next/how-to/automate-terreno-upgrades.md +169 -0
- package/dist/docs/versioned/next/how-to/build-list-detail-screens.md +160 -0
- package/dist/docs/versioned/next/how-to/circleci.md +15 -5
- package/dist/docs/versioned/next/how-to/configure-better-auth.md +673 -53
- package/dist/docs/versioned/next/how-to/create-a-terreno-app.md +8 -5
- package/dist/docs/versioned/next/how-to/debug-with-mcp.md +393 -23
- package/dist/docs/versioned/next/how-to/enable-mcp-oauth.md +54 -3
- package/dist/docs/versioned/next/how-to/migrate-rtk-to-syncdb.md +9 -0
- package/dist/docs/versioned/next/how-to/password-reset.md +1 -1
- package/dist/docs/versioned/next/how-to/recover-locked-out-user.md +112 -0
- package/dist/docs/versioned/next/how-to/run-tests-locally.md +37 -8
- package/dist/docs/versioned/next/how-to/set-up-social-login.md +424 -0
- package/dist/docs/versioned/next/how-to/set-up-terreno-mcp.md +241 -36
- package/dist/docs/versioned/next/how-to/sign-consent-on-behalf.md +139 -0
- package/dist/docs/versioned/next/how-to/upgrade-terreno.md +4 -0
- package/dist/docs/versioned/next/implementationPlans/ai-dev-loop-boost.md +47 -32
- package/dist/docs/versioned/next/implementationPlans/auth-experience.md +952 -0
- package/dist/docs/versioned/next/implementationPlans/consent-forms.md +8 -0
- package/dist/docs/versioned/next/implementationPlans/list-detail-layout.md +297 -0
- package/dist/docs/versioned/next/implementationPlans/login-redesign/HANDOFF.md +211 -0
- package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/AnotherWay.html +121 -0
- package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/BackupCode.html +72 -0
- package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/Cancelled.html +87 -0
- package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/ContinueEmail.html +64 -0
- package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/FirstVisit.html +81 -0
- package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/Main.html +88 -0
- package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/ManageAccounts.html +77 -0
- package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/NotLinked.html +92 -0
- package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/SigningIn.html +60 -0
- package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/TwoFactor.html +76 -0
- package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/TwoFactorError.html +81 -0
- package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/WrongPassword.html +69 -0
- package/dist/docs/versioned/next/implementationPlans/mcp-boost-parity.md +1 -1
- package/dist/docs/versioned/next/plans/2026-10-23-provisional-consent.md +297 -0
- package/dist/docs/versioned/next/plans/2026-10-29-fix-mcp-oauth-native-and-machine.md +68 -0
- package/dist/docs/versioned/next/reference/README.md +1 -0
- package/dist/docs/versioned/next/reference/account-security.md +700 -0
- package/dist/docs/versioned/next/reference/admin-backend.md +3 -1
- package/dist/docs/versioned/next/reference/admin-frontend.md +225 -1
- package/dist/docs/versioned/next/reference/api.md +461 -35
- package/dist/docs/versioned/next/reference/cli.md +1 -1
- package/dist/docs/versioned/next/reference/comms.md +8 -0
- package/dist/docs/versioned/next/reference/create-terreno-app.md +3 -1
- package/dist/docs/versioned/next/reference/environment-variables.md +9 -3
- package/dist/docs/versioned/next/reference/legacy/rtk.md +19 -3
- package/dist/docs/versioned/next/reference/lifecycle-plugin.md +6 -0
- package/dist/docs/versioned/next/reference/mcp-server.md +1158 -441
- package/dist/docs/versioned/next/reference/syncdb.md +8 -1
- package/dist/docs/versioned/next/reference/ui.md +1271 -27
- package/dist/docs/versioned/next/tasks/ai-dev-loop-boost.md +28 -16
- package/dist/docs/versioned/next/tutorials/README.md +4 -0
- package/dist/httpApp.d.ts +14 -0
- package/dist/httpApp.d.ts.map +1 -0
- package/dist/httpApp.js +99 -0
- package/dist/httpApp.js.map +1 -0
- package/dist/index.js +8 -90
- package/dist/index.js.map +1 -1
- package/dist/local/localTools.d.ts.map +1 -1
- package/dist/local/localTools.js +44 -14
- package/dist/local/localTools.js.map +1 -1
- package/dist/local/metro/metroDevSession.d.ts +18 -0
- package/dist/local/metro/metroDevSession.d.ts.map +1 -1
- package/dist/local/metro/metroDevSession.js +42 -3
- package/dist/local/metro/metroDevSession.js.map +1 -1
- package/dist/local/tools/databaseQuery.d.ts.map +1 -1
- package/dist/local/tools/databaseQuery.js +3 -13
- package/dist/local/tools/databaseQuery.js.map +1 -1
- package/dist/local/tools/databaseSchema.d.ts.map +1 -1
- package/dist/local/tools/databaseSchema.js +3 -13
- package/dist/local/tools/databaseSchema.js.map +1 -1
- package/dist/local/tools/localMongo.d.ts +10 -0
- package/dist/local/tools/localMongo.d.ts.map +1 -0
- package/dist/local/tools/localMongo.js +30 -0
- package/dist/local/tools/localMongo.js.map +1 -0
- package/dist/local/tools/runtime.d.ts +18 -2
- package/dist/local/tools/runtime.d.ts.map +1 -1
- package/dist/local/tools/runtime.js +75 -20
- package/dist/local/tools/runtime.js.map +1 -1
- package/dist/local/tools/syncDb.d.ts +11 -0
- package/dist/local/tools/syncDb.d.ts.map +1 -1
- package/dist/local/tools/syncDb.js +15 -3
- package/dist/local/tools/syncDb.js.map +1 -1
- package/dist/rateLimit.d.ts +35 -0
- package/dist/rateLimit.d.ts.map +1 -0
- package/dist/rateLimit.js +112 -0
- package/dist/rateLimit.js.map +1 -0
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +42 -60
- package/dist/tools.js.map +1 -1
- package/package.json +4 -3
- package/src/docs/guidelines/ui/guidelines/core.md +1 -1
- package/src/docs/resources/overview.md +4 -2
- package/src/docs/resources/patterns.md +237 -176
- package/src/docs/resources/ui.md +48 -17
- package/src/docs/templates/bootstrap/frontend/app/(tabs)/profile.template.tsx +0 -26
- package/src/docs/templates/bootstrap/frontend/app/_layout.template.tsx +3 -2
- package/src/docs/templates/bootstrap/frontend/app/login.template.tsx +1 -1
- package/src/docs/upgrades/57.20.0.md +238 -0
- package/src/docs/versioned/next/approval-rubric.md +30 -12
- package/src/docs/versioned/next/explanation/README.md +5 -0
- package/src/docs/versioned/next/explanation/account-linking.md +169 -0
- package/src/docs/versioned/next/explanation/admin-interface.md +1 -1
- package/src/docs/versioned/next/explanation/agent-guidelines.md +173 -0
- package/src/docs/versioned/next/explanation/ai-development-loop.md +196 -0
- package/src/docs/versioned/next/explanation/authentication.md +45 -1
- package/src/docs/versioned/next/explanation/consent-forms.md +105 -0
- package/src/docs/versioned/next/explanation/example-coverage.md +1 -0
- package/src/docs/versioned/next/explanation/local-first-data.md +1 -1
- package/src/docs/versioned/next/explanation/organizations.md +37 -0
- package/src/docs/versioned/next/explanation/planning-plugins.md +4 -0
- package/src/docs/versioned/next/explanation/responsive-design.md +143 -0
- package/src/docs/versioned/next/explanation/roadmap-seed-issues.md +11 -0
- package/src/docs/versioned/next/explanation/schema-metadata.md +5 -0
- package/src/docs/versioned/next/how-to/README.md +7 -2
- package/src/docs/versioned/next/how-to/audit-log.md +22 -3
- package/src/docs/versioned/next/how-to/automate-terreno-upgrades.md +169 -0
- package/src/docs/versioned/next/how-to/build-list-detail-screens.md +160 -0
- package/src/docs/versioned/next/how-to/circleci.md +15 -5
- package/src/docs/versioned/next/how-to/configure-better-auth.md +673 -53
- package/src/docs/versioned/next/how-to/create-a-terreno-app.md +8 -5
- package/src/docs/versioned/next/how-to/debug-with-mcp.md +393 -23
- package/src/docs/versioned/next/how-to/enable-mcp-oauth.md +54 -3
- package/src/docs/versioned/next/how-to/migrate-rtk-to-syncdb.md +9 -0
- package/src/docs/versioned/next/how-to/password-reset.md +1 -1
- package/src/docs/versioned/next/how-to/recover-locked-out-user.md +112 -0
- package/src/docs/versioned/next/how-to/run-tests-locally.md +37 -8
- package/src/docs/versioned/next/how-to/set-up-social-login.md +424 -0
- package/src/docs/versioned/next/how-to/set-up-terreno-mcp.md +241 -36
- package/src/docs/versioned/next/how-to/sign-consent-on-behalf.md +139 -0
- package/src/docs/versioned/next/how-to/upgrade-terreno.md +4 -0
- package/src/docs/versioned/next/implementationPlans/ai-dev-loop-boost.md +47 -32
- package/src/docs/versioned/next/implementationPlans/auth-experience.md +952 -0
- package/src/docs/versioned/next/implementationPlans/consent-forms.md +8 -0
- package/src/docs/versioned/next/implementationPlans/list-detail-layout.md +297 -0
- package/src/docs/versioned/next/implementationPlans/login-redesign/HANDOFF.md +211 -0
- package/src/docs/versioned/next/implementationPlans/login-redesign/boards/AnotherWay.html +121 -0
- package/src/docs/versioned/next/implementationPlans/login-redesign/boards/BackupCode.html +72 -0
- package/src/docs/versioned/next/implementationPlans/login-redesign/boards/Cancelled.html +87 -0
- package/src/docs/versioned/next/implementationPlans/login-redesign/boards/ContinueEmail.html +64 -0
- package/src/docs/versioned/next/implementationPlans/login-redesign/boards/FirstVisit.html +81 -0
- package/src/docs/versioned/next/implementationPlans/login-redesign/boards/Main.html +88 -0
- package/src/docs/versioned/next/implementationPlans/login-redesign/boards/ManageAccounts.html +77 -0
- package/src/docs/versioned/next/implementationPlans/login-redesign/boards/NotLinked.html +92 -0
- package/src/docs/versioned/next/implementationPlans/login-redesign/boards/SigningIn.html +60 -0
- package/src/docs/versioned/next/implementationPlans/login-redesign/boards/TwoFactor.html +76 -0
- package/src/docs/versioned/next/implementationPlans/login-redesign/boards/TwoFactorError.html +81 -0
- package/src/docs/versioned/next/implementationPlans/login-redesign/boards/WrongPassword.html +69 -0
- package/src/docs/versioned/next/implementationPlans/mcp-boost-parity.md +1 -1
- package/src/docs/versioned/next/plans/2026-10-23-provisional-consent.md +297 -0
- package/src/docs/versioned/next/plans/2026-10-29-fix-mcp-oauth-native-and-machine.md +68 -0
- package/src/docs/versioned/next/reference/README.md +1 -0
- package/src/docs/versioned/next/reference/account-security.md +700 -0
- package/src/docs/versioned/next/reference/admin-backend.md +3 -1
- package/src/docs/versioned/next/reference/admin-frontend.md +225 -1
- package/src/docs/versioned/next/reference/api.md +461 -35
- package/src/docs/versioned/next/reference/cli.md +1 -1
- package/src/docs/versioned/next/reference/comms.md +8 -0
- package/src/docs/versioned/next/reference/create-terreno-app.md +3 -1
- package/src/docs/versioned/next/reference/environment-variables.md +9 -3
- package/src/docs/versioned/next/reference/legacy/rtk.md +19 -3
- package/src/docs/versioned/next/reference/lifecycle-plugin.md +6 -0
- package/src/docs/versioned/next/reference/mcp-server.md +1158 -441
- package/src/docs/versioned/next/reference/syncdb.md +8 -1
- package/src/docs/versioned/next/reference/ui.md +1271 -27
- package/src/docs/versioned/next/tasks/ai-dev-loop-boost.md +28 -16
- package/src/docs/versioned/next/tutorials/README.md +4 -0
|
@@ -1,216 +1,269 @@
|
|
|
1
1
|
# Terreno Patterns & Best Practices
|
|
2
2
|
|
|
3
|
+
Collection CRUD is local-first: the backend syncs a collection with `syncPlugin` and a `sync`
|
|
4
|
+
config on `modelRouter`, and the frontend reads and writes it with `@terreno/syncdb/react`
|
|
5
|
+
(`useQuery`, `useMutate`). Generated RTK Query hooks from `openApiSdk.ts` are only for routes
|
|
6
|
+
that are not synced: auth (`/auth/me`), AI, feature flags, and custom request/response actions.
|
|
7
|
+
|
|
3
8
|
## Backend Patterns
|
|
4
9
|
|
|
5
10
|
### Model Definition
|
|
6
11
|
|
|
12
|
+
A synced model uses a String `_id` (offline clients mint ids), `isDeletedPlugin` (via the app's
|
|
13
|
+
default plugins), and `syncPlugin`. Types live in `src/types/models/`. Every field has a
|
|
14
|
+
`description`.
|
|
15
|
+
|
|
7
16
|
```typescript
|
|
17
|
+
import {syncPlugin} from "@terreno/api";
|
|
8
18
|
import mongoose from "mongoose";
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
interface TodoDocument extends mongoose.Document {
|
|
12
|
-
title: string;
|
|
13
|
-
completed: boolean;
|
|
14
|
-
ownerId: mongoose.Types.ObjectId;
|
|
15
|
-
created: Date;
|
|
16
|
-
updated: Date;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
interface TodoModel extends mongoose.Model<TodoDocument> {
|
|
20
|
-
findByOwner(ownerId: string): Promise<TodoDocument[]>;
|
|
21
|
-
}
|
|
19
|
+
import type {TodoDocument, TodoModel} from "../types/models/todoTypes";
|
|
20
|
+
import {addDefaultPlugins} from "./modelPlugins";
|
|
22
21
|
|
|
23
22
|
const todoSchema = new mongoose.Schema<TodoDocument, TodoModel>(
|
|
24
23
|
{
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
24
|
+
_id: {
|
|
25
|
+
default: (): string => new mongoose.Types.ObjectId().toHexString(),
|
|
26
|
+
description: "The document id (String so offline sync clients can mint ids)",
|
|
27
|
+
type: String,
|
|
28
|
+
},
|
|
29
|
+
completed: {
|
|
30
|
+
default: false,
|
|
31
|
+
description: "Whether the todo item has been completed",
|
|
32
|
+
type: Boolean,
|
|
33
|
+
},
|
|
34
|
+
ownerId: {
|
|
35
|
+
description: "The user who owns this todo",
|
|
36
|
+
ref: "User",
|
|
37
|
+
required: true,
|
|
38
|
+
type: mongoose.Schema.Types.ObjectId,
|
|
39
|
+
},
|
|
40
|
+
title: {
|
|
41
|
+
description: "The title of the todo item",
|
|
42
|
+
required: true,
|
|
43
|
+
trim: true,
|
|
44
|
+
type: String,
|
|
45
|
+
},
|
|
28
46
|
},
|
|
29
|
-
{
|
|
30
|
-
strict: "throw",
|
|
31
|
-
toJSON: { virtuals: true },
|
|
32
|
-
toObject: { virtuals: true },
|
|
33
|
-
}
|
|
47
|
+
{strict: "throw", toJSON: {virtuals: true}, toObject: {virtuals: true}}
|
|
34
48
|
);
|
|
35
49
|
|
|
50
|
+
// addDefaultPlugins applies createdUpdatedPlugin, isDeletedPlugin, findExactlyOne, findOneOrNone.
|
|
36
51
|
addDefaultPlugins(todoSchema);
|
|
37
|
-
|
|
38
|
-
todoSchema.
|
|
39
|
-
return this.find({ ownerId });
|
|
40
|
-
};
|
|
52
|
+
// Stamps a per-stream _syncSeq on every write; required by the router's sync config.
|
|
53
|
+
todoSchema.plugin(syncPlugin);
|
|
41
54
|
|
|
42
55
|
export const Todo = mongoose.model<TodoDocument, TodoModel>("Todo", todoSchema);
|
|
43
56
|
```
|
|
44
57
|
|
|
45
58
|
### Route Setup
|
|
46
59
|
|
|
60
|
+
Use the three-argument `modelRouter` and register it on `TerrenoApp` together with `SyncApp`
|
|
61
|
+
and `RealtimeApp`. Sync needs a MongoDB replica set (change streams).
|
|
62
|
+
|
|
47
63
|
```typescript
|
|
48
|
-
import {
|
|
49
|
-
import {
|
|
50
|
-
import {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
ownerId: (req.user as UserDocument)?._id,
|
|
70
|
-
}),
|
|
71
|
-
postCreate: async (todo, req) => {
|
|
72
|
-
// Send notification, update stats, etc.
|
|
73
|
-
logger.info("Todo created", { todoId: todo._id });
|
|
74
|
-
},
|
|
75
|
-
})
|
|
76
|
-
);
|
|
77
|
-
};
|
|
64
|
+
import {modelRouter, OwnerQueryFilter, Permissions} from "@terreno/api";
|
|
65
|
+
import {Todo} from "../models/todo";
|
|
66
|
+
import type {UserDocument} from "../types/models/userTypes";
|
|
67
|
+
|
|
68
|
+
export const todoRouter = modelRouter("/todos", Todo, {
|
|
69
|
+
permissions: {
|
|
70
|
+
create: [Permissions.IsAuthenticated],
|
|
71
|
+
delete: [Permissions.IsOwner],
|
|
72
|
+
list: [Permissions.IsAuthenticated],
|
|
73
|
+
read: [Permissions.IsOwner],
|
|
74
|
+
update: [Permissions.IsOwner],
|
|
75
|
+
},
|
|
76
|
+
preCreate: (body, req) => ({
|
|
77
|
+
...body,
|
|
78
|
+
ownerId: (req.user as unknown as UserDocument)?._id,
|
|
79
|
+
}),
|
|
80
|
+
queryFields: ["completed", "ownerId"],
|
|
81
|
+
queryFilter: OwnerQueryFilter,
|
|
82
|
+
sort: "-created",
|
|
83
|
+
sync: {scope: {type: "owner"}},
|
|
84
|
+
});
|
|
78
85
|
```
|
|
79
86
|
|
|
80
87
|
### Custom Endpoints
|
|
81
88
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
const toggleCompleteBuilder = createOpenApiBuilder()
|
|
86
|
-
.withTags(["Todos"])
|
|
87
|
-
.withSummary("Toggle todo completion status")
|
|
88
|
-
.withResponse(200, TodoSchema);
|
|
89
|
-
|
|
90
|
-
router.post(
|
|
91
|
-
"/todos/:id/toggle",
|
|
92
|
-
authenticateMiddleware,
|
|
93
|
-
toggleCompleteBuilder.build(),
|
|
94
|
-
async (req, res, next) => {
|
|
95
|
-
try {
|
|
96
|
-
const todo = await Todo.findOneOrThrow({ _id: req.params.id });
|
|
97
|
-
|
|
98
|
-
if (!todo.ownerId.equals((req.user as UserDocument)._id)) {
|
|
99
|
-
throw new APIError({
|
|
100
|
-
status: 403,
|
|
101
|
-
title: "Forbidden",
|
|
102
|
-
detail: "You do not own this todo",
|
|
103
|
-
});
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
todo.completed = !todo.completed;
|
|
107
|
-
await todo.save();
|
|
89
|
+
Add non-CRUD endpoints as `collectionActions` or `instanceActions` on the same `modelRouter`,
|
|
90
|
+
not with `router.post`. They get permissions, validation, and OpenAPI automatically.
|
|
108
91
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
92
|
+
```typescript
|
|
93
|
+
import {APIError, modelRouter, Permissions, z} from "@terreno/api";
|
|
94
|
+
|
|
95
|
+
export const todoRouter = modelRouter("/todos", Todo, {
|
|
96
|
+
// ...permissions, sync, and the rest from Route Setup
|
|
97
|
+
collectionActions: {
|
|
98
|
+
bulkComplete: {
|
|
99
|
+
body: z.object({ids: z.array(z.string()).min(1)}).strict(),
|
|
100
|
+
handler: async ({body, user}) => {
|
|
101
|
+
const result = await Todo.updateMany(
|
|
102
|
+
{_id: {$in: body.ids}, ownerId: user?._id},
|
|
103
|
+
{$set: {completed: true}}
|
|
104
|
+
);
|
|
105
|
+
return {matched: result.matchedCount, modified: result.modifiedCount};
|
|
106
|
+
},
|
|
107
|
+
method: "POST",
|
|
108
|
+
permissions: [Permissions.IsAuthenticated],
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
instanceActions: {
|
|
112
|
+
markComplete: {
|
|
113
|
+
handler: async ({doc}) => {
|
|
114
|
+
if (doc.completed) {
|
|
115
|
+
throw new APIError({status: 409, title: "Todo is already completed"});
|
|
116
|
+
}
|
|
117
|
+
doc.completed = true;
|
|
118
|
+
await doc.save();
|
|
119
|
+
return doc;
|
|
120
|
+
},
|
|
121
|
+
method: "POST",
|
|
122
|
+
permissions: [Permissions.IsOwner],
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
});
|
|
115
126
|
```
|
|
116
127
|
|
|
117
128
|
## Frontend Patterns
|
|
118
129
|
|
|
119
130
|
### Screen with Data Fetching
|
|
120
131
|
|
|
132
|
+
Synced collections are read from the local store with `useQuery` and written with `useMutate`.
|
|
133
|
+
Writes apply immediately and sync through the durable outbox: there is no loading state per
|
|
134
|
+
request, no `.unwrap()`, and no refetch after a mutation. Wrap the screen (or the app) in
|
|
135
|
+
`SyncDbProvider`, and do not call `mutate` until `syncDb.start()` has resolved.
|
|
136
|
+
|
|
121
137
|
```typescript
|
|
122
|
-
import
|
|
123
|
-
import {
|
|
124
|
-
import {
|
|
138
|
+
import {generateMutationId} from "@terreno/syncdb";
|
|
139
|
+
import {SyncDbProvider, useMutate, useQuery} from "@terreno/syncdb/react";
|
|
140
|
+
import {Box, Button, Card, CheckBox, Heading, IconButton, Text, TextField} from "@terreno/ui";
|
|
141
|
+
import {DateTime} from "luxon";
|
|
142
|
+
import type React from "react";
|
|
143
|
+
import {useCallback, useState} from "react";
|
|
144
|
+
import {useSyncDbReady} from "@/hooks/useSyncDbReady";
|
|
145
|
+
import {syncDb} from "@/store/syncdb";
|
|
146
|
+
|
|
147
|
+
interface Todo {
|
|
148
|
+
_id: string;
|
|
149
|
+
completed?: boolean;
|
|
150
|
+
created: string;
|
|
151
|
+
ownerId: string;
|
|
152
|
+
title: string;
|
|
153
|
+
}
|
|
125
154
|
|
|
126
|
-
const
|
|
127
|
-
const
|
|
128
|
-
const
|
|
129
|
-
const
|
|
155
|
+
const TodoList: React.FC = () => {
|
|
156
|
+
const isSyncDbReady = useSyncDbReady();
|
|
157
|
+
const [newTitle, setNewTitle] = useState<string>("");
|
|
158
|
+
const todos = useQuery<Todo>("todos", {sort: "-created"});
|
|
159
|
+
const {create, update, remove} = useMutate("todos");
|
|
130
160
|
|
|
131
|
-
const handleCreate = useCallback(
|
|
132
|
-
|
|
161
|
+
const handleCreate = useCallback((): void => {
|
|
162
|
+
const title = newTitle.trim();
|
|
163
|
+
if (!title || !isSyncDbReady) {
|
|
133
164
|
return;
|
|
134
165
|
}
|
|
166
|
+
// Mint the id locally so the optimistic row is addressable before the server acks it.
|
|
167
|
+
const id = generateMutationId();
|
|
168
|
+
create({data: {_id: id, completed: false, created: DateTime.now().toISO(), title}, id});
|
|
169
|
+
setNewTitle("");
|
|
170
|
+
}, [create, isSyncDbReady, newTitle]);
|
|
171
|
+
|
|
172
|
+
const handleToggle = useCallback(
|
|
173
|
+
(todo: Todo): void => {
|
|
174
|
+
update({data: {completed: !todo.completed}, id: todo._id});
|
|
175
|
+
},
|
|
176
|
+
[update]
|
|
177
|
+
);
|
|
135
178
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
}, [newTitle, createTodo]);
|
|
143
|
-
|
|
144
|
-
if (isLoading) {
|
|
145
|
-
return (
|
|
146
|
-
<Page navigation={undefined}>
|
|
147
|
-
<Box flex={1} alignItems="center" justifyContent="center">
|
|
148
|
-
<Text>Loading...</Text>
|
|
149
|
-
</Box>
|
|
150
|
-
</Page>
|
|
151
|
-
);
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
if (error) {
|
|
155
|
-
return (
|
|
156
|
-
<Page navigation={undefined}>
|
|
157
|
-
<Box flex={1} alignItems="center" justifyContent="center" gap={2}>
|
|
158
|
-
<Text color="error500">Failed to load todos</Text>
|
|
159
|
-
<Button text="Retry" onClick={refetch} />
|
|
160
|
-
</Box>
|
|
161
|
-
</Page>
|
|
162
|
-
);
|
|
163
|
-
}
|
|
179
|
+
const handleDelete = useCallback(
|
|
180
|
+
(id: string): void => {
|
|
181
|
+
remove({id});
|
|
182
|
+
},
|
|
183
|
+
[remove]
|
|
184
|
+
);
|
|
164
185
|
|
|
165
186
|
return (
|
|
166
|
-
<
|
|
167
|
-
<
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
187
|
+
<Box gap={3} padding={4} testID="todos-screen">
|
|
188
|
+
<Heading size="lg">Todos</Heading>
|
|
189
|
+
<Card>
|
|
190
|
+
<Box gap={3}>
|
|
191
|
+
<TextField
|
|
192
|
+
onChange={setNewTitle}
|
|
193
|
+
onEnter={handleCreate}
|
|
194
|
+
placeholder="New todo"
|
|
195
|
+
testID="todos-title-input"
|
|
196
|
+
title="New Todo"
|
|
197
|
+
value={newTitle}
|
|
198
|
+
/>
|
|
199
|
+
<Button
|
|
200
|
+
disabled={!isSyncDbReady || !newTitle.trim()}
|
|
201
|
+
iconName="plus"
|
|
202
|
+
onClick={handleCreate}
|
|
203
|
+
testID="todos-create-button"
|
|
204
|
+
text="Add Todo"
|
|
205
|
+
/>
|
|
206
|
+
</Box>
|
|
207
|
+
</Card>
|
|
208
|
+
{todos.length === 0 ? <Text testID="todos-empty-state">No todos yet</Text> : null}
|
|
209
|
+
{todos.map((todo) => (
|
|
210
|
+
<Card key={todo._id} testID={`todo-item-${todo._id}`}>
|
|
211
|
+
<Box alignItems="center" direction="row" gap={2}>
|
|
212
|
+
<Box onClick={() => handleToggle(todo)} testID={`todo-toggle-${todo._id}`}>
|
|
213
|
+
<CheckBox selected={Boolean(todo.completed)} size="md" />
|
|
214
|
+
</Box>
|
|
215
|
+
<Box flex="grow">
|
|
216
|
+
<Text>{todo.title}</Text>
|
|
176
217
|
</Box>
|
|
177
|
-
<
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
218
|
+
<IconButton
|
|
219
|
+
accessibilityLabel="Delete todo"
|
|
220
|
+
iconName="trash"
|
|
221
|
+
onClick={() => handleDelete(todo._id)}
|
|
222
|
+
testID={`todo-delete-${todo._id}`}
|
|
223
|
+
variant="destructive"
|
|
182
224
|
/>
|
|
183
225
|
</Box>
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
))}
|
|
188
|
-
</Box>
|
|
189
|
-
</ScrollView>
|
|
190
|
-
</Page>
|
|
226
|
+
</Card>
|
|
227
|
+
))}
|
|
228
|
+
</Box>
|
|
191
229
|
);
|
|
192
230
|
};
|
|
193
231
|
|
|
194
|
-
|
|
232
|
+
const TodosScreen: React.FC = () => (
|
|
233
|
+
<SyncDbProvider client={syncDb}>
|
|
234
|
+
<TodoList />
|
|
235
|
+
</SyncDbProvider>
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
export default TodosScreen;
|
|
195
239
|
```
|
|
196
240
|
|
|
241
|
+
For large lists, render `useEntityIds` in the list and `useEntity` in each row, so a change to
|
|
242
|
+
one row re-renders only that row. Running `terreno-syncdb-codegen` generates typed hooks such as
|
|
243
|
+
`useTodo`, `useCreateTodo`, and `useUpdateTodo` in `store/syncDbSdk.ts`, which wrap the same
|
|
244
|
+
calls.
|
|
245
|
+
|
|
197
246
|
### Form with Validation
|
|
198
247
|
|
|
248
|
+
A form that calls a non-synced route uses the generated RTK Query mutation and `.unwrap()`.
|
|
249
|
+
Here it updates the signed-in user's profile through `PATCH /auth/me`.
|
|
250
|
+
|
|
199
251
|
```typescript
|
|
200
|
-
import
|
|
201
|
-
import
|
|
202
|
-
import {
|
|
252
|
+
import {Box, Button, EmailField, TextField} from "@terreno/ui";
|
|
253
|
+
import type React from "react";
|
|
254
|
+
import {useCallback, useState} from "react";
|
|
255
|
+
import {usePatchMeMutation} from "@/store/sdk";
|
|
203
256
|
|
|
204
257
|
interface FormErrors {
|
|
205
258
|
name?: string;
|
|
206
259
|
email?: string;
|
|
207
260
|
}
|
|
208
261
|
|
|
209
|
-
const
|
|
210
|
-
const [name, setName] = useState("");
|
|
211
|
-
const [email, setEmail] = useState("");
|
|
262
|
+
const ProfileForm: React.FC = () => {
|
|
263
|
+
const [name, setName] = useState<string>("");
|
|
264
|
+
const [email, setEmail] = useState<string>("");
|
|
212
265
|
const [errors, setErrors] = useState<FormErrors>({});
|
|
213
|
-
const [
|
|
266
|
+
const [updateProfile, {isLoading}] = usePatchMeMutation();
|
|
214
267
|
|
|
215
268
|
const validate = useCallback((): boolean => {
|
|
216
269
|
const newErrors: FormErrors = {};
|
|
@@ -229,40 +282,43 @@ const CreateUserForm: React.FC = () => {
|
|
|
229
282
|
return Object.keys(newErrors).length === 0;
|
|
230
283
|
}, [name, email]);
|
|
231
284
|
|
|
232
|
-
const handleSubmit = useCallback(async () => {
|
|
285
|
+
const handleSubmit = useCallback(async (): Promise<void> => {
|
|
233
286
|
if (!validate()) {
|
|
234
287
|
return;
|
|
235
288
|
}
|
|
236
289
|
|
|
237
290
|
try {
|
|
238
|
-
await
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
if (
|
|
242
|
-
setErrors(
|
|
291
|
+
await updateProfile({email, name}).unwrap();
|
|
292
|
+
} catch (error: unknown) {
|
|
293
|
+
const fields = (error as {data?: {fields?: FormErrors}}).data?.fields;
|
|
294
|
+
if (fields) {
|
|
295
|
+
setErrors(fields);
|
|
243
296
|
}
|
|
244
297
|
}
|
|
245
|
-
}, [name, email, validate,
|
|
298
|
+
}, [name, email, validate, updateProfile]);
|
|
246
299
|
|
|
247
300
|
return (
|
|
248
|
-
<Box
|
|
301
|
+
<Box gap={3} padding={4}>
|
|
249
302
|
<TextField
|
|
250
|
-
|
|
303
|
+
errorText={errors.name}
|
|
304
|
+
onChange={setName}
|
|
305
|
+
testID="profile-name-input"
|
|
306
|
+
title="Name"
|
|
251
307
|
value={name}
|
|
252
|
-
onChangeText={setName}
|
|
253
|
-
error={errors.name}
|
|
254
308
|
/>
|
|
255
309
|
<EmailField
|
|
256
|
-
|
|
310
|
+
errorText={errors.email}
|
|
311
|
+
onChange={setEmail}
|
|
312
|
+
testID="profile-email-input"
|
|
313
|
+
title="Email"
|
|
257
314
|
value={email}
|
|
258
|
-
onChangeText={setEmail}
|
|
259
|
-
error={errors.email}
|
|
260
315
|
/>
|
|
261
316
|
<Button
|
|
262
|
-
text="Create User"
|
|
263
|
-
onClick={handleSubmit}
|
|
264
|
-
loading={isLoading}
|
|
265
317
|
fullWidth
|
|
318
|
+
loading={isLoading}
|
|
319
|
+
onClick={handleSubmit}
|
|
320
|
+
testID="profile-save-button"
|
|
321
|
+
text="Save"
|
|
266
322
|
/>
|
|
267
323
|
</Box>
|
|
268
324
|
);
|
|
@@ -305,19 +361,24 @@ throw new APIError({
|
|
|
305
361
|
|
|
306
362
|
### Frontend
|
|
307
363
|
|
|
364
|
+
A synced write does not return the server's answer to the caller. A rejected or conflicting
|
|
365
|
+
mutation shows up in `useSyncStatus()` and `useConflicts()`; render the conflict UI from those
|
|
366
|
+
hooks. Errors from
|
|
367
|
+
non-synced RTK Query routes arrive on `.unwrap()`:
|
|
368
|
+
|
|
308
369
|
```typescript
|
|
309
370
|
try {
|
|
310
371
|
await someApiCall().unwrap();
|
|
311
|
-
} catch (
|
|
372
|
+
} catch (error: unknown) {
|
|
373
|
+
const data = (error as {data?: {detail?: string; fields?: Record<string, string>}}).data;
|
|
312
374
|
// Handle field-specific errors
|
|
313
|
-
if (
|
|
314
|
-
setErrors(
|
|
375
|
+
if (data?.fields) {
|
|
376
|
+
setErrors(data.fields);
|
|
315
377
|
return;
|
|
316
378
|
}
|
|
317
379
|
|
|
318
380
|
// Handle general errors
|
|
319
|
-
|
|
320
|
-
toast.error(message);
|
|
381
|
+
toast.error(data?.detail ?? "An error occurred");
|
|
321
382
|
}
|
|
322
383
|
```
|
|
323
384
|
|
package/src/docs/resources/ui.md
CHANGED
|
@@ -100,7 +100,6 @@ import { Text, Heading, Title } from "@terreno/ui";
|
|
|
100
100
|
import {
|
|
101
101
|
TextField,
|
|
102
102
|
EmailField,
|
|
103
|
-
PasswordField,
|
|
104
103
|
NumberField,
|
|
105
104
|
TextArea,
|
|
106
105
|
SelectField,
|
|
@@ -110,19 +109,26 @@ import {
|
|
|
110
109
|
RadioField,
|
|
111
110
|
} from "@terreno/ui";
|
|
112
111
|
|
|
112
|
+
// Fields share `title`, `value`, `onChange`, `errorText`, and `helperText`.
|
|
113
113
|
<TextField
|
|
114
|
-
|
|
114
|
+
title="Name"
|
|
115
115
|
value={name}
|
|
116
|
-
|
|
116
|
+
onChange={setName}
|
|
117
117
|
placeholder="Enter your name"
|
|
118
|
-
|
|
118
|
+
errorText={errors.name}
|
|
119
119
|
helperText="Your full legal name"
|
|
120
120
|
/>
|
|
121
121
|
|
|
122
|
+
// Passwords: TextField with type="password" (adds a show/hide control).
|
|
123
|
+
<TextField title="Password" type="password" value={password} onChange={setPassword} />
|
|
124
|
+
|
|
125
|
+
// NumberField values are strings; type is "number" or "decimal".
|
|
126
|
+
<NumberField title="Age" type="number" value={age} onChange={setAge} />
|
|
127
|
+
|
|
122
128
|
<SelectField
|
|
123
|
-
|
|
129
|
+
title="Country"
|
|
124
130
|
value={country}
|
|
125
|
-
|
|
131
|
+
onChange={setCountry}
|
|
126
132
|
options={[
|
|
127
133
|
{ label: "United States", value: "US" },
|
|
128
134
|
{ label: "Canada", value: "CA" },
|
|
@@ -130,16 +136,16 @@ import {
|
|
|
130
136
|
/>
|
|
131
137
|
|
|
132
138
|
<DateTimeField
|
|
133
|
-
|
|
139
|
+
title="Date of Birth"
|
|
140
|
+
type="date"
|
|
134
141
|
value={dob}
|
|
135
142
|
onChange={setDob}
|
|
136
|
-
mode="date"
|
|
137
143
|
/>
|
|
138
144
|
|
|
139
145
|
<BooleanField
|
|
140
|
-
|
|
146
|
+
title="Accept Terms"
|
|
141
147
|
value={accepted}
|
|
142
|
-
|
|
148
|
+
onChange={setAccepted}
|
|
143
149
|
/>
|
|
144
150
|
```
|
|
145
151
|
|
|
@@ -202,7 +208,7 @@ import {
|
|
|
202
208
|
### Page & Layout
|
|
203
209
|
|
|
204
210
|
```typescript
|
|
205
|
-
import { Page,
|
|
211
|
+
import { Page, ScrollView } from "@terreno/ui";
|
|
206
212
|
|
|
207
213
|
<Page
|
|
208
214
|
navigation={navigation}
|
|
@@ -215,6 +221,32 @@ import { Page, SplitPage, ScrollView } from "@terreno/ui";
|
|
|
215
221
|
</Page>
|
|
216
222
|
```
|
|
217
223
|
|
|
224
|
+
List/detail screens (mail, inbox, records) use `ListDetailLayout` as a route group's
|
|
225
|
+
`_layout.tsx`. Phones push the detail route; tablets and wide browsers show the list in a left
|
|
226
|
+
pane beside it. The open item lives in the URL.
|
|
227
|
+
|
|
228
|
+
```typescript
|
|
229
|
+
import { ListDetailIndex, ListDetailLayout, useListDetail } from "@terreno/ui";
|
|
230
|
+
|
|
231
|
+
// app/mail/_layout.tsx
|
|
232
|
+
export const unstable_settings = { initialRouteName: "index" };
|
|
233
|
+
export default () => (
|
|
234
|
+
<ListDetailLayout
|
|
235
|
+
detailHref={(id) => `/mail/${id}`}
|
|
236
|
+
indexHref="/mail"
|
|
237
|
+
list={<MailList />}
|
|
238
|
+
/>
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
// app/mail/index.tsx
|
|
242
|
+
export default () => <ListDetailIndex />;
|
|
243
|
+
|
|
244
|
+
// MailList rows read and change the open item
|
|
245
|
+
const { selectedId, select } = useListDetail();
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
Use `SplitPage` only for list/detail views without their own routes.
|
|
249
|
+
|
|
218
250
|
## Theme System
|
|
219
251
|
|
|
220
252
|
### Using Theme
|
|
@@ -253,17 +285,16 @@ radiusSm, radiusMd, radiusLg, radiusXl, radius2xl, radius3xl
|
|
|
253
285
|
## Utilities
|
|
254
286
|
|
|
255
287
|
```typescript
|
|
256
|
-
import {
|
|
288
|
+
import {isNarrowViewport, isSupportedDesktopWidth, useStoredState} from "@terreno/ui";
|
|
289
|
+
import {useWindowDimensions} from "react-native";
|
|
257
290
|
|
|
258
291
|
// Persist state to storage
|
|
259
292
|
const [value, setValue] = useStoredState("key", defaultValue);
|
|
260
293
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
<DesktopLayout />
|
|
264
|
-
</MediaQuery>
|
|
294
|
+
const {width} = useWindowDimensions();
|
|
295
|
+
const isDesktopLayout = isSupportedDesktopWidth({width});
|
|
265
296
|
|
|
266
|
-
// Current window is below the desktop
|
|
297
|
+
// Current window is below the desktop floor (web lg / native xl, 1024pt).
|
|
267
298
|
// isMobileDevice() is deprecated in favor of isNarrowViewport().
|
|
268
299
|
if (isNarrowViewport()) {
|
|
269
300
|
// Narrow-viewport code
|
|
@@ -11,7 +11,6 @@ const ProfileScreen: React.FC = () => {
|
|
|
11
11
|
const [updateProfile] = usePatchMeMutation();
|
|
12
12
|
const [name, setName] = useState<string>("");
|
|
13
13
|
const [email, setEmail] = useState<string>("");
|
|
14
|
-
const [password, setPassword] = useState<string>("");
|
|
15
14
|
const [currentPassword, setCurrentPassword] = useState<string>("");
|
|
16
15
|
const [saveError, setSaveError] = useState<string | null>(null);
|
|
17
16
|
|
|
@@ -75,23 +74,6 @@ const ProfileScreen: React.FC = () => {
|
|
|
75
74
|
[currentPassword, isEmailChanged, updateProfile]
|
|
76
75
|
);
|
|
77
76
|
|
|
78
|
-
const handleSavePassword = useCallback(
|
|
79
|
-
async (value: string): Promise<void> => {
|
|
80
|
-
if (!value) {
|
|
81
|
-
return;
|
|
82
|
-
}
|
|
83
|
-
setSaveError(null);
|
|
84
|
-
try {
|
|
85
|
-
await updateProfile({password: value}).unwrap();
|
|
86
|
-
setPassword("");
|
|
87
|
-
} catch (err) {
|
|
88
|
-
console.error("Error updating password:", err);
|
|
89
|
-
setSaveError("Failed to update password");
|
|
90
|
-
}
|
|
91
|
-
},
|
|
92
|
-
[updateProfile]
|
|
93
|
-
);
|
|
94
|
-
|
|
95
77
|
if (isLoading) {
|
|
96
78
|
return (
|
|
97
79
|
<Page navigation={undefined} title="Profile">
|
|
@@ -132,14 +114,6 @@ const ProfileScreen: React.FC = () => {
|
|
|
132
114
|
value={currentPassword}
|
|
133
115
|
/>
|
|
134
116
|
)}
|
|
135
|
-
<TapToEdit
|
|
136
|
-
helperText="Leave blank to keep your current password"
|
|
137
|
-
onSave={handleSavePassword}
|
|
138
|
-
setValue={setPassword}
|
|
139
|
-
title="New Password"
|
|
140
|
-
type="password"
|
|
141
|
-
value={password}
|
|
142
|
-
/>
|
|
143
117
|
{saveError && <Text color="error">{saveError}</Text>}
|
|
144
118
|
</Box>
|
|
145
119
|
</Card>
|