@rljson/db 0.0.13 → 0.0.14

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, 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;
@@ -13,15 +10,79 @@ export declare class Connector {
13
10
  private _origin;
14
11
  private _callbacks;
15
12
  private _isListening;
16
- private _sentRefs;
17
- private _receivedRefs;
18
- constructor(_db: Db, _route: Route, _socket: Socket);
13
+ private _sentRefsCurrent;
14
+ private _sentRefsPrevious;
15
+ private _receivedRefsCurrent;
16
+ private _receivedRefsPrevious;
17
+ private readonly _maxDedup;
18
+ private readonly _syncConfig;
19
+ private readonly _clientId;
20
+ private readonly _events;
21
+ private _seq;
22
+ private _lastPredecessors;
23
+ private _peerSeqs;
24
+ constructor(_db: Db, _route: Route, _socket: Socket, syncConfig?: SyncConfig, clientIdentity?: ClientId);
25
+ /**
26
+ * Sends a ref to the server via the socket.
27
+ * Enriches the payload based on SyncConfig flags.
28
+ * @param ref - The ref to send
29
+ */
19
30
  send(ref: string): void;
20
- listen(callback: (editHistoryRef: string) => Promise<void>): void;
31
+ /**
32
+ * Sends a ref and waits for server acknowledgment.
33
+ * Only meaningful when `syncConfig.requireAck` is `true`.
34
+ * @param ref - The ref to send
35
+ * @returns A promise that resolves with the AckPayload
36
+ */
37
+ sendWithAck(ref: string): Promise<AckPayload>;
38
+ /**
39
+ * Sets the causal predecessors for the next send.
40
+ * @param predecessors - The InsertHistory timeIds of causal predecessors
41
+ */
42
+ setPredecessors(predecessors: InsertHistoryTimeId[]): void;
43
+ /**
44
+ * Registers a callback for incoming refs on this route.
45
+ *
46
+ * Incoming refs are processed through the full sync pipeline:
47
+ * origin filtering, dedup, gap detection, and ACK.
48
+ *
49
+ * @param callback - The callback to invoke with each deduplicated incoming ref
50
+ */
51
+ listen(callback: ConnectorCallback): void;
52
+ /**
53
+ * Returns the current sequence number.
54
+ * Only meaningful when `causalOrdering` is enabled.
55
+ */
56
+ get seq(): number;
57
+ /**
58
+ * Returns the stable client identity.
59
+ * Only available when `includeClientIdentity` is enabled.
60
+ */
61
+ get clientIdentity(): ClientId | undefined;
62
+ /**
63
+ * Returns the sync configuration, if any.
64
+ */
65
+ get syncConfig(): SyncConfig | undefined;
66
+ /**
67
+ * Returns the typed event names for this connector's route.
68
+ */
69
+ get events(): SyncEventNames;
21
70
  private _init;
22
- teardown(): void;
71
+ tearDown(): void;
72
+ private _hasSentRef;
73
+ private _addSentRef;
74
+ private _hasReceivedRef;
75
+ private _addReceivedRef;
23
76
  private _notifyCallbacks;
77
+ private _processIncoming;
24
78
  private _registerSocketObserver;
79
+ private _registerGapFillHandler;
80
+ /**
81
+ * Listens for bootstrap messages from the server.
82
+ * The server sends the latest ref on connect and optionally via heartbeat.
83
+ * _processIncoming handles dedup so already-seen refs are filtered out.
84
+ */
85
+ private _registerBootstrapHandler;
25
86
  private _registerDbObserver;
26
87
  get socket(): Socket;
27
88
  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 { 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';
@@ -90,6 +90,28 @@ export declare class Db {
90
90
  skipNotification?: boolean;
91
91
  skipHistory?: boolean;
92
92
  }): Promise<InsertHistoryRow<any>[]>;
93
+ /**
94
+ * Insert pre-decomposed tree nodes into a tree table.
95
+ *
96
+ * Unlike `insert()`, which expects a plain nested object and decomposes it
97
+ * via `treeFromObject()`, this method accepts an array of already-decomposed
98
+ * `Tree` nodes (e.g. from FsScanner). The **root node must be the last
99
+ * element** in the array.
100
+ *
101
+ * The method goes through the full insert pipeline:
102
+ * 1. Writes each node via TreeController
103
+ * 2. Creates an InsertHistoryRow automatically
104
+ * 3. Calls `notify.notify()` so Connector observers fire
105
+ *
106
+ * @param treeKey - The tree table key (must end with "Tree")
107
+ * @param trees - Pre-decomposed Tree nodes, root LAST
108
+ * @param options - Optional: skip notification or history
109
+ * @returns The InsertHistoryRow for the root node
110
+ */
111
+ insertTrees(treeKey: string, trees: Tree[], options?: {
112
+ skipNotification?: boolean;
113
+ skipHistory?: boolean;
114
+ }): Promise<InsertHistoryRow<any>[]>;
93
115
  /**
94
116
  * Recursively runs controllers based on the route of the Insert
95
117
  * @param insert - The Insert to run