@rljson/db 0.0.13 → 0.0.15

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.
@@ -777,6 +777,68 @@ describe('Tree WHERE clause fix', () => {
777
777
  });
778
778
  ```
779
779
 
780
+ ## Connector (Sync Protocol)
781
+
782
+ The `Connector` class implements the client side of the RLJSON sync protocol. It sits between a local `Db` and a socket, enriching outgoing refs with protocol metadata and processing incoming refs with safety guarantees.
783
+
784
+ ### Responsibilities
785
+
786
+ ```
787
+ ┌────────────────────────────────────────────────┐
788
+ │ Connector │
789
+ │ │
790
+ │ Outgoing path (Db → socket): │
791
+ │ 1. Db.notify fires with InsertHistoryRow │
792
+ │ 2. Auto-populate predecessors from │
793
+ │ InsertHistoryRow.previous │
794
+ │ 3. Enrich with seq, c, t, p (per config) │
795
+ │ 4. Add to sent-refs dedup set │
796
+ │ 5. Emit ConnectorPayload on socket │
797
+ │ │
798
+ │ Incoming path (socket → callbacks): │
799
+ │ 1. Receive ConnectorPayload from socket │
800
+ │ 2. Reject self-echo (origin === own origin) │
801
+ │ 3. Reject duplicate (ref already received) │
802
+ │ 4. Detect sequence gaps → request gap-fill │
803
+ │ 5. Emit client ACK (if requireAck) │
804
+ │ 6. Invoke listen() callbacks │
805
+ └────────────────────────────────────────────────┘
806
+ ```
807
+
808
+ ### Bounded dedup strategy
809
+
810
+ The Connector tracks recently sent and received refs to prevent duplicates. Both sets use **two-generation eviction**:
811
+
812
+ 1. A `current` Set accumulates refs.
813
+ 2. When `current.size ≥ maxDedupSetSize`, it rotates: `previous = current`, `current = new Set()`.
814
+ 3. Lookups check **both** `current` and `previous`.
815
+ 4. This caps memory at ≈ 2 × `maxDedupSetSize` entries (default 10 000 per generation).
816
+
817
+ The eviction is O(1) — no per-entry bookkeeping — and avoids the overhead of a full LRU cache.
818
+
819
+ ### Auto-predecessor from InsertHistory
820
+
821
+ When `causalOrdering` is enabled, the Connector's Db observer reads the `previous` field from each `InsertHistoryRow` emitted by `Db.notify`. These `InsertHistoryTimeId` values become the `p` (predecessors) array in the outgoing `ConnectorPayload`. This eliminates the need for manual `setPredecessors()` calls in normal database-driven workflows.
822
+
823
+ ### listen()
824
+
825
+ `listen()` registers callbacks that receive incoming refs through the full sync pipeline: origin filtering, dedup, gap detection, and ACK. All protocol safeguards are applied before callbacks are invoked.
826
+
827
+ ### sendWithAck ordering
828
+
829
+ The `sendWithAck()` method registers the ACK listener **before** emitting the ref on the socket. This prevents a race condition with synchronous transports (e.g., in-memory sockets used in tests) where the ACK fires during `send()` and would be lost if the listener were registered after.
830
+
831
+ ### Bootstrap handler
832
+
833
+ The Connector registers a listener on `events.bootstrap` in `_init()` via `_registerBootstrapHandler()`. When the server sends a bootstrap message (latest ref on connect, or periodic heartbeat), the handler feeds the `ConnectorPayload` directly into `_processIncoming()`. This means:
834
+
835
+ - **Dedup**: Refs already received via multicast are filtered out automatically
836
+ - **Gap detection**: If `causalOrdering` is enabled, bootstrap refs participate in sequence tracking
837
+ - **Callbacks**: `listen()` callbacks fire for genuinely new refs
838
+ - **ACK**: If `requireAck` is enabled, client ACKs are sent back
839
+
840
+ The `tearDown()` method cleans up the bootstrap listener alongside all other socket listeners.
841
+
780
842
  ## Future Enhancements
781
843
 
782
844
  ### Planned Features
package/README.public.md CHANGED
@@ -906,6 +906,79 @@ const db = new Db(multi);
906
906
  // Priority: io1 > io2 > io3
907
907
  ```
908
908
 
909
+ ## Connector (sync protocol)
910
+
911
+ The `Connector` bridges a local `Db` with a remote server via socket events. It enriches outgoing refs with protocol metadata and processes incoming refs with dedup, origin filtering, and gap detection.
912
+
913
+ ### Creating a Connector
914
+
915
+ ```typescript
916
+ import { Connector } from '@rljson/db';
917
+ import { Route } from '@rljson/rljson';
918
+ import type { SyncConfig } from '@rljson/rljson';
919
+
920
+ const route = Route.fromFlat('/sharedTree');
921
+
922
+ // Minimal — no enrichment
923
+ const connector = new Connector(db, route, socket);
924
+
925
+ // With sync config — enables enriched payloads
926
+ const syncConfig: SyncConfig = {
927
+ causalOrdering: true,
928
+ requireAck: true,
929
+ ackTimeoutMs: 5_000,
930
+ includeClientIdentity: true,
931
+ maxDedupSetSize: 10_000,
932
+ };
933
+ const connector = new Connector(db, route, socket, { syncConfig });
934
+ ```
935
+
936
+ ### Sending refs
937
+
938
+ ```typescript
939
+ // Fire-and-forget
940
+ connector.send(ref);
941
+
942
+ // Wait for server ACK (requires requireAck: true)
943
+ const ack = await connector.sendWithAck(ref);
944
+ // ack: { r, ok, receivedBy, totalClients }
945
+ ```
946
+
947
+ ### Receiving refs
948
+
949
+ ```typescript
950
+ // Safe callback with dedup, origin filtering, gap detection
951
+ connector.listen(async (ref) => {
952
+ console.log('New ref:', ref);
953
+ });
954
+ ```
955
+
956
+ ### Predecessors
957
+
958
+ When `causalOrdering` is enabled, the Connector automatically populates the `p` (predecessors) field from the `InsertHistoryRow.previous` array whenever the local Db notifies about a new insert. No manual call to `setPredecessors()` is needed for standard database-driven sends.
959
+
960
+ ```typescript
961
+ // Manual override (advanced) — sets predecessors for the NEXT send only
962
+ connector.setPredecessors(['1700000000000:AbCd']);
963
+ ```
964
+
965
+ ### Bounded dedup
966
+
967
+ The Connector tracks recently sent and received refs to prevent duplicates. The dedup sets use **two-generation eviction**: when the current set reaches `maxDedupSetSize` (default 10 000), it rotates to previous and a new current set starts. Lookups check both generations. This caps memory usage at ≈ 2 × `maxDedupSetSize` entries.
968
+
969
+ ### Bootstrap handling
970
+
971
+ The Connector automatically listens for `${route}:bootstrap` events from the server. When a client joins after data has already been sent, the server pushes the latest ref via this event. The Connector feeds it into `_processIncoming()`, so dedup, gap detection, and `listen()` callbacks all work identically to regular multicast refs.
972
+
973
+ No additional setup is needed — the bootstrap handler is registered in `_init()` and cleaned up in `tearDown()`.
974
+
975
+ ### Cleanup
976
+
977
+ ```typescript
978
+ connector.tearDown();
979
+ // Removes all socket listeners and clears internal state
980
+ ```
981
+
909
982
  ## Examples
910
983
 
911
984
  See [src/example.ts](src/example.ts) for a complete working example demonstrating:
@@ -777,6 +777,68 @@ describe('Tree WHERE clause fix', () => {
777
777
  });
778
778
  ```
779
779
 
780
+ ## Connector (Sync Protocol)
781
+
782
+ The `Connector` class implements the client side of the RLJSON sync protocol. It sits between a local `Db` and a socket, enriching outgoing refs with protocol metadata and processing incoming refs with safety guarantees.
783
+
784
+ ### Responsibilities
785
+
786
+ ```
787
+ ┌────────────────────────────────────────────────┐
788
+ │ Connector │
789
+ │ │
790
+ │ Outgoing path (Db → socket): │
791
+ │ 1. Db.notify fires with InsertHistoryRow │
792
+ │ 2. Auto-populate predecessors from │
793
+ │ InsertHistoryRow.previous │
794
+ │ 3. Enrich with seq, c, t, p (per config) │
795
+ │ 4. Add to sent-refs dedup set │
796
+ │ 5. Emit ConnectorPayload on socket │
797
+ │ │
798
+ │ Incoming path (socket → callbacks): │
799
+ │ 1. Receive ConnectorPayload from socket │
800
+ │ 2. Reject self-echo (origin === own origin) │
801
+ │ 3. Reject duplicate (ref already received) │
802
+ │ 4. Detect sequence gaps → request gap-fill │
803
+ │ 5. Emit client ACK (if requireAck) │
804
+ │ 6. Invoke listen() callbacks │
805
+ └────────────────────────────────────────────────┘
806
+ ```
807
+
808
+ ### Bounded dedup strategy
809
+
810
+ The Connector tracks recently sent and received refs to prevent duplicates. Both sets use **two-generation eviction**:
811
+
812
+ 1. A `current` Set accumulates refs.
813
+ 2. When `current.size ≥ maxDedupSetSize`, it rotates: `previous = current`, `current = new Set()`.
814
+ 3. Lookups check **both** `current` and `previous`.
815
+ 4. This caps memory at ≈ 2 × `maxDedupSetSize` entries (default 10 000 per generation).
816
+
817
+ The eviction is O(1) — no per-entry bookkeeping — and avoids the overhead of a full LRU cache.
818
+
819
+ ### Auto-predecessor from InsertHistory
820
+
821
+ When `causalOrdering` is enabled, the Connector's Db observer reads the `previous` field from each `InsertHistoryRow` emitted by `Db.notify`. These `InsertHistoryTimeId` values become the `p` (predecessors) array in the outgoing `ConnectorPayload`. This eliminates the need for manual `setPredecessors()` calls in normal database-driven workflows.
822
+
823
+ ### listen()
824
+
825
+ `listen()` registers callbacks that receive incoming refs through the full sync pipeline: origin filtering, dedup, gap detection, and ACK. All protocol safeguards are applied before callbacks are invoked.
826
+
827
+ ### sendWithAck ordering
828
+
829
+ The `sendWithAck()` method registers the ACK listener **before** emitting the ref on the socket. This prevents a race condition with synchronous transports (e.g., in-memory sockets used in tests) where the ACK fires during `send()` and would be lost if the listener were registered after.
830
+
831
+ ### Bootstrap handler
832
+
833
+ The Connector registers a listener on `events.bootstrap` in `_init()` via `_registerBootstrapHandler()`. When the server sends a bootstrap message (latest ref on connect, or periodic heartbeat), the handler feeds the `ConnectorPayload` directly into `_processIncoming()`. This means:
834
+
835
+ - **Dedup**: Refs already received via multicast are filtered out automatically
836
+ - **Gap detection**: If `causalOrdering` is enabled, bootstrap refs participate in sequence tracking
837
+ - **Callbacks**: `listen()` callbacks fire for genuinely new refs
838
+ - **ACK**: If `requireAck` is enabled, client ACKs are sent back
839
+
840
+ The `tearDown()` method cleans up the bootstrap listener alongside all other socket listeners.
841
+
780
842
  ## Future Enhancements
781
843
 
782
844
  ### Planned Features
@@ -906,6 +906,79 @@ const db = new Db(multi);
906
906
  // Priority: io1 > io2 > io3
907
907
  ```
908
908
 
909
+ ## Connector (sync protocol)
910
+
911
+ The `Connector` bridges a local `Db` with a remote server via socket events. It enriches outgoing refs with protocol metadata and processes incoming refs with dedup, origin filtering, and gap detection.
912
+
913
+ ### Creating a Connector
914
+
915
+ ```typescript
916
+ import { Connector } from '@rljson/db';
917
+ import { Route } from '@rljson/rljson';
918
+ import type { SyncConfig } from '@rljson/rljson';
919
+
920
+ const route = Route.fromFlat('/sharedTree');
921
+
922
+ // Minimal — no enrichment
923
+ const connector = new Connector(db, route, socket);
924
+
925
+ // With sync config — enables enriched payloads
926
+ const syncConfig: SyncConfig = {
927
+ causalOrdering: true,
928
+ requireAck: true,
929
+ ackTimeoutMs: 5_000,
930
+ includeClientIdentity: true,
931
+ maxDedupSetSize: 10_000,
932
+ };
933
+ const connector = new Connector(db, route, socket, { syncConfig });
934
+ ```
935
+
936
+ ### Sending refs
937
+
938
+ ```typescript
939
+ // Fire-and-forget
940
+ connector.send(ref);
941
+
942
+ // Wait for server ACK (requires requireAck: true)
943
+ const ack = await connector.sendWithAck(ref);
944
+ // ack: { r, ok, receivedBy, totalClients }
945
+ ```
946
+
947
+ ### Receiving refs
948
+
949
+ ```typescript
950
+ // Safe callback with dedup, origin filtering, gap detection
951
+ connector.listen(async (ref) => {
952
+ console.log('New ref:', ref);
953
+ });
954
+ ```
955
+
956
+ ### Predecessors
957
+
958
+ When `causalOrdering` is enabled, the Connector automatically populates the `p` (predecessors) field from the `InsertHistoryRow.previous` array whenever the local Db notifies about a new insert. No manual call to `setPredecessors()` is needed for standard database-driven sends.
959
+
960
+ ```typescript
961
+ // Manual override (advanced) — sets predecessors for the NEXT send only
962
+ connector.setPredecessors(['1700000000000:AbCd']);
963
+ ```
964
+
965
+ ### Bounded dedup
966
+
967
+ The Connector tracks recently sent and received refs to prevent duplicates. The dedup sets use **two-generation eviction**: when the current set reaches `maxDedupSetSize` (default 10 000), it rotates to previous and a new current set starts. Lookups check both generations. This caps memory usage at ≈ 2 × `maxDedupSetSize` entries.
968
+
969
+ ### Bootstrap handling
970
+
971
+ The Connector automatically listens for `${route}:bootstrap` events from the server. When a client joins after data has already been sent, the server pushes the latest ref via this event. The Connector feeds it into `_processIncoming()`, so dedup, gap detection, and `listen()` callbacks all work identically to regular multicast refs.
972
+
973
+ No additional setup is needed — the bootstrap handler is registered in `_init()` and cleaned up in `tearDown()`.
974
+
975
+ ### Cleanup
976
+
977
+ ```typescript
978
+ connector.tearDown();
979
+ // Removes all socket listeners and clears internal state
980
+ ```
981
+
909
982
  ## Examples
910
983
 
911
984
  See [src/example.ts](src/example.ts) for a complete working example demonstrating:
@@ -1,10 +1,7 @@
1
1
  import { Socket } from '@rljson/io';
2
- import { Route } from '@rljson/rljson';
2
+ import { AckPayload, ClientId, ConflictCallback, InsertHistoryTimeId, Route, SyncConfig, SyncEventNames } from '@rljson/rljson';
3
3
  import { Db } from '../db.ts';
4
- export type ConnectorPayload = {
5
- o: string;
6
- r: string;
7
- };
4
+ export type { ConnectorPayload } from '@rljson/rljson';
8
5
  export type ConnectorCallback = (ref: string) => Promise<any>;
9
6
  export declare class Connector {
10
7
  private readonly _db;
@@ -12,16 +9,94 @@ export declare class Connector {
12
9
  private readonly _socket;
13
10
  private _origin;
14
11
  private _callbacks;
12
+ private _conflictCallbacks;
15
13
  private _isListening;
16
- private _sentRefs;
17
- private _receivedRefs;
18
- constructor(_db: Db, _route: Route, _socket: Socket);
14
+ private _sentRefsCurrent;
15
+ private _sentRefsPrevious;
16
+ private _receivedRefsCurrent;
17
+ private _receivedRefsPrevious;
18
+ private readonly _maxDedup;
19
+ private readonly _syncConfig;
20
+ private readonly _clientId;
21
+ private readonly _events;
22
+ private _seq;
23
+ private _lastPredecessors;
24
+ private _peerSeqs;
25
+ constructor(_db: Db, _route: Route, _socket: Socket, syncConfig?: SyncConfig, clientIdentity?: ClientId);
26
+ /**
27
+ * Sends a ref to the server via the socket.
28
+ * Enriches the payload based on SyncConfig flags.
29
+ * @param ref - The ref to send
30
+ */
19
31
  send(ref: string): void;
20
- listen(callback: (editHistoryRef: string) => Promise<void>): void;
32
+ /**
33
+ * Sends a ref and waits for server acknowledgment.
34
+ * Only meaningful when `syncConfig.requireAck` is `true`.
35
+ * @param ref - The ref to send
36
+ * @returns A promise that resolves with the AckPayload
37
+ */
38
+ sendWithAck(ref: string): Promise<AckPayload>;
39
+ /**
40
+ * Sets the causal predecessors for the next send.
41
+ * @param predecessors - The InsertHistory timeIds of causal predecessors
42
+ */
43
+ setPredecessors(predecessors: InsertHistoryTimeId[]): void;
44
+ /**
45
+ * Registers a callback for incoming refs on this route.
46
+ *
47
+ * Incoming refs are processed through the full sync pipeline:
48
+ * origin filtering, dedup, gap detection, and ACK.
49
+ *
50
+ * @param callback - The callback to invoke with each deduplicated incoming ref
51
+ */
52
+ listen(callback: ConnectorCallback): void;
53
+ /**
54
+ * Registers a callback that fires when a DAG conflict is detected.
55
+ *
56
+ * A conflict occurs when the InsertHistory for this route's table
57
+ * has multiple "tips" (leaf nodes), indicating concurrent writes
58
+ * from different clients that have not yet been merged.
59
+ *
60
+ * Detection-only: the callback receives a `Conflict` object
61
+ * describing the branches. Resolution is left to upper layers.
62
+ * @param callback - Invoked with the detected Conflict
63
+ */
64
+ onConflict(callback: ConflictCallback): void;
65
+ /**
66
+ * Returns the current sequence number.
67
+ * Only meaningful when `causalOrdering` is enabled.
68
+ */
69
+ get seq(): number;
70
+ /**
71
+ * Returns the stable client identity.
72
+ * Only available when `includeClientIdentity` is enabled.
73
+ */
74
+ get clientIdentity(): ClientId | undefined;
75
+ /**
76
+ * Returns the sync configuration, if any.
77
+ */
78
+ get syncConfig(): SyncConfig | undefined;
79
+ /**
80
+ * Returns the typed event names for this connector's route.
81
+ */
82
+ get events(): SyncEventNames;
21
83
  private _init;
22
- teardown(): void;
84
+ tearDown(): void;
85
+ private _hasSentRef;
86
+ private _addSentRef;
87
+ private _hasReceivedRef;
88
+ private _addReceivedRef;
23
89
  private _notifyCallbacks;
90
+ private _processIncoming;
24
91
  private _registerSocketObserver;
92
+ private _registerGapFillHandler;
93
+ /**
94
+ * Listens for bootstrap messages from the server.
95
+ * The server sends the latest ref on connect and optionally via heartbeat.
96
+ * _processIncoming handles dedup so already-seen refs are filtered out.
97
+ */
98
+ private _registerBootstrapHandler;
99
+ private _registerConflictObserver;
25
100
  private _registerDbObserver;
26
101
  get socket(): Socket;
27
102
  get route(): Route;
package/dist/db.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Io } from '@rljson/io';
2
2
  import { Json, JsonValue } from '@rljson/json';
3
- import { Edit, EditHistory, InsertHistoryRow, InsertHistoryTimeId, MultiEdit, Ref, Rljson, Route, SliceId, TableType } from '@rljson/rljson';
3
+ import { Conflict, ConflictCallback, Edit, EditHistory, InsertHistoryRow, InsertHistoryTimeId, MultiEdit, Ref, Rljson, Route, SliceId, TableType, Tree } from '@rljson/rljson';
4
4
  import { Controller, ControllerChildProperty, ControllerRefs } from './controller/controller.ts';
5
5
  import { Core } from './core.ts';
6
6
  import { Join } from './join/join.ts';
@@ -43,6 +43,7 @@ export declare class Db {
43
43
  * Notification system to register callbacks on data changes
44
44
  */
45
45
  readonly notify: Notify;
46
+ private _conflictCallbacks;
46
47
  private _cache;
47
48
  /**
48
49
  * Get data from a route with optional filtering
@@ -90,6 +91,28 @@ export declare class Db {
90
91
  skipNotification?: boolean;
91
92
  skipHistory?: boolean;
92
93
  }): Promise<InsertHistoryRow<any>[]>;
94
+ /**
95
+ * Insert pre-decomposed tree nodes into a tree table.
96
+ *
97
+ * Unlike `insert()`, which expects a plain nested object and decomposes it
98
+ * via `treeFromObject()`, this method accepts an array of already-decomposed
99
+ * `Tree` nodes (e.g. from FsScanner). The **root node must be the last
100
+ * element** in the array.
101
+ *
102
+ * The method goes through the full insert pipeline:
103
+ * 1. Writes each node via TreeController
104
+ * 2. Creates an InsertHistoryRow automatically
105
+ * 3. Calls `notify.notify()` so Connector observers fire
106
+ *
107
+ * @param treeKey - The tree table key (must end with "Tree")
108
+ * @param trees - Pre-decomposed Tree nodes, root LAST
109
+ * @param options - Optional: skip notification or history
110
+ * @returns The InsertHistoryRow for the root node
111
+ */
112
+ insertTrees(treeKey: string, trees: Tree[], options?: {
113
+ skipNotification?: boolean;
114
+ skipHistory?: boolean;
115
+ }): Promise<InsertHistoryRow<any>[]>;
93
116
  /**
94
117
  * Recursively runs controllers based on the route of the Insert
95
118
  * @param insert - The Insert to run
@@ -115,6 +138,24 @@ export declare class Db {
115
138
  * Unregisters all observers from all routes
116
139
  */
117
140
  unregisterAllObservers(route: Route): void;
141
+ /**
142
+ * Registers a callback to be called when a DAG conflict is detected
143
+ * on the given route.
144
+ * @param route - The route to register the conflict callback on
145
+ * @param callback - The callback to invoke with the Conflict
146
+ */
147
+ registerConflictObserver(route: Route, callback: ConflictCallback): void;
148
+ /**
149
+ * Unregisters a specific conflict callback from the given route.
150
+ * @param route - The route to unregister the callback from
151
+ * @param callback - The callback to remove
152
+ */
153
+ unregisterConflictObserver(route: Route, callback: ConflictCallback): void;
154
+ /**
155
+ * Unregisters all conflict callbacks from the given route.
156
+ * @param route - The route to clear conflict callbacks for
157
+ */
158
+ unregisterAllConflictObservers(route: Route): void;
118
159
  /**
119
160
  * Get a controller for a specific table
120
161
  * @param tableKey - The key of the table to get the controller for
@@ -124,6 +165,22 @@ export declare class Db {
124
165
  */
125
166
  getController(tableKey: string, refs?: ControllerRefs): Promise<Controller<TableType, any, string>>;
126
167
  indexedControllers(route: Route): Promise<Record<string, Controller<any, any, any>>>;
168
+ /**
169
+ * Detects whether the InsertHistory for a table has diverged into
170
+ * multiple branches (multiple "tips" — leaf nodes in the DAG).
171
+ *
172
+ * A tip is a timeId that is NOT referenced as `previous` by any other
173
+ * InsertHistory row. Two or more tips indicate a DAG fork (conflict).
174
+ * @param table - The table name (without "InsertHistory" suffix)
175
+ * @returns A Conflict if a DAG branch is detected, or null otherwise
176
+ */
177
+ detectDagBranch(table: string): Promise<Conflict | null>;
178
+ /**
179
+ * Fires conflict callbacks registered on the route derived from the table.
180
+ * @param table - The table name
181
+ * @param conflict - The detected conflict
182
+ */
183
+ private _notifyConflict;
127
184
  /**
128
185
  * Adds an InsertHistory row to the InsertHistory table of a table
129
186
  * @param table - The table the Insert was made on