@terreno/mcp 0.31.0 → 56.0.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bootstrap.d.ts.map +1 -1
- package/dist/bootstrap.js +8 -9
- package/dist/bootstrap.js.map +1 -1
- package/dist/docs/templates/bootstrap/frontend/app/_layout.template.tsx +1 -1
- package/dist/docs/upgrades/56.0.0-beta.2.md +59 -0
- package/dist/docs/versioned/next/README.md +2 -0
- package/dist/docs/versioned/next/explanation/roadmap-seed-issues.md +21 -0
- package/dist/docs/versioned/next/how-to/README.md +2 -0
- package/dist/docs/versioned/next/how-to/migrate-rtk-to-syncdb.md +445 -0
- package/dist/docs/versioned/next/implementationPlans/PLAN_INDEX.md +28 -50
- package/dist/docs/versioned/next/implementationPlans/b2b-platform-program.md +3 -2
- package/dist/docs/versioned/next/implementationPlans/comms-abstraction.md +98 -15
- package/dist/docs/versioned/next/implementationPlans/comms-adapter-sendgrid.md +63 -13
- package/dist/docs/versioned/next/implementationPlans/comms-adapter-twilio-sms.md +65 -12
- package/dist/docs/versioned/next/implementationPlans/comms-adapter-twilio-verify.md +9 -3
- package/dist/docs/versioned/next/implementationPlans/comms-admin-dashboard.md +180 -0
- package/dist/docs/versioned/next/implementationPlans/migrate-cicd-to-circleci-research.md +126 -0
- package/dist/docs/versioned/next/implementationPlans/migrate-cicd-to-circleci.md +269 -0
- package/dist/docs/versioned/next/implementationPlans/rtk-to-syncdb-migration-docs.md +52 -47
- package/dist/docs/versioned/next/implementationPlans/syncdb-api-inventory.md +303 -0
- package/dist/docs/versioned/next/implementationPlans/syncdb-codegen.md +252 -0
- package/dist/docs/versioned/next/implementationPlans/syncdb-local-first.md +468 -420
- package/dist/docs/versioned/next/implementationPlans/syncdb-phase-c-design.md +425 -0
- package/dist/docs/versioned/next/implementationPlans/terreno-syncdb-2.md +800 -0
- package/dist/docs/versioned/next/reference/README.md +2 -0
- package/dist/docs/versioned/next/reference/comms.md +132 -0
- package/dist/docs/versioned/next/reference/environment-variables.md +9 -0
- package/dist/docs/versioned/next/reference/syncdb.md +456 -0
- package/dist/docs/versioned/next/superpowers/plans/2026-07-28-admin-mobile-navigation.md +97 -0
- package/dist/docs/versioned/next/superpowers/specs/2026-07-28-admin-mobile-navigation-design.md +36 -0
- package/dist/docs/versioned/next/tasks/comms-abstraction.md +29 -16
- package/dist/docs/versioned/next/tasks/comms-adapter-sendgrid.md +17 -7
- package/dist/docs/versioned/next/tasks/comms-adapter-twilio-sms.md +17 -7
- package/dist/docs/versioned/next/tasks/comms-adapter-twilio-verify.md +3 -3
- package/dist/docs/versioned/next/tasks/comms-admin-dashboard.md +52 -0
- package/dist/docs/versioned/next/tasks/migrate-cicd-to-circleci.md +215 -0
- package/dist/docs/versioned/next/tasks/rtk-to-syncdb-migration-docs.md +10 -10
- package/dist/docs/versioned/next/tasks/syncdb-codegen.md +65 -0
- package/dist/docs/versioned/next/tasks/syncdb-local-first.md +434 -117
- package/package.json +3 -3
- package/src/docs/templates/bootstrap/frontend/app/_layout.template.tsx +1 -1
- package/src/docs/upgrades/56.0.0-beta.2.md +59 -0
- package/src/docs/versioned/next/README.md +2 -0
- package/src/docs/versioned/next/explanation/roadmap-seed-issues.md +21 -0
- package/src/docs/versioned/next/how-to/README.md +2 -0
- package/src/docs/versioned/next/how-to/migrate-rtk-to-syncdb.md +445 -0
- package/src/docs/versioned/next/implementationPlans/PLAN_INDEX.md +28 -50
- package/src/docs/versioned/next/implementationPlans/b2b-platform-program.md +3 -2
- package/src/docs/versioned/next/implementationPlans/comms-abstraction.md +98 -15
- package/src/docs/versioned/next/implementationPlans/comms-adapter-sendgrid.md +63 -13
- package/src/docs/versioned/next/implementationPlans/comms-adapter-twilio-sms.md +65 -12
- package/src/docs/versioned/next/implementationPlans/comms-adapter-twilio-verify.md +9 -3
- package/src/docs/versioned/next/implementationPlans/comms-admin-dashboard.md +180 -0
- package/src/docs/versioned/next/implementationPlans/migrate-cicd-to-circleci-research.md +126 -0
- package/src/docs/versioned/next/implementationPlans/migrate-cicd-to-circleci.md +269 -0
- package/src/docs/versioned/next/implementationPlans/rtk-to-syncdb-migration-docs.md +52 -47
- package/src/docs/versioned/next/implementationPlans/syncdb-api-inventory.md +303 -0
- package/src/docs/versioned/next/implementationPlans/syncdb-codegen.md +252 -0
- package/src/docs/versioned/next/implementationPlans/syncdb-local-first.md +468 -420
- package/src/docs/versioned/next/implementationPlans/syncdb-phase-c-design.md +425 -0
- package/src/docs/versioned/next/implementationPlans/terreno-syncdb-2.md +800 -0
- package/src/docs/versioned/next/reference/README.md +2 -0
- package/src/docs/versioned/next/reference/comms.md +132 -0
- package/src/docs/versioned/next/reference/environment-variables.md +9 -0
- package/src/docs/versioned/next/reference/syncdb.md +456 -0
- package/src/docs/versioned/next/superpowers/plans/2026-07-28-admin-mobile-navigation.md +97 -0
- package/src/docs/versioned/next/superpowers/specs/2026-07-28-admin-mobile-navigation-design.md +36 -0
- package/src/docs/versioned/next/tasks/comms-abstraction.md +29 -16
- package/src/docs/versioned/next/tasks/comms-adapter-sendgrid.md +17 -7
- package/src/docs/versioned/next/tasks/comms-adapter-twilio-sms.md +17 -7
- package/src/docs/versioned/next/tasks/comms-adapter-twilio-verify.md +3 -3
- package/src/docs/versioned/next/tasks/comms-admin-dashboard.md +52 -0
- package/src/docs/versioned/next/tasks/migrate-cicd-to-circleci.md +215 -0
- package/src/docs/versioned/next/tasks/rtk-to-syncdb-migration-docs.md +10 -10
- package/src/docs/versioned/next/tasks/syncdb-codegen.md +65 -0
- package/src/docs/versioned/next/tasks/syncdb-local-first.md +434 -117
- package/dist/__tests__/bootstrap.test.d.ts +0 -2
- package/dist/__tests__/bootstrap.test.d.ts.map +0 -1
- package/dist/__tests__/bootstrap.test.js +0 -299
- package/dist/__tests__/bootstrap.test.js.map +0 -1
- package/dist/__tests__/databaseQuery.test.d.ts +0 -2
- package/dist/__tests__/databaseQuery.test.d.ts.map +0 -1
- package/dist/__tests__/databaseQuery.test.js +0 -83
- package/dist/__tests__/databaseQuery.test.js.map +0 -1
- package/dist/__tests__/docsRoot.test.d.ts +0 -2
- package/dist/__tests__/docsRoot.test.d.ts.map +0 -1
- package/dist/__tests__/docsRoot.test.js +0 -59
- package/dist/__tests__/docsRoot.test.js.map +0 -1
- package/dist/__tests__/inferPackages.test.d.ts +0 -2
- package/dist/__tests__/inferPackages.test.d.ts.map +0 -1
- package/dist/__tests__/inferPackages.test.js +0 -35
- package/dist/__tests__/inferPackages.test.js.map +0 -1
- package/dist/__tests__/preload.d.ts +0 -2
- package/dist/__tests__/preload.d.ts.map +0 -1
- package/dist/__tests__/preload.js +0 -12
- package/dist/__tests__/preload.js.map +0 -1
- package/dist/__tests__/prompts.test.d.ts +0 -2
- package/dist/__tests__/prompts.test.d.ts.map +0 -1
- package/dist/__tests__/prompts.test.js +0 -295
- package/dist/__tests__/prompts.test.js.map +0 -1
- package/dist/__tests__/resources.test.d.ts +0 -2
- package/dist/__tests__/resources.test.d.ts.map +0 -1
- package/dist/__tests__/resources.test.js +0 -352
- package/dist/__tests__/resources.test.js.map +0 -1
- package/dist/__tests__/search.test.d.ts +0 -2
- package/dist/__tests__/search.test.d.ts.map +0 -1
- package/dist/__tests__/search.test.js +0 -163
- package/dist/__tests__/search.test.js.map +0 -1
- package/dist/__tests__/tools.test.d.ts +0 -2
- package/dist/__tests__/tools.test.d.ts.map +0 -1
- package/dist/__tests__/tools.test.js +0 -540
- package/dist/__tests__/tools.test.js.map +0 -1
- package/dist/__tests__/upgradeGuide.test.d.ts +0 -2
- package/dist/__tests__/upgradeGuide.test.d.ts.map +0 -1
- package/dist/__tests__/upgradeGuide.test.js +0 -22
- package/dist/__tests__/upgradeGuide.test.js.map +0 -1
|
@@ -7,11 +7,13 @@ Technical reference for Terreno packages and APIs. Information-oriented, precise
|
|
|
7
7
|
- [@terreno/api](api.md) — modelRouter, auth, permissions, setupServer
|
|
8
8
|
- [@terreno/ui](ui.md) — Components, theming, layout
|
|
9
9
|
- [@terreno/rtk](rtk.md) — Auth slice, SDK codegen, token handling
|
|
10
|
+
- [@terreno/syncdb](syncdb.md) — Local-first data layer (reads, writes, offline sync)
|
|
10
11
|
- [@terreno/ai](ai.md) — AI service, GPT routes, Langfuse integration
|
|
11
12
|
- [@terreno/admin-backend](admin-backend.md) — Auto-generated admin CRUD endpoints
|
|
12
13
|
- [@terreno/admin-frontend](admin-frontend.md) — Admin panel UI components
|
|
13
14
|
- [@terreno/admin-spa](admin-spa.md) — Standalone admin SPA + Express serve plugin
|
|
14
15
|
- [@terreno/api-health](api-health.md) — Health check TerrenoPlugin
|
|
16
|
+
- [@terreno/comms](comms.md) — Pluggable mail, SMS, push, and verification providers
|
|
15
17
|
- [@terreno/feature-flags](feature-flags.md) — Feature flags and A/B testing plugin
|
|
16
18
|
- [@terreno/mcp](mcp-server.md) — AI coding assistant integration (MCP)
|
|
17
19
|
- [@terreno/test](test.md) — Bun test helpers and in-memory MongoDB
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# @terreno/comms
|
|
2
|
+
|
|
3
|
+
Provider-agnostic transactional communications for Terreno backends. The core package defines
|
|
4
|
+
mail, SMS, push, and verification contracts without installing SendGrid, Twilio, or Expo SDKs.
|
|
5
|
+
|
|
6
|
+
## Install
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
bun add @terreno/comms
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Peer dependency: `mongoose ^8.0.0 || ^9.0.0`.
|
|
13
|
+
|
|
14
|
+
## Register the plugin
|
|
15
|
+
|
|
16
|
+
```typescript
|
|
17
|
+
import {
|
|
18
|
+
CommsApp,
|
|
19
|
+
ConsoleMailProvider,
|
|
20
|
+
ConsolePushProvider,
|
|
21
|
+
ConsoleSmsProvider,
|
|
22
|
+
ConsoleVerificationProvider,
|
|
23
|
+
} from "@terreno/comms";
|
|
24
|
+
import {TerrenoApp} from "@terreno/api";
|
|
25
|
+
|
|
26
|
+
new TerrenoApp({userModel: User})
|
|
27
|
+
.register(
|
|
28
|
+
new CommsApp({
|
|
29
|
+
defaultFrom: "notifications@example.com",
|
|
30
|
+
mail: new ConsoleMailProvider(),
|
|
31
|
+
push: new ConsolePushProvider(),
|
|
32
|
+
sms: new ConsoleSmsProvider(),
|
|
33
|
+
verification: new ConsoleVerificationProvider(),
|
|
34
|
+
})
|
|
35
|
+
)
|
|
36
|
+
.start();
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Console providers log only non-sensitive metadata such as message lengths and recipient counts.
|
|
40
|
+
They never log message content, recipient identifiers, push tokens, or verification codes.
|
|
41
|
+
|
|
42
|
+
## Send messages
|
|
43
|
+
|
|
44
|
+
Use the registered service from routes, jobs, or other plugins:
|
|
45
|
+
|
|
46
|
+
```typescript
|
|
47
|
+
import {getCommsService} from "@terreno/comms";
|
|
48
|
+
|
|
49
|
+
await getCommsService().sendMail({
|
|
50
|
+
subject: "Welcome",
|
|
51
|
+
text: "Thanks for joining.",
|
|
52
|
+
to: "person@example.com",
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
await getCommsService().sendSms({
|
|
56
|
+
body: "Your appointment is tomorrow.",
|
|
57
|
+
to: "+15555550100",
|
|
58
|
+
});
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
`sendPushToUser()` resolves active device tokens and deactivates tokens only when a provider marks
|
|
62
|
+
a failure as permanent. `startVerification()` and `checkVerification()` delegate to the configured
|
|
63
|
+
verification provider. `startVerification()` accepts `channel: "sms"` or `channel: "email"`;
|
|
64
|
+
`checkVerification()` verifies the code against the same phone number or email destination and may
|
|
65
|
+
return an `error` reason when `valid` is false. Start attempts store the verification channel in
|
|
66
|
+
delivery-log metadata while recipient values remain redacted.
|
|
67
|
+
|
|
68
|
+
## Provider contracts
|
|
69
|
+
|
|
70
|
+
| Interface | Method |
|
|
71
|
+
|---|---|
|
|
72
|
+
| `MailProvider` | `sendMail(message)` |
|
|
73
|
+
| `SmsProvider` | `sendSms(message)` |
|
|
74
|
+
| `PushProvider` | `sendPush(message)` — one `SendResult` per token |
|
|
75
|
+
| `VerificationProvider` | `startVerification(options)`, `checkVerification(options)` |
|
|
76
|
+
|
|
77
|
+
Concrete SendGrid, Twilio, and Expo providers are separate adapter packages/subpath exports. Core
|
|
78
|
+
`@terreno/comms` has no concrete provider SDK dependencies.
|
|
79
|
+
|
|
80
|
+
## Configuration
|
|
81
|
+
|
|
82
|
+
```typescript
|
|
83
|
+
interface CommsAppOptions {
|
|
84
|
+
basePath?: string; // default: "/comms"
|
|
85
|
+
defaultFrom?: string;
|
|
86
|
+
logMessages?: boolean; // default: true
|
|
87
|
+
mail?: MailProvider;
|
|
88
|
+
onDeliveryEvent?: (event: DeliveryEvent) => Promise<void>;
|
|
89
|
+
push?: PushProvider;
|
|
90
|
+
redactRecipients?: boolean; // default: true
|
|
91
|
+
sms?: SmsProvider;
|
|
92
|
+
verification?: VerificationProvider;
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
When a channel is unconfigured:
|
|
97
|
+
|
|
98
|
+
- non-production environments use the matching console provider and emit a warning;
|
|
99
|
+
- production throws a `501` `APIError` titled `Comms channel not configured`.
|
|
100
|
+
|
|
101
|
+
Delivery attempts are stored in `CommsMessage`. Recipient values are stored as `[redacted]` unless
|
|
102
|
+
`redactRecipients` is explicitly `false`.
|
|
103
|
+
|
|
104
|
+
## Routes
|
|
105
|
+
|
|
106
|
+
The default `basePath` is `/comms`.
|
|
107
|
+
|
|
108
|
+
| Method | Path | Access | Purpose |
|
|
109
|
+
|---|---|---|---|
|
|
110
|
+
| `POST` | `/comms/pushTokens` | Authenticated | Register or refresh a device token |
|
|
111
|
+
| `GET` | `/comms/pushTokens` | Authenticated owner | List the current user's tokens |
|
|
112
|
+
| `GET` | `/comms/pushTokens/:id` | Owner | Read one token |
|
|
113
|
+
| `DELETE` | `/comms/pushTokens/:id` | Owner | Deactivate a token |
|
|
114
|
+
| `GET` | `/comms/messages` | Admin | Filtered, paginated delivery log |
|
|
115
|
+
|
|
116
|
+
An active token cannot be claimed by another user. After its owner deactivates it, another
|
|
117
|
+
authenticated user on the same device may register it.
|
|
118
|
+
|
|
119
|
+
## Templates
|
|
120
|
+
|
|
121
|
+
`renderTemplate()` replaces top-level `{{variable}}` placeholders in `subject`, `text`, and `html`.
|
|
122
|
+
Missing or inherited properties render as empty strings.
|
|
123
|
+
|
|
124
|
+
```typescript
|
|
125
|
+
const message = renderTemplate({
|
|
126
|
+
data: {name: "Ada"},
|
|
127
|
+
template: {
|
|
128
|
+
subject: "Welcome, {{name}}",
|
|
129
|
+
text: "Hello {{name}}",
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
```
|
|
@@ -128,6 +128,15 @@ Resolution order for API base URL (`rtk/src/constants.ts`):
|
|
|
128
128
|
|----------|---------|----------|---------|--------|-------|
|
|
129
129
|
| `FEATURE_FLAGS_DEBUG` | `@terreno/feature-flags` | ❌ | — | No | server |
|
|
130
130
|
|
|
131
|
+
## Communications
|
|
132
|
+
|
|
133
|
+
| Variable | Read by | Required | Default | Secret | Scope |
|
|
134
|
+
|----------|---------|----------|---------|--------|-------|
|
|
135
|
+
| `COMMS_ENABLED` | example-backend | ❌ | `true` | No | server |
|
|
136
|
+
|
|
137
|
+
Set `COMMS_ENABLED=false` to omit the example backend's communications plugin and routes.
|
|
138
|
+
Provider credentials are documented by their separate adapter packages.
|
|
139
|
+
|
|
131
140
|
## Observability
|
|
132
141
|
|
|
133
142
|
| Variable | Read by | Required | Default | Secret | Scope |
|
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
# @terreno/syncdb
|
|
2
|
+
|
|
3
|
+
Local-first data layer for Terreno frontends. A TinyBase `MergeableStore` on device is the UI source of truth: reads come from the local store, writes apply optimistically into a durable outbox, and the server reconciles asynchronously over a socket delta protocol with HTTP snapshot catch-up. Supersedes `@terreno/rtk` for data-synchronization concerns.
|
|
4
|
+
|
|
5
|
+
## Table of Contents
|
|
6
|
+
|
|
7
|
+
- [Installation](#installation)
|
|
8
|
+
- [Architecture](#architecture)
|
|
9
|
+
- [Backend setup](#backend-setup)
|
|
10
|
+
- [createSyncDb configuration](#createsyncdb-configuration)
|
|
11
|
+
- [SyncDb client methods](#syncdb-client-methods)
|
|
12
|
+
- [React hooks](#react-hooks)
|
|
13
|
+
- [Conflict API](#conflict-api)
|
|
14
|
+
- [Sync status API](#sync-status-api)
|
|
15
|
+
- [Stream scoping](#stream-scoping)
|
|
16
|
+
- [Sync protocol](#sync-protocol)
|
|
17
|
+
- [Encryption at rest](#encryption-at-rest)
|
|
18
|
+
- [Testing](#testing)
|
|
19
|
+
- [Environment variables](#environment-variables)
|
|
20
|
+
- [Conventions](#conventions)
|
|
21
|
+
|
|
22
|
+
## Key exports
|
|
23
|
+
|
|
24
|
+
- `createSyncDb`, `SyncDb`, `SyncDbConfig`, `MutateArgs`
|
|
25
|
+
- `betterAuthAdapter`, `AuthProvider`
|
|
26
|
+
- `listConflicts`, `wipeLocalData`, `generateMutationId`
|
|
27
|
+
- React (`@terreno/syncdb/react`): `SyncDbProvider`, `useEntity`, `useQuery`, `useEntityIds`, `useMutate`, `useSyncStatus`, `useConflicts`, `useSyncDebugLog`
|
|
28
|
+
- Testing (`@terreno/syncdb/testing`): `createFakeTransport`
|
|
29
|
+
|
|
30
|
+
## Installation
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
bun install @terreno/syncdb
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
**Peer dependencies:** `react` (optional — only for `@terreno/syncdb/react`), `luxon`, `tinybase`.
|
|
37
|
+
|
|
38
|
+
**Native persistence:** install `expo-sqlite` in the **app** (not only in a library):
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
bunx expo install expo-sqlite
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Expo autolinking walks the app's own dependencies; a nested copy leaves the native module out of the build. Rebuild the native project after adding it.
|
|
45
|
+
|
|
46
|
+
**Why `@terreno/syncdb/react` is a separate subpath:** `react` is an optional peer dependency. Keeping React bindings off the main entry lets non-React consumers (Node tests, scripts) import `@terreno/syncdb` without loading React.
|
|
47
|
+
|
|
48
|
+
## Architecture
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
FRONTEND (@terreno/syncdb) BACKEND (@terreno/api)
|
|
52
|
+
┌───────────────────────────────────┐ ┌────────────────────────────────────┐
|
|
53
|
+
│ React hooks (useQuery, useEntity, │ │ modelRouter(path, Model, {sync}) │
|
|
54
|
+
│ useMutate, useSyncStatus, ...) │ │ └─ sync registry + validation │
|
|
55
|
+
│ │ ▲ │ │ │
|
|
56
|
+
│ ▼ │ │ │ syncPlugin (schema) │
|
|
57
|
+
│ TinyBase MergeableStore │ │ └─ stamps _syncSeq per write │
|
|
58
|
+
│ {collection} tables + _outbox │ │ │
|
|
59
|
+
│ + _cursors + _conflicts │ │ SyncApp (HTTP) RealtimeApp │
|
|
60
|
+
│ │ ▲ │ │ /sync/snapshot (Socket.io + │
|
|
61
|
+
│ ▼ │ │ │ /sync/mutate change streams)│
|
|
62
|
+
│ Persister (AES-GCM IndexedDB on │ │ /sync/key │ │
|
|
63
|
+
│ web, expo-sqlite on native) │ └─────────┬───────────────┼──────────┘
|
|
64
|
+
└──────┬─────────────────▲──────────┘ │ │
|
|
65
|
+
│ │ │ │
|
|
66
|
+
│ sync:mutate ─────────────────────────────►│ │
|
|
67
|
+
│ sync:ack / sync:nack ◄────────────────────┘ │
|
|
68
|
+
│ sync:delta ◄──────────────────────────────────────────────┘
|
|
69
|
+
│ GET /sync/snapshot (bootstrap + catch-up, HTTP)
|
|
70
|
+
└── POST /sync/mutate (fallback while the socket is down)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Every mutation executes the same `@terreno/api` modelRouter write path as REST — identical permissions, hooks, and validation.
|
|
74
|
+
|
|
75
|
+
## Backend setup
|
|
76
|
+
|
|
77
|
+
Register sync on the backend before the frontend client can sync a collection.
|
|
78
|
+
|
|
79
|
+
### Required schema plugins
|
|
80
|
+
|
|
81
|
+
```typescript
|
|
82
|
+
import {isDeletedPlugin, syncPlugin} from "@terreno/api";
|
|
83
|
+
|
|
84
|
+
todoSchema.plugin(isDeletedPlugin); // soft delete — required for tombstone catch-up
|
|
85
|
+
todoSchema.plugin(syncPlugin); // stamps per-stream _syncSeq on every write
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Apply plugins **before** the model compiles. Registration validates their presence.
|
|
89
|
+
|
|
90
|
+
### modelRouter sync config
|
|
91
|
+
|
|
92
|
+
Use the three-argument `modelRouter` form:
|
|
93
|
+
|
|
94
|
+
```typescript
|
|
95
|
+
import {modelRouter, OwnerQueryFilter, Permissions} from "@terreno/api";
|
|
96
|
+
|
|
97
|
+
const todoRouter = modelRouter("/todos", Todo, {
|
|
98
|
+
permissions: {
|
|
99
|
+
create: [Permissions.IsAuthenticated],
|
|
100
|
+
delete: [Permissions.IsOwner],
|
|
101
|
+
list: [Permissions.IsAuthenticated],
|
|
102
|
+
read: [Permissions.IsOwner],
|
|
103
|
+
update: [Permissions.IsOwner],
|
|
104
|
+
},
|
|
105
|
+
preCreate: (body, req) => ({...body, ownerId: (req.user as unknown as UserDocument)?._id}),
|
|
106
|
+
queryFilter: OwnerQueryFilter,
|
|
107
|
+
sync: {
|
|
108
|
+
scope: {type: "owner"}, // stream = todos|owner:{ownerId}
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### SyncApp and RealtimeApp
|
|
114
|
+
|
|
115
|
+
```typescript
|
|
116
|
+
import {RealtimeApp, SyncApp, TerrenoApp} from "@terreno/api";
|
|
117
|
+
|
|
118
|
+
new TerrenoApp({userModel: User})
|
|
119
|
+
.register(todoRouter)
|
|
120
|
+
.register(new SyncApp()) // GET /sync/snapshot, POST /sync/mutate, GET /sync/key, ...
|
|
121
|
+
.register(new RealtimeApp()) // Socket.io; sync:subscribe, sync:delta, sync:mutate
|
|
122
|
+
.start();
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
- **`SyncApp`** — HTTP sync routes (`/sync/snapshot`, `/sync/mutate`, `/sync/mutate/batch`, `/sync/key`, `/sync/streams`, `/sync/entities`).
|
|
126
|
+
- **`RealtimeApp`** — Socket.io server with change-stream-driven `sync:delta` emission. Requires a **MongoDB replica set** (change streams).
|
|
127
|
+
- **`ensureSyncIndexes`** — `TerrenoApp.start()` awaits index builds for snapshot queries and sync bookkeeping (`SyncMutation.mutationId` unique, `SyncCounter.stream` unique, etc.). Hosts that build Express without `TerrenoApp.start()` should await `ensureSyncIndexes()` themselves.
|
|
128
|
+
|
|
129
|
+
Socket auth accepts legacy JWTs by default. For Better Auth sessions:
|
|
130
|
+
|
|
131
|
+
```typescript
|
|
132
|
+
new RealtimeApp({betterAuth: {auth, userModel: User}})
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## createSyncDb configuration
|
|
136
|
+
|
|
137
|
+
```typescript
|
|
138
|
+
import {betterAuthAdapter, createSyncDb} from "@terreno/syncdb";
|
|
139
|
+
|
|
140
|
+
export const syncDb = createSyncDb({
|
|
141
|
+
name: "myapp",
|
|
142
|
+
collections: ["todos"],
|
|
143
|
+
authProvider: betterAuthAdapter(authClient),
|
|
144
|
+
baseUrl: "http://localhost:4000",
|
|
145
|
+
});
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
| Option | Type | Default | Description |
|
|
149
|
+
|--------|------|---------|-------------|
|
|
150
|
+
| `name` | `string` | — (required) | Persisted database name |
|
|
151
|
+
| `collections` | `string[]` | — (required) | Collection names to sync (local tables + subscriptions) |
|
|
152
|
+
| `authProvider` | `AuthProvider` | — (required) | `{getToken, getUserId, onAuthChange, refresh?}` |
|
|
153
|
+
| `baseUrl` | `string` | — | Server origin; required unless both `transport` and `httpChannel` are injected |
|
|
154
|
+
| `transport` | `SyncTransport` | socket transport from `baseUrl` | Override for tests or custom wiring |
|
|
155
|
+
| `httpChannel` | `HttpChannel` | built from `baseUrl` | HTTP fallback channel override |
|
|
156
|
+
| `persisterFactory` | `PersisterFactory` | platform default (IndexedDB web, expo-sqlite native) | Storage backend override |
|
|
157
|
+
| `keyProvider` | `KeyProvider` | server-derived via `GET /sync/key` | Web encryption key provider |
|
|
158
|
+
| `idbGetImpl` / `idbSetImpl` | test hooks | — | Simulate IndexedDB failures (tests only) |
|
|
159
|
+
| `reconcileIntervalMs` | `number` | `300000` (5 min); `0` disables | Periodic reconcile + outbox replay timer |
|
|
160
|
+
| `seqJumpReconcileMinIntervalMs` | `number` | `30000` (30s) | Per-stream rate limit for seq-jump-triggered reconciles |
|
|
161
|
+
| `now` | `() => number` | `Date.now` | Injectable clock (tests) |
|
|
162
|
+
| `random` | `() => number` | `Math.random` | Injectable RNG for backoff jitter (tests) |
|
|
163
|
+
| `debug` | `boolean \| SyncDebugLogOptions` | `false` | In-memory debug event log |
|
|
164
|
+
| `onAuthRequired` | `() => void` | — | Fires once per auth-pause episode (prompt re-login) |
|
|
165
|
+
| `wipeOnSignOut` | `boolean` | `false` | `signOut()` also wipes local data |
|
|
166
|
+
| `batchSize` | `number` | `50` (server caps at 100) | Max mutations per batched drain send |
|
|
167
|
+
| `haltQueueOnConflict` | `boolean` | `false` | `true` = conflict halts entire drain; `false` = per-entity blocking |
|
|
168
|
+
| `onDecryptFailure` | `() => void` | wipe + re-bootstrap (with `console.warn`) | Override web decrypt-failure recovery |
|
|
169
|
+
| `tombstoneRetentionMs` | `number` | `7776000000` (90 days); `0` disables | Client tombstone compaction after successful reconcile |
|
|
170
|
+
| `startAuthRetryAttempts` | `number` | `3`; `1` disables | `start()` attempts to resolve `getUserId()` |
|
|
171
|
+
| `startAuthRetryDelayMs` | `number` | `250` | Delay between those attempts |
|
|
172
|
+
|
|
173
|
+
`start()` needs an authenticated user. It retries `getUserId()` briefly (transient session races after login) before rejecting.
|
|
174
|
+
|
|
175
|
+
## SyncDb client methods
|
|
176
|
+
|
|
177
|
+
| Method | Description |
|
|
178
|
+
|--------|-------------|
|
|
179
|
+
| `start()` | Resolve user, run wipe-on-user-change check, start persistence, connect transport, subscribe collections, start reconcile timer. Resolves even when offline. Idempotent while already started. |
|
|
180
|
+
| `stop()` | Disconnect, stop persistence, clear timers and listeners. |
|
|
181
|
+
| `goOffline()` | Simulated outage: disconnect transport, pause replay/reconcile/timer; local mutations keep queueing. |
|
|
182
|
+
| `goOnline()` | End simulated outage; reconnect triggers reconcile + outbox replay. |
|
|
183
|
+
| `mutate({collection, operation, id?, data?})` | Optimistic local write + durable outbox enqueue + fire-and-forget replay. Returns `{mutationId, id}`. |
|
|
184
|
+
| `reconcile()` | HTTP snapshot catch-up for every known stream; runs tombstone compaction on success. |
|
|
185
|
+
| `forceResync()` | Purge every known stream locally and re-bootstrap from cursor 0 (outbox/conflicts untouched). Returns `{ok, reason?, streams, purged, repaired}`. |
|
|
186
|
+
| `replayOutbox()` | Drain queued mutations for the current user now. |
|
|
187
|
+
| `resolveConflict({mutationId, strategy})` | Apply `"useServer"` or `"keepMine"` to a recorded conflict. |
|
|
188
|
+
| `retryFailed({entityId})` | Re-enable an entity's queued successors after a terminal validation failure. |
|
|
189
|
+
| `signOut()` | Explicit teardown like `stop()`; wipes local data only when `wipeOnSignOut: true`. |
|
|
190
|
+
| `getSyncStatus()` | Snapshot of aggregate sync state (see [Sync status API](#sync-status-api)). |
|
|
191
|
+
| `onStatusChange(callback)` | Subscribe to connectivity/syncing changes; returns unsubscribe. |
|
|
192
|
+
| `store` | Read surface over the local MergeableStore (`SyncStore`). |
|
|
193
|
+
| `outbox` | Durable mutation outbox (`Outbox`). |
|
|
194
|
+
| `debug` | `SyncDebugLog` when `debug` is enabled; otherwise `undefined`. |
|
|
195
|
+
|
|
196
|
+
## React hooks
|
|
197
|
+
|
|
198
|
+
Import from `@terreno/syncdb/react`:
|
|
199
|
+
|
|
200
|
+
```typescript
|
|
201
|
+
import {
|
|
202
|
+
SyncDbProvider,
|
|
203
|
+
useConflicts,
|
|
204
|
+
useEntity,
|
|
205
|
+
useEntityIds,
|
|
206
|
+
useMutate,
|
|
207
|
+
useQuery,
|
|
208
|
+
useSyncDbClient,
|
|
209
|
+
useSyncDebugLog,
|
|
210
|
+
useSyncStatus,
|
|
211
|
+
} from "@terreno/syncdb/react";
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
Wrap the app (or a subtree) in `SyncDbProvider`:
|
|
215
|
+
|
|
216
|
+
```tsx
|
|
217
|
+
<SyncDbProvider client={syncDb}>
|
|
218
|
+
<TodoList />
|
|
219
|
+
</SyncDbProvider>
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
### `useEntity(collection, id)`
|
|
223
|
+
|
|
224
|
+
```typescript
|
|
225
|
+
const {data, deleted, seq, isPending} = useEntity<Todo>("todos", id);
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
Subscribe to a single entity. Re-renders when that row changes. `isPending` is true while an outbox mutation protects optimistic state.
|
|
229
|
+
|
|
230
|
+
### `useQuery(collection, options?)`
|
|
231
|
+
|
|
232
|
+
```typescript
|
|
233
|
+
const todos = useQuery<Todo>("todos", {
|
|
234
|
+
filter: (t) => !t.completed,
|
|
235
|
+
sort: (a, b) => b.created.localeCompare(a.created),
|
|
236
|
+
includeDeleted: false,
|
|
237
|
+
});
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
Returns decoded entity data arrays. Tombstones excluded unless `includeDeleted: true`. Filter and sort run in JS — memoize callbacks when collections are large.
|
|
241
|
+
|
|
242
|
+
### `useEntityIds(collection, options?)`
|
|
243
|
+
|
|
244
|
+
```typescript
|
|
245
|
+
const ids = useEntityIds<Todo>("todos", {filter: (t) => !t.completed, sort: byCreatedDesc});
|
|
246
|
+
return ids.map((id) => <TodoRow key={id} id={id} />);
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
Same options as `useQuery`, but returns only ordered ids with **referential stability** — the array identity changes only when membership or order changes, not on field updates. Pair with per-row `useEntity` for large lists.
|
|
250
|
+
|
|
251
|
+
### `useMutate(collection)`
|
|
252
|
+
|
|
253
|
+
```typescript
|
|
254
|
+
const {create, update, remove} = useMutate("todos");
|
|
255
|
+
|
|
256
|
+
const {id, mutationId} = create({data: {title: "Milk", completed: false}});
|
|
257
|
+
update({id, data: {completed: true}});
|
|
258
|
+
remove({id});
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
Each call applies locally, enqueues in the outbox, and kicks off replay.
|
|
262
|
+
|
|
263
|
+
### `useSyncStatus()`
|
|
264
|
+
|
|
265
|
+
```typescript
|
|
266
|
+
const status = useSyncStatus();
|
|
267
|
+
// status.isOnline, status.isSyncing, status.queuedCount, status.conflictCount, ...
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
Reactive aggregate status; re-renders on outbox, conflict, cursor, connectivity, and drain-progress changes.
|
|
271
|
+
|
|
272
|
+
### `useConflicts()`
|
|
273
|
+
|
|
274
|
+
```typescript
|
|
275
|
+
const {conflicts, resolve} = useConflicts();
|
|
276
|
+
resolve({mutationId, strategy: "useServer"});
|
|
277
|
+
resolve({mutationId, strategy: "keepMine"});
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
### `useSyncDebugLog()`
|
|
281
|
+
|
|
282
|
+
```typescript
|
|
283
|
+
const {enabled, events, stats, log, clear} = useSyncDebugLog();
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
Available when the client was created with `debug: true`. `log?.snapshot()` returns a JSON-serializable object suitable for debug UIs or tooling.
|
|
287
|
+
|
|
288
|
+
### `useSyncDbClient()`
|
|
289
|
+
|
|
290
|
+
Returns the `SyncDb` instance from context (escape hatch for imperative calls like `forceResync()`).
|
|
291
|
+
|
|
292
|
+
## Conflict API
|
|
293
|
+
|
|
294
|
+
A **conflict** occurs when the client's `baseVersion` (last seen `_syncSeq`) does not match the server's current seq. The server responds with a `conflict` nack (HTTP **409** on `POST /sync/mutate`, or `sync:nack` with `code: "conflict"` on the socket) carrying the canonical server document.
|
|
295
|
+
|
|
296
|
+
The client records conflicts in the local `_conflicts` table. Read them with:
|
|
297
|
+
|
|
298
|
+
```typescript
|
|
299
|
+
import {listConflicts} from "@terreno/syncdb";
|
|
300
|
+
|
|
301
|
+
const conflicts = listConflicts({store: client.store});
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
In React, prefer `useConflicts()`.
|
|
305
|
+
|
|
306
|
+
### Resolution strategies
|
|
307
|
+
|
|
308
|
+
| Strategy | Behavior |
|
|
309
|
+
|----------|----------|
|
|
310
|
+
| `"useServer"` | Overwrite local entity with canonical server data/seq; clear pending state; discard conflicted outbox row. Writes a **tombstone** when the server side is deleted. |
|
|
311
|
+
| `"keepMine"` | Re-enqueue the mutation under a **fresh** `mutationId` with `baseVersion` set to the server's seq; local optimistic data is kept. |
|
|
312
|
+
|
|
313
|
+
Resolve via `client.resolveConflict({mutationId, strategy})` or `useConflicts().resolve(...)`.
|
|
314
|
+
|
|
315
|
+
### Conflict handling modes
|
|
316
|
+
|
|
317
|
+
- **Default (`haltQueueOnConflict: false`)** — per-entity blocking. Other entities keep draining.
|
|
318
|
+
- **`haltQueueOnConflict: true`** — any conflict halts the entire drain until resolved.
|
|
319
|
+
|
|
320
|
+
`getSyncStatus().blockedEntities` counts entities blocked by conflicts or terminal validation failures.
|
|
321
|
+
|
|
322
|
+
## Sync status API
|
|
323
|
+
|
|
324
|
+
`getSyncStatus()` / `useSyncStatus()` return `SyncStatus`:
|
|
325
|
+
|
|
326
|
+
| Field | Type | Description |
|
|
327
|
+
|-------|------|-------------|
|
|
328
|
+
| `isOnline` | `boolean` | Transport connected |
|
|
329
|
+
| `isSyncing` | `boolean` | Reconcile or replay in flight |
|
|
330
|
+
| `queuedCount` | `number` | Outbox rows awaiting send |
|
|
331
|
+
| `conflictCount` | `number` | Unresolved conflicts |
|
|
332
|
+
| `failedCount` | `number` | Terminal `failed` outbox rows |
|
|
333
|
+
| `paused` | `"auth"?` | Replay paused for auth failure |
|
|
334
|
+
| `blockedEntities` | `number` | Entities blocked (conflict or validation) |
|
|
335
|
+
| `draining` | `boolean` | Outbox drain actively running |
|
|
336
|
+
| `sentThisDrain` | `number` | Mutations sent in current/most recent drain |
|
|
337
|
+
| `totalThisDrain` | `number` | Queue length when drain began |
|
|
338
|
+
| `streams` | `Record<string, number>` | Per-stream cursors (stream → highest applied seq) |
|
|
339
|
+
| `collections` | `Record<string, SyncCollectionStatus>` | Per-collection queued/conflict/failed counts |
|
|
340
|
+
| `persistence` | `"durable" \| "memory" \| "error"` | Local persistence health (web) |
|
|
341
|
+
|
|
342
|
+
`SyncCollectionStatus`: `{queuedCount, conflictCount, failedCount}`.
|
|
343
|
+
|
|
344
|
+
## Stream scoping
|
|
345
|
+
|
|
346
|
+
Streams are the unit of ordered delivery, keyed `{collection}|{scope}`:
|
|
347
|
+
|
|
348
|
+
```typescript
|
|
349
|
+
sync: {scope: {type: "owner"}} // todos|owner:{ownerId}
|
|
350
|
+
sync: {scope: {type: "owner", field: "userId"}} // todos|owner:{userId}
|
|
351
|
+
sync: {scope: {type: "tenant", field: "organizationId"}} // todos|tenant:{orgId}
|
|
352
|
+
sync: {scope: {type: "broadcast"}} // todos|all
|
|
353
|
+
sync: {
|
|
354
|
+
scope: (doc) => String(doc.workspaceId),
|
|
355
|
+
snapshotFilter: (user) => ({workspaceId: {$in: [...]}}), // required for custom
|
|
356
|
+
}
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
- **Owner** streams use the authenticated socket's user id (client cannot pick another user's stream).
|
|
360
|
+
- **Tenant/custom** scopes resolve memberships via `SyncApp` `getUserScopes`.
|
|
361
|
+
- **`snapshotFilter`** restricts `GET /sync/snapshot` server-side. Auto-derived for owner/tenant; required for custom resolver scopes.
|
|
362
|
+
|
|
363
|
+
## Sync protocol
|
|
364
|
+
|
|
365
|
+
### HTTP routes (`SyncApp`, all authenticated)
|
|
366
|
+
|
|
367
|
+
| Endpoint | Purpose |
|
|
368
|
+
|----------|---------|
|
|
369
|
+
| `GET /sync/snapshot?collection=&stream=&cursor=&limit=` | Bootstrap + catch-up per stream |
|
|
370
|
+
| `GET /sync/streams` | Current stream membership for the user |
|
|
371
|
+
| `GET /sync/entities` | Point lookup for entity repair |
|
|
372
|
+
| `POST /sync/mutate` | Single mutation (HTTP fallback) |
|
|
373
|
+
| `POST /sync/mutate/batch` | Batched mutations (max 100, strict order, stop at first non-ack) |
|
|
374
|
+
| `GET /sync/key` | Per-user encryption key material (web) |
|
|
375
|
+
|
|
376
|
+
Conflict responses on mutate: **409** with `{nack}` body (`code: "conflict"`).
|
|
377
|
+
|
|
378
|
+
### Socket events (`RealtimeApp`)
|
|
379
|
+
|
|
380
|
+
| Event | Direction | Payload |
|
|
381
|
+
|-------|-----------|---------|
|
|
382
|
+
| `sync:subscribe` / `sync:unsubscribe` | client → server | `{collections: string[]}` |
|
|
383
|
+
| `sync:subscribed` | server → client | `{collection, streams}` |
|
|
384
|
+
| `sync:error` | server → client | `{collection, message}` |
|
|
385
|
+
| `sync:delta` | server → client | `{collection, id, method, data?, seq, stream, deleted?, frontierSeq?}` |
|
|
386
|
+
| `sync:mutate` | client → server | `{mutationId, collection, operation, id?, data?, baseVersion?}` |
|
|
387
|
+
| `sync:ack` | server → client | `{mutationId, id, seq}` |
|
|
388
|
+
| `sync:nack` | server → client | `{mutationId, code, serverDoc?, serverSeq?, serverDeleted?, message?, retryAfterMs?}` |
|
|
389
|
+
| `sync:mutateBatch` | client → server | `{mutations: SyncMutateRequest[], batchId?}` |
|
|
390
|
+
| `sync:auth-expired` | server → client | `{reason: "expired" \| "disabled"}` — session re-validation sweep; client enters auth-pause |
|
|
391
|
+
|
|
392
|
+
Limits: 50 collection subscriptions per socket; 100 mutations/second per socket (each batch mutation counts); batches capped at 100.
|
|
393
|
+
|
|
394
|
+
## Encryption at rest
|
|
395
|
+
|
|
396
|
+
**Web:** AES-256-GCM encrypted IndexedDB by default via pluggable `KeyProvider`:
|
|
397
|
+
|
|
398
|
+
- **`createServerKeyProvider`** (default) — derives key from `GET /sync/key` material (HKDF); server can rotate/revoke.
|
|
399
|
+
- **`createLocalKeyProvider`** — device-local random key; no server copy.
|
|
400
|
+
|
|
401
|
+
```typescript
|
|
402
|
+
import {createLocalKeyProvider, createSyncDb} from "@terreno/syncdb";
|
|
403
|
+
|
|
404
|
+
const syncDb = createSyncDb({
|
|
405
|
+
name: "myapp",
|
|
406
|
+
collections,
|
|
407
|
+
authProvider,
|
|
408
|
+
baseUrl,
|
|
409
|
+
keyProvider: createLocalKeyProvider(),
|
|
410
|
+
});
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
Undecryptable persisted data triggers wipe + re-bootstrap by default (`onDecryptFailure` to override). Storage read errors surface `persistence: "error"` without overwriting the blob.
|
|
414
|
+
|
|
415
|
+
**Native:** plaintext SQLite in the OS sandbox (by design).
|
|
416
|
+
|
|
417
|
+
## Testing
|
|
418
|
+
|
|
419
|
+
```typescript
|
|
420
|
+
import {createFakeTransport} from "@terreno/syncdb/testing";
|
|
421
|
+
import {createSyncDb} from "@terreno/syncdb";
|
|
422
|
+
|
|
423
|
+
const transport = createFakeTransport();
|
|
424
|
+
const client = createSyncDb({
|
|
425
|
+
name: "test",
|
|
426
|
+
collections: ["todos"],
|
|
427
|
+
authProvider: fakeAuth,
|
|
428
|
+
transport,
|
|
429
|
+
httpChannel: fakeHttp,
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
transport.respondWithAck();
|
|
433
|
+
transport.deliverDelta({...});
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
`createFakeTransport` records sent mutations, simulates connectivity, delivers deltas, and queues ack/nack responders.
|
|
437
|
+
|
|
438
|
+
## Environment variables
|
|
439
|
+
|
|
440
|
+
Syncdb reads no environment variables directly. Host apps typically set:
|
|
441
|
+
|
|
442
|
+
| Variable | Used by | Description |
|
|
443
|
+
|----------|---------|-------------|
|
|
444
|
+
| `EXPO_PUBLIC_API_URL` | `@terreno/rtk` `baseUrl` | Backend origin passed to `createSyncDb({baseUrl})` |
|
|
445
|
+
|
|
446
|
+
Backend sync requires a MongoDB replica set (`MONGO_URI` with `replicaSet=`) for `RealtimeApp` change streams.
|
|
447
|
+
|
|
448
|
+
## Conventions
|
|
449
|
+
|
|
450
|
+
- **Local-first only** — there is no server-first mode. The local store is always the read source.
|
|
451
|
+
- **Synced models need `String` `_id`** — offline creates mint client ids (UUIDs); default ObjectId `_id` cast-fails.
|
|
452
|
+
- **Soft delete only** on synced models — `isDeletedPlugin` required; hard deletes break tombstone catch-up.
|
|
453
|
+
- **No `bulkWrite` / `updateMany` / `deleteMany`** on synced models — use per-document loops.
|
|
454
|
+
- **Do not write reserved tables** (`_outbox`, `_cursors`, `_conflicts`) directly — use `client.mutate`, hooks, and `resolveConflict`.
|
|
455
|
+
- **User switch wipes local data** — confirmed different-user login clears the previous user's store; bare logout/401 does not (INV-2).
|
|
456
|
+
- **Keep `@terreno/rtk` for non-synced routes** — generated OpenAPI hooks remain the right tool for custom REST endpoints, admin, and auth configuration. See [How to migrate from RTK to syncdb](../how-to/migrate-rtk-to-syncdb.md).
|