@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.
Files changed (197) hide show
  1. package/CHANGELOG.md +393 -0
  2. package/README.md +32 -269
  3. package/dist/bootstrap.d.ts +12 -0
  4. package/dist/bootstrap.d.ts.map +1 -1
  5. package/dist/bootstrap.js +77 -122
  6. package/dist/bootstrap.js.map +1 -1
  7. package/dist/docs/guidelines/ui/guidelines/core.md +1 -1
  8. package/dist/docs/resources/overview.md +4 -2
  9. package/dist/docs/resources/patterns.md +237 -176
  10. package/dist/docs/resources/ui.md +48 -17
  11. package/dist/docs/templates/bootstrap/frontend/app/(tabs)/profile.template.tsx +0 -26
  12. package/dist/docs/templates/bootstrap/frontend/app/_layout.template.tsx +3 -2
  13. package/dist/docs/templates/bootstrap/frontend/app/login.template.tsx +1 -1
  14. package/dist/docs/upgrades/57.20.0.md +238 -0
  15. package/dist/docs/versioned/next/approval-rubric.md +30 -12
  16. package/dist/docs/versioned/next/explanation/README.md +5 -0
  17. package/dist/docs/versioned/next/explanation/account-linking.md +169 -0
  18. package/dist/docs/versioned/next/explanation/admin-interface.md +1 -1
  19. package/dist/docs/versioned/next/explanation/agent-guidelines.md +173 -0
  20. package/dist/docs/versioned/next/explanation/ai-development-loop.md +196 -0
  21. package/dist/docs/versioned/next/explanation/authentication.md +45 -1
  22. package/dist/docs/versioned/next/explanation/consent-forms.md +105 -0
  23. package/dist/docs/versioned/next/explanation/example-coverage.md +1 -0
  24. package/dist/docs/versioned/next/explanation/local-first-data.md +1 -1
  25. package/dist/docs/versioned/next/explanation/organizations.md +37 -0
  26. package/dist/docs/versioned/next/explanation/planning-plugins.md +4 -0
  27. package/dist/docs/versioned/next/explanation/responsive-design.md +143 -0
  28. package/dist/docs/versioned/next/explanation/roadmap-seed-issues.md +11 -0
  29. package/dist/docs/versioned/next/explanation/schema-metadata.md +5 -0
  30. package/dist/docs/versioned/next/how-to/README.md +7 -2
  31. package/dist/docs/versioned/next/how-to/audit-log.md +22 -3
  32. package/dist/docs/versioned/next/how-to/automate-terreno-upgrades.md +169 -0
  33. package/dist/docs/versioned/next/how-to/build-list-detail-screens.md +160 -0
  34. package/dist/docs/versioned/next/how-to/circleci.md +15 -5
  35. package/dist/docs/versioned/next/how-to/configure-better-auth.md +673 -53
  36. package/dist/docs/versioned/next/how-to/create-a-terreno-app.md +8 -5
  37. package/dist/docs/versioned/next/how-to/debug-with-mcp.md +393 -23
  38. package/dist/docs/versioned/next/how-to/enable-mcp-oauth.md +54 -3
  39. package/dist/docs/versioned/next/how-to/migrate-rtk-to-syncdb.md +9 -0
  40. package/dist/docs/versioned/next/how-to/password-reset.md +1 -1
  41. package/dist/docs/versioned/next/how-to/recover-locked-out-user.md +112 -0
  42. package/dist/docs/versioned/next/how-to/run-tests-locally.md +37 -8
  43. package/dist/docs/versioned/next/how-to/set-up-social-login.md +424 -0
  44. package/dist/docs/versioned/next/how-to/set-up-terreno-mcp.md +241 -36
  45. package/dist/docs/versioned/next/how-to/sign-consent-on-behalf.md +139 -0
  46. package/dist/docs/versioned/next/how-to/upgrade-terreno.md +4 -0
  47. package/dist/docs/versioned/next/implementationPlans/ai-dev-loop-boost.md +47 -32
  48. package/dist/docs/versioned/next/implementationPlans/auth-experience.md +952 -0
  49. package/dist/docs/versioned/next/implementationPlans/consent-forms.md +8 -0
  50. package/dist/docs/versioned/next/implementationPlans/list-detail-layout.md +297 -0
  51. package/dist/docs/versioned/next/implementationPlans/login-redesign/HANDOFF.md +211 -0
  52. package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/AnotherWay.html +121 -0
  53. package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/BackupCode.html +72 -0
  54. package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/Cancelled.html +87 -0
  55. package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/ContinueEmail.html +64 -0
  56. package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/FirstVisit.html +81 -0
  57. package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/Main.html +88 -0
  58. package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/ManageAccounts.html +77 -0
  59. package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/NotLinked.html +92 -0
  60. package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/SigningIn.html +60 -0
  61. package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/TwoFactor.html +76 -0
  62. package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/TwoFactorError.html +81 -0
  63. package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/WrongPassword.html +69 -0
  64. package/dist/docs/versioned/next/implementationPlans/mcp-boost-parity.md +1 -1
  65. package/dist/docs/versioned/next/plans/2026-10-23-provisional-consent.md +297 -0
  66. package/dist/docs/versioned/next/plans/2026-10-29-fix-mcp-oauth-native-and-machine.md +68 -0
  67. package/dist/docs/versioned/next/reference/README.md +1 -0
  68. package/dist/docs/versioned/next/reference/account-security.md +700 -0
  69. package/dist/docs/versioned/next/reference/admin-backend.md +3 -1
  70. package/dist/docs/versioned/next/reference/admin-frontend.md +225 -1
  71. package/dist/docs/versioned/next/reference/api.md +461 -35
  72. package/dist/docs/versioned/next/reference/cli.md +1 -1
  73. package/dist/docs/versioned/next/reference/comms.md +8 -0
  74. package/dist/docs/versioned/next/reference/create-terreno-app.md +3 -1
  75. package/dist/docs/versioned/next/reference/environment-variables.md +9 -3
  76. package/dist/docs/versioned/next/reference/legacy/rtk.md +19 -3
  77. package/dist/docs/versioned/next/reference/lifecycle-plugin.md +6 -0
  78. package/dist/docs/versioned/next/reference/mcp-server.md +1158 -441
  79. package/dist/docs/versioned/next/reference/syncdb.md +8 -1
  80. package/dist/docs/versioned/next/reference/ui.md +1271 -27
  81. package/dist/docs/versioned/next/tasks/ai-dev-loop-boost.md +28 -16
  82. package/dist/docs/versioned/next/tutorials/README.md +4 -0
  83. package/dist/httpApp.d.ts +14 -0
  84. package/dist/httpApp.d.ts.map +1 -0
  85. package/dist/httpApp.js +99 -0
  86. package/dist/httpApp.js.map +1 -0
  87. package/dist/index.js +8 -90
  88. package/dist/index.js.map +1 -1
  89. package/dist/local/localTools.d.ts.map +1 -1
  90. package/dist/local/localTools.js +44 -14
  91. package/dist/local/localTools.js.map +1 -1
  92. package/dist/local/metro/metroDevSession.d.ts +18 -0
  93. package/dist/local/metro/metroDevSession.d.ts.map +1 -1
  94. package/dist/local/metro/metroDevSession.js +42 -3
  95. package/dist/local/metro/metroDevSession.js.map +1 -1
  96. package/dist/local/tools/databaseQuery.d.ts.map +1 -1
  97. package/dist/local/tools/databaseQuery.js +3 -13
  98. package/dist/local/tools/databaseQuery.js.map +1 -1
  99. package/dist/local/tools/databaseSchema.d.ts.map +1 -1
  100. package/dist/local/tools/databaseSchema.js +3 -13
  101. package/dist/local/tools/databaseSchema.js.map +1 -1
  102. package/dist/local/tools/localMongo.d.ts +10 -0
  103. package/dist/local/tools/localMongo.d.ts.map +1 -0
  104. package/dist/local/tools/localMongo.js +30 -0
  105. package/dist/local/tools/localMongo.js.map +1 -0
  106. package/dist/local/tools/runtime.d.ts +18 -2
  107. package/dist/local/tools/runtime.d.ts.map +1 -1
  108. package/dist/local/tools/runtime.js +75 -20
  109. package/dist/local/tools/runtime.js.map +1 -1
  110. package/dist/local/tools/syncDb.d.ts +11 -0
  111. package/dist/local/tools/syncDb.d.ts.map +1 -1
  112. package/dist/local/tools/syncDb.js +15 -3
  113. package/dist/local/tools/syncDb.js.map +1 -1
  114. package/dist/rateLimit.d.ts +35 -0
  115. package/dist/rateLimit.d.ts.map +1 -0
  116. package/dist/rateLimit.js +112 -0
  117. package/dist/rateLimit.js.map +1 -0
  118. package/dist/tools.d.ts.map +1 -1
  119. package/dist/tools.js +42 -60
  120. package/dist/tools.js.map +1 -1
  121. package/package.json +4 -3
  122. package/src/docs/guidelines/ui/guidelines/core.md +1 -1
  123. package/src/docs/resources/overview.md +4 -2
  124. package/src/docs/resources/patterns.md +237 -176
  125. package/src/docs/resources/ui.md +48 -17
  126. package/src/docs/templates/bootstrap/frontend/app/(tabs)/profile.template.tsx +0 -26
  127. package/src/docs/templates/bootstrap/frontend/app/_layout.template.tsx +3 -2
  128. package/src/docs/templates/bootstrap/frontend/app/login.template.tsx +1 -1
  129. package/src/docs/upgrades/57.20.0.md +238 -0
  130. package/src/docs/versioned/next/approval-rubric.md +30 -12
  131. package/src/docs/versioned/next/explanation/README.md +5 -0
  132. package/src/docs/versioned/next/explanation/account-linking.md +169 -0
  133. package/src/docs/versioned/next/explanation/admin-interface.md +1 -1
  134. package/src/docs/versioned/next/explanation/agent-guidelines.md +173 -0
  135. package/src/docs/versioned/next/explanation/ai-development-loop.md +196 -0
  136. package/src/docs/versioned/next/explanation/authentication.md +45 -1
  137. package/src/docs/versioned/next/explanation/consent-forms.md +105 -0
  138. package/src/docs/versioned/next/explanation/example-coverage.md +1 -0
  139. package/src/docs/versioned/next/explanation/local-first-data.md +1 -1
  140. package/src/docs/versioned/next/explanation/organizations.md +37 -0
  141. package/src/docs/versioned/next/explanation/planning-plugins.md +4 -0
  142. package/src/docs/versioned/next/explanation/responsive-design.md +143 -0
  143. package/src/docs/versioned/next/explanation/roadmap-seed-issues.md +11 -0
  144. package/src/docs/versioned/next/explanation/schema-metadata.md +5 -0
  145. package/src/docs/versioned/next/how-to/README.md +7 -2
  146. package/src/docs/versioned/next/how-to/audit-log.md +22 -3
  147. package/src/docs/versioned/next/how-to/automate-terreno-upgrades.md +169 -0
  148. package/src/docs/versioned/next/how-to/build-list-detail-screens.md +160 -0
  149. package/src/docs/versioned/next/how-to/circleci.md +15 -5
  150. package/src/docs/versioned/next/how-to/configure-better-auth.md +673 -53
  151. package/src/docs/versioned/next/how-to/create-a-terreno-app.md +8 -5
  152. package/src/docs/versioned/next/how-to/debug-with-mcp.md +393 -23
  153. package/src/docs/versioned/next/how-to/enable-mcp-oauth.md +54 -3
  154. package/src/docs/versioned/next/how-to/migrate-rtk-to-syncdb.md +9 -0
  155. package/src/docs/versioned/next/how-to/password-reset.md +1 -1
  156. package/src/docs/versioned/next/how-to/recover-locked-out-user.md +112 -0
  157. package/src/docs/versioned/next/how-to/run-tests-locally.md +37 -8
  158. package/src/docs/versioned/next/how-to/set-up-social-login.md +424 -0
  159. package/src/docs/versioned/next/how-to/set-up-terreno-mcp.md +241 -36
  160. package/src/docs/versioned/next/how-to/sign-consent-on-behalf.md +139 -0
  161. package/src/docs/versioned/next/how-to/upgrade-terreno.md +4 -0
  162. package/src/docs/versioned/next/implementationPlans/ai-dev-loop-boost.md +47 -32
  163. package/src/docs/versioned/next/implementationPlans/auth-experience.md +952 -0
  164. package/src/docs/versioned/next/implementationPlans/consent-forms.md +8 -0
  165. package/src/docs/versioned/next/implementationPlans/list-detail-layout.md +297 -0
  166. package/src/docs/versioned/next/implementationPlans/login-redesign/HANDOFF.md +211 -0
  167. package/src/docs/versioned/next/implementationPlans/login-redesign/boards/AnotherWay.html +121 -0
  168. package/src/docs/versioned/next/implementationPlans/login-redesign/boards/BackupCode.html +72 -0
  169. package/src/docs/versioned/next/implementationPlans/login-redesign/boards/Cancelled.html +87 -0
  170. package/src/docs/versioned/next/implementationPlans/login-redesign/boards/ContinueEmail.html +64 -0
  171. package/src/docs/versioned/next/implementationPlans/login-redesign/boards/FirstVisit.html +81 -0
  172. package/src/docs/versioned/next/implementationPlans/login-redesign/boards/Main.html +88 -0
  173. package/src/docs/versioned/next/implementationPlans/login-redesign/boards/ManageAccounts.html +77 -0
  174. package/src/docs/versioned/next/implementationPlans/login-redesign/boards/NotLinked.html +92 -0
  175. package/src/docs/versioned/next/implementationPlans/login-redesign/boards/SigningIn.html +60 -0
  176. package/src/docs/versioned/next/implementationPlans/login-redesign/boards/TwoFactor.html +76 -0
  177. package/src/docs/versioned/next/implementationPlans/login-redesign/boards/TwoFactorError.html +81 -0
  178. package/src/docs/versioned/next/implementationPlans/login-redesign/boards/WrongPassword.html +69 -0
  179. package/src/docs/versioned/next/implementationPlans/mcp-boost-parity.md +1 -1
  180. package/src/docs/versioned/next/plans/2026-10-23-provisional-consent.md +297 -0
  181. package/src/docs/versioned/next/plans/2026-10-29-fix-mcp-oauth-native-and-machine.md +68 -0
  182. package/src/docs/versioned/next/reference/README.md +1 -0
  183. package/src/docs/versioned/next/reference/account-security.md +700 -0
  184. package/src/docs/versioned/next/reference/admin-backend.md +3 -1
  185. package/src/docs/versioned/next/reference/admin-frontend.md +225 -1
  186. package/src/docs/versioned/next/reference/api.md +461 -35
  187. package/src/docs/versioned/next/reference/cli.md +1 -1
  188. package/src/docs/versioned/next/reference/comms.md +8 -0
  189. package/src/docs/versioned/next/reference/create-terreno-app.md +3 -1
  190. package/src/docs/versioned/next/reference/environment-variables.md +9 -3
  191. package/src/docs/versioned/next/reference/legacy/rtk.md +19 -3
  192. package/src/docs/versioned/next/reference/lifecycle-plugin.md +6 -0
  193. package/src/docs/versioned/next/reference/mcp-server.md +1158 -441
  194. package/src/docs/versioned/next/reference/syncdb.md +8 -1
  195. package/src/docs/versioned/next/reference/ui.md +1271 -27
  196. package/src/docs/versioned/next/tasks/ai-dev-loop-boost.md +28 -16
  197. 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 { addDefaultPlugins } from "@terreno/api";
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
- title: { type: String, required: true, trim: true },
26
- completed: { type: Boolean, default: false },
27
- ownerId: { type: mongoose.Schema.Types.ObjectId, ref: "User", required: true },
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.statics.findByOwner = async function (ownerId: string) {
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 { Router } from "express";
49
- import { modelRouter, Permissions, OwnerQueryFilter } from "@terreno/api";
50
- import { Todo, TodoDocument } from "../models/todo";
51
- import { UserDocument } from "../models/user";
52
-
53
- export const addTodoRoutes = (router: Router) => {
54
- router.use(
55
- "/todos",
56
- modelRouter(Todo, {
57
- permissions: {
58
- create: [Permissions.IsAuthenticated],
59
- list: [Permissions.IsAuthenticated],
60
- read: [Permissions.IsOwner],
61
- update: [Permissions.IsOwner],
62
- delete: [Permissions.IsOwner],
63
- },
64
- queryFields: ["completed"],
65
- queryFilter: OwnerQueryFilter,
66
- sort: "-created",
67
- preCreate: (body, req) => ({
68
- ...body,
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
- ```typescript
83
- import { createOpenApiBuilder, APIError, authenticateMiddleware } from "@terreno/api";
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
- res.json(todo);
110
- } catch (error) {
111
- next(error);
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 React, { useCallback, useState } from "react";
123
- import { Box, Page, Text, Button, TextField, ScrollView } from "@terreno/ui";
124
- import { useGetTodosQuery, useCreateTodoMutation } from "@/store/openApiSdk";
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 TodoScreen: React.FC = () => {
127
- const [newTitle, setNewTitle] = useState("");
128
- const { data: todos, isLoading, error, refetch } = useGetTodosQuery({});
129
- const [createTodo, { isLoading: isCreating }] = useCreateTodoMutation();
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(async () => {
132
- if (!newTitle.trim()) {
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
- try {
137
- await createTodo({ title: newTitle }).unwrap();
138
- setNewTitle("");
139
- } catch (err) {
140
- console.error("Failed to create todo:", err);
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
- <Page navigation={undefined} title="Todos">
167
- <ScrollView>
168
- <Box padding={4} gap={3}>
169
- <Box direction="row" gap={2}>
170
- <Box flex={1}>
171
- <TextField
172
- placeholder="New todo..."
173
- value={newTitle}
174
- onChangeText={setNewTitle}
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
- <Button
178
- text="Add"
179
- onClick={handleCreate}
180
- loading={isCreating}
181
- disabled={!newTitle.trim()}
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
- {todos?.map((todo) => (
186
- <TodoItem key={todo.id} todo={todo} />
187
- ))}
188
- </Box>
189
- </ScrollView>
190
- </Page>
226
+ </Card>
227
+ ))}
228
+ </Box>
191
229
  );
192
230
  };
193
231
 
194
- export default TodoScreen;
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 React, { useCallback, useState } from "react";
201
- import { Box, Button, TextField, EmailField, Text } from "@terreno/ui";
202
- import { useCreateUserMutation } from "@/store/openApiSdk";
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 CreateUserForm: React.FC = () => {
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 [createUser, { isLoading }] = useCreateUserMutation();
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 createUser({ name, email }).unwrap();
239
- // Success - navigate or show message
240
- } catch (err: any) {
241
- if (err.data?.fields) {
242
- setErrors(err.data.fields);
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, createUser]);
298
+ }, [name, email, validate, updateProfile]);
246
299
 
247
300
  return (
248
- <Box padding={4} gap={3}>
301
+ <Box gap={3} padding={4}>
249
302
  <TextField
250
- label="Name"
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
- label="Email"
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 (err: any) {
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 (err.data?.fields) {
314
- setErrors(err.data.fields);
375
+ if (data?.fields) {
376
+ setErrors(data.fields);
315
377
  return;
316
378
  }
317
379
 
318
380
  // Handle general errors
319
- const message = err.data?.detail || err.message || "An error occurred";
320
- toast.error(message);
381
+ toast.error(data?.detail ?? "An error occurred");
321
382
  }
322
383
  ```
323
384
 
@@ -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
- label="Name"
114
+ title="Name"
115
115
  value={name}
116
- onChangeText={setName}
116
+ onChange={setName}
117
117
  placeholder="Enter your name"
118
- error={errors.name}
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
- label="Country"
129
+ title="Country"
124
130
  value={country}
125
- onChangeValue={setCountry}
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
- label="Date of Birth"
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
- label="Accept Terms"
146
+ title="Accept Terms"
141
147
  value={accepted}
142
- onChangeValue={setAccepted}
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, SplitPage, ScrollView } from "@terreno/ui";
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 { useStoredState, MediaQuery, isNarrowViewport } from "@terreno/ui";
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
- // Responsive rendering
262
- <MediaQuery minWidth={768}>
263
- <DesktopLayout />
264
- </MediaQuery>
294
+ const {width} = useWindowDimensions();
295
+ const isDesktopLayout = isSupportedDesktopWidth({width});
265
296
 
266
- // Current window is below the desktop breakpoint.
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>