@terreno/mcp 57.12.0 → 57.14.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/dist/docs/upgrades/57.13.0.md +75 -0
- package/dist/docs/upgrades/57.14.0.md +43 -0
- package/dist/docs/versioned/next/explanation/README.md +1 -1
- package/dist/docs/versioned/next/explanation/agent-ui-blocks.md +69 -9
- package/dist/docs/versioned/next/explanation/example-coverage.md +3 -3
- package/dist/docs/versioned/next/explanation/roadmap-seed-issues.md +12 -0
- package/dist/docs/versioned/next/how-to/README.md +3 -0
- package/dist/docs/versioned/next/how-to/agent-ui-blocks.md +187 -3
- package/dist/docs/versioned/next/how-to/circleci.md +2 -0
- package/dist/docs/versioned/next/how-to/preview-demo.md +5 -5
- package/dist/docs/versioned/next/how-to/rate-limiting.md +17 -0
- package/dist/docs/versioned/next/how-to/test-ui-accessibility.md +35 -0
- package/dist/docs/versioned/next/how-to/use-syncdb-correctly.md +111 -0
- package/dist/docs/versioned/next/implementationPlans/agent-ui-rich-replies.md +359 -0
- package/dist/docs/versioned/next/reference/README.md +1 -1
- package/dist/docs/versioned/next/reference/admin-backend.md +26 -0
- package/dist/docs/versioned/next/reference/admin-frontend.md +15 -0
- package/dist/docs/versioned/next/reference/ai.md +55 -3
- package/dist/docs/versioned/next/reference/api.md +4 -0
- package/dist/docs/versioned/next/reference/blocks.md +243 -11
- package/dist/docs/versioned/next/reference/environment-variables.md +3 -0
- package/dist/docs/versioned/next/reference/syncdb.md +1 -0
- package/dist/docs/versioned/next/reference/ui.md +212 -10
- package/dist/docs/versioned/next/tasks/agent-ui-rich-replies.md +188 -0
- package/package.json +3 -3
- package/src/docs/upgrades/57.13.0.md +75 -0
- package/src/docs/upgrades/57.14.0.md +43 -0
- package/src/docs/versioned/next/explanation/README.md +1 -1
- package/src/docs/versioned/next/explanation/agent-ui-blocks.md +69 -9
- package/src/docs/versioned/next/explanation/example-coverage.md +3 -3
- package/src/docs/versioned/next/explanation/roadmap-seed-issues.md +12 -0
- package/src/docs/versioned/next/how-to/README.md +3 -0
- package/src/docs/versioned/next/how-to/agent-ui-blocks.md +187 -3
- package/src/docs/versioned/next/how-to/circleci.md +2 -0
- package/src/docs/versioned/next/how-to/preview-demo.md +5 -5
- package/src/docs/versioned/next/how-to/rate-limiting.md +17 -0
- package/src/docs/versioned/next/how-to/test-ui-accessibility.md +35 -0
- package/src/docs/versioned/next/how-to/use-syncdb-correctly.md +111 -0
- package/src/docs/versioned/next/implementationPlans/agent-ui-rich-replies.md +359 -0
- package/src/docs/versioned/next/reference/README.md +1 -1
- package/src/docs/versioned/next/reference/admin-backend.md +26 -0
- package/src/docs/versioned/next/reference/admin-frontend.md +15 -0
- package/src/docs/versioned/next/reference/ai.md +55 -3
- package/src/docs/versioned/next/reference/api.md +4 -0
- package/src/docs/versioned/next/reference/blocks.md +243 -11
- package/src/docs/versioned/next/reference/environment-variables.md +3 -0
- package/src/docs/versioned/next/reference/syncdb.md +1 -0
- package/src/docs/versioned/next/reference/ui.md +212 -10
- package/src/docs/versioned/next/tasks/agent-ui-rich-replies.md +188 -0
|
@@ -9,19 +9,79 @@ in [Agent UI Asks](agent-ui-asks.md).
|
|
|
9
9
|
The design record is
|
|
10
10
|
[agent-ui-blocks.md](../implementationPlans/agent-ui-blocks.md). Pick executes that work as
|
|
11
11
|
Phase 5 of [Agent UI Asks](../implementationPlans/agent-ui-asks.md).
|
|
12
|
+
The stepper, checklist, gallery, list, card eyebrow, and copy additions are recorded in
|
|
13
|
+
`docs/implementationPlans/agent-ui-rich-replies.md`.
|
|
12
14
|
|
|
13
15
|
## Why a closed catalog
|
|
14
16
|
|
|
15
17
|
An agent that can emit arbitrary components can also emit styles, scripts, and layout the
|
|
16
18
|
app theme does not own. The catalog is the list of `@terreno/ui` components the chat can
|
|
17
|
-
paint
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
paint. Unknown keys and unknown block types fail validation, so a reply cannot smuggle a
|
|
20
|
+
`style` object or a hex color.
|
|
21
|
+
|
|
22
|
+
| Group | Blocks | Notes |
|
|
23
|
+
| --- | --- | --- |
|
|
24
|
+
| Text | `heading`, `text`, `context`, `callout`, `details` | `text` is markdown, including GFM tables |
|
|
25
|
+
| Values | `metric`, `badge`, `divider` | |
|
|
26
|
+
| Data | `chart`, `table` | Read an inline or `ref` dataset |
|
|
27
|
+
| Media | `image`, `gallery`, `list` | Sources are `data:image`, `file:`, or an allowed `https` host |
|
|
28
|
+
| Interactive | `actions` (`button`, `segmented`), `stepper`, `checklist` | Button actions: `reply`, `open`, `select`, `callback`, `copy` |
|
|
29
|
+
| Layout | `columns`, `card` (optional `title` and `eyebrow`) | Leaf blocks only inside |
|
|
30
|
+
| Escape hatch | `html` | Only with `uiBlocks.html`; sanitized before storage |
|
|
31
|
+
|
|
32
|
+
Field rules for each block are in the [reference](../reference/blocks.md).
|
|
21
33
|
|
|
22
34
|
The same pure functions run wherever a document is checked. `parseBlocks` and
|
|
23
|
-
`validateBlocks` live in `@terreno/blocks`, which has no React and no Express. The renderer
|
|
24
|
-
|
|
35
|
+
`validateBlocks` live in `@terreno/blocks`, which has no React and no Express. The renderer,
|
|
36
|
+
the chat route, and the `terreno-blocks` CLI all call them; none grows a second grammar.
|
|
37
|
+
|
|
38
|
+
## Why interactive blocks call the server
|
|
39
|
+
|
|
40
|
+
A stepper that rescales a shopping list, or a checklist that remembers ticks, needs logic.
|
|
41
|
+
Terreno does not give the document a shared `state` with `{{formula}}` bindings, and it does
|
|
42
|
+
not run scripted html. Both need an expression language the theme and the validator cannot
|
|
43
|
+
see into. Instead, an interactive block names a host callback, as a callback button does:
|
|
44
|
+
|
|
45
|
+
| Interaction | Who runs it | Result |
|
|
46
|
+
| --- | --- | --- |
|
|
47
|
+
| Stepper − / + | Host action with `handles: "stepper"`, through `POST /gpt/actions` | The replacement stepper, swapped in by block id |
|
|
48
|
+
| Checklist tick | Host action with `handles: "checklist"`, or the device when the checklist has no callback | The replacement checklist, or a local tick |
|
|
49
|
+
| `copy` button | The device | Text on the clipboard; nothing reaches the server |
|
|
50
|
+
|
|
51
|
+
The cost is one round trip per tap. The view disables the block while the call is pending, so
|
|
52
|
+
fast taps are dropped, not queued, and a checklist tick cannot send ticks that miss the one
|
|
53
|
+
before it. The gain is that the agent writes plain data, and the app's code decides what a
|
|
54
|
+
tap means. `scaleStepperHostAction` and `toggleChecklistHostAction` in `@terreno/ai` cover the
|
|
55
|
+
common case. They read the block the agent wrote from the stored history and never trust the
|
|
56
|
+
tap request's copy of it. The history owner can still edit stored prompts, so an app whose
|
|
57
|
+
numbers matter (prices, stock) registers its own action over its own data.
|
|
58
|
+
|
|
59
|
+
Overrides live in client memory. A reload shows the agent's original block again.
|
|
60
|
+
|
|
61
|
+
## Why photos come from a stored library
|
|
62
|
+
|
|
63
|
+
A model cannot make a photo while it writes a reply, and an `https` image needs an
|
|
64
|
+
`imageHosts` allowlist the model cannot know. The example app generates a photo library once
|
|
65
|
+
per environment with the Vertex image model, stores it through `FileStorageService`, and gives
|
|
66
|
+
the agent a `findPhotos` tool that returns `file:` ids. The reply cites ids; the client
|
|
67
|
+
resolves each id to a signed URL. Generation cost and latency stay out of the chat turn, and
|
|
68
|
+
every photo the user sees was made and stored ahead of time. See
|
|
69
|
+
[Give the agent photos](../how-to/agent-ui-blocks.md#give-the-agent-photos).
|
|
70
|
+
|
|
71
|
+
## Rollout: rich blocks are on by default
|
|
72
|
+
|
|
73
|
+
`uiBlocks.richBlocks` defaults to `true`. The prompt then offers `checklist`, `gallery`,
|
|
74
|
+
`list`, `card.eyebrow`, and `copy`, and offers `stepper` when a host action has
|
|
75
|
+
`handles: "stepper"`. A stepper without such an action would have dead buttons, so the
|
|
76
|
+
prompt does not mention it.
|
|
77
|
+
|
|
78
|
+
The server and the web client upgrade together. A native build already in the app stores
|
|
79
|
+
does not know the new blocks: a reply that uses them fails its validation and shows the
|
|
80
|
+
error banner with the raw YAML. A host with such builds sets `uiBlocks.richBlocks: false`
|
|
81
|
+
until they update. The prompt is then the same as before rich blocks. Validation still
|
|
82
|
+
accepts the new blocks, so a stored reply that uses them still renders on new clients.
|
|
83
|
+
A client-declared catalog version, so the server picks blocks per client, would remove that
|
|
84
|
+
manual step; it is not built yet.
|
|
25
85
|
|
|
26
86
|
## Why the whole reply is YAML
|
|
27
87
|
|
|
@@ -49,9 +109,9 @@ another, which makes a small document able to expand in ways the limits cannot s
|
|
|
49
109
|
|
|
50
110
|
| Layer | Owns |
|
|
51
111
|
| --- | --- |
|
|
52
|
-
| `@terreno/blocks` | Schema, parse, partial parse, validate, JSON Schema, prompt section, CLI, limits, error codes |
|
|
53
|
-
| `@terreno/ui` | `BlocksView`
|
|
54
|
-
| `@terreno/ai` | When `uiBlocks` is on: the chat system prompt, the post-stream `{blocks}` check, `AIDataset` storage, and `POST /gpt/actions`. `AIService.generateBlocks` returns one validated document outside a chat turn. |
|
|
112
|
+
| `@terreno/blocks` | Schema, parse, partial parse, validate, JSON Schema, prompt section, `blockPlainText`, CLI, limits, error codes |
|
|
113
|
+
| `@terreno/ui` | `BlocksView` renders every block in the catalog. A select action changes the target chart or table dataset in that view. A segmented control starts on the option that matches that dataset. A callback button or stepper stays disabled when `hostActions` is set and does not include its name; a checklist with such a callback ticks locally instead. A `copy` button writes the clipboard on the device. `GPTChat` `uiBlocks` renders assistant messages through `BlocksView`, including a spinner while the reply is still streaming, and swaps a block when a callback returns `{replace: "block"}`. A `file:` image stays as alt text until `resolveImage` returns a URL. |
|
|
114
|
+
| `@terreno/ai` | When `uiBlocks` is on: the chat system prompt, the post-stream `{blocks}` check, `AIDataset` storage, and `POST /gpt/actions`. The opt-in `scaleStepperHostAction` and `toggleChecklistHostAction`. `AIService.generateBlocks` returns one validated document outside a chat turn. |
|
|
55
115
|
|
|
56
116
|
Asks use the same package under `src/asks/`. A block shows something. An ask collects an
|
|
57
117
|
answer and returns it to the agent.
|
|
@@ -16,14 +16,14 @@ that file (and its imports) to a working example. Gaps stay explicit.
|
|
|
16
16
|
| Admin panel (embedded) | yes — Admin plugin registration in `example-backend/src/server.ts` | yes — `example-frontend/app/admin/` | Expo routes wrap `@terreno/admin-frontend` screens. |
|
|
17
17
|
| Admin SPA | yes — `AdminSpaServeApp` in `example-backend/src/server.ts` | n/a | Standalone SPA is served from the backend; not a frontend route. |
|
|
18
18
|
| AI streaming chat | yes — `example-backend/src/api/ai.ts` (`addGptRoutes`) | yes — `example-frontend/app/(tabs)/ai.tsx` (`GPTChat`, optional `mascot`) | Empty-chat mascot is consumer-owned; the example fox is not a framework default. |
|
|
19
|
-
| UI blocks | yes — `uiBlocks` with `repair: true` and `
|
|
19
|
+
| UI blocks | yes — `uiBlocks` with `repair: true`, `exportDataset`, and the opt-in `scaleStepper` and `toggleChecklist` callbacks in `example-backend/src/ai/hostActions.ts`, wired from `example-backend/src/api/ai.ts`; `todoStats` in `example-backend/src/ai/tools.ts` stores a `ref` dataset; `findPhotos` in the same file cites the generated photo library, and `example-backend/src/api/photoLibrary.ts` signs its URLs | yes — `example-frontend/app/(tabs)/ai.tsx` (`uiBlocks`, dataset and action hooks, `hostActions` includes `exportDataset`, `scaleStepper`, and `toggleChecklist`, `resolveImage` resolves `file:` photo ids through `useLazyPhotoLibraryUrlQuery`) | e2e mocks a document with one inline and one `ref` chart. A second e2e test stores the Sunday roast reply (`blocks/src/fixtures/golden/sunday-roast.yaml` with `file:` photo ids) in a real `GptHistory`, streams it from the mocked model, and runs the stepper and checklist through the real `POST /gpt/actions`; photo URLs come from the real `GET /photoLibrary/:id/url`, and only the image download is stubbed (`example-frontend/e2e/helpers/seedGptHistory.ts`). Steps: [agent-ui-blocks.md](../how-to/agent-ui-blocks.md). |
|
|
20
20
|
| Agent asks | yes — `asks: {approvals}` in `example-backend/src/api/ai.ts`, with the per-request `deleteCompletedTodos` approval tool from `example-backend/src/api/todoTools.ts`; it also passes `chat` to `addGptHistoryRoutes` for the headless `pendingAsks` and `turn` endpoints; keyless scripted agent with short compact-surface replies in `example-backend/src/api/demoAgent.ts` | yes — `example-frontend/app/(tabs)/ai.tsx` (`onAskSubmit`, `askErrors`), helpers in `example-frontend/lib/gptAsks.ts` | Without `GEMINI_API_KEY`, "Help me pick a plan" asks a `choice` in chat, "Pick toppings" asks a `select: "many"` `choice` with an Other field, "Send the weekly report" and "Archive old chats" ask a `confirm` (the second `destructive`), "Draft an announcement" asks a `markdown` edit of a draft, "Fill in the invoice details" asks a `form` with eight typed fields, "Upload a receipt" asks for `files`, sent as uploads when `GCS_BUCKET` is set and as data URLs otherwise, and "Delete my completed todos" calls `deleteCompletedTodos`, which runs only after the user approves the server's destructive approval card. e2e: `example-frontend/e2e/ai-chat.spec.ts`. Small clients: the curl walkthrough in the how-to and the demo's watch-sized `SimpleAskCard` story. Steps: [agent-ui-asks.md](../how-to/agent-ui-asks.md). |
|
|
21
21
|
| AI structured output | no | no | `@terreno/ai` supports structured generation; the example only streams chat. Recorded gap — not claimed by a current tutorial. |
|
|
22
22
|
| Feature flags + live updates | yes — `FeatureFlagsApp` in `example-backend/src/server.ts`, seed `example-backend/src/scripts/seed-feature-flags.ts` | yes — `useTerrenoFeatureFlags` in `example-frontend/app/_layout.tsx` | Socket-backed live updates share the realtime connection. |
|
|
23
23
|
| Websockets / realtime | yes — `RealtimeApp` in `example-backend/src/server.ts`, `example-backend/src/websockets.ts` | yes — `useSocketConnection` in `example-frontend/app/_layout.tsx` | Sync deltas also use the socket (`SyncApp` + `@terreno/syncdb`). |
|
|
24
|
-
| Consent forms | yes — `example-backend/src/consentDefinitions.ts`, seed scripts | yes — `example-frontend/app/(tabs)/
|
|
24
|
+
| Consent forms | yes — `example-backend/src/consentDefinitions.ts`, seed scripts | yes — `example-frontend/app/(tabs)/documents.tsx` (Consents section), admin `example-frontend/app/admin/consent-forms/` | First-login flow is covered by e2e (`example-frontend/e2e/consents.spec.ts`). |
|
|
25
25
|
| Product announcements | yes — `AnnouncementsApp` in `example-backend/src/server.ts`, release upload token via `ANNOUNCEMENTS_UPLOAD_TOKEN`, seed `example-backend/src/scripts/seed-announcements.ts`, sample release pack `example-backend/announcements/releases/1.14.0/` with `bun run announcements:pack` | yes — `AnnouncementNavigator` in `example-frontend/app/_layout.tsx` and admin announcement routes | Release packs support multiple Markdown surfaces and idempotent draft or explicit live API import. |
|
|
26
|
-
| File upload / GCS | yes — `example-backend/src/api/settings.ts`, AI file routes in `example-backend/src/api/ai.ts`, `file-uploads` flag in `example-backend/src/api/fileUploads.ts` | yes — `example-frontend/app/(tabs)/
|
|
26
|
+
| File upload / GCS | yes — `example-backend/src/api/settings.ts`, AI file routes in `example-backend/src/api/ai.ts`, `file-uploads` flag in `example-backend/src/api/fileUploads.ts` | yes — `example-frontend/app/(tabs)/documents.tsx` (Files section, every signed-in user, 1 upload / IP / min), `example-frontend/app/(tabs)/ai.tsx`, `example-frontend/app/gcs-settings.tsx` | The `file-uploads` feature flag hides upload controls and rejects uploads when off. A missing flag leaves uploads enabled. Deployed bucket is terraform-managed (`google_storage_bucket.example_documents`). |
|
|
27
27
|
| Better Auth | yes — `example-backend/src/utils/betterAuthConfig.ts`, `BetterAuthApp` in `example-backend/src/server.ts` | yes — `example-frontend/app/login.tsx`, `example-frontend/lib/betterAuth.ts` | Default `AUTH_PROVIDER` is `better-auth`. |
|
|
28
28
|
| syncdb local-first | yes — `SyncApp` in `example-backend/src/server.ts` | yes — `example-frontend/store/syncdb.ts`, `example-frontend/app/(tabs)/index.tsx` | PR #869 closed without merge; `@terreno/syncdb` shipped on the launch line and is the example todos path. |
|
|
29
29
|
| SyncDB diagnostics | n/a | yes — `debug` registration in `example-frontend/store/syncdb.ts`, debugger UI at `example-frontend/app/syncdb-debug.tsx` | `terreno-mcp-local` can inspect, snapshot/compare/merge, mutate, flush, reconcile/resync, and exercise offline/conflict paths against the running development client. |
|
|
@@ -1319,6 +1319,18 @@ One easy-to-use harness inside `@terreno/ai` that runs long-lived agentic work d
|
|
|
1319
1319
|
|
|
1320
1320
|
---
|
|
1321
1321
|
|
|
1322
|
+
## agent-ui-rich-replies
|
|
1323
|
+
|
|
1324
|
+
**Title:** `Agent UI Rich Replies: steppers, checklists, galleries, and copy`
|
|
1325
|
+
|
|
1326
|
+
**Labels:** `area:dx`, `type:feature`
|
|
1327
|
+
**Project fields:** Area=`dx`, Target=`Next`, Impact=`Feature`, IP=`agent-ui-rich-replies`, Status=`Planned`
|
|
1328
|
+
|
|
1329
|
+
- **Implementation plan:** [agent-ui-rich-replies.md](https://github.com/TerrenoLabs/terreno/blob/master/docs/implementationPlans/agent-ui-rich-replies.md)
|
|
1330
|
+
- **Tasks:** [agent-ui-rich-replies.md](https://github.com/TerrenoLabs/terreno/blob/master/docs/tasks/agent-ui-rich-replies.md)
|
|
1331
|
+
|
|
1332
|
+
---
|
|
1333
|
+
|
|
1322
1334
|
# Shipped, umbrella, and declined IPs
|
|
1323
1335
|
|
|
1324
1336
|
Tracking issues created for IPs that previously lacked a `**Roadmap issue:**` header.
|
|
@@ -47,12 +47,15 @@ Problem-oriented, practical steps. Use these when you know what you want to do.
|
|
|
47
47
|
- [Compose charts in a dashboard grid](charts-and-dashboards.md) — `LineChart` / `BarChart` / `AreaChart` / `DonutChart` inside `Card`s in `DashboardGrid` (ops-dashboard scorecards and spans: [dashboard-chart-parity](../implementationPlans/dashboard-chart-parity.md))
|
|
48
48
|
- [Compare rendered chart snapshots](compare-chart-rendered-snapshots.md) — PNG goldens for the chart visual gallery, not JSON snapshots
|
|
49
49
|
- [Migrate from @terreno/rtk to @terreno/syncdb](migrate-rtk-to-syncdb.md) — Move data sync to the local-first layer
|
|
50
|
+
- [Use syncdb correctly](use-syncdb-correctly.md) — Serial snapshot startup, outbox-halting 403s, phantom creates, conflicts, and other library behavior apps must handle
|
|
50
51
|
- [Generate a REST CLI from OpenAPI](generate-a-rest-cli.md) — Scaffold `terreno generate rest-cli` for an app API
|
|
51
52
|
- [In-app notifications](in-app-notifications.md) — Register `NotificationsApp`, syncdb inbox, and UI bell
|
|
52
53
|
- [Add organizations](add-organizations.md) — Opt into Membership-backed tenancy, RBAC, admin, and seeded examples
|
|
53
54
|
- [Add a GPT chat mascot](add-gpt-chat-mascot.md) — Optional consumer-owned character on empty `GPTChat`
|
|
54
55
|
- [Add agent asks to a chat](agent-ui-asks.md) — Turn on `asks`, show and answer them in `GPTChat`, try the keyless demo agent, and answer asks from an Apple Watch or another small client
|
|
55
56
|
- [Validate a block document locally](agent-ui-blocks.md) — `terreno-blocks validate` for a whole-reply YAML document
|
|
57
|
+
- [Add a stepper callback](agent-ui-blocks.md#add-a-stepper-callback) — `scaleStepperHostAction` or your own `handles: "stepper"` action; checklist ticks, copy buttons, the `richBlocks: false` opt-out, and the `blocks:smoke` model check are on the same page
|
|
58
|
+
- [Give the agent photos](agent-ui-blocks.md#give-the-agent-photos) — Generate a stored photo library once and let the agent cite it with `findPhotos`
|
|
56
59
|
|
|
57
60
|
## Admin
|
|
58
61
|
|
|
@@ -14,9 +14,17 @@ The same check is `validateBlocks` in `@terreno/blocks`. Field tables are in the
|
|
|
14
14
|
## Preview a document in the playground
|
|
15
15
|
|
|
16
16
|
1. Open the component demo and choose **BlocksPlayground**.
|
|
17
|
-
2. Start from
|
|
17
|
+
2. Start from a preset: **All blocks** (one of each block type), **Block type** (one preset per
|
|
18
|
+
block type, with every chart kind, callout status, and action kind), Layout, Invalid, or
|
|
19
|
+
Sunday roast. Or paste your own document into the text area.
|
|
18
20
|
3. The preview updates as you type. An invalid document shows the error banner and keeps
|
|
19
21
|
the raw text collapsed.
|
|
22
|
+
4. Press the controls. The playground has no server: a stepper's − and + and a checklist tick
|
|
23
|
+
run on the device with `scaleStepperBlock` and `applyChecklistState` from `@terreno/blocks`
|
|
24
|
+
(the helpers `scaleStepperHostAction` and `toggleChecklistHostAction` use), and a toast names
|
|
25
|
+
the callback and its result, such as "scaleStepper → 6 People". A reply, open, select, or
|
|
26
|
+
other callback shows a toast describing the event instead. Copy writes the clipboard.
|
|
27
|
+
Editing the text drops the local results.
|
|
20
28
|
|
|
21
29
|
## Require documents from the model
|
|
22
30
|
|
|
@@ -72,6 +80,133 @@ addGptRoutes(router, {
|
|
|
72
80
|
|
|
73
81
|
The path is `/gpt/actions`, so a limiter on `/gpt` covers it. Each call is stored as an `AIRequest` with `requestType: "ui_action"`.
|
|
74
82
|
|
|
83
|
+
## Add a stepper callback
|
|
84
|
+
|
|
85
|
+
A `stepper` is a − value + control. Each tap goes to a host action, and the action returns the
|
|
86
|
+
replacement stepper. Use it when a number changes other numbers, such as guests changing a
|
|
87
|
+
shopping list.
|
|
88
|
+
|
|
89
|
+
1. Register a stepper action. `scaleStepperHostAction` from `@terreno/ai` scales every item
|
|
90
|
+
linearly from the stepper the agent wrote:
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
import {addGptRoutes, scaleStepperHostAction} from "@terreno/ai";
|
|
94
|
+
|
|
95
|
+
addGptRoutes(router, {
|
|
96
|
+
aiService,
|
|
97
|
+
uiBlocks: {hostActions: {scaleStepper: scaleStepperHostAction}},
|
|
98
|
+
});
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
2. Leave `uiBlocks.richBlocks` unset (it defaults to `true`). The prompt offers `stepper` only
|
|
102
|
+
when some host action has `handles: "stepper"`, and names those actions as its callbacks.
|
|
103
|
+
3. List the same name in `GPTChat` `hostActions` (`["scaleStepper"]`). `onBlockCallback` posts
|
|
104
|
+
to `POST /gpt/actions` and returns the result, as in [Wire the example chat](#wire-the-example-chat).
|
|
105
|
+
4. The agent writes a stepper whose `callback.name` is that action:
|
|
106
|
+
|
|
107
|
+
```yaml
|
|
108
|
+
- type: stepper
|
|
109
|
+
id: guests
|
|
110
|
+
label: Number of people
|
|
111
|
+
unit: People
|
|
112
|
+
value: 5
|
|
113
|
+
min: 1
|
|
114
|
+
max: 20
|
|
115
|
+
callback: {name: scaleStepper}
|
|
116
|
+
items:
|
|
117
|
+
- {label: Bone-in leg of lamb, amount: 2, unit: kg, decimals: 1}
|
|
118
|
+
- {label: Carrots, amount: 8, round: up}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
5. Press +. The client sends `payload: {...callback.payload, value: 6}`, with `blockId: guests`
|
|
122
|
+
and `elementId: guests_increase`. The action returns `{replace: "block", blocks}` and
|
|
123
|
+
`GPTChat` swaps the stepper: lamb reads "2.4 kg", carrots "10". Both buttons are disabled
|
|
124
|
+
until the reply lands, so a fast second tap is dropped.
|
|
125
|
+
|
|
126
|
+
A stepper whose `callback.name` is not a `handles: "stepper"` action fails `UNKNOWN_HOST_ACTION`.
|
|
127
|
+
`value` outside `min`–`max`, or off the `step` grid, returns 400.
|
|
128
|
+
|
|
129
|
+
**Stored prompts are owner-writable.** The history owner can change any stored prompt with
|
|
130
|
+
`PATCH /gpt/histories/:id`, including the stepper `scaleStepperHostAction` reads. When the numbers
|
|
131
|
+
matter (prices, stock, dosage), write your own action and compute them from your own data:
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
import {findAgentBlock, type HostAction} from "@terreno/ai";
|
|
135
|
+
import {z} from "@terreno/api";
|
|
136
|
+
|
|
137
|
+
const priceGuests: HostAction = {
|
|
138
|
+
handles: "stepper",
|
|
139
|
+
logResponse: false, // keep the returned block out of the AIRequest log
|
|
140
|
+
payload: z.object({value: z.number().int().min(1).max(20)}).passthrough(),
|
|
141
|
+
handler: async ({blockId, history, messageId, payload, user}) => {
|
|
142
|
+
const stepper = findAgentBlock({blockId, history, messageId, type: "stepper"});
|
|
143
|
+
const {value} = payload as {value: number};
|
|
144
|
+
const items = await quoteItems({guests: value, userId: user._id}); // your data, not the stored block
|
|
145
|
+
return {replace: "block", blocks: {v: 1, blocks: [{...stepper, items, value}]}};
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
`findAgentBlock` reads `history.prompts[n]` for `messageId: msg-<n>`, otherwise the only assistant
|
|
151
|
+
prompt holding that block: several matches throw 409, none 404. Field rules are in
|
|
152
|
+
[Stepper](../reference/blocks.md#stepper) and [Host actions](../reference/ai.md#host-actions).
|
|
153
|
+
|
|
154
|
+
## Add a checklist callback
|
|
155
|
+
|
|
156
|
+
A `checklist` without a `callback` ticks on the device only. To send ticks to the server:
|
|
157
|
+
|
|
158
|
+
1. Register `toggleChecklistHostAction` (or your own `handles: "checklist"` action) under a name,
|
|
159
|
+
such as `toggleChecklist`, and list it in `GPTChat` `hostActions`.
|
|
160
|
+
2. The prompt then tells the model to set `callback: {name: toggleChecklist}` on a checklist.
|
|
161
|
+
3. A tick sends `{...callback.payload, itemId, checked, state}`, where `state` holds every item's
|
|
162
|
+
tick after this one. The whole checklist is locked until the reply lands, and the "n of m"
|
|
163
|
+
counter changes when the returned checklist arrives.
|
|
164
|
+
|
|
165
|
+
`toggleChecklistHostAction` returns the stored checklist with `checked` from `state` and saves
|
|
166
|
+
nothing. An unknown item id returns 400. An app that records progress writes its own action.
|
|
167
|
+
|
|
168
|
+
## Copy a block's text
|
|
169
|
+
|
|
170
|
+
Add a button with `action: {kind: copy, target: <block id>}` (or `text` for a literal). The
|
|
171
|
+
target is a `stepper`, `checklist`, `list`, `table`, or `text` block. `BlocksView` copies the
|
|
172
|
+
block as it is shown now, so a stepper at 6 copies the scaled amounts. Nothing reaches the
|
|
173
|
+
server, so `hostActions` does not apply. See [Copy](../reference/blocks.md#copy).
|
|
174
|
+
|
|
175
|
+
## Keep the old catalog for shipped native builds
|
|
176
|
+
|
|
177
|
+
Rich blocks (`stepper`, `checklist`, `gallery`, `list`, `card.eyebrow`, `copy`) are in the prompt
|
|
178
|
+
by default. A native build released before them shows an error banner for a reply that uses
|
|
179
|
+
them. Until those builds update, turn them off:
|
|
180
|
+
|
|
181
|
+
```ts
|
|
182
|
+
addGptRoutes(router, {aiService, uiBlocks: {hostActions, richBlocks: false}});
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
The prompt is then the same as before rich blocks, even with a stepper action registered.
|
|
186
|
+
Validation still accepts the new blocks.
|
|
187
|
+
|
|
188
|
+
## Smoke-test a real model
|
|
189
|
+
|
|
190
|
+
`bun run blocks:smoke` (from the repo root) sends "Give me a plan for a sunday lamb roast, I'm
|
|
191
|
+
having friends over still figuring out numbers tbh" through one real chat turn with the example
|
|
192
|
+
backend's `uiBlocks` options and the `findPhotos` tool.
|
|
193
|
+
|
|
194
|
+
| Check | Passes when |
|
|
195
|
+
| --- | --- |
|
|
196
|
+
| Document | The reply parses and passes `validateBlocks` |
|
|
197
|
+
| Blocks | It holds a `gallery`, `list`, `stepper`, and `checklist`, and a `copy` action |
|
|
198
|
+
| Photos | Every image `src` is a `file:` id that `findPhotos` returned in that turn |
|
|
199
|
+
|
|
200
|
+
| Variable | Use |
|
|
201
|
+
| --- | --- |
|
|
202
|
+
| `GOOGLE_VERTEX_PROJECT` (with Application Default Credentials) or `GEMINI_API_KEY` | The model. With neither, the script prints that it skipped and exits 0 |
|
|
203
|
+
| `BLOCKS_SMOKE_MODEL` | Optional model id instead of the server default |
|
|
204
|
+
| `MONGO_URI`, `MONGO_DB_NAME` | The database holding the photo library ([Give the agent photos](#give-the-agent-photos)) |
|
|
205
|
+
| `BLOCKS_SMOKE_OUT_DIR` or `--out <dir>` | Where `reply.yaml` and `result.json` go (default: a new temp directory) |
|
|
206
|
+
|
|
207
|
+
It exits 1 when any check fails. The turn runs as `blocks-smoke@system.invalid`, and its history
|
|
208
|
+
is deleted afterwards.
|
|
209
|
+
|
|
75
210
|
## Wire the example chat
|
|
76
211
|
|
|
77
212
|
1. Turn `uiBlocks` on in `addGptRoutes` so `GET /gpt/datasets/:id` and `POST /gpt/actions` are in the OpenAPI spec.
|
|
@@ -84,11 +219,57 @@ The path is `/gpt/actions`, so a limiter on `/gpt` covers it. Each call is store
|
|
|
84
219
|
|
|
85
220
|
## Turn blocks on in the example backend
|
|
86
221
|
|
|
87
|
-
`example-backend` passes `uiBlocks`
|
|
222
|
+
`example-backend` passes `uiBlocks` (`exampleUiBlocksOptions` in `src/ai/hostActions.ts`) with
|
|
223
|
+
`repair: true`, `html: true`, and three host callbacks:
|
|
224
|
+
|
|
225
|
+
| Name | Handles | Does |
|
|
226
|
+
| --- | --- | --- |
|
|
227
|
+
| `exportDataset` | buttons | Payload `{dataset: string}`. Returns a badge that names that dataset |
|
|
228
|
+
| `scaleStepper` | `stepper` | `scaleStepperHostAction` |
|
|
229
|
+
| `toggleChecklist` | `checklist` | `toggleChecklistHostAction` |
|
|
230
|
+
|
|
231
|
+
`richBlocks` is left at its default (`true`). Dataset TTL stays at the default, so stored rows are kept.
|
|
88
232
|
|
|
89
233
|
The per-request tool `todoStats` counts the signed-in user's open and completed todos, calls `registerAiDataset`, and returns `datasetId`. It stores the rows on `req.body.historyId` when that id belongs to the caller. With no history id it uses the caller's newest history, and with no history it returns `{datasetId: null, rowCount: 0}`. A chart dataset uses `source: ref` and that id. Columns are `status` and `count`.
|
|
90
234
|
|
|
91
|
-
`GPTChat` lists `exportDataset` in `hostActions`, so
|
|
235
|
+
`GPTChat` lists `exportDataset`, `scaleStepper`, and `toggleChecklist` in `hostActions`, so those callbacks stay enabled. The e2e mock also allows `export_csv`.
|
|
236
|
+
|
|
237
|
+
## Give the agent photos
|
|
238
|
+
|
|
239
|
+
The model cannot make photos on the fly, and an `https` image needs `imageHosts`. The example
|
|
240
|
+
app generates a photo library once, and the agent cites its photos as `file:` ids.
|
|
241
|
+
|
|
242
|
+
1. Generate the library, once per environment. From `example-backend`, with `GOOGLE_VERTEX_PROJECT`,
|
|
243
|
+
Application Default Credentials for Vertex, and `GCS_BUCKET` set, run `bun run photos:generate`.
|
|
244
|
+
Each prompt in `src/scripts/photoPrompts.ts` becomes one `PhotoLibraryEntry`. A re-run skips
|
|
245
|
+
prompts that already have an entry; pass `--force` to regenerate them. `PHOTO_IMAGE_MODEL`
|
|
246
|
+
(default `imagen-4.0-fast-generate-001`) and `GOOGLE_VERTEX_LOCATION` (default `us-central1`)
|
|
247
|
+
are optional.
|
|
248
|
+
2. Register the `findPhotos` tool. `createFindPhotosTool()` from `src/ai/tools.ts` is in the
|
|
249
|
+
example's per-request tools. `findPhotos({query, count})` matches the query words against each
|
|
250
|
+
entry's `tags` and `alt`. `count` is 1–6. It returns `{photos: [{src: "file:<entry id>", alt}], note}`.
|
|
251
|
+
Entries that match more words come first; ties keep entry id order. Soft-deleted entries are
|
|
252
|
+
left out. When nothing matches, `photos` is empty and `note` tells the model not to invent an
|
|
253
|
+
id. The tool description tells the model to put each `src` into a `gallery`, `list`, or
|
|
254
|
+
`image` block exactly as returned.
|
|
255
|
+
3. Serve the URLs. `photoLibraryRouter` (`src/api/photoLibrary.ts`) mounts `/photoLibrary`.
|
|
256
|
+
List and read need a signed-in user; create, update, and delete return 405.
|
|
257
|
+
`GET /photoLibrary/:id/url` returns `{url}`, a signed read URL that lasts one hour, to any
|
|
258
|
+
signed-in user. The library is shared, so the per-user `GET /files/*` route does not fit.
|
|
259
|
+
An unknown or soft-deleted id is 404. Without a bucket (`GCS_BUCKET` unset and none saved in
|
|
260
|
+
Profile) it returns 503 `Photo storage is not configured`.
|
|
261
|
+
4. Regenerate the frontend SDK: `cd example-frontend && bun run sdk`. This adds
|
|
262
|
+
`useLazyPhotoLibraryUrlQuery`.
|
|
263
|
+
5. Pass `resolveImage` to `GPTChat`. The example builds it with `createPhotoImageResolver`
|
|
264
|
+
(`example-frontend/lib/photoLibraryImages.ts`) around `openapi.useLazyPhotoLibraryUrlQuery`.
|
|
265
|
+
It fetches each id once while the URL is fresh (50 minutes). A failed lookup returns
|
|
266
|
+
`undefined`, so the block keeps its labelled placeholder, and a later render tries again.
|
|
267
|
+
|
|
268
|
+
In the example app, every `file:` image id in a block is a photo library entry id. Nothing else
|
|
269
|
+
in the example writes `file:` image ids, so the ids carry no extra prefix. A host that also cites
|
|
270
|
+
its own files must tell the two apart in its `resolveImage`. An id that is not in the library,
|
|
271
|
+
such as `file:roast-lamb` in the reference examples, resolves to `undefined` and shows the
|
|
272
|
+
placeholder.
|
|
92
273
|
|
|
93
274
|
## Render documents in chat
|
|
94
275
|
|
|
@@ -100,5 +281,8 @@ is true, finished top-level blocks render and a spinner marks the block still ar
|
|
|
100
281
|
- `callback` calls `onBlockCallback`. Return `{replace: "block", blocks}` to swap that
|
|
101
282
|
block, or `{text}` to append an assistant message. The button shows loading until the
|
|
102
283
|
promise settles.
|
|
284
|
+
- Stepper taps and checklist ticks with a callback also call `onBlockCallback`. The block is
|
|
285
|
+
disabled until the promise settles.
|
|
286
|
+
- `copy` writes the clipboard on the device and calls nothing.
|
|
103
287
|
- Pass `hostActions` to disable callbacks the host does not run, `resolveDataset` to
|
|
104
288
|
load `ref` datasets, and `resolveImage` to turn a `file:` image id into a URL.
|
|
@@ -179,6 +179,8 @@ an isolated `pr-N` backend: `deploy-frontend-preview` always points
|
|
|
179
179
|
up to 20 minutes for its `/health` before publishing, because `gcp-cd-preview`
|
|
180
180
|
runs in a separate workflow.
|
|
181
181
|
|
|
182
|
+
Those backend deploys set `GCS_BUCKET` to `flourish-terreno-example-documents` (override with `GCP_DOCUMENTS_BUCKET`). Preview revisions also set `GCS_FOLDER_PREFIX=pr-<number>/` so each PR keeps its own folder. Without `GCS_BUCKET`, `GET /documents/` returns 503 and the Files section shows “Storage is not configured.”
|
|
183
|
+
|
|
182
184
|
Backend previews prune not-Ready tagged revisions from traffic
|
|
183
185
|
(`rebuild-cloud-run-ready-traffic.sh`), deploy untagged with
|
|
184
186
|
`--revision-suffix`, then point the `pr-N` tag at that revision. Terraform
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# Preview a component demo
|
|
2
2
|
|
|
3
|
-
Open a
|
|
4
|
-
navigation header on demo and dev
|
|
3
|
+
Open a demo or dev route and change the shareable query. The preview controls sit in the
|
|
4
|
+
navigation header on every demo and dev screen, including the catalogs: dropdowns for theme, viewport, and
|
|
5
5
|
background, plus a settings button (sliders icon) that opens a modal for right-to-left,
|
|
6
6
|
reduced motion, locale, and the share query. In windows narrower than 768px every
|
|
7
|
-
preview control moves into that modal; the Dev/Demo mode link stays in the header. `?embed=1` applies the same query and hides
|
|
7
|
+
preview control moves into that modal; the Dev/Demo mode link stays in the header. The header chrome uses the same `theme` query. `?embed=1` applies the same query and hides
|
|
8
8
|
the controls.
|
|
9
9
|
|
|
10
10
|
```text
|
|
@@ -13,11 +13,11 @@ the controls.
|
|
|
13
13
|
|
|
14
14
|
| Query | Values |
|
|
15
15
|
| --- | --- |
|
|
16
|
-
| `theme` | `light` (default) or `
|
|
16
|
+
| `theme` | `light` (default), `dark`, or `system` (follow the device color scheme) |
|
|
17
17
|
| `viewport` | `full` (default), `320`, `375`, `1024`, `1280` |
|
|
18
18
|
| `background` | `default`, `inverse`, `transparent` |
|
|
19
19
|
| `locale` | Passed to components that already read a locale, such as consent |
|
|
20
20
|
| `rtl` | `1` sets document direction to `rtl` on web |
|
|
21
21
|
| `reducedMotion` | `1` sets `data-reduced-motion="1"` on the document element |
|
|
22
22
|
|
|
23
|
-
Reload keeps the query. The settings modal shows the current query when any value is not the default. Choosing a control's default removes that key from the URL.
|
|
23
|
+
Reload keeps the query. The settings modal shows the current query when any value is not the default. Choosing a control's default removes that key from the URL. Opening a component from the catalog keeps the current preview query. Leaving a component route restores document direction and reduced motion on web. Custom palettes stay on `/palette`.
|
|
@@ -52,6 +52,23 @@ Pass `{store: "mongo"}`. Hits live in `rateLimitHits` on the same mongoose conne
|
|
|
52
52
|
|
|
53
53
|
Unauthenticated keys use `req.ip`. Express `trust proxy` defaults to **off** so a client cannot rotate `X-Forwarded-For` to bypass the auth bucket. On Cloud Run (one hop via GFE), pass `trustProxy: 1`. Extra proxies: hop count or a subnet list. `trustProxy: false` is the same as the default.
|
|
54
54
|
|
|
55
|
+
## Limit one route
|
|
56
|
+
|
|
57
|
+
Use `createRouteRateLimitMiddleware` for a stricter limit on one expensive route. It works with or without the global limiter.
|
|
58
|
+
|
|
59
|
+
```typescript
|
|
60
|
+
import {createRouteRateLimitMiddleware} from "@terreno/api";
|
|
61
|
+
|
|
62
|
+
const uploadLimit = createRouteRateLimitMiddleware({
|
|
63
|
+
name: "uploads",
|
|
64
|
+
max: 1,
|
|
65
|
+
windowMs: 60_000,
|
|
66
|
+
store: "mongo",
|
|
67
|
+
});
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`DocumentStorageApp` takes the same options as `uploadRateLimit`. The example backend allows one upload per IP per minute and sets `trust proxy` to `1` on Cloud Run so the key is the real client IP.
|
|
71
|
+
|
|
55
72
|
## Auth vs API buckets
|
|
56
73
|
|
|
57
74
|
| Path | Bucket |
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Test UI accessibility
|
|
2
|
+
|
|
3
|
+
Run the WCAG scanner against every changed rendered state:
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
bun run accessibility:scan \
|
|
7
|
+
"http://localhost:8085/dev/Theme?story=ThemeComponent&theme=light" \
|
|
8
|
+
"http://localhost:8085/dev/Theme?story=ThemeComponent&theme=dark"
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
The scanner applies axe rules tagged WCAG 2.0 A/AA, WCAG 2.1 A/AA, and WCAG
|
|
12
|
+
2.2 AA. It writes a JSON report and full-page screenshots under
|
|
13
|
+
`/opt/cursor/artifacts/accessibility/` and exits nonzero when any violation is
|
|
14
|
+
found.
|
|
15
|
+
|
|
16
|
+
## Verify what automation cannot
|
|
17
|
+
|
|
18
|
+
1. Use only the keyboard to reach and operate each changed control.
|
|
19
|
+
2. Confirm focus order and the visible focus indicator match the visual order.
|
|
20
|
+
3. Inspect each control's accessible name, role, state, and error relationship.
|
|
21
|
+
4. Check light and dark modes, plus loading, empty, disabled, and error states
|
|
22
|
+
affected by the change.
|
|
23
|
+
5. At 200% browser zoom, confirm content remains usable without clipping or
|
|
24
|
+
losing actions.
|
|
25
|
+
|
|
26
|
+
Automated scans do not prove keyboard behavior, announcement quality, reading
|
|
27
|
+
order, or whether alternative text conveys the intended meaning. Record those
|
|
28
|
+
manual checks with the automated report in the pull request Verification table.
|
|
29
|
+
|
|
30
|
+
## Fixing findings
|
|
31
|
+
|
|
32
|
+
Fix violations in the changed feature before submission. If a finding is
|
|
33
|
+
pre-existing and outside the changed path, capture a baseline on an unaffected
|
|
34
|
+
route and name both routes in the pull request. Do not suppress an axe rule
|
|
35
|
+
without a linked issue and a documented reason.
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# How to Use syncdb Correctly
|
|
2
|
+
|
|
3
|
+
This page lists `@terreno/syncdb` behavior that a working app has to account for. Each entry says what the library does today, what breaks if you ignore it, and what to do instead. It was checked against `syncdb/src` and `api/src/sync` at 57.13.0. Several entries are library gaps rather than intended behavior. Delete an entry when its gap is fixed.
|
|
4
|
+
|
|
5
|
+
Read [Local-first data](../explanation/local-first-data.md) and [Migrate from @terreno/rtk to @terreno/syncdb](migrate-rtk-to-syncdb.md) first.
|
|
6
|
+
|
|
7
|
+
## Startup and snapshots
|
|
8
|
+
|
|
9
|
+
### Cold start costs one round trip per stream
|
|
10
|
+
|
|
11
|
+
A stream is one collection in one scope, so a user has roughly (synced collections) × (tenants they belong to) streams. Each stream costs at least one `GET /sync/snapshot` round trip, even when it is empty. Reconcile, subscribe catch-up, `forceResync` and wipe re-bootstrap page up to `snapshotConcurrency` streams side by side (default 6). Each stream runs one pass at a time, and a pass that purges (force resync, wipe) waits for the stream's in-flight pass to finish first.
|
|
12
|
+
|
|
13
|
+
Measured with a fake HTTP channel at 150 ms latency, 13 collections and 2 workspaces (26 streams): 4.1 s to the first full reconcile when paging one stream at a time, 0.95 s at the default concurrency.
|
|
14
|
+
|
|
15
|
+
| Do | Why |
|
|
16
|
+
| --- | --- |
|
|
17
|
+
| Sync only the collections the client reads. | Each collection adds one stream per tenant to cold start. |
|
|
18
|
+
| Gate each screen on its **own** stream's bootstrap (see [No loading flag on synced reads](#no-loading-flag-on-synced-reads)), not on "sync finished". | The stream a screen needs may land after others. |
|
|
19
|
+
| Don't call `forceResync()` on a hot path. | It purges and re-pages every stream. |
|
|
20
|
+
| Raise `snapshotConcurrency` only if the backend can take it. | Each in-flight stream is one concurrent snapshot query on the server. |
|
|
21
|
+
|
|
22
|
+
The startup reconcile and each collection's `sync:subscribed` catch-up both page the same streams. A catch-up queues behind a pass that started before the room join, because that pass may have missed a write. So a cold start still pages each stream twice, one pass after the other.
|
|
23
|
+
|
|
24
|
+
### No loading flag on synced reads
|
|
25
|
+
|
|
26
|
+
`useQuery` returns a plain array. `useListQuery` returns `{data}`. The `isPending` on `useEntity`/`useReadQuery` means an outbox write is pending, not that data is loading. An empty array before bootstrap looks the same as an empty collection, so a screen that renders its empty state from `[]` flashes "nothing here" on every cold start.
|
|
27
|
+
|
|
28
|
+
The per-stream `bootstrapped` mark is the right signal, but `isStreamBootstrapped` is not exported. Read the cell directly:
|
|
29
|
+
|
|
30
|
+
```typescript
|
|
31
|
+
const isBootstrapped = (stream: string): boolean =>
|
|
32
|
+
syncDb.store.raw.getCell("_cursors", stream, "bootstrapped") === true;
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Don't use the cursor (`useSyncStatus().streams`) for this. A live delta can move the cursor to the head before the snapshot below it has been paged. `useWindowQuery` (query collections only) does expose `isLoading`.
|
|
36
|
+
|
|
37
|
+
A stream key is `<collection>|<scopeType>:<value>`, for example `documents|tenant:<workspaceId>`. A user with several tenants has one stream per tenant. Treat a collection as loaded only once every known stream for it is bootstrapped (`syncDb.store.getKnownStreams()`).
|
|
38
|
+
|
|
39
|
+
## Writes
|
|
40
|
+
|
|
41
|
+
### Mutations return an id, not an outcome
|
|
42
|
+
|
|
43
|
+
`create`, `update` and `remove` from `useMutate` (and from the generated `useCreate*`/`useUpdate*`/`useDelete*` hooks) return `{mutationId, id}` synchronously. There is no promise for the server ack. The client also drops the ack's `warning`. A `warning` means a server post-hook threw after the write committed.
|
|
44
|
+
|
|
45
|
+
If a screen needs to know the outcome (close a modal only once the create landed, toast a refusal), wrap the transport and HTTP channel and record ack and nack outcomes by `mutationId`. Then await that `mutationId`. Don't poll the store.
|
|
46
|
+
|
|
47
|
+
### A 403 from a write halts the whole outbox
|
|
48
|
+
|
|
49
|
+
The server maps 403/405 to an `unauthorized` nack and 400/404/422 to `validation`. They behave very differently:
|
|
50
|
+
|
|
51
|
+
| Nack | Client behavior |
|
|
52
|
+
| --- | --- |
|
|
53
|
+
| `unauthorized` | The mutation is re-queued and the **whole drain stops** (auth-pause). On re-auth the same `mutationId` replays, reads the same stored nack, and pauses again. It never settles. |
|
|
54
|
+
| `validation` | The mutation is marked `failed` and the drain continues. That entity's later writes stay blocked until `retryFailed({entityId})`. |
|
|
55
|
+
| `conflict` | That entity's later writes stay blocked (or every write is blocked, with `haltQueueOnConflict: true`) until `resolveConflict`. |
|
|
56
|
+
| `error` | Retried up to the retry budget, then handled like `validation`. |
|
|
57
|
+
|
|
58
|
+
So a router hook that refuses a synced write for a business reason must throw **422**, not 403. That includes `preCreate`/`preUpdate` membership checks. Keep 403 for real auth failures.
|
|
59
|
+
|
|
60
|
+
### Don't send a stale tenant field on create
|
|
61
|
+
|
|
62
|
+
For a tenant-scoped collection, `enforceWriteScope` checks a client-sent scope field (for example `workspaceId`) against the user's memberships **before** `preCreate` runs. A value the user does not belong to is a 403, which halts the outbox (see above). If the field is missing or null, the check is skipped and `preCreate` can fill it in.
|
|
63
|
+
|
|
64
|
+
Either omit the tenant field and let `preCreate` set it, or send it only from the currently synced row of the parent tenant, never from a persisted id that may be stale.
|
|
65
|
+
|
|
66
|
+
### The local row never gets server-computed fields from the create
|
|
67
|
+
|
|
68
|
+
After an ack, the client stamps the row with the ack's seq and keeps the optimistic data. The server's create delta has the same seq, so the client skips it. Whatever `preCreate`, schema defaults or hooks set on the server does not reach the local row until that row changes again or is repaired. If the delta arrives before the ack, the row is marked for repair, but repair only runs on the next `reconcile()` (reconnect, seq jump, or every 5 minutes).
|
|
69
|
+
|
|
70
|
+
Do one of these:
|
|
71
|
+
|
|
72
|
+
- Send every field the UI reads in the create payload, matching what the server would compute (defaults, `order`, ownership fields).
|
|
73
|
+
- After the ack, patch the local row yourself.
|
|
74
|
+
|
|
75
|
+
The local row's `data` is exactly what you passed. Include `_id` in the payload if anything reads `row._id`. Server-written rows carry `_id` and `id`.
|
|
76
|
+
|
|
77
|
+
### A refused create leaves a phantom row
|
|
78
|
+
|
|
79
|
+
When a create is nacked as `validation` (or runs out of `error` retries), the repair fetch finds no server row and only clears the repair mark. The optimistic row stays in the store with `stream: ""` and shows up in queries. Only `forceResync` purges such rows.
|
|
80
|
+
|
|
81
|
+
On a refused create, tombstone the local row yourself and call `retryFailed({entityId})` so later writes to that id are released.
|
|
82
|
+
|
|
83
|
+
### Conflicts are never resolved for you
|
|
84
|
+
|
|
85
|
+
A `conflict` nack blocks every later write to that row, and any queued write whose args mention its id, until someone calls `client.resolveConflict({mutationId, strategy: "keepMine" | "useServer"})` (or `useConflicts().resolve`). Local edits keep applying optimistically, so the UI looks fine while nothing reaches the server.
|
|
86
|
+
|
|
87
|
+
If the app has no conflict UI, run a watcher that resolves each conflict as it is recorded. Use `keepMine` for fields the user types, and `useServer` for everything else.
|
|
88
|
+
|
|
89
|
+
## Server side
|
|
90
|
+
|
|
91
|
+
### Write synced models one document at a time, by `_id`
|
|
92
|
+
|
|
93
|
+
On a synced model, `updateMany`, `deleteMany`, `deleteOne`, `findOneAndDelete` and `bulkWrite` throw. Single-document writes by `_id` without upsert are allowed: `save()`, `updateOne`, `findOneAndUpdate`, `findByIdAndUpdate`, `replaceOne`. Delete by setting `deleted: true`, because a hard delete emits no delta.
|
|
94
|
+
|
|
95
|
+
In tests, clean up with `Model.collection.deleteMany(...)`, which bypasses Mongoose.
|
|
96
|
+
|
|
97
|
+
### Deltas don't apply `queryFilter`
|
|
98
|
+
|
|
99
|
+
Snapshots and `/sync/entities` apply the router's `queryFilter`. Live deltas check only `permissions.read` (or RBAC) per recipient. A router that restricts visibility only through `queryFilter` leaks rows over the socket. Every synced router's `read` permission must enforce the same restriction itself.
|
|
100
|
+
|
|
101
|
+
### Membership grants arrive late
|
|
102
|
+
|
|
103
|
+
A user's new tenant is picked up by the socket's session revalidation sweep (`sessionRevalidationIntervalMs`, 60 s by default) or a resubscribe. The client finds the new stream on its next `reconcile()` (`reconcileIntervalMs`, 5 minutes by default, plus reconnects). Until then the new tenant's rows don't appear.
|
|
104
|
+
|
|
105
|
+
When the product grants membership, push a content-free event to the user's sockets and have the client reconnect syncdb. Reconnecting resubscribes and reconciles. Grants written another way (admin, scripts) still wait for the timers.
|
|
106
|
+
|
|
107
|
+
## Auth
|
|
108
|
+
|
|
109
|
+
### Logging out does not wipe local data
|
|
110
|
+
|
|
111
|
+
The client's own `onAuthChange` handling treats a logout as a pause and **never wipes**. A different user logging in does wipe. To clear one user's data on sign-out, set `wipeOnSignOut: true` and call `client.signOut()` explicitly when the app's user clears.
|