@loro-dev/flock 4.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loro-dev/flock",
3
- "version": "4.3.0",
3
+ "version": "4.4.0",
4
4
  "description": "TypeScript bindings for the Flock CRDT with mergeable export/import utilities.",
5
5
  "exports": {
6
6
  ".": {
package/src/index.ts CHANGED
@@ -844,6 +844,18 @@ function isImportOptions(value: unknown): value is ImportOptions {
844
844
 
845
845
  export class Flock {
846
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;
847
859
 
848
860
  constructor(peerId?: string) {
849
861
  this.inner = newFlock(normalizePeerId(peerId));
@@ -1167,14 +1179,169 @@ export class Flock {
1167
1179
  }));
1168
1180
  }
1169
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
+
1170
1231
  subscribe(listener: (batch: EventBatch) => void): () => void {
1171
- const unsubscribe = subscribe_ffi(this.inner, (payload: unknown) => {
1172
- listener(decodeEventBatch(payload));
1173
- });
1174
- if (typeof unsubscribe !== "function") {
1175
- 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");
1278
+ }
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 = [];
1176
1337
  }
1177
- return unsubscribe as () => void;
1338
+ }
1339
+
1340
+ /**
1341
+ * Check if auto-debounce mode is currently active.
1342
+ */
1343
+ isAutoDebounceActive(): boolean {
1344
+ return this.debounceState !== undefined;
1178
1345
  }
1179
1346
 
1180
1347
  /**