@rljson/server 0.0.5 → 0.0.6

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.
@@ -167,9 +167,14 @@ The `Server` class acts as a central coordination point that:
167
167
 
168
168
  **Lifecycle and controls:**
169
169
 
170
- - `addSocket()` attaches a stable `__clientId`, builds `IoPeer`/`BsPeer`, queues them, rebuilds multis once, and refreshes servers in a batch.
171
- - Multicast uses `__origin` markers plus `_multicastedRefs` to avoid echo loops and duplicate ref forwarding.
170
+ - `addSocket()` attaches a stable `__clientId`, builds `IoPeer`/`BsPeer` (guarded by `peerInitTimeoutMs`), queues them, rebuilds multis once, refreshes servers in a batch, and registers an auto-disconnect handler.
171
+ - `removeSocket(clientId)` removes a client’s listeners and peers, rebuilds multis, and re-establishes multicast for remaining clients.
172
+ - `tearDown()` stops the eviction timer, removes all listeners/disconnect handlers, clears clients, closes IoMulti, and resets all internal state.
173
+ - Multicast uses `__origin` markers plus a **two-generation ref set** (`_multicastedRefsCurrent` / `_multicastedRefsPrevious`) to avoid echo loops and duplicate ref forwarding. Refs are evicted on a configurable interval (`refEvictionIntervalMs`, default 60 s) to prevent unbounded memory growth.
172
174
  - Pending sockets are refreshed together so multiple joins trigger a single multi rebuild.
175
+ - All lifecycle events, errors, and traffic are logged via the injected `ServerLogger` (defaults to `NoopLogger`).
176
+ - Traffic logging captures inbound refs from clients and outbound multicasts with `from`/`to` client IDs.
177
+ - Disconnected sockets are auto-detected: a `'disconnect'` listener triggers `removeSocket()`, cleaning up dead peers and rebuilding multis.
173
178
 
174
179
  ### 3. Multi-Layer Priority System
175
180
 
@@ -637,9 +642,9 @@ await server.createTables({ withInsertHistory: [cakeCfg] });
637
642
  await clientA.createTables({ withInsertHistory: [cakeCfg] });
638
643
  await clientB.createTables({ withInsertHistory: [cakeCfg] });
639
644
 
640
- // Create Db instances
641
- const dbA = new Db(clientA.io!);
642
- const dbB = new Db(clientB.io!);
645
+ // When route was passed to Client constructor, Db is available directly:
646
+ const dbA = clientA.db!;
647
+ const dbB = clientB.db!;
643
648
 
644
649
  // Client A: Insert data (stores locally)
645
650
  const route = Route.fromFlat('carCake');
@@ -708,8 +713,9 @@ await server.createTables({ withInsertHistory: [treeCfg] });
708
713
  await clientA.createTables({ withInsertHistory: [treeCfg] });
709
714
  await clientB.createTables({ withInsertHistory: [treeCfg] });
710
715
 
711
- const dbA = new Db(clientA.io!);
712
- const dbB = new Db(clientB.io!);
716
+ // When route was passed to Client constructor, Db is available directly:
717
+ const dbA = clientA.db!;
718
+ const dbB = clientB.db!;
713
719
 
714
720
  // Client A: Create tree from object
715
721
  const projectData = {
@@ -991,11 +997,27 @@ Application configuration distribution:
991
997
  ### Client Initialization
992
998
 
993
999
  ```typescript
994
- const client = new Client(socket, localIo, localBs);
995
- await client.init(); // Sets up IoMulti and BsMulti
1000
+ // With route: Db and Connector are created automatically during init()
1001
+ const client = new Client(socket, localIo, localBs, route);
1002
+ await client.init(); // Sets up IoMulti, BsMulti, Db, and Connector
996
1003
  await client.ready(); // Waits for IoMulti to be ready
997
1004
 
998
- const db = new Db(client.io!); // Create Db on top of IoMulti
1005
+ const db = client.db!; // Db wrapping IoMulti
1006
+ const connector = client.connector!; // Connector wired to route + socket
1007
+
1008
+ // With logging:
1009
+ import { BufferedLogger } from '@rljson/server';
1010
+ const logger = new BufferedLogger();
1011
+ const client = new Client(socket, localIo, localBs, route, { logger });
1012
+ await client.init();
1013
+ // logger.entries now contains lifecycle events:
1014
+ // Constructing client, Initializing client, Setting up Io multi,
1015
+ // Io peer bridge started, Io multi ready, Setting up Bs multi, ...
1016
+
1017
+ // Without route (legacy): only IoMulti and BsMulti are created
1018
+ const client = new Client(socket, localIo, localBs);
1019
+ await client.init();
1020
+ const db = new Db(client.io!); // Caller creates Db manually
999
1021
  ```
1000
1022
 
1001
1023
  ### Server Initialization
@@ -1018,10 +1040,12 @@ When `server.addSocket(socket)` is called:
1018
1040
  4. **Refresh servers**: Update IoServer/BsServer with new multis
1019
1041
  5. **Setup multicast**: Register listeners for route broadcasting
1020
1042
 
1043
+ Each step is logged at `info` level. Errors in any step are logged at `error` level and re-thrown.
1044
+
1021
1045
  ### Teardown
1022
1046
 
1023
1047
  ```typescript
1024
- await client.tearDown(); // Closes IoMulti, clears state
1048
+ await client.tearDown(); // Closes IoMulti, clears Db, Connector, and all state
1025
1049
  ```
1026
1050
 
1027
1051
  ## Testing Patterns
@@ -1116,6 +1140,214 @@ await expect(dbB.get(route, {})).rejects.toThrow();
1116
1140
  - **@rljson/db**: Db operations (insert, get, join, etc.)
1117
1141
  - **@rljson/rljson**: Data structures (Route, TableCfg, etc.)
1118
1142
 
1143
+ ## Observability
1144
+
1145
+ ### Structured Logging
1146
+
1147
+ Both `Server` and `Client` accept an optional `ServerLogger` via their options parameter. The logger is called at every significant lifecycle point, error boundary, and network traffic event.
1148
+
1149
+ **Logger interface:**
1150
+
1151
+ ```typescript
1152
+ interface ServerLogger {
1153
+ info(source: string, message: string, data?: Record<string, unknown>): void;
1154
+ warn(source: string, message: string, data?: Record<string, unknown>): void;
1155
+ error(source: string, message: string, error?: unknown, data?: Record<string, unknown>): void;
1156
+ traffic(direction: 'in' | 'out', source: string, event: string, data?: Record<string, unknown>): void;
1157
+ }
1158
+ ```
1159
+
1160
+ **What gets logged:**
1161
+
1162
+ | Phase | Source | Level | Events |
1163
+ | --------------- | ------------------------- | ------- | ------------------------------------------- |
1164
+ | Construction | `Server` / `Client` | info | Route, options |
1165
+ | Initialization | `Server` / `Client` | info | Start, success |
1166
+ | Io/Bs setup | `Client.Io` / `Client.Bs` | info | Multi creation, peer bridges, peer creation |
1167
+ | Peer creation | `Server.Io` / `Server.Bs` | info | Per-client peer setup |
1168
+ | Multi rebuild | `Server` | info | Peer count, rebuild success |
1169
+ | Server refresh | `Server` | info | Pending socket count, completion |
1170
+ | Multicast in | `Server.Multicast` | traffic | Ref, sender clientId |
1171
+ | Multicast out | `Server.Multicast` | traffic | Ref, sender clientId, receiver clientId |
1172
+ | Duplicate ref | `Server.Multicast` | warn | Ref, sender |
1173
+ | Loop prevention | `Server.Multicast` | warn | Ref, origin, sender |
1174
+ | Any failure | Various | error | Error object, context data |
1175
+ | TearDown | `Client` | info | Start, completion |
1176
+ | Socket removal | `Server` | info | Removing, rebuilding multis, removal done |
1177
+ | Server tearDown | `Server` | info | Tearing down, timer stop, completion |
1178
+ | Disconnect | `Server` | info | Client disconnected, auto-removal |
1179
+
1180
+ **Built-in implementations:**
1181
+
1182
+ - `NoopLogger` — zero overhead, used by default
1183
+ - `ConsoleLogger` — `console.log`/`warn`/`error` with formatted prefixes
1184
+ - `BufferedLogger` — in-memory array with `byLevel()`, `bySource()`, `clear()` helpers
1185
+ - `FilteredLogger` — wraps another logger, filters by `levels` and/or `sources`
1186
+
1187
+ **Production recommendation:** Use `FilteredLogger` wrapping your framework's logger, filtering to `['error', 'warn']` levels. Enable `traffic` level only for debugging multicast issues.
1188
+
1189
+ ## Sync Protocol (opt-in hardening)
1190
+
1191
+ The server supports an optional sync protocol that provides production-grade guarantees on top of the basic multicast mechanism. Enabled by passing `syncConfig` in `ServerOptions`.
1192
+
1193
+ ### Architecture
1194
+
1195
+ ```text
1196
+ Client A (Connector) Server Client B (Connector)
1197
+ ──────────────────── ────── ────────────────────
1198
+ send(ref) →
1199
+ enriches payload:
1200
+ {o, r, c?, t?, seq?, p?}
1201
+ ────emit(route)───►
1202
+ ┌─ append to ref log
1203
+ ├─ setup ACK collection
1204
+ ├─ forward to Client B ──emit(route)──►
1205
+ │ processIncoming()
1206
+ │ ◄──ackClient──
1207
+ ├─ collect ackClient
1208
+ ├─ emit aggregated ACK
1209
+ ◄───ack────────────┘
1210
+ ```
1211
+
1212
+ ### Wire format reference
1213
+
1214
+ All sync payloads are JSON objects. The types are defined in `@rljson/rljson` (Layer 0) and used unchanged across all layers.
1215
+
1216
+ #### ConnectorPayload (bidirectional, event: `${route}`)
1217
+
1218
+ The main wire message between Connector and Server. Two required fields provide backward compatibility; optional fields activate based on `SyncConfig` flags.
1219
+
1220
+ | Field | Type | Required | Activated by | Purpose |
1221
+ | ------- | ----------------------- | -------- | ----------------------- | ---------------------------------------------------- |
1222
+ | `r` | `string` | ✅ | always | The ref (InsertHistory timeId) being announced |
1223
+ | `o` | `string` | ✅ | always | Ephemeral origin of the sender (self-echo filtering) |
1224
+ | `c` | `ClientId` | ❌ | `includeClientIdentity` | Stable client identity (survives reconnections) |
1225
+ | `t` | `number` | ❌ | `includeClientIdentity` | Client-side wall-clock timestamp (ms since epoch) |
1226
+ | `seq` | `number` | ❌ | `causalOrdering` | Monotonic counter per (client, route) pair |
1227
+ | `p` | `InsertHistoryTimeId[]` | ❌ | `causalOrdering` | Causal predecessor timeIds |
1228
+ | `cksum` | `string` | ❌ | — | Content checksum for ACK verification |
1229
+
1230
+ Minimal payload (no SyncConfig): `{ o: "...", r: "..." }`
1231
+
1232
+ Full payload (all flags): `{ o, r, c, t, seq, p }`
1233
+
1234
+ #### AckPayload (Server → Client, event: `${route}:ack`)
1235
+
1236
+ | Field | Type | Required | Purpose |
1237
+ | -------------- | --------- | -------- | ------------------------------------------------------------- |
1238
+ | `r` | `string` | ✅ | The ref being acknowledged |
1239
+ | `ok` | `boolean` | ✅ | `true` if all clients confirmed; `false` on timeout / partial |
1240
+ | `receivedBy` | `number` | ❌ | Count of clients that confirmed receipt |
1241
+ | `totalClients` | `number` | ❌ | Total receiver clients at broadcast time |
1242
+
1243
+ #### GapFillRequest (Client → Server, event: `${route}:gapfill:req`)
1244
+
1245
+ | Field | Type | Required | Purpose |
1246
+ | ------------- | --------------------- | -------- | ------------------------------------------ |
1247
+ | `route` | `string` | ✅ | The route for which refs are missing |
1248
+ | `afterSeq` | `number` | ✅ | Last seq the client successfully processed |
1249
+ | `afterTimeId` | `InsertHistoryTimeId` | ❌ | Alternative anchor if seq unavailable |
1250
+
1251
+ #### GapFillResponse (Server → Client, event: `${route}:gapfill:res`)
1252
+
1253
+ | Field | Type | Required | Purpose |
1254
+ | ------- | -------------------- | -------- | ----------------------------------------------- |
1255
+ | `route` | `string` | ✅ | The route this response corresponds to |
1256
+ | `refs` | `ConnectorPayload[]` | ✅ | Ordered list of missing payloads (oldest first) |
1257
+
1258
+ #### Event name derivation
1259
+
1260
+ All event names are route-specific, derived by `syncEvents(route)` from `@rljson/rljson`:
1261
+
1262
+ | Property | Derived name | Direction |
1263
+ | ------------ | ------------------------ | --------------- |
1264
+ | `ref` | `"${route}"` | Bidirectional |
1265
+ | `ack` | `"${route}:ack"` | Server → Client |
1266
+ | `ackClient` | `"${route}:ack:client"` | Client → Server |
1267
+ | `gapFillReq` | `"${route}:gapfill:req"` | Client → Server |
1268
+ | `gapFillRes` | `"${route}:gapfill:res"` | Server → Client |
1269
+ | `bootstrap` | `"${route}:bootstrap"` | Server → Client |
1270
+
1271
+ #### SyncConfig flag activation matrix
1272
+
1273
+ | SyncConfig flag | Payload fields activated | Events activated |
1274
+ | ----------------------- | ------------------------------ | ------------------------------ |
1275
+ | _(none / default)_ | `o`, `r` | `${route}` only |
1276
+ | `causalOrdering` | + `seq`, `p` | + `gapfill:req`, `gapfill:res` |
1277
+ | `requireAck` | _(no extra fields)_ | + `ack`, `ack:client` |
1278
+ | `includeClientIdentity` | + `c`, `t` | _(no extra events)_ |
1279
+ | All flags combined | `o`, `r`, `c`, `t`, `seq`, `p` | All 6 events |
1280
+ | `maxDedupSetSize` | _(Connector-only setting)_ | _(no events)_ |
1281
+ | `bootstrapHeartbeatMs` | _(no extra fields)_ | + `bootstrap` (periodic) |
1282
+
1283
+ #### ClientId format
1284
+
1285
+ A `ClientId` is a `"client_"` prefix followed by a 12-character nanoid (e.g. `client_V1StGXR8_Z5j`). Unlike the ephemeral `origin` (which changes per Connector instantiation), a `ClientId` persists across reconnections and should be stored by the application.
1286
+
1287
+ ### Ref log (ring buffer)
1288
+
1289
+ The server maintains a bounded ring buffer of recent `ConnectorPayload` entries. When the buffer exceeds `refLogSize` (default: 1000), the oldest entry is dropped. The ref log serves as the data source for gap-fill responses.
1290
+
1291
+ ### ACK aggregation
1292
+
1293
+ When `requireAck` is enabled:
1294
+
1295
+ 1. **Before broadcast**: The server registers `ackClient` listeners on all receiver sockets.
1296
+ 2. **During broadcast**: Payloads are forwarded to all other clients.
1297
+ 3. **After broadcast**: The server waits for individual `ackClient` events from each receiver.
1298
+ 4. **On completion or timeout**: An aggregated `AckPayload` is emitted back to the sender on the `ack` event.
1299
+
1300
+ The ACK includes `receivedBy` (count of confirmed receivers) and `totalClients` (total receiver count). If all receivers confirm, `ok: true`; if timeout fires first, `ok: false`.
1301
+
1302
+ ### Gap-fill responder
1303
+
1304
+ When `causalOrdering` is enabled:
1305
+
1306
+ 1. The server listens for `gapfill:req` events from each client.
1307
+ 2. On request, it filters the ref log for payloads with `seq > afterSeq`.
1308
+ 3. The matching payloads are sent back on the `gapfill:res` event.
1309
+
1310
+ ### Bootstrap (late joiner support)
1311
+
1312
+ The server tracks the most recent ref seen on `_latestRef` (updated in `_multicastRefs` on every broadcast). This enables two mechanisms:
1313
+
1314
+ **Immediate bootstrap on connect:**
1315
+
1316
+ When `addSocket()` completes, the server calls `_sendBootstrap(ioDown)` which emits a `ConnectorPayload` with `o: '__server__'` and `r: _latestRef` on the `${route}:bootstrap` event. The Connector's `_registerBootstrapHandler()` feeds this into `_processIncoming()`, triggering listen callbacks and applying dedup automatically.
1317
+
1318
+ **Periodic heartbeat (optional):**
1319
+
1320
+ When `bootstrapHeartbeatMs > 0` in `SyncConfig`, `_startBootstrapHeartbeat()` starts an interval timer that calls `_broadcastBootstrapHeartbeat()` to emit the latest ref to all connected clients. The timer calls `.unref()` so it doesn't keep the process alive. `tearDown()` clears the timer.
1321
+
1322
+ ```text
1323
+ addSocket(socketB)
1324
+
1325
+ ├─ setup IoPeer, BsPeer, multicast listeners
1326
+ ├─ _sendBootstrap(ioDown) → emit(bootstrap, { o: '__server__', r: latestRef })
1327
+ └─ _startBootstrapHeartbeat() → setInterval(broadcastBootstrapHeartbeat, ms)
1328
+ ```
1329
+
1330
+ **Design decisions:**
1331
+
1332
+ - `_events` is always initialized (even without `syncConfig`) because bootstrap needs event names regardless of sync config
1333
+ - Bootstrap uses a dedicated event (`${route}:bootstrap`) rather than the main `${route}` event to avoid interfering with multicast payload processing
1334
+ - The `'__server__'` origin ensures no Connector treats bootstrap as a self-echo
1335
+
1336
+ ### Event registration lifecycle
1337
+
1338
+ - `_multicastRefs()` sets up all sync listeners (ref, ackClient, gapFillReq) per client.
1339
+ - `_removeAllListeners()` tears down all sync listeners (route, ackClient, gapFillReq).
1340
+ - `addSocket()` and `removeSocket()` trigger rebuild of all listeners.
1341
+ - `tearDown()` clears the ref log in addition to existing cleanup.
1342
+
1343
+ ### Client-side integration
1344
+
1345
+ The `Client` class accepts `syncConfig`, `clientIdentity`, and `peerInitTimeoutMs` in `ClientOptions`.
1346
+
1347
+ - **`peerInitTimeoutMs`** (default 30 s, 0 = disable): Guards `IoPeer` and `BsPeer` initialization during `init()` with a `Promise.race`-based timeout. If the server is unreachable, `init()` rejects cleanly instead of hanging indefinitely. Uses the same `_withTimeout()` pattern as the server.
1348
+ - **`syncConfig`** + **`clientIdentity`**: When a route is provided, these are passed through to the `Connector` constructor, activating enriched payloads (sequence numbers, causal ordering, client identity) on the client side.
1349
+ - **`tearDown()`**: Calls `connector.tearDown()` to remove all socket listeners before clearing internal references. This prevents leaked listeners that would keep the socket alive after the client is disposed.
1350
+
1119
1351
  ## Future Considerations
1120
1352
 
1121
1353
  - **Write replication**: Automatically sync writes to server
@@ -1123,3 +1355,7 @@ await expect(dbB.get(route, {})).rejects.toThrow();
1123
1355
  - **Change detection**: Notify on data changes
1124
1356
  - **Batch operations**: Optimize bulk transfers
1125
1357
  - **Compression**: Reduce network payload size
1358
+ - **Authentication hooks**: Verify client identity in `addSocket()`
1359
+ - **Connection health introspection**: Query connected client state, connection time, etc.
1360
+ - **Backpressure / rate limiting**: Protect against misbehaving clients flooding multicast
1361
+ - **Metrics / counters**: Numeric counters (connected clients, refs/sec) for monitoring dashboards
package/README.md CHANGED
@@ -13,6 +13,10 @@ Local-first, pull-by-reference server layer for Rljson. Clients keep writes loca
13
13
  - Writes stay local; reads cascade: local ➜ server ➜ peers
14
14
  - References (hashes) flow; data is pulled on demand
15
15
  - Server aggregates sockets and multicasts refs, but only stores what you explicitly import
16
+ - Graceful lifecycle: `tearDown()` for both Server and Client, automatic disconnect cleanup, `removeSocket()` for manual removal
17
+ - Configurable production defaults: ref eviction interval, peer init timeout (server and client)
18
+ - Structured logging via injectable `ServerLogger` (NoopLogger default, ConsoleLogger, BufferedLogger, FilteredLogger included)
19
+ - **Sync protocol**: Optional ACK aggregation, causal ordering with gap-fill, enriched payload forwarding via `SyncConfig`
16
20
 
17
21
  ## Quick start
18
22
 
@@ -49,11 +53,15 @@ import { BsMem } from '@rljson/bs';
49
53
  import { IoMem } from '@rljson/io';
50
54
  import { Client, SocketIoBridge } from '@rljson/server';
51
55
 
52
- const client = new Client(new SocketIoBridge(clientSocket), new IoMem(), new BsMem());
56
+ // Pass the same route as the server to get Db and Connector automatically
57
+ const route = Route.fromFlat('my.app');
58
+ const client = new Client(new SocketIoBridge(clientSocket), new IoMem(), new BsMem(), route);
53
59
  await client.init();
54
60
 
55
- const io = client.io; // IoMulti merged interface
56
- const bs = client.bs; // BsMulti merged interface
61
+ const io = client.io; // IoMulti merged interface
62
+ const bs = client.bs; // BsMulti merged interface
63
+ const db = client.db; // Db (available when route provided)
64
+ const connector = client.connector; // Connector (available when route provided)
57
65
  ```
58
66
 
59
67
  Run tests and lint: