@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 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;