@loro-dev/flock 4.2.0 → 4.4.0

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/src/index.ts CHANGED
@@ -23,6 +23,10 @@ import {
23
23
  check_consistency_ffi,
24
24
  check_invariants_ffi,
25
25
  from_json_ffi,
26
+ txn_begin_ffi,
27
+ txn_commit_ffi,
28
+ txn_rollback_ffi,
29
+ is_in_txn_ffi,
26
30
  } from "./_moon_flock";
27
31
 
28
32
  type RawVersionVector = Record<string, [number, number]>;
@@ -840,6 +844,18 @@ function isImportOptions(value: unknown): value is ImportOptions {
840
844
 
841
845
  export class Flock {
842
846
  private inner: ReturnType<typeof newFlock>;
847
+ private listeners: Set<(batch: EventBatch) => void> = new Set();
848
+ private nativeUnsubscribe: (() => void) | undefined;
849
+ /** Debounce state for autoDebounceCommit */
850
+ private debounceState:
851
+ | {
852
+ timeout: number;
853
+ maxDebounceTime: number;
854
+ timerId: ReturnType<typeof setTimeout> | undefined;
855
+ maxTimerId: ReturnType<typeof setTimeout> | undefined;
856
+ pendingEvents: Event[];
857
+ }
858
+ | undefined;
843
859
 
844
860
  constructor(peerId?: string) {
845
861
  this.inner = newFlock(normalizePeerId(peerId));
@@ -1163,13 +1179,218 @@ export class Flock {
1163
1179
  }));
1164
1180
  }
1165
1181
 
1182
+ private ensureNativeSubscription(): void {
1183
+ if (this.nativeUnsubscribe !== undefined) {
1184
+ return;
1185
+ }
1186
+ this.nativeUnsubscribe = subscribe_ffi(this.inner, (payload: unknown) => {
1187
+ const batch = decodeEventBatch(payload);
1188
+ this.handleBatch(batch);
1189
+ }) as () => void;
1190
+ }
1191
+
1192
+ private handleBatch(batch: EventBatch): void {
1193
+ if (this.debounceState !== undefined) {
1194
+ // Debounce active: accumulate events and reset timer
1195
+ const wasEmpty = this.debounceState.pendingEvents.length === 0;
1196
+ this.debounceState.pendingEvents.push(...batch.events);
1197
+ this.resetDebounceTimer(wasEmpty);
1198
+ } else {
1199
+ // Normal mode: emit immediately
1200
+ this.emitBatch(batch);
1201
+ }
1202
+ }
1203
+
1204
+ private emitBatch(batch: EventBatch): void {
1205
+ for (const listener of this.listeners) {
1206
+ listener(batch);
1207
+ }
1208
+ }
1209
+
1210
+ private resetDebounceTimer(isFirstEvent: boolean): void {
1211
+ if (this.debounceState === undefined) {
1212
+ return;
1213
+ }
1214
+
1215
+ if (this.debounceState.timerId !== undefined) {
1216
+ clearTimeout(this.debounceState.timerId);
1217
+ }
1218
+
1219
+ this.debounceState.timerId = setTimeout(() => {
1220
+ this.commit();
1221
+ }, this.debounceState.timeout);
1222
+
1223
+ // Start max debounce timer on first pending event
1224
+ if (this.debounceState.maxTimerId === undefined && isFirstEvent) {
1225
+ this.debounceState.maxTimerId = setTimeout(() => {
1226
+ this.commit();
1227
+ }, this.debounceState.maxDebounceTime);
1228
+ }
1229
+ }
1230
+
1166
1231
  subscribe(listener: (batch: EventBatch) => void): () => void {
1167
- const unsubscribe = subscribe_ffi(this.inner, (payload: unknown) => {
1168
- listener(decodeEventBatch(payload));
1169
- });
1170
- if (typeof unsubscribe !== "function") {
1171
- throw new TypeError("subscribe ffi did not return a function");
1232
+ this.listeners.add(listener);
1233
+ this.ensureNativeSubscription();
1234
+
1235
+ return () => {
1236
+ this.listeners.delete(listener);
1237
+ // Optionally clean up native subscription when no listeners remain
1238
+ if (this.listeners.size === 0 && this.nativeUnsubscribe !== undefined) {
1239
+ this.nativeUnsubscribe();
1240
+ this.nativeUnsubscribe = undefined;
1241
+ }
1242
+ };
1243
+ }
1244
+
1245
+ /**
1246
+ * Enable auto-debounce mode. Events will be accumulated and emitted after
1247
+ * the specified timeout of inactivity. Each new operation resets the timer.
1248
+ *
1249
+ * Use `commit()` to force immediate emission of pending events.
1250
+ * Use `disableAutoDebounceCommit()` to disable and emit pending events.
1251
+ *
1252
+ * @param timeout - Debounce timeout in milliseconds
1253
+ * @param options - Optional configuration object with maxDebounceTime (default: 10000ms)
1254
+ * @throws Error if called while a transaction is active
1255
+ * @throws Error if autoDebounceCommit is already active
1256
+ *
1257
+ * @example
1258
+ * ```ts
1259
+ * flock.autoDebounceCommit(100);
1260
+ * flock.put(["a"], 1);
1261
+ * flock.put(["b"], 2);
1262
+ * // No events emitted yet...
1263
+ * // After 100ms of inactivity, subscribers receive single EventBatch
1264
+ * // If operations keep coming, commit happens after maxDebounceTime (10s default)
1265
+ * ```
1266
+ */
1267
+ autoDebounceCommit(
1268
+ timeout: number,
1269
+ options?: { maxDebounceTime?: number },
1270
+ ): void {
1271
+ if (this.isInTxn()) {
1272
+ throw new Error(
1273
+ "Cannot enable autoDebounceCommit while transaction is active",
1274
+ );
1275
+ }
1276
+ if (this.debounceState !== undefined) {
1277
+ throw new Error("autoDebounceCommit is already active");
1172
1278
  }
1173
- return unsubscribe as () => void;
1279
+
1280
+ const maxDebounceTime = options?.maxDebounceTime ?? 10000;
1281
+
1282
+ this.debounceState = {
1283
+ timeout,
1284
+ maxDebounceTime,
1285
+ timerId: undefined,
1286
+ maxTimerId: undefined,
1287
+ pendingEvents: [],
1288
+ };
1289
+ }
1290
+
1291
+ /**
1292
+ * Disable auto-debounce mode and emit any pending events immediately.
1293
+ * No-op if autoDebounceCommit is not active.
1294
+ */
1295
+ disableAutoDebounceCommit(): void {
1296
+ if (this.debounceState === undefined) {
1297
+ return;
1298
+ }
1299
+
1300
+ const { timerId, maxTimerId, pendingEvents } = this.debounceState;
1301
+ if (timerId !== undefined) {
1302
+ clearTimeout(timerId);
1303
+ }
1304
+ if (maxTimerId !== undefined) {
1305
+ clearTimeout(maxTimerId);
1306
+ }
1307
+ this.debounceState = undefined;
1308
+
1309
+ if (pendingEvents.length > 0) {
1310
+ this.emitBatch({ source: "local", events: pendingEvents });
1311
+ }
1312
+ }
1313
+
1314
+ /**
1315
+ * Force immediate emission of any pending debounced events.
1316
+ * Does not disable auto-debounce mode - new operations will continue to be debounced.
1317
+ * No-op if autoDebounceCommit is not active or no events are pending.
1318
+ */
1319
+ commit(): void {
1320
+ if (this.debounceState === undefined) {
1321
+ return;
1322
+ }
1323
+
1324
+ const { timerId, maxTimerId, pendingEvents } = this.debounceState;
1325
+ if (timerId !== undefined) {
1326
+ clearTimeout(timerId);
1327
+ this.debounceState.timerId = undefined;
1328
+ }
1329
+ if (maxTimerId !== undefined) {
1330
+ clearTimeout(maxTimerId);
1331
+ this.debounceState.maxTimerId = undefined;
1332
+ }
1333
+
1334
+ if (pendingEvents.length > 0) {
1335
+ this.emitBatch({ source: "local", events: pendingEvents });
1336
+ this.debounceState.pendingEvents = [];
1337
+ }
1338
+ }
1339
+
1340
+ /**
1341
+ * Check if auto-debounce mode is currently active.
1342
+ */
1343
+ isAutoDebounceActive(): boolean {
1344
+ return this.debounceState !== undefined;
1345
+ }
1346
+
1347
+ /**
1348
+ * Execute operations within a transaction. All put/delete operations inside
1349
+ * the callback will be batched and emitted as a single EventBatch when the
1350
+ * transaction commits successfully.
1351
+ *
1352
+ * If the callback throws an error, the transaction is rolled back and no
1353
+ * events are emitted. Note: Data changes are NOT rolled back - only event
1354
+ * emission is affected.
1355
+ *
1356
+ * The callback must be synchronous. For async operations, use FlockSQLite.
1357
+ *
1358
+ * @param callback - Synchronous function containing put/delete operations
1359
+ * @returns The return value of the callback
1360
+ * @throws Error if nested transaction attempted
1361
+ * @throws Error if import is called during the transaction (auto-commits first)
1362
+ *
1363
+ * @example
1364
+ * ```ts
1365
+ * flock.txn(() => {
1366
+ * flock.put(["a"], 1);
1367
+ * flock.put(["b"], 2);
1368
+ * flock.put(["c"], 3);
1369
+ * });
1370
+ * // Subscribers receive a single EventBatch with 3 events
1371
+ * ```
1372
+ */
1373
+ txn<T>(callback: () => T): T {
1374
+ txn_begin_ffi(this.inner);
1375
+ try {
1376
+ const result = callback();
1377
+ txn_commit_ffi(this.inner);
1378
+ return result;
1379
+ } catch (e) {
1380
+ // Only rollback if transaction is still active.
1381
+ // import_json auto-commits the transaction before throwing,
1382
+ // so we must check before attempting rollback.
1383
+ if (is_in_txn_ffi(this.inner)) {
1384
+ txn_rollback_ffi(this.inner);
1385
+ }
1386
+ throw e;
1387
+ }
1388
+ }
1389
+
1390
+ /**
1391
+ * Check if a transaction is currently active.
1392
+ */
1393
+ isInTxn(): boolean {
1394
+ return Boolean(is_in_txn_ffi(this.inner));
1174
1395
  }
1175
1396
  }