@rljson/db 0.0.14 → 0.0.17

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.
@@ -839,6 +839,46 @@ The Connector registers a listener on `events.bootstrap` in `_init()` via `_regi
839
839
 
840
840
  The `tearDown()` method cleans up the bootstrap listener alongside all other socket listeners.
841
841
 
842
+ ### Conflict Detection
843
+
844
+ The `Db` class integrates DAG branch conflict detection directly into the write path. After every call to `_writeInsertHistory()`, the system invokes `detectDagBranch(table)` to scan the InsertHistory for forks.
845
+
846
+ **Algorithm (`detectDagBranch`)**:
847
+
848
+ 1. Load all rows from `{table}InsertHistory`.
849
+ 2. Build a set of all timeIds that appear as someone's `previous`.
850
+ 3. Tips = rows whose `timeId` is **not** in that set (no descendant).
851
+ 4. If more than one tip exists → DAG branch conflict.
852
+
853
+ ```text
854
+ ┌─── tip A (17000…:AbCd)
855
+ root ──┤
856
+ └─── tip B (17000…:EfGh)
857
+
858
+ DAG branch: two concurrent writes, no merge yet
859
+ ```
860
+
861
+ **Observer pipeline:**
862
+
863
+ ```text
864
+ _writeInsertHistory()
865
+ └──► detectDagBranch(table)
866
+ └──► if conflict found:
867
+ _notifyConflict(table, conflict)
868
+ └──► fire all callbacks in _conflictCallbacks[route]
869
+ ```
870
+
871
+ **Connector integration**: `Connector.onConflict(callback)` calls `db.registerConflictObserver()` under the hood, so callbacks fire for conflicts on the route managed by that Connector. `tearDown()` calls `db.unregisterAllConflictObservers()` to clean up.
872
+
873
+ **Key properties:**
874
+
875
+ | Property | Value |
876
+ | ----------------- | ---------------------------------------------------------------------------- |
877
+ | Detection trigger | Every `_writeInsertHistory()` call |
878
+ | Scope | Per-table (each table's InsertHistory is independent) |
879
+ | Resolution | Not provided — detection and signaling only |
880
+ | Merge detection | A conflict **disappears** when a merge row references all tips as `previous` |
881
+
842
882
  ## Future Enhancements
843
883
 
844
884
  ### Planned Features
package/README.public.md CHANGED
@@ -979,6 +979,57 @@ connector.tearDown();
979
979
  // Removes all socket listeners and clears internal state
980
980
  ```
981
981
 
982
+ ### Conflict Detection
983
+
984
+ The `Db` class detects DAG branch conflicts in the InsertHistory and notifies registered observers. A **DAG branch** occurs when two or more InsertHistory rows have no descendant — i.e., they are "tips" of the history graph — indicating concurrent writes from different clients that have not yet been merged.
985
+
986
+ #### Manual Detection
987
+
988
+ ```typescript
989
+ // Check whether a table's InsertHistory has diverged
990
+ const conflict = await db.detectDagBranch('cars');
991
+ if (conflict) {
992
+ console.log(conflict.table); // 'cars'
993
+ console.log(conflict.type); // 'dagBranch'
994
+ console.log(conflict.branches); // ['17000…:AbCd', '17000…:EfGh']
995
+ }
996
+ // Returns null when the history is linear (no conflict)
997
+ ```
998
+
999
+ #### Automatic Detection via Observers
1000
+
1001
+ Conflict detection runs automatically after every `_writeInsertHistory()` call. Register callbacks on the `Db` to be notified immediately:
1002
+
1003
+ ```typescript
1004
+ import type { Conflict, ConflictCallback } from '@rljson/db';
1005
+
1006
+ const onConflict: ConflictCallback = (conflict: Conflict) => {
1007
+ console.warn(`DAG branch in ${conflict.table}:`, conflict.branches);
1008
+ };
1009
+
1010
+ // Register
1011
+ db.registerConflictObserver(route, onConflict);
1012
+
1013
+ // Unregister a specific callback
1014
+ db.unregisterConflictObserver(route, onConflict);
1015
+
1016
+ // Unregister all callbacks for a route
1017
+ db.unregisterAllConflictObservers(route);
1018
+ ```
1019
+
1020
+ #### Via Connector
1021
+
1022
+ The `Connector` provides a convenience method that wraps the Db observer API:
1023
+
1024
+ ```typescript
1025
+ connector.onConflict((conflict) => {
1026
+ console.warn(`Conflict detected:`, conflict);
1027
+ });
1028
+ // Cleaned up automatically on connector.tearDown()
1029
+ ```
1030
+
1031
+ **Detection only — no resolution.** The system signals that a conflict exists; merging divergent branches is left to application code.
1032
+
982
1033
  ## Examples
983
1034
 
984
1035
  See [src/example.ts](src/example.ts) for a complete working example demonstrating:
@@ -839,6 +839,46 @@ The Connector registers a listener on `events.bootstrap` in `_init()` via `_regi
839
839
 
840
840
  The `tearDown()` method cleans up the bootstrap listener alongside all other socket listeners.
841
841
 
842
+ ### Conflict Detection
843
+
844
+ The `Db` class integrates DAG branch conflict detection directly into the write path. After every call to `_writeInsertHistory()`, the system invokes `detectDagBranch(table)` to scan the InsertHistory for forks.
845
+
846
+ **Algorithm (`detectDagBranch`)**:
847
+
848
+ 1. Load all rows from `{table}InsertHistory`.
849
+ 2. Build a set of all timeIds that appear as someone's `previous`.
850
+ 3. Tips = rows whose `timeId` is **not** in that set (no descendant).
851
+ 4. If more than one tip exists → DAG branch conflict.
852
+
853
+ ```text
854
+ ┌─── tip A (17000…:AbCd)
855
+ root ──┤
856
+ └─── tip B (17000…:EfGh)
857
+
858
+ DAG branch: two concurrent writes, no merge yet
859
+ ```
860
+
861
+ **Observer pipeline:**
862
+
863
+ ```text
864
+ _writeInsertHistory()
865
+ └──► detectDagBranch(table)
866
+ └──► if conflict found:
867
+ _notifyConflict(table, conflict)
868
+ └──► fire all callbacks in _conflictCallbacks[route]
869
+ ```
870
+
871
+ **Connector integration**: `Connector.onConflict(callback)` calls `db.registerConflictObserver()` under the hood, so callbacks fire for conflicts on the route managed by that Connector. `tearDown()` calls `db.unregisterAllConflictObservers()` to clean up.
872
+
873
+ **Key properties:**
874
+
875
+ | Property | Value |
876
+ | ----------------- | ---------------------------------------------------------------------------- |
877
+ | Detection trigger | Every `_writeInsertHistory()` call |
878
+ | Scope | Per-table (each table's InsertHistory is independent) |
879
+ | Resolution | Not provided — detection and signaling only |
880
+ | Merge detection | A conflict **disappears** when a merge row references all tips as `previous` |
881
+
842
882
  ## Future Enhancements
843
883
 
844
884
  ### Planned Features
@@ -979,6 +979,57 @@ connector.tearDown();
979
979
  // Removes all socket listeners and clears internal state
980
980
  ```
981
981
 
982
+ ### Conflict Detection
983
+
984
+ The `Db` class detects DAG branch conflicts in the InsertHistory and notifies registered observers. A **DAG branch** occurs when two or more InsertHistory rows have no descendant — i.e., they are "tips" of the history graph — indicating concurrent writes from different clients that have not yet been merged.
985
+
986
+ #### Manual Detection
987
+
988
+ ```typescript
989
+ // Check whether a table's InsertHistory has diverged
990
+ const conflict = await db.detectDagBranch('cars');
991
+ if (conflict) {
992
+ console.log(conflict.table); // 'cars'
993
+ console.log(conflict.type); // 'dagBranch'
994
+ console.log(conflict.branches); // ['17000…:AbCd', '17000…:EfGh']
995
+ }
996
+ // Returns null when the history is linear (no conflict)
997
+ ```
998
+
999
+ #### Automatic Detection via Observers
1000
+
1001
+ Conflict detection runs automatically after every `_writeInsertHistory()` call. Register callbacks on the `Db` to be notified immediately:
1002
+
1003
+ ```typescript
1004
+ import type { Conflict, ConflictCallback } from '@rljson/db';
1005
+
1006
+ const onConflict: ConflictCallback = (conflict: Conflict) => {
1007
+ console.warn(`DAG branch in ${conflict.table}:`, conflict.branches);
1008
+ };
1009
+
1010
+ // Register
1011
+ db.registerConflictObserver(route, onConflict);
1012
+
1013
+ // Unregister a specific callback
1014
+ db.unregisterConflictObserver(route, onConflict);
1015
+
1016
+ // Unregister all callbacks for a route
1017
+ db.unregisterAllConflictObservers(route);
1018
+ ```
1019
+
1020
+ #### Via Connector
1021
+
1022
+ The `Connector` provides a convenience method that wraps the Db observer API:
1023
+
1024
+ ```typescript
1025
+ connector.onConflict((conflict) => {
1026
+ console.warn(`Conflict detected:`, conflict);
1027
+ });
1028
+ // Cleaned up automatically on connector.tearDown()
1029
+ ```
1030
+
1031
+ **Detection only — no resolution.** The system signals that a conflict exists; merging divergent branches is left to application code.
1032
+
982
1033
  ## Examples
983
1034
 
984
1035
  See [src/example.ts](src/example.ts) for a complete working example demonstrating:
@@ -1,5 +1,5 @@
1
1
  import { Socket } from '@rljson/io';
2
- import { AckPayload, ClientId, InsertHistoryTimeId, Route, SyncConfig, SyncEventNames } from '@rljson/rljson';
2
+ import { AckPayload, ClientId, ConflictCallback, InsertHistoryTimeId, Route, SyncConfig, SyncEventNames } from '@rljson/rljson';
3
3
  import { Db } from '../db.ts';
4
4
  export type { ConnectorPayload } from '@rljson/rljson';
5
5
  export type ConnectorCallback = (ref: string) => Promise<any>;
@@ -9,6 +9,8 @@ export declare class Connector {
9
9
  private readonly _socket;
10
10
  private _origin;
11
11
  private _callbacks;
12
+ private _conflictCallbacks;
13
+ private _missedRef;
12
14
  private _isListening;
13
15
  private _sentRefsCurrent;
14
16
  private _sentRefsPrevious;
@@ -49,6 +51,18 @@ export declare class Connector {
49
51
  * @param callback - The callback to invoke with each deduplicated incoming ref
50
52
  */
51
53
  listen(callback: ConnectorCallback): void;
54
+ /**
55
+ * Registers a callback that fires when a DAG conflict is detected.
56
+ *
57
+ * A conflict occurs when the InsertHistory for this route's table
58
+ * has multiple "tips" (leaf nodes), indicating concurrent writes
59
+ * from different clients that have not yet been merged.
60
+ *
61
+ * Detection-only: the callback receives a `Conflict` object
62
+ * describing the branches. Resolution is left to upper layers.
63
+ * @param callback - Invoked with the detected Conflict
64
+ */
65
+ onConflict(callback: ConflictCallback): void;
52
66
  /**
53
67
  * Returns the current sequence number.
54
68
  * Only meaningful when `causalOrdering` is enabled.
@@ -83,6 +97,7 @@ export declare class Connector {
83
97
  * _processIncoming handles dedup so already-seen refs are filtered out.
84
98
  */
85
99
  private _registerBootstrapHandler;
100
+ private _registerConflictObserver;
86
101
  private _registerDbObserver;
87
102
  get socket(): Socket;
88
103
  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, Tree } 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
@@ -137,6 +138,24 @@ export declare class Db {
137
138
  * Unregisters all observers from all routes
138
139
  */
139
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;
140
159
  /**
141
160
  * Get a controller for a specific table
142
161
  * @param tableKey - The key of the table to get the controller for
@@ -146,6 +165,22 @@ export declare class Db {
146
165
  */
147
166
  getController(tableKey: string, refs?: ControllerRefs): Promise<Controller<TableType, any, string>>;
148
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;
149
184
  /**
150
185
  * Adds an InsertHistory row to the InsertHistory table of a table
151
186
  * @param table - The table the Insert was made on
package/dist/db.js CHANGED
@@ -22,6 +22,8 @@ class Connector {
22
22
  }
23
23
  _origin;
24
24
  _callbacks = [];
25
+ _conflictCallbacks = [];
26
+ _missedRef = null;
25
27
  _isListening = false;
26
28
  // Two-generation dedup sets — bounded memory
27
29
  _sentRefsCurrent = /* @__PURE__ */ new Set();
@@ -45,6 +47,7 @@ class Connector {
45
47
  send(ref) {
46
48
  if (this._hasSentRef(ref) || this._hasReceivedRef(ref)) return;
47
49
  this._addSentRef(ref);
50
+ this._missedRef = null;
48
51
  const payload = {
49
52
  o: this._origin,
50
53
  r: ref
@@ -105,6 +108,26 @@ class Connector {
105
108
  */
106
109
  listen(callback) {
107
110
  this._callbacks.push(callback);
111
+ if (this._missedRef !== null) {
112
+ const ref = this._missedRef;
113
+ this._missedRef = null;
114
+ Promise.resolve(callback(ref)).catch(console.error);
115
+ }
116
+ }
117
+ // ...........................................................................
118
+ /**
119
+ * Registers a callback that fires when a DAG conflict is detected.
120
+ *
121
+ * A conflict occurs when the InsertHistory for this route's table
122
+ * has multiple "tips" (leaf nodes), indicating concurrent writes
123
+ * from different clients that have not yet been merged.
124
+ *
125
+ * Detection-only: the callback receives a `Conflict` object
126
+ * describing the branches. Resolution is left to upper layers.
127
+ * @param callback - Invoked with the detected Conflict
128
+ */
129
+ onConflict(callback) {
130
+ this._conflictCallbacks.push(callback);
108
131
  }
109
132
  // ...........................................................................
110
133
  /**
@@ -143,6 +166,7 @@ class Connector {
143
166
  this._registerSocketObserver();
144
167
  this._registerBootstrapHandler();
145
168
  this._registerDbObserver();
169
+ this._registerConflictObserver();
146
170
  if (this._syncConfig?.causalOrdering) {
147
171
  this._registerGapFillHandler();
148
172
  }
@@ -158,6 +182,7 @@ class Connector {
158
182
  this._socket.removeAllListeners(this._events.ack);
159
183
  }
160
184
  this._db.unregisterAllObservers(this._route);
185
+ this._db.unregisterAllConflictObservers(this._route);
161
186
  this._isListening = false;
162
187
  }
163
188
  // ...........................................................................
@@ -184,6 +209,10 @@ class Connector {
184
209
  }
185
210
  }
186
211
  _notifyCallbacks(ref) {
212
+ if (this._callbacks.length === 0) {
213
+ this._missedRef = ref;
214
+ return;
215
+ }
187
216
  Promise.all(this._callbacks.map((cb) => cb(ref))).catch((err) => {
188
217
  console.error(`Error notifying connector callbacks for ref ${ref}:`, err);
189
218
  });
@@ -235,6 +264,13 @@ class Connector {
235
264
  this._processIncoming(p);
236
265
  });
237
266
  }
267
+ _registerConflictObserver() {
268
+ this._db.registerConflictObserver(this._route, (conflict) => {
269
+ for (const cb of this._conflictCallbacks) {
270
+ cb(conflict);
271
+ }
272
+ });
273
+ }
238
274
  _registerDbObserver() {
239
275
  this._db.registerObserver(this._route, (ins) => {
240
276
  return new Promise((resolve) => {
@@ -3092,6 +3128,7 @@ class Db {
3092
3128
  * Notification system to register callbacks on data changes
3093
3129
  */
3094
3130
  notify;
3131
+ _conflictCallbacks = /* @__PURE__ */ new Map();
3095
3132
  _cache = /* @__PURE__ */ new Map();
3096
3133
  // ...........................................................................
3097
3134
  /**
@@ -4061,6 +4098,44 @@ class Db {
4061
4098
  this.notify.unregisterAll(route);
4062
4099
  }
4063
4100
  // ...........................................................................
4101
+ /**
4102
+ * Registers a callback to be called when a DAG conflict is detected
4103
+ * on the given route.
4104
+ * @param route - The route to register the conflict callback on
4105
+ * @param callback - The callback to invoke with the Conflict
4106
+ */
4107
+ registerConflictObserver(route, callback) {
4108
+ const key = route.flat;
4109
+ this._conflictCallbacks.set(key, [
4110
+ ...this._conflictCallbacks.get(key) || [],
4111
+ callback
4112
+ ]);
4113
+ }
4114
+ // ...........................................................................
4115
+ /**
4116
+ * Unregisters a specific conflict callback from the given route.
4117
+ * @param route - The route to unregister the callback from
4118
+ * @param callback - The callback to remove
4119
+ */
4120
+ unregisterConflictObserver(route, callback) {
4121
+ const key = route.flat;
4122
+ const callbacks = this._conflictCallbacks.get(key);
4123
+ if (callbacks) {
4124
+ this._conflictCallbacks.set(
4125
+ key,
4126
+ callbacks.filter((cb) => cb !== callback)
4127
+ );
4128
+ }
4129
+ }
4130
+ // ...........................................................................
4131
+ /**
4132
+ * Unregisters all conflict callbacks from the given route.
4133
+ * @param route - The route to clear conflict callbacks for
4134
+ */
4135
+ unregisterAllConflictObservers(route) {
4136
+ this._conflictCallbacks.delete(route.flat);
4137
+ }
4138
+ // ...........................................................................
4064
4139
  /**
4065
4140
  * Get a controller for a specific table
4066
4141
  * @param tableKey - The key of the table to get the controller for
@@ -4093,6 +4168,57 @@ class Db {
4093
4168
  return controllers;
4094
4169
  }
4095
4170
  // ...........................................................................
4171
+ /**
4172
+ * Detects whether the InsertHistory for a table has diverged into
4173
+ * multiple branches (multiple "tips" — leaf nodes in the DAG).
4174
+ *
4175
+ * A tip is a timeId that is NOT referenced as `previous` by any other
4176
+ * InsertHistory row. Two or more tips indicate a DAG fork (conflict).
4177
+ * @param table - The table name (without "InsertHistory" suffix)
4178
+ * @returns A Conflict if a DAG branch is detected, or null otherwise
4179
+ */
4180
+ async detectDagBranch(table) {
4181
+ const insertHistoryTable = table + "InsertHistory";
4182
+ const hasTable = await this.core.hasTable(insertHistoryTable);
4183
+ if (!hasTable) return null;
4184
+ const dump = await this.core.dumpTable(insertHistoryTable);
4185
+ const rows = dump[insertHistoryTable]._data;
4186
+ if (rows.length < 2) return null;
4187
+ const referencedAsParent = /* @__PURE__ */ new Set();
4188
+ for (const row of rows) {
4189
+ if (row.previous) {
4190
+ for (const p of row.previous) {
4191
+ referencedAsParent.add(p);
4192
+ }
4193
+ }
4194
+ }
4195
+ const tips = rows.filter((row) => !referencedAsParent.has(row.timeId));
4196
+ if (tips.length > 1) {
4197
+ return {
4198
+ table,
4199
+ type: "dagBranch",
4200
+ detectedAt: Date.now(),
4201
+ branches: tips.map((t) => t.timeId)
4202
+ };
4203
+ }
4204
+ return null;
4205
+ }
4206
+ // ...........................................................................
4207
+ /**
4208
+ * Fires conflict callbacks registered on the route derived from the table.
4209
+ * @param table - The table name
4210
+ * @param conflict - The detected conflict
4211
+ */
4212
+ _notifyConflict(table, conflict) {
4213
+ const route = Route.fromFlat(`/${table}`);
4214
+ const callbacks = this._conflictCallbacks.get(route.flat);
4215
+ if (callbacks) {
4216
+ for (const cb of callbacks) {
4217
+ cb(conflict);
4218
+ }
4219
+ }
4220
+ }
4221
+ // ...........................................................................
4096
4222
  /**
4097
4223
  * Adds an InsertHistory row to the InsertHistory table of a table
4098
4224
  * @param table - The table the Insert was made on
@@ -4107,6 +4233,10 @@ class Db {
4107
4233
  _type: "insertHistory"
4108
4234
  }
4109
4235
  });
4236
+ const conflict = await this.detectDagBranch(table);
4237
+ if (conflict) {
4238
+ this._notifyConflict(table, conflict);
4239
+ }
4110
4240
  }
4111
4241
  // ...........................................................................
4112
4242
  /**