@adobe/data-sync 0.9.90
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/LICENSE +21 -0
- package/README.md +646 -0
- package/dist/create-sync-server.d.ts +29 -0
- package/dist/create-sync-server.js +156 -0
- package/dist/create-sync-server.js.map +1 -0
- package/dist/create-sync-service.d.ts +120 -0
- package/dist/create-sync-service.js +192 -0
- package/dist/create-sync-service.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/keepalive.test.d.ts +1 -0
- package/dist/keepalive.test.js +161 -0
- package/dist/keepalive.test.js.map +1 -0
- package/dist/loopback-transport.d.ts +20 -0
- package/dist/loopback-transport.js +98 -0
- package/dist/loopback-transport.js.map +1 -0
- package/dist/sync.reconnect.test.d.ts +1 -0
- package/dist/sync.reconnect.test.js +248 -0
- package/dist/sync.reconnect.test.js.map +1 -0
- package/dist/sync.test.d.ts +1 -0
- package/dist/sync.test.js +233 -0
- package/dist/sync.test.js.map +1 -0
- package/dist/transport-onclose.test.d.ts +1 -0
- package/dist/transport-onclose.test.js +74 -0
- package/dist/transport-onclose.test.js.map +1 -0
- package/dist/transport.d.ts +94 -0
- package/dist/transport.js +3 -0
- package/dist/transport.js.map +1 -0
- package/dist/websocket-transport.d.ts +71 -0
- package/dist/websocket-transport.js +148 -0
- package/dist/websocket-transport.js.map +1 -0
- package/package.json +38 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025-2026 Adobe, Inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,646 @@
|
|
|
1
|
+
# @adobe/data-sync
|
|
2
|
+
|
|
3
|
+
Real-time multi-user synchronization for `@adobe/data` ECS databases.
|
|
4
|
+
|
|
5
|
+
Brings multiple `Database` instances — on different machines, tabs, or
|
|
6
|
+
processes — into convergent agreement using a lightweight ordering server and
|
|
7
|
+
an OT-free, replay-based consistency model.
|
|
8
|
+
|
|
9
|
+
## Mental model
|
|
10
|
+
|
|
11
|
+
Application code calls `db.transactions.X(args)`. That is the entire sync API.
|
|
12
|
+
The sync layer is a background service that subscribes to the database's
|
|
13
|
+
outbound envelope stream and forwards envelopes to the transport — there is no
|
|
14
|
+
`propose` / `sendTransient` surface for callers to call.
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
┌──────────────────────────────┐
|
|
18
|
+
│ SyncServer │
|
|
19
|
+
│ (pure ordering service, │
|
|
20
|
+
│ stateless except for log) │
|
|
21
|
+
└────────┬─────────────────────┘
|
|
22
|
+
propose │ │ committed (broadcast)
|
|
23
|
+
▼ ▼
|
|
24
|
+
┌─────────────────────────────────┐
|
|
25
|
+
│ Database + SyncService │
|
|
26
|
+
│ ┌──────────────────────────┐ │
|
|
27
|
+
│ │ ReconcilingDatabase │ │
|
|
28
|
+
│ │ committed │ transients │ │
|
|
29
|
+
│ │ ──────────┼─────────── │ │
|
|
30
|
+
│ │ apply(t) │ rollback / │ │
|
|
31
|
+
│ │ │ replay │ │
|
|
32
|
+
│ └──────────────────────────┘ │
|
|
33
|
+
└─────────────────────────────────┘
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The sync layer is not a CRDT and does not merge conflicting mutations at the
|
|
37
|
+
byte level. Instead it exploits a property of the ECS model: **transaction
|
|
38
|
+
functions are deterministic pure functions of `(store, args)`**, so replaying
|
|
39
|
+
the same sequence of `(name, args)` pairs from any starting state always
|
|
40
|
+
produces the same result. The server assigns a canonical ordering; clients
|
|
41
|
+
execute that ordering locally.
|
|
42
|
+
|
|
43
|
+
### Commit lifecycle
|
|
44
|
+
|
|
45
|
+
1. App code calls `db.transactions.spawnUnit({ x: 0, y: 0, hp: 100 })`.
|
|
46
|
+
2. The wrapper applies the envelope locally as a **transient** (optimistic)
|
|
47
|
+
because the sync service has put the database in deferred-commit mode.
|
|
48
|
+
3. The wrapper notifies `db.observe.envelopes` with `intent: "commit"`.
|
|
49
|
+
4. The sync service forwards the envelope as `kind: "propose"`.
|
|
50
|
+
5. The server assigns a monotonic `time` and broadcasts a `committed` envelope
|
|
51
|
+
to every connected client.
|
|
52
|
+
6. Each client (including the proposer) calls `db.apply(committed)`, which:
|
|
53
|
+
- Rolls back all local transients.
|
|
54
|
+
- Applies the committed transaction on the clean committed state.
|
|
55
|
+
- Re-applies remaining transients on top.
|
|
56
|
+
7. The proposer's transient is promoted to committed; other clients see the
|
|
57
|
+
change as an incoming commit from a peer.
|
|
58
|
+
|
|
59
|
+
The result is **convergent, causally ordered** state across all peers with
|
|
60
|
+
no server-side database.
|
|
61
|
+
|
|
62
|
+
### Transient envelopes (presence, drag previews, cursors)
|
|
63
|
+
|
|
64
|
+
Async-generator transactions yield intermediate transient envelopes. The
|
|
65
|
+
sync service forwards each yield as `kind: "transient"` — relayed to peers,
|
|
66
|
+
never logged, never persisted. The server drops them if the rate exceeds
|
|
67
|
+
`maxTransientsPerSecond`. Each peer's `(userId, id)` compound key keeps the
|
|
68
|
+
transient queue de-duplicated: a new yield with the same id replaces the
|
|
69
|
+
previous one in the reconciler.
|
|
70
|
+
|
|
71
|
+
The "never-ending transaction" pattern uses an async generator that yields
|
|
72
|
+
forever (e.g. for live cursor positions):
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
db.transactions.movePresence(async function* () {
|
|
76
|
+
for await (const [x, y] of pointerObservable) {
|
|
77
|
+
yield { userId, x, y };
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
The transaction never commits; every yield is a transient envelope; sync
|
|
83
|
+
forwards each to peers; peers see continuously-updated presence state.
|
|
84
|
+
|
|
85
|
+
### nonPersistent resources stay local
|
|
86
|
+
|
|
87
|
+
Resources marked `nonPersistent: true` in their schema (or entities allocated
|
|
88
|
+
with the built-in `nonPersistent` component) produce transactions whose
|
|
89
|
+
`TransactionResult.persistent === false`. The sync service skips those
|
|
90
|
+
envelopes entirely — they never reach the wire. Use this for per-tab UI
|
|
91
|
+
state (selection, panel positions, signaling intermediaries, etc.) without
|
|
92
|
+
inventing a second database.
|
|
93
|
+
|
|
94
|
+
## Quick start
|
|
95
|
+
|
|
96
|
+
### Server (Node.js)
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
import { createSyncServer, createWebSocketServerTransport } from "@adobe/data-sync";
|
|
100
|
+
import { WebSocketServer } from "ws";
|
|
101
|
+
|
|
102
|
+
const syncServer = createSyncServer();
|
|
103
|
+
const wss = new WebSocketServer({ port: 4000 });
|
|
104
|
+
|
|
105
|
+
wss.on("connection", (ws) => {
|
|
106
|
+
const transport = createWebSocketServerTransport(ws);
|
|
107
|
+
const disconnect = syncServer.connect(transport);
|
|
108
|
+
ws.on("close", disconnect);
|
|
109
|
+
});
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### Client (browser or Node.js)
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
import { Database } from "@adobe/data/ecs";
|
|
116
|
+
import { createSyncService, createWebSocketClientTransport } from "@adobe/data-sync";
|
|
117
|
+
import { gamePlugin } from "./game-plugin.js";
|
|
118
|
+
|
|
119
|
+
// `sync.userId` MUST be unique per peer — the reconciler uses (userId, id)
|
|
120
|
+
// as its compound key for transient-replace and cancel semantics, so two
|
|
121
|
+
// peers using the same userId would clobber each other's transients.
|
|
122
|
+
// Passing `sync` also enables deferred-commit mode (see API section).
|
|
123
|
+
const db = Database.create(gamePlugin, { sync: { userId: crypto.randomUUID() } });
|
|
124
|
+
|
|
125
|
+
const transport = createWebSocketClientTransport({ url: "ws://localhost:4000/sync" });
|
|
126
|
+
const sync = createSyncService({ database: db, transport });
|
|
127
|
+
|
|
128
|
+
// All mutations flow through the standard transactions API:
|
|
129
|
+
db.transactions.spawnUnit({ x: 0, y: 0, hp: 100 });
|
|
130
|
+
|
|
131
|
+
// Tear down when done:
|
|
132
|
+
sync.dispose();
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### In-process / single machine (testing, embedded)
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
import { createSyncServer, createSyncService, createLoopbackTransport } from "@adobe/data-sync";
|
|
139
|
+
|
|
140
|
+
const server = createSyncServer();
|
|
141
|
+
const { client: ct, server: st } = createLoopbackTransport();
|
|
142
|
+
server.connect(st);
|
|
143
|
+
|
|
144
|
+
const db = Database.create(myPlugin, { sync: { userId: "test-peer" } });
|
|
145
|
+
const sync = createSyncService({ database: db, transport: ct });
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## API
|
|
149
|
+
|
|
150
|
+
### `createSyncServer(options?)`
|
|
151
|
+
|
|
152
|
+
Returns a `SyncServer` — a pure ordering service that:
|
|
153
|
+
|
|
154
|
+
- Assigns monotonically increasing commit timestamps to accepted proposals.
|
|
155
|
+
- Broadcasts each `committed` envelope to all connected clients.
|
|
156
|
+
- Maintains a `committedLog` so late-joining clients receive full history on
|
|
157
|
+
connect and converge to current state without a separate snapshot.
|
|
158
|
+
- Is entirely stateless with respect to ECS data — it never owns a database.
|
|
159
|
+
|
|
160
|
+
```ts
|
|
161
|
+
interface SyncServerOptions {
|
|
162
|
+
/** Gap (ms) after which a silent client's transport is closed. Default 25 000. 0 = disabled. */
|
|
163
|
+
livenessTimeoutMs?: number;
|
|
164
|
+
/** Optional sink for lifecycle log messages (handshake, proposals, liveness). */
|
|
165
|
+
logger?: (msg: string) => void;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
interface SyncServer {
|
|
169
|
+
readonly sessionId: string; // stable random id for this instance
|
|
170
|
+
connect(transport: ServerTransport): () => void; // returns disconnect fn
|
|
171
|
+
dispose(): void;
|
|
172
|
+
}
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Auth, validation, and access control are **not** built into the server —
|
|
176
|
+
intercept `propose` messages in your own middleware before forwarding to
|
|
177
|
+
`SyncServer`.
|
|
178
|
+
|
|
179
|
+
### `createSyncService(options)`
|
|
180
|
+
|
|
181
|
+
Wires a `Database` to a `ClientTransport`. There is no public mutation
|
|
182
|
+
surface — application code calls `db.transactions.X(args)`.
|
|
183
|
+
|
|
184
|
+
```ts
|
|
185
|
+
interface SyncServiceOptions {
|
|
186
|
+
readonly database: Database<any, any, any, any>;
|
|
187
|
+
readonly transport: ClientTransport;
|
|
188
|
+
/** Default 20. Lossy rate limit for `kind: "transient"` messages. */
|
|
189
|
+
readonly maxTransientsPerSecond?: number;
|
|
190
|
+
/** Session id from the previous connection's welcome. Omit for a fresh client. */
|
|
191
|
+
readonly priorSessionId?: string;
|
|
192
|
+
/** Highest committed time already applied locally. Default 0 (full replay). */
|
|
193
|
+
readonly initialWatermark?: number;
|
|
194
|
+
/** Called once when welcome arrives, before replay is applied. */
|
|
195
|
+
readonly onWelcome?: (msg: { sessionId: string; resetRequired: boolean }) => void;
|
|
196
|
+
/** Interval (ms) between outbound pings. Default 10 000. 0 = disabled. */
|
|
197
|
+
readonly pingIntervalMs?: number;
|
|
198
|
+
/** Gap (ms) with no inbound traffic before transport.close() is called. Default 25 000. 0 = disabled. */
|
|
199
|
+
readonly livenessTimeoutMs?: number;
|
|
200
|
+
/** Optional sink for lifecycle log messages (handshake, commits, liveness). */
|
|
201
|
+
readonly logger?: (msg: string) => void;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
interface SyncService {
|
|
205
|
+
dispose(): void;
|
|
206
|
+
/** Highest committed time applied so far (for reconnect watermark). */
|
|
207
|
+
lastAppliedTime(): number;
|
|
208
|
+
/** Session id from the server's welcome (undefined until welcome arrives). */
|
|
209
|
+
sessionId(): string | undefined;
|
|
210
|
+
}
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
`createSyncService` will throw if the database wasn't created in sync mode
|
|
214
|
+
(`Database.create(plugin, { sync: { userId } })`).
|
|
215
|
+
|
|
216
|
+
### `Database.create(plugin, { sync: { userId } })`
|
|
217
|
+
|
|
218
|
+
The `sync` option opts the database into deferred-commit mode and is
|
|
219
|
+
required for any database that will be attached to a sync service. Two
|
|
220
|
+
things change:
|
|
221
|
+
|
|
222
|
+
1. Every locally-generated envelope is stamped with `userId` and the
|
|
223
|
+
reconciler keys its transient queue by the compound `(userId, id)` so
|
|
224
|
+
independent per-peer id counters can never collide.
|
|
225
|
+
2. `db.transactions.X(args)` calls apply locally as transients (negative
|
|
226
|
+
`time`) and wait for the server's echoed `committed` envelope to
|
|
227
|
+
promote them via the reconciler's rebase-replay. This is what
|
|
228
|
+
guarantees that concurrent inserts from two peers end up with
|
|
229
|
+
identical entity IDs on every peer.
|
|
230
|
+
|
|
231
|
+
In a single-peer / no-sync deployment `sync` may be omitted; commits then
|
|
232
|
+
apply immediately with positive `time` and envelopes are not stamped with
|
|
233
|
+
a `userId`.
|
|
234
|
+
|
|
235
|
+
```ts
|
|
236
|
+
interface DatabaseSyncOptions {
|
|
237
|
+
readonly userId: number | string;
|
|
238
|
+
}
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
### Transports
|
|
242
|
+
|
|
243
|
+
| Factory | Use case |
|
|
244
|
+
|---|---|
|
|
245
|
+
| `createWebSocketClientTransport(opts)` | Browser or Node.js client |
|
|
246
|
+
| `createWebSocketServerTransport(ws)` | Server-side wrapper for an accepted WebSocket |
|
|
247
|
+
| `createLoopbackTransport()` | In-process pair for tests or single-tab use |
|
|
248
|
+
|
|
249
|
+
### `TransactionEnvelope`
|
|
250
|
+
|
|
251
|
+
The unit of currency on the wire:
|
|
252
|
+
|
|
253
|
+
```ts
|
|
254
|
+
type TransactionEnvelope = {
|
|
255
|
+
readonly id: number; // per-peer counter
|
|
256
|
+
readonly userId?: number | string; // peer identifier (compound key with id)
|
|
257
|
+
readonly name: string; // transaction function name
|
|
258
|
+
readonly args: unknown; // must be JSON-serializable
|
|
259
|
+
readonly time: number; // negative = transient, positive = committed (server-assigned)
|
|
260
|
+
};
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
### `t.userId` in transaction functions
|
|
264
|
+
|
|
265
|
+
Every transaction function receives the calling peer's `userId` (or
|
|
266
|
+
`undefined` in local-only mode) via `t.userId` on the transaction context.
|
|
267
|
+
This lets transaction functions **self-authorize**: they can act as a no-op
|
|
268
|
+
when the caller doesn't have permission instead of relying on caller-side
|
|
269
|
+
guards.
|
|
270
|
+
|
|
271
|
+
```ts
|
|
272
|
+
const plugin = Database.Plugin.create({
|
|
273
|
+
resources: { board: { default: initialBoard } },
|
|
274
|
+
transactions: {
|
|
275
|
+
// Only the current player may move. Other peers calling this with
|
|
276
|
+
// their own userId will produce a no-op that is never replicated.
|
|
277
|
+
playMove(t, { index }: { index: number }) {
|
|
278
|
+
const mark = currentPlayer(t.resources.board);
|
|
279
|
+
if (t.userId !== undefined && t.userId !== mark) return; // no-op
|
|
280
|
+
// ...apply the move
|
|
281
|
+
},
|
|
282
|
+
},
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
// Host is "X", joiner is "O"
|
|
286
|
+
const hostDb = Database.create(plugin, { sync: { userId: "X" } });
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
When a transaction produces **no store changes** (empty redo/undo operations),
|
|
290
|
+
the dispatcher suppresses the envelope notification. The `SyncService` never
|
|
291
|
+
sees the envelope, nothing is sent to the peer, and no server timestamp is
|
|
292
|
+
consumed. This is the **no-op replication guarantee**: invalid operations are
|
|
293
|
+
silently discarded at the source peer.
|
|
294
|
+
|
|
295
|
+
> **Note**: The server may still replicate a committed no-op envelope received
|
|
296
|
+
> from another peer (a peer that applied the op before learning it was
|
|
297
|
+
> invalid). This is harmless — the op produces no changes on the receiving
|
|
298
|
+
> peer either.
|
|
299
|
+
|
|
300
|
+
## Designing transactions for concurrent correctness
|
|
301
|
+
|
|
302
|
+
The sync layer guarantees **convergent ordering**, not conflict-free
|
|
303
|
+
mutations. Whether two concurrent operations from different peers compose
|
|
304
|
+
correctly depends entirely on how transaction functions are written.
|
|
305
|
+
|
|
306
|
+
### The fundamental rule
|
|
307
|
+
|
|
308
|
+
**Transaction functions must be pure functions of `(store, args)`.**
|
|
309
|
+
|
|
310
|
+
The reconciling database achieves convergence by rolling back local
|
|
311
|
+
transients, applying the server's canonical ordering, and replaying
|
|
312
|
+
remaining transients on top. A transaction that reads from external state
|
|
313
|
+
(clocks, random numbers, local variables outside the store) will produce
|
|
314
|
+
different results on different peers after replay, breaking convergence.
|
|
315
|
+
|
|
316
|
+
```ts
|
|
317
|
+
// ❌ Non-deterministic — reads Date.now() which differs across peers
|
|
318
|
+
createTimestampedNote(t, args: { text: string }) {
|
|
319
|
+
t.archetypes.Note.insert({ text: args.text, createdAt: Date.now() });
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ✅ Deterministic — timestamp is part of args, captured once by the proposing peer
|
|
323
|
+
createTimestampedNote(t, args: { text: string; createdAt: number }) {
|
|
324
|
+
t.archetypes.Note.insert({ text: args.text, createdAt: args.createdAt });
|
|
325
|
+
}
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
Other common violations: `Math.random()`, `crypto.randomUUID()`, reading
|
|
329
|
+
from a module-level cache, or branching on anything that isn't derived from
|
|
330
|
+
`store` or `args`.
|
|
331
|
+
|
|
332
|
+
### Entity IDs are not stable cross-peer references
|
|
333
|
+
|
|
334
|
+
Entity IDs are assigned during transaction execution, not in `args`. Two
|
|
335
|
+
peers that both create an entity concurrently will get the same local ID
|
|
336
|
+
while their operations are transient. After the server orders them and
|
|
337
|
+
replay runs, IDs are reassigned canonically — the peer that "lost" the
|
|
338
|
+
ordering race will find that the ID it captured in local JavaScript no
|
|
339
|
+
longer refers to the same entity.
|
|
340
|
+
|
|
341
|
+
**Never store an entity ID in a JavaScript variable and use it in a later
|
|
342
|
+
`db.transactions.X(...)` call across the network boundary.** The ID seen
|
|
343
|
+
synchronously after a transaction call is a transient artifact; after
|
|
344
|
+
round-tripping through the server it may have shifted.
|
|
345
|
+
|
|
346
|
+
### Pattern: embed selection in the store, not in JS variables
|
|
347
|
+
|
|
348
|
+
A common multi-user interaction is "select something, then act on the
|
|
349
|
+
selection". The naïve approach breaks under reordering:
|
|
350
|
+
|
|
351
|
+
```ts
|
|
352
|
+
// ❌ Breaks under concurrent edits
|
|
353
|
+
const id = db.transactions.createItem({ x: 0, y: 0 }); // local transient ID = 1
|
|
354
|
+
db.transactions.moveItem({ entity: id, dx: 10, dy: 0 }); // captured wrong ID
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
The fix is to record **intent** in the store as part of the same atomic
|
|
358
|
+
transaction, keyed by userId rather than by a local ID:
|
|
359
|
+
|
|
360
|
+
```ts
|
|
361
|
+
// ✅ Robust — selection state lives in the store, query by userId
|
|
362
|
+
createAndSelectItem(t, args: { x: number; y: number; userId: string }) {
|
|
363
|
+
const entity = t.archetypes.Item.insert({ x: args.x, y: args.y });
|
|
364
|
+
t.archetypes.Selection.insert({ userId: args.userId, target: entity });
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
moveSelectedItems(t, args: { userId: string; dx: number; dy: number }) {
|
|
368
|
+
for (const sel of t.archetypes.Selection.query({ userId: args.userId })) {
|
|
369
|
+
const item = t.read(sel.target);
|
|
370
|
+
if (item) t.update(sel.target, { x: item.x + args.dx, y: item.y + args.dy });
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
`createAndSelectItem` is atomic — the insert and the selection record land
|
|
376
|
+
in the same transaction. `moveSelectedItems` doesn't capture any ID at the
|
|
377
|
+
call site; it re-derives the target from the store at replay time. Both
|
|
378
|
+
operations compose correctly regardless of how the server orders concurrent
|
|
379
|
+
proposals from different peers.
|
|
380
|
+
|
|
381
|
+
### Pattern: idempotent "set" over non-idempotent "delta"
|
|
382
|
+
|
|
383
|
+
When two peers modify the same component concurrently, last-write-wins (by
|
|
384
|
+
server ordering) is usually the correct semantic. Write transactions that
|
|
385
|
+
set an absolute value rather than applying a relative delta when the intent
|
|
386
|
+
is "take ownership of this value":
|
|
387
|
+
|
|
388
|
+
```ts
|
|
389
|
+
// ❌ Fragile — concurrent +10 and +10 produce +20, but the intent was
|
|
390
|
+
// each user independently setting a new position
|
|
391
|
+
moveItem(t, args: { entity: number; dx: number; dy: number }) {
|
|
392
|
+
const item = t.read(args.entity)!;
|
|
393
|
+
t.update(args.entity, { x: item.x + args.dx, y: item.y + args.dy });
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// ✅ Explicit intent — server ordering arbitrates; loser's write is overwritten
|
|
397
|
+
setItemPosition(t, args: { entity: number; x: number; y: number }) {
|
|
398
|
+
t.update(args.entity, { x: args.x, y: args.y });
|
|
399
|
+
}
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
Relative deltas are correct for commutative operations (e.g. a counter
|
|
403
|
+
where every increment is independent). Use them deliberately, not as a
|
|
404
|
+
default.
|
|
405
|
+
|
|
406
|
+
### Pattern: per-user state archetypes
|
|
407
|
+
|
|
408
|
+
Model user-specific nonPersistent state (cursors, selections, presence) as its
|
|
409
|
+
own archetype keyed by `userId`. This avoids all contention: each user only
|
|
410
|
+
ever writes to their own row.
|
|
411
|
+
|
|
412
|
+
```ts
|
|
413
|
+
const plugin = Database.Plugin.create({
|
|
414
|
+
archetypes: {
|
|
415
|
+
Cursor: ["userId", "x", "y"],
|
|
416
|
+
Selection: ["userId", "target"],
|
|
417
|
+
},
|
|
418
|
+
transactions: {
|
|
419
|
+
// Continuous cursor stream — never-ending async generator yields
|
|
420
|
+
// each new position as a transient envelope. Sync forwards as
|
|
421
|
+
// `kind: "transient"`; peers see it as a transient too. Each new
|
|
422
|
+
// yield replaces the previous transient via the (userId, id)
|
|
423
|
+
// compound key.
|
|
424
|
+
movePresence(t, args: { userId: string; x: number; y: number }) {
|
|
425
|
+
for (const e of t.archetypes.Cursor.query({ userId: args.userId })) {
|
|
426
|
+
t.delete(e);
|
|
427
|
+
}
|
|
428
|
+
t.archetypes.Cursor.insert(args);
|
|
429
|
+
},
|
|
430
|
+
},
|
|
431
|
+
});
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
## Server architecture notes
|
|
435
|
+
|
|
436
|
+
The `SyncServer` is deliberately minimal. For production deployments you
|
|
437
|
+
will want to layer on:
|
|
438
|
+
|
|
439
|
+
- **Authentication** — validate the `userId` in incoming envelopes before
|
|
440
|
+
passing to `server.connect`. Reject connections whose token does not
|
|
441
|
+
match.
|
|
442
|
+
- **Authorization** — intercept `propose` messages and check whether the
|
|
443
|
+
`userId` is allowed to execute the named transaction with those args.
|
|
444
|
+
- **Persistence** — the `committedLog` lives in memory. For crash
|
|
445
|
+
recovery, persist each committed envelope to durable storage (a
|
|
446
|
+
database, a WAL file) and replay on restart. The
|
|
447
|
+
`@adobe/data-persistence` package can serve as the persistence layer on
|
|
448
|
+
the server side.
|
|
449
|
+
- **Horizontal scaling** — multiple server instances require a shared log
|
|
450
|
+
(Redis Streams, Kafka, Postgres LISTEN/NOTIFY) to broadcast committed
|
|
451
|
+
envelopes across instances.
|
|
452
|
+
- **Late-join efficiency** — the in-memory `committedLog` replay is fine
|
|
453
|
+
for short-lived sessions. For long-running documents, replace the replay
|
|
454
|
+
with a snapshot + incremental log: serve the latest `db.toData()`
|
|
455
|
+
snapshot and only replay envelopes since that snapshot's checkpoint.
|
|
456
|
+
|
|
457
|
+
## Transport contract
|
|
458
|
+
|
|
459
|
+
Transports must preserve **send ordering** and guarantee **delivery** for
|
|
460
|
+
`propose` and `committed` messages. The committed stream is the source of
|
|
461
|
+
truth; a dropped `committed` message will leave a client permanently
|
|
462
|
+
out-of-sync.
|
|
463
|
+
|
|
464
|
+
`transient` messages may be dropped or reordered — the server never logs
|
|
465
|
+
them and clients treat them as best-effort previews.
|
|
466
|
+
|
|
467
|
+
WebSocket fulfills the transport contract. UDP, raw QUIC, or other
|
|
468
|
+
unordered channels require a reliability layer on top before use.
|
|
469
|
+
|
|
470
|
+
## Connection resilience
|
|
471
|
+
|
|
472
|
+
### Reconnection and session continuity (`hello` / `welcome`)
|
|
473
|
+
|
|
474
|
+
Every `SyncServer` instance is assigned a stable random `sessionId` at
|
|
475
|
+
creation time. When a `SyncService` connects it immediately sends a `hello`
|
|
476
|
+
message carrying:
|
|
477
|
+
|
|
478
|
+
- `sessionId` — the `sessionId` received from the *previous* connection's
|
|
479
|
+
`welcome`, or omitted for a fresh client.
|
|
480
|
+
- `lastAppliedTime` — the highest committed `time` the client has already
|
|
481
|
+
applied locally.
|
|
482
|
+
|
|
483
|
+
The server responds with `welcome` before replaying any history:
|
|
484
|
+
|
|
485
|
+
```
|
|
486
|
+
client → server { kind: "hello", sessionId?: string, lastAppliedTime: number }
|
|
487
|
+
server → client { kind: "welcome", sessionId: string, resetRequired: boolean }
|
|
488
|
+
server → client … committed envelopes (replay) …
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
`resetRequired` is `true` when the server cannot satisfy the client's
|
|
492
|
+
watermark claim safely:
|
|
493
|
+
|
|
494
|
+
| Condition | `resetRequired` | Replay |
|
|
495
|
+
|---|---|---|
|
|
496
|
+
| Fresh client (no prior session) | `false` | Full log |
|
|
497
|
+
| Same session, watermark ≤ server max | `false` | Tail from `lastAppliedTime` |
|
|
498
|
+
| Different session (server restarted) | `true` | Full log |
|
|
499
|
+
| Client watermark ahead of server | `true` | Full log |
|
|
500
|
+
|
|
501
|
+
When `resetRequired` is `true`, the client must wipe its database (call
|
|
502
|
+
`db.reset()`) before applying the replay. The `onWelcome` callback in
|
|
503
|
+
`createSyncService` is the right place to do this — it fires
|
|
504
|
+
synchronously before the replay buffer is flushed:
|
|
505
|
+
|
|
506
|
+
```ts
|
|
507
|
+
const sync = createSyncService({
|
|
508
|
+
database: db,
|
|
509
|
+
transport,
|
|
510
|
+
priorSessionId, // retained from the previous connection
|
|
511
|
+
initialWatermark, // retained from sync.lastAppliedTime()
|
|
512
|
+
onWelcome: ({ sessionId, resetRequired }) => {
|
|
513
|
+
if (resetRequired) db.reset(); // wipe before replay arrives
|
|
514
|
+
},
|
|
515
|
+
});
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
The `SyncService` exposes `sessionId()` and `lastAppliedTime()` so a
|
|
519
|
+
reconnect wrapper can read them after a disconnect:
|
|
520
|
+
|
|
521
|
+
```ts
|
|
522
|
+
const priorSessionId = sync.sessionId();
|
|
523
|
+
const initialWatermark = sync.lastAppliedTime();
|
|
524
|
+
sync.dispose(); // close old transport
|
|
525
|
+
|
|
526
|
+
const newSync = createSyncService({
|
|
527
|
+
database: db,
|
|
528
|
+
transport: newTransport,
|
|
529
|
+
priorSessionId,
|
|
530
|
+
initialWatermark,
|
|
531
|
+
onWelcome: ({ resetRequired }) => {
|
|
532
|
+
if (resetRequired) db.reset();
|
|
533
|
+
},
|
|
534
|
+
});
|
|
535
|
+
```
|
|
536
|
+
|
|
537
|
+
`db.reset()` wipes all entities and resets all resources to their plugin
|
|
538
|
+
defaults in O(archetypes + resources) time, preserving database identity
|
|
539
|
+
(observers, sync options, transaction wrappers stay intact).
|
|
540
|
+
|
|
541
|
+
### Keep-alive: `ping` / `pong`
|
|
542
|
+
|
|
543
|
+
WebRTC DataChannels and WebSockets can silently drop without closing the
|
|
544
|
+
transport object — the underlying TCP or SCTP connection may be gone while
|
|
545
|
+
the browser's JS handle still looks open. The sync protocol includes an
|
|
546
|
+
application-level keep-alive to detect this:
|
|
547
|
+
|
|
548
|
+
- `SyncService` sends a `ping` message at a configurable interval.
|
|
549
|
+
- `SyncServer` echoes a `pong` on every `ping` it receives.
|
|
550
|
+
- Any inbound traffic (committed envelopes, pongs, welcome) resets the
|
|
551
|
+
client's liveness deadline.
|
|
552
|
+
- If the deadline expires — no inbound traffic for `livenessTimeoutMs` —
|
|
553
|
+
the service calls `transport.close()`, which fires all registered
|
|
554
|
+
`onClose` listeners and lets the application drive a reconnect.
|
|
555
|
+
|
|
556
|
+
The server applies a symmetric check: any inbound traffic from a client
|
|
557
|
+
(pings, proposals, hello) resets that client's deadline. A client that
|
|
558
|
+
goes silent is disconnected server-side after `livenessTimeoutMs`.
|
|
559
|
+
|
|
560
|
+
**`SyncServiceOptions`** keep-alive fields:
|
|
561
|
+
|
|
562
|
+
| Option | Default | Effect |
|
|
563
|
+
|---|---|---|
|
|
564
|
+
| `pingIntervalMs` | `10_000` | How often to send `ping`. `0` disables ping sending. |
|
|
565
|
+
| `livenessTimeoutMs` | `25_000` | Gap after which `transport.close()` is called. `0` disables the check. |
|
|
566
|
+
|
|
567
|
+
**`SyncServerOptions`** keep-alive fields:
|
|
568
|
+
|
|
569
|
+
| Option | Default | Effect |
|
|
570
|
+
|---|---|---|
|
|
571
|
+
| `livenessTimeoutMs` | `25_000` | Gap after which a silent client's transport is closed. `0` disables the check. |
|
|
572
|
+
|
|
573
|
+
With the defaults, a silent drop is detected within ~25–31 seconds on
|
|
574
|
+
either side. NAT keepalive is an implicit benefit: pings keep the UDP
|
|
575
|
+
mapping alive on routers with short idle timeouts.
|
|
576
|
+
|
|
577
|
+
```ts
|
|
578
|
+
// Tighter timeouts for a controlled LAN environment:
|
|
579
|
+
const server = createSyncServer({ livenessTimeoutMs: 10_000 });
|
|
580
|
+
const sync = createSyncService({
|
|
581
|
+
database: db,
|
|
582
|
+
transport,
|
|
583
|
+
pingIntervalMs: 3_000,
|
|
584
|
+
livenessTimeoutMs: 10_000,
|
|
585
|
+
onWelcome: ({ resetRequired }) => { if (resetRequired) db.reset(); },
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
// Wire transport close to a reconnect flow:
|
|
589
|
+
transport.onClose(() => {
|
|
590
|
+
const priorSessionId = sync.sessionId();
|
|
591
|
+
const initialWatermark = sync.lastAppliedTime();
|
|
592
|
+
sync.dispose();
|
|
593
|
+
// ... reconnect with priorSessionId + initialWatermark
|
|
594
|
+
});
|
|
595
|
+
```
|
|
596
|
+
|
|
597
|
+
### Debug logging
|
|
598
|
+
|
|
599
|
+
Both `createSyncServer` and `createSyncService` accept an optional
|
|
600
|
+
`logger` function for human-readable lifecycle messages. The library
|
|
601
|
+
itself never imports `console`; consumers wire one in explicitly:
|
|
602
|
+
|
|
603
|
+
```ts
|
|
604
|
+
const server = createSyncServer({
|
|
605
|
+
logger: (msg) => console.log(`[sync-server] ${msg}`),
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
const sync = createSyncService({
|
|
609
|
+
database: db,
|
|
610
|
+
transport,
|
|
611
|
+
logger: (msg) => console.log(`[sync] ${msg}`),
|
|
612
|
+
});
|
|
613
|
+
```
|
|
614
|
+
|
|
615
|
+
Logged events include: `hello` / `welcome` (with replay count and
|
|
616
|
+
`resetRequired`), outbound `propose` / `cancel`, inbound `committed` /
|
|
617
|
+
`cancelled`, liveness timeout, and `dispose`. Pings and pongs are
|
|
618
|
+
intentionally omitted — they fire every 10 seconds and would flood the
|
|
619
|
+
console.
|
|
620
|
+
|
|
621
|
+
## Testing
|
|
622
|
+
|
|
623
|
+
Use `createLoopbackTransport` to wire server and client in-process without
|
|
624
|
+
any network or async I/O. The loopback transport buffers messages that are
|
|
625
|
+
sent before a listener is registered, so synchronous test code works
|
|
626
|
+
without `await` gymnastics:
|
|
627
|
+
|
|
628
|
+
```ts
|
|
629
|
+
import { Database } from "@adobe/data/ecs";
|
|
630
|
+
import { createSyncServer, createSyncService, createLoopbackTransport } from "@adobe/data-sync";
|
|
631
|
+
|
|
632
|
+
const server = createSyncServer();
|
|
633
|
+
const { client: ct, server: st } = createLoopbackTransport();
|
|
634
|
+
server.connect(st);
|
|
635
|
+
|
|
636
|
+
const db = Database.create(myPlugin, { sync: { userId: "test" } });
|
|
637
|
+
const sync = createSyncService({ database: db, transport: ct });
|
|
638
|
+
|
|
639
|
+
db.transactions.createPoint({ x: 1, y: 2 });
|
|
640
|
+
// State is synchronously committed through the loopback — no await needed.
|
|
641
|
+
expect(db.select(["x"]).length).toBe(1);
|
|
642
|
+
```
|
|
643
|
+
|
|
644
|
+
See `src/sync.test.ts` for the full soundness suite, including concurrent
|
|
645
|
+
insert ordering, late-join convergence, nonPersistent-no-replicate, and
|
|
646
|
+
compound `(userId, id)` isolation.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { ServerTransport } from "./transport.js";
|
|
2
|
+
export interface SyncServerOptions {
|
|
3
|
+
/**
|
|
4
|
+
* Maximum gap between inbound messages from a connected client before the
|
|
5
|
+
* server closes that client's transport. Clients are expected to send a
|
|
6
|
+
* `ping` at least once per interval to keep their entry alive. Pass `0`
|
|
7
|
+
* to disable per-client liveness checking. Defaults to `25_000`.
|
|
8
|
+
*/
|
|
9
|
+
readonly livenessTimeoutMs?: number;
|
|
10
|
+
/**
|
|
11
|
+
* Optional sink for human-readable lifecycle messages — connect, hello /
|
|
12
|
+
* welcome handshake, propose / cancel, liveness drops, dispose. Pings
|
|
13
|
+
* and pongs are intentionally not logged.
|
|
14
|
+
*/
|
|
15
|
+
readonly logger?: (msg: string) => void;
|
|
16
|
+
}
|
|
17
|
+
export interface SyncServer {
|
|
18
|
+
/**
|
|
19
|
+
* Stable random identifier for this server instance's lifetime. Clients
|
|
20
|
+
* send their prior session id in `hello`; a mismatch signals that the host
|
|
21
|
+
* restarted and the client must reset and replay the full log.
|
|
22
|
+
*/
|
|
23
|
+
readonly sessionId: string;
|
|
24
|
+
/** Connect a new client transport to this server. */
|
|
25
|
+
readonly connect: (transport: ServerTransport) => () => void;
|
|
26
|
+
/** Dispose the server and disconnect all clients. */
|
|
27
|
+
readonly dispose: () => void;
|
|
28
|
+
}
|
|
29
|
+
export declare const createSyncServer: (options?: SyncServerOptions) => SyncServer;
|