@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.
package/dist/db.js CHANGED
@@ -1,75 +1,276 @@
1
- import { timeId, Route, createInsertHistoryTableCfg, Validate, BaseValidator, treeFromObject, isTimeId, getTimeIdTimestamp, createSliceIdsTableCfg, createLayerTableCfg, createCakeTableCfg } from "@rljson/rljson";
1
+ import { timeId, syncEvents, clientId, Route, createInsertHistoryTableCfg, Validate, BaseValidator, treeFromObject, isTimeId, getTimeIdTimestamp, createSliceIdsTableCfg, createLayerTableCfg, createCakeTableCfg } from "@rljson/rljson";
2
2
  import { rmhsh, hsh, Hash, hip } from "@rljson/hash";
3
3
  import { equals, merge } from "@rljson/json";
4
4
  import { IoMem } from "@rljson/io";
5
5
  import { traverse } from "object-traversal";
6
6
  import { compileExpression } from "filtrex";
7
7
  class Connector {
8
- constructor(_db, _route, _socket) {
8
+ constructor(_db, _route, _socket, syncConfig, clientIdentity) {
9
9
  this._db = _db;
10
10
  this._route = _route;
11
11
  this._socket = _socket;
12
12
  this._origin = timeId();
13
+ this._syncConfig = syncConfig;
14
+ this._events = syncEvents(this._route.flat);
15
+ if (clientIdentity) {
16
+ this._clientId = clientIdentity;
17
+ } else if (syncConfig?.includeClientIdentity) {
18
+ this._clientId = clientId();
19
+ }
20
+ this._maxDedup = syncConfig?.maxDedupSetSize ?? 1e4;
13
21
  this._init();
14
22
  }
15
23
  _origin;
16
24
  _callbacks = [];
25
+ _conflictCallbacks = [];
17
26
  _isListening = false;
18
- _sentRefs = /* @__PURE__ */ new Set();
19
- _receivedRefs = /* @__PURE__ */ new Set();
27
+ // Two-generation dedup sets bounded memory
28
+ _sentRefsCurrent = /* @__PURE__ */ new Set();
29
+ _sentRefsPrevious = /* @__PURE__ */ new Set();
30
+ _receivedRefsCurrent = /* @__PURE__ */ new Set();
31
+ _receivedRefsPrevious = /* @__PURE__ */ new Set();
32
+ _maxDedup;
33
+ // Sync protocol state
34
+ _syncConfig;
35
+ _clientId;
36
+ _events;
37
+ _seq = 0;
38
+ _lastPredecessors = [];
39
+ _peerSeqs = /* @__PURE__ */ new Map();
40
+ // ...........................................................................
41
+ /**
42
+ * Sends a ref to the server via the socket.
43
+ * Enriches the payload based on SyncConfig flags.
44
+ * @param ref - The ref to send
45
+ */
20
46
  send(ref) {
21
- if (this._sentRefs.has(ref) || this._receivedRefs.has(ref)) return;
22
- this._sentRefs.add(ref);
23
- this.socket.emit(this.route.flat, {
47
+ if (this._hasSentRef(ref) || this._hasReceivedRef(ref)) return;
48
+ this._addSentRef(ref);
49
+ const payload = {
24
50
  o: this._origin,
25
51
  r: ref
52
+ };
53
+ if (this._syncConfig?.includeClientIdentity && this._clientId) {
54
+ payload.c = this._clientId;
55
+ payload.t = Date.now();
56
+ }
57
+ if (this._syncConfig?.causalOrdering) {
58
+ payload.seq = ++this._seq;
59
+ if (this._lastPredecessors.length > 0) {
60
+ payload.p = [...this._lastPredecessors];
61
+ }
62
+ }
63
+ this.socket.emit(this._events.ref, payload);
64
+ }
65
+ // ...........................................................................
66
+ /**
67
+ * Sends a ref and waits for server acknowledgment.
68
+ * Only meaningful when `syncConfig.requireAck` is `true`.
69
+ * @param ref - The ref to send
70
+ * @returns A promise that resolves with the AckPayload
71
+ */
72
+ async sendWithAck(ref) {
73
+ const timeoutMs = this._syncConfig?.ackTimeoutMs ?? 1e4;
74
+ return new Promise((resolve, reject) => {
75
+ const timeout = setTimeout(() => {
76
+ this._socket.off(this._events.ack, handler);
77
+ reject(new Error(`ACK timeout for ref ${ref} after ${timeoutMs}ms`));
78
+ }, timeoutMs);
79
+ const handler = (ack) => {
80
+ if (ack.r === ref) {
81
+ clearTimeout(timeout);
82
+ this._socket.off(this._events.ack, handler);
83
+ resolve(ack);
84
+ }
85
+ };
86
+ this._socket.on(this._events.ack, handler);
87
+ this.send(ref);
26
88
  });
27
89
  }
90
+ // ...........................................................................
91
+ /**
92
+ * Sets the causal predecessors for the next send.
93
+ * @param predecessors - The InsertHistory timeIds of causal predecessors
94
+ */
95
+ setPredecessors(predecessors) {
96
+ this._lastPredecessors = predecessors;
97
+ }
98
+ // ...........................................................................
99
+ /**
100
+ * Registers a callback for incoming refs on this route.
101
+ *
102
+ * Incoming refs are processed through the full sync pipeline:
103
+ * origin filtering, dedup, gap detection, and ACK.
104
+ *
105
+ * @param callback - The callback to invoke with each deduplicated incoming ref
106
+ */
28
107
  listen(callback) {
29
- this._socket.on(this._route.flat, async (payload) => {
30
- try {
31
- await callback(payload.r);
32
- } catch (error) {
33
- console.error("Error in connector listener callback:", error);
34
- }
35
- });
108
+ this._callbacks.push(callback);
109
+ }
110
+ // ...........................................................................
111
+ /**
112
+ * Registers a callback that fires when a DAG conflict is detected.
113
+ *
114
+ * A conflict occurs when the InsertHistory for this route's table
115
+ * has multiple "tips" (leaf nodes), indicating concurrent writes
116
+ * from different clients that have not yet been merged.
117
+ *
118
+ * Detection-only: the callback receives a `Conflict` object
119
+ * describing the branches. Resolution is left to upper layers.
120
+ * @param callback - Invoked with the detected Conflict
121
+ */
122
+ onConflict(callback) {
123
+ this._conflictCallbacks.push(callback);
124
+ }
125
+ // ...........................................................................
126
+ /**
127
+ * Returns the current sequence number.
128
+ * Only meaningful when `causalOrdering` is enabled.
129
+ */
130
+ get seq() {
131
+ return this._seq;
132
+ }
133
+ // ...........................................................................
134
+ /**
135
+ * Returns the stable client identity.
136
+ * Only available when `includeClientIdentity` is enabled.
137
+ */
138
+ get clientIdentity() {
139
+ return this._clientId;
140
+ }
141
+ // ...........................................................................
142
+ /**
143
+ * Returns the sync configuration, if any.
144
+ */
145
+ get syncConfig() {
146
+ return this._syncConfig;
147
+ }
148
+ // ...........................................................................
149
+ /**
150
+ * Returns the typed event names for this connector's route.
151
+ */
152
+ get events() {
153
+ return this._events;
36
154
  }
155
+ // ######################
156
+ // Private
157
+ // ######################
37
158
  _init() {
38
159
  this._registerSocketObserver();
160
+ this._registerBootstrapHandler();
39
161
  this._registerDbObserver();
162
+ this._registerConflictObserver();
163
+ if (this._syncConfig?.causalOrdering) {
164
+ this._registerGapFillHandler();
165
+ }
40
166
  this._isListening = true;
41
167
  }
42
- teardown() {
43
- this._socket.removeAllListeners(this._route.flat);
168
+ tearDown() {
169
+ this._socket.removeAllListeners(this._events.ref);
170
+ this._socket.removeAllListeners(this._events.bootstrap);
171
+ if (this._syncConfig?.causalOrdering) {
172
+ this._socket.removeAllListeners(this._events.gapFillRes);
173
+ }
174
+ if (this._syncConfig?.requireAck) {
175
+ this._socket.removeAllListeners(this._events.ack);
176
+ }
44
177
  this._db.unregisterAllObservers(this._route);
178
+ this._db.unregisterAllConflictObservers(this._route);
45
179
  this._isListening = false;
46
180
  }
181
+ // ...........................................................................
182
+ // Two-generation dedup helpers
183
+ // ...........................................................................
184
+ _hasSentRef(ref) {
185
+ return this._sentRefsCurrent.has(ref) || this._sentRefsPrevious.has(ref);
186
+ }
187
+ _addSentRef(ref) {
188
+ this._sentRefsCurrent.add(ref);
189
+ if (this._sentRefsCurrent.size >= this._maxDedup) {
190
+ this._sentRefsPrevious = this._sentRefsCurrent;
191
+ this._sentRefsCurrent = /* @__PURE__ */ new Set();
192
+ }
193
+ }
194
+ _hasReceivedRef(ref) {
195
+ return this._receivedRefsCurrent.has(ref) || this._receivedRefsPrevious.has(ref);
196
+ }
197
+ _addReceivedRef(ref) {
198
+ this._receivedRefsCurrent.add(ref);
199
+ if (this._receivedRefsCurrent.size >= this._maxDedup) {
200
+ this._receivedRefsPrevious = this._receivedRefsCurrent;
201
+ this._receivedRefsCurrent = /* @__PURE__ */ new Set();
202
+ }
203
+ }
47
204
  _notifyCallbacks(ref) {
48
205
  Promise.all(this._callbacks.map((cb) => cb(ref))).catch((err) => {
49
206
  console.error(`Error notifying connector callbacks for ref ${ref}:`, err);
50
207
  });
51
208
  }
209
+ _processIncoming(payload) {
210
+ const ref = payload.r;
211
+ if (this._hasReceivedRef(ref)) {
212
+ return;
213
+ }
214
+ if (this._syncConfig?.causalOrdering && payload.seq != null && payload.c) {
215
+ const lastSeq = this._peerSeqs.get(payload.c) ?? 0;
216
+ if (payload.seq > lastSeq + 1) {
217
+ const gapReq = {
218
+ route: this._route.flat,
219
+ afterSeq: lastSeq
220
+ };
221
+ this._socket.emit(this._events.gapFillReq, gapReq);
222
+ }
223
+ this._peerSeqs.set(payload.c, payload.seq);
224
+ }
225
+ this._addReceivedRef(ref);
226
+ this._notifyCallbacks(ref);
227
+ if (this._syncConfig?.requireAck) {
228
+ this._socket.emit(this._events.ackClient, { r: ref });
229
+ }
230
+ }
52
231
  _registerSocketObserver() {
53
- this.socket.on(this.route.flat, (p) => {
232
+ this.socket.on(this._events.ref, (p) => {
54
233
  if (p.o === this._origin) {
55
234
  return;
56
235
  }
57
- const ref = p.r;
58
- if (this._receivedRefs.has(ref)) {
59
- return;
236
+ this._processIncoming(p);
237
+ });
238
+ }
239
+ _registerGapFillHandler() {
240
+ this._socket.on(this._events.gapFillRes, (res) => {
241
+ for (const p of res.refs) {
242
+ this._processIncoming(p);
243
+ }
244
+ });
245
+ }
246
+ /**
247
+ * Listens for bootstrap messages from the server.
248
+ * The server sends the latest ref on connect and optionally via heartbeat.
249
+ * _processIncoming handles dedup so already-seen refs are filtered out.
250
+ */
251
+ _registerBootstrapHandler() {
252
+ this._socket.on(this._events.bootstrap, (p) => {
253
+ this._processIncoming(p);
254
+ });
255
+ }
256
+ _registerConflictObserver() {
257
+ this._db.registerConflictObserver(this._route, (conflict) => {
258
+ for (const cb of this._conflictCallbacks) {
259
+ cb(conflict);
60
260
  }
61
- this._receivedRefs.add(p.r);
62
- this._notifyCallbacks(p.r);
63
261
  });
64
262
  }
65
263
  _registerDbObserver() {
66
264
  this._db.registerObserver(this._route, (ins) => {
67
265
  return new Promise((resolve) => {
68
266
  const ref = ins[this.route.root.tableKey + "Ref"];
69
- if (this._sentRefs.has(ref)) {
267
+ if (this._hasSentRef(ref)) {
70
268
  resolve();
71
269
  return;
72
270
  }
271
+ if (this._syncConfig?.causalOrdering && ins.previous?.length) {
272
+ this._lastPredecessors = [...ins.previous];
273
+ }
73
274
  this.send(ref);
74
275
  resolve();
75
276
  });
@@ -2134,7 +2335,7 @@ class ColumnFilterProcessor {
2134
2335
  }
2135
2336
  }
2136
2337
  // ...........................................................................
2137
- /* v8 ignore stop */
2338
+ /* v8 ignore stop -- @preserve */
2138
2339
  static operatorsForType(type) {
2139
2340
  switch (type) {
2140
2341
  case "string":
@@ -2916,6 +3117,7 @@ class Db {
2916
3117
  * Notification system to register callbacks on data changes
2917
3118
  */
2918
3119
  notify;
3120
+ _conflictCallbacks = /* @__PURE__ */ new Map();
2919
3121
  _cache = /* @__PURE__ */ new Map();
2920
3122
  // ...........................................................................
2921
3123
  /**
@@ -3585,6 +3787,56 @@ class Db {
3585
3787
  return insertHistoryRow;
3586
3788
  }
3587
3789
  // ...........................................................................
3790
+ /**
3791
+ * Insert pre-decomposed tree nodes into a tree table.
3792
+ *
3793
+ * Unlike `insert()`, which expects a plain nested object and decomposes it
3794
+ * via `treeFromObject()`, this method accepts an array of already-decomposed
3795
+ * `Tree` nodes (e.g. from FsScanner). The **root node must be the last
3796
+ * element** in the array.
3797
+ *
3798
+ * The method goes through the full insert pipeline:
3799
+ * 1. Writes each node via TreeController
3800
+ * 2. Creates an InsertHistoryRow automatically
3801
+ * 3. Calls `notify.notify()` so Connector observers fire
3802
+ *
3803
+ * @param treeKey - The tree table key (must end with "Tree")
3804
+ * @param trees - Pre-decomposed Tree nodes, root LAST
3805
+ * @param options - Optional: skip notification or history
3806
+ * @returns The InsertHistoryRow for the root node
3807
+ */
3808
+ async insertTrees(treeKey, trees, options) {
3809
+ if (!trees || trees.length === 0) {
3810
+ throw new Error(
3811
+ "Db.insertTrees: trees array must contain at least one node."
3812
+ );
3813
+ }
3814
+ const controller = await this.getController(treeKey);
3815
+ const writePromises = trees.map(
3816
+ (tree) => controller.insert("add", tree, "db.insertTrees")
3817
+ );
3818
+ const writeResults = await Promise.all(writePromises);
3819
+ const lastResult = writeResults[writeResults.length - 1];
3820
+ if (!lastResult || lastResult.length === 0) {
3821
+ throw new Error(
3822
+ `Db.insertTrees: TreeController returned no result for root node of table "${treeKey}".`
3823
+ );
3824
+ }
3825
+ const rootResult = lastResult[0];
3826
+ const route = Route.fromFlat(`/${treeKey}`);
3827
+ const result = {
3828
+ ...rootResult,
3829
+ route: route.flat
3830
+ };
3831
+ if (!options?.skipHistory) {
3832
+ await this._writeInsertHistory(treeKey, result);
3833
+ }
3834
+ if (!options?.skipNotification) {
3835
+ this.notify.notify(route, result);
3836
+ }
3837
+ return [result];
3838
+ }
3839
+ // ...........................................................................
3588
3840
  /**
3589
3841
  * Recursively runs controllers based on the route of the Insert
3590
3842
  * @param insert - The Insert to run
@@ -3835,6 +4087,44 @@ class Db {
3835
4087
  this.notify.unregisterAll(route);
3836
4088
  }
3837
4089
  // ...........................................................................
4090
+ /**
4091
+ * Registers a callback to be called when a DAG conflict is detected
4092
+ * on the given route.
4093
+ * @param route - The route to register the conflict callback on
4094
+ * @param callback - The callback to invoke with the Conflict
4095
+ */
4096
+ registerConflictObserver(route, callback) {
4097
+ const key = route.flat;
4098
+ this._conflictCallbacks.set(key, [
4099
+ ...this._conflictCallbacks.get(key) || [],
4100
+ callback
4101
+ ]);
4102
+ }
4103
+ // ...........................................................................
4104
+ /**
4105
+ * Unregisters a specific conflict callback from the given route.
4106
+ * @param route - The route to unregister the callback from
4107
+ * @param callback - The callback to remove
4108
+ */
4109
+ unregisterConflictObserver(route, callback) {
4110
+ const key = route.flat;
4111
+ const callbacks = this._conflictCallbacks.get(key);
4112
+ if (callbacks) {
4113
+ this._conflictCallbacks.set(
4114
+ key,
4115
+ callbacks.filter((cb) => cb !== callback)
4116
+ );
4117
+ }
4118
+ }
4119
+ // ...........................................................................
4120
+ /**
4121
+ * Unregisters all conflict callbacks from the given route.
4122
+ * @param route - The route to clear conflict callbacks for
4123
+ */
4124
+ unregisterAllConflictObservers(route) {
4125
+ this._conflictCallbacks.delete(route.flat);
4126
+ }
4127
+ // ...........................................................................
3838
4128
  /**
3839
4129
  * Get a controller for a specific table
3840
4130
  * @param tableKey - The key of the table to get the controller for
@@ -3867,6 +4157,57 @@ class Db {
3867
4157
  return controllers;
3868
4158
  }
3869
4159
  // ...........................................................................
4160
+ /**
4161
+ * Detects whether the InsertHistory for a table has diverged into
4162
+ * multiple branches (multiple "tips" — leaf nodes in the DAG).
4163
+ *
4164
+ * A tip is a timeId that is NOT referenced as `previous` by any other
4165
+ * InsertHistory row. Two or more tips indicate a DAG fork (conflict).
4166
+ * @param table - The table name (without "InsertHistory" suffix)
4167
+ * @returns A Conflict if a DAG branch is detected, or null otherwise
4168
+ */
4169
+ async detectDagBranch(table) {
4170
+ const insertHistoryTable = table + "InsertHistory";
4171
+ const hasTable = await this.core.hasTable(insertHistoryTable);
4172
+ if (!hasTable) return null;
4173
+ const dump = await this.core.dumpTable(insertHistoryTable);
4174
+ const rows = dump[insertHistoryTable]._data;
4175
+ if (rows.length < 2) return null;
4176
+ const referencedAsParent = /* @__PURE__ */ new Set();
4177
+ for (const row of rows) {
4178
+ if (row.previous) {
4179
+ for (const p of row.previous) {
4180
+ referencedAsParent.add(p);
4181
+ }
4182
+ }
4183
+ }
4184
+ const tips = rows.filter((row) => !referencedAsParent.has(row.timeId));
4185
+ if (tips.length > 1) {
4186
+ return {
4187
+ table,
4188
+ type: "dagBranch",
4189
+ detectedAt: Date.now(),
4190
+ branches: tips.map((t) => t.timeId)
4191
+ };
4192
+ }
4193
+ return null;
4194
+ }
4195
+ // ...........................................................................
4196
+ /**
4197
+ * Fires conflict callbacks registered on the route derived from the table.
4198
+ * @param table - The table name
4199
+ * @param conflict - The detected conflict
4200
+ */
4201
+ _notifyConflict(table, conflict) {
4202
+ const route = Route.fromFlat(`/${table}`);
4203
+ const callbacks = this._conflictCallbacks.get(route.flat);
4204
+ if (callbacks) {
4205
+ for (const cb of callbacks) {
4206
+ cb(conflict);
4207
+ }
4208
+ }
4209
+ }
4210
+ // ...........................................................................
3870
4211
  /**
3871
4212
  * Adds an InsertHistory row to the InsertHistory table of a table
3872
4213
  * @param table - The table the Insert was made on
@@ -3881,6 +4222,10 @@ class Db {
3881
4222
  _type: "insertHistory"
3882
4223
  }
3883
4224
  });
4225
+ const conflict = await this.detectDagBranch(table);
4226
+ if (conflict) {
4227
+ this._notifyConflict(table, conflict);
4228
+ }
3884
4229
  }
3885
4230
  // ...........................................................................
3886
4231
  /**