@loro-dev/flock-sqlite 0.4.0 → 0.6.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-sqlite",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "SQLite-backed Flock CRDT replica for Node, browsers, and Cloudflare Workers.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -62,7 +62,7 @@
62
62
  "tsdown": "^0.15.4",
63
63
  "typescript": "^5.9.2",
64
64
  "vitest": "^3.2.4",
65
- "@loro-dev/flock": "4.2.0"
65
+ "@loro-dev/flock": "4.4.0"
66
66
  },
67
67
  "scripts": {
68
68
  "build": "tsdown",
package/src/index.ts CHANGED
@@ -627,6 +627,24 @@ export class FlockSQLite {
627
627
  private maxHlc: { physicalTime: number; logicalCounter: number };
628
628
  private listeners: Set<EventListener>;
629
629
  private tables: TableNames;
630
+ /** Transaction state: undefined when not in transaction, array when accumulating */
631
+ private txnEventSink:
632
+ | Array<{ key: KeyPart[]; payload: ExportPayload; source: string }>
633
+ | undefined;
634
+ /** Debounce state for autoDebounceCommit */
635
+ private debounceState:
636
+ | {
637
+ timeout: number;
638
+ maxDebounceTime: number;
639
+ timerId: ReturnType<typeof setTimeout> | undefined;
640
+ maxTimerId: ReturnType<typeof setTimeout> | undefined;
641
+ pendingEvents: Array<{
642
+ key: KeyPart[];
643
+ payload: ExportPayload;
644
+ source: string;
645
+ }>;
646
+ }
647
+ | undefined;
630
648
 
631
649
  private constructor(
632
650
  db: UniStoreConnection,
@@ -641,6 +659,8 @@ export class FlockSQLite {
641
659
  this.maxHlc = maxHlc;
642
660
  this.listeners = new Set();
643
661
  this.tables = tables;
662
+ this.txnEventSink = undefined;
663
+ this.debounceState = undefined;
644
664
  }
645
665
 
646
666
  static async open(options: FlockSQLiteOptions): Promise<FlockSQLite> {
@@ -662,6 +682,20 @@ export class FlockSQLite {
662
682
  }
663
683
 
664
684
  async close(): Promise<void> {
685
+ // Commit any pending debounced events
686
+ if (this.debounceState !== undefined) {
687
+ this.disableAutoDebounceCommit();
688
+ }
689
+
690
+ // Commit any transaction events (edge case: close during txn)
691
+ if (this.txnEventSink !== undefined) {
692
+ const pending = this.txnEventSink;
693
+ this.txnEventSink = undefined;
694
+ if (pending.length > 0) {
695
+ this.emitEvents("local", pending);
696
+ }
697
+ }
698
+
665
699
  await this.db.close();
666
700
  }
667
701
 
@@ -908,14 +942,48 @@ export class FlockSQLite {
908
942
  source: operation.source,
909
943
  };
910
944
  if (operation.eventSink) {
945
+ // Explicit event sink provided (e.g., import)
911
946
  operation.eventSink.push(eventPayload);
947
+ } else if (this.txnEventSink) {
948
+ // In transaction: accumulate events
949
+ this.txnEventSink.push(eventPayload);
950
+ } else if (this.debounceState) {
951
+ // Debounce active: accumulate and reset timer
952
+ this.debounceState.pendingEvents.push(eventPayload);
953
+ this.resetDebounceTimer();
912
954
  } else {
955
+ // Normal: emit immediately
913
956
  this.emitEvents(operation.source, [eventPayload]);
914
957
  }
915
958
  }
916
959
  return applied;
917
960
  }
918
961
 
962
+ private resetDebounceTimer(): void {
963
+ if (this.debounceState === undefined) {
964
+ return;
965
+ }
966
+
967
+ if (this.debounceState.timerId !== undefined) {
968
+ clearTimeout(this.debounceState.timerId);
969
+ }
970
+
971
+ this.debounceState.timerId = setTimeout(() => {
972
+ this.commit();
973
+ }, this.debounceState.timeout);
974
+
975
+ // Start max debounce timer on first pending event
976
+ // Note: this is called after the event is pushed, so length === 1 means first event
977
+ if (
978
+ this.debounceState.maxTimerId === undefined &&
979
+ this.debounceState.pendingEvents.length === 1
980
+ ) {
981
+ this.debounceState.maxTimerId = setTimeout(() => {
982
+ this.commit();
983
+ }, this.debounceState.maxDebounceTime);
984
+ }
985
+ }
986
+
919
987
  private emitEvents(
920
988
  source: string,
921
989
  events: Array<{ key: KeyPart[]; payload: ExportPayload }>,
@@ -1473,6 +1541,23 @@ export class FlockSQLite {
1473
1541
  }
1474
1542
 
1475
1543
  private async importInternal(bundle: ExportBundle): Promise<ImportReport> {
1544
+ // Force commit if in transaction - this is an error condition
1545
+ if (this.txnEventSink !== undefined) {
1546
+ const pending = this.txnEventSink;
1547
+ this.txnEventSink = undefined;
1548
+ if (pending.length > 0) {
1549
+ this.emitEvents("local", pending);
1550
+ }
1551
+ throw new Error(
1552
+ "import called during transaction - transaction was auto-committed",
1553
+ );
1554
+ }
1555
+
1556
+ // Force commit if in debounce mode
1557
+ if (this.debounceState !== undefined) {
1558
+ this.commit();
1559
+ }
1560
+
1476
1561
  if (bundle.version !== 0) {
1477
1562
  throw new TypeError("Unsupported bundle version");
1478
1563
  }
@@ -1597,6 +1682,170 @@ export class FlockSQLite {
1597
1682
  this.listeners.delete(listener);
1598
1683
  };
1599
1684
  }
1685
+
1686
+ /**
1687
+ * Execute operations within a transaction. All put/delete operations inside
1688
+ * the callback will be batched and emitted as a single EventBatch when the
1689
+ * transaction commits successfully.
1690
+ *
1691
+ * If the callback throws or rejects, the transaction is rolled back and no
1692
+ * events are emitted. Note: Database operations are NOT rolled back - only
1693
+ * event emission is affected.
1694
+ *
1695
+ * @param callback - Async function containing put/delete operations
1696
+ * @returns The return value of the callback
1697
+ * @throws Error if nested transaction attempted
1698
+ * @throws Error if called while autoDebounceCommit is active
1699
+ *
1700
+ * @example
1701
+ * ```ts
1702
+ * await flock.txn(async () => {
1703
+ * await flock.put(["a"], 1);
1704
+ * await flock.put(["b"], 2);
1705
+ * await flock.put(["c"], 3);
1706
+ * });
1707
+ * // Subscribers receive a single EventBatch with 3 events
1708
+ * ```
1709
+ */
1710
+ async txn<T>(callback: () => Promise<T>): Promise<T> {
1711
+ if (this.txnEventSink !== undefined) {
1712
+ throw new Error("Nested transactions are not supported");
1713
+ }
1714
+ if (this.debounceState !== undefined) {
1715
+ throw new Error(
1716
+ "Cannot start transaction while autoDebounceCommit is active",
1717
+ );
1718
+ }
1719
+
1720
+ const eventSink: Array<{
1721
+ key: KeyPart[];
1722
+ payload: ExportPayload;
1723
+ source: string;
1724
+ }> = [];
1725
+ this.txnEventSink = eventSink;
1726
+
1727
+ try {
1728
+ const result = await callback();
1729
+ // Commit: emit all accumulated events as single batch
1730
+ if (eventSink.length > 0) {
1731
+ this.emitEvents("local", eventSink);
1732
+ }
1733
+ return result;
1734
+ } finally {
1735
+ this.txnEventSink = undefined;
1736
+ }
1737
+ }
1738
+
1739
+ /**
1740
+ * Check if a transaction is currently active.
1741
+ */
1742
+ isInTxn(): boolean {
1743
+ return this.txnEventSink !== undefined;
1744
+ }
1745
+
1746
+ /**
1747
+ * Enable auto-debounce mode. Events will be accumulated and emitted after
1748
+ * the specified timeout of inactivity. Each new operation resets the timer.
1749
+ *
1750
+ * Use `commit()` to force immediate emission of pending events.
1751
+ * Use `disableAutoDebounceCommit()` to disable and emit pending events.
1752
+ *
1753
+ * Import operations will automatically call `commit()` before proceeding.
1754
+ *
1755
+ * @param timeout - Debounce timeout in milliseconds
1756
+ * @param options - Optional configuration object with maxDebounceTime (default: 10000ms)
1757
+ * @throws Error if called while a transaction is active
1758
+ * @throws Error if autoDebounceCommit is already active
1759
+ *
1760
+ * @example
1761
+ * ```ts
1762
+ * flock.autoDebounceCommit(100);
1763
+ * await flock.put(["a"], 1);
1764
+ * await flock.put(["b"], 2);
1765
+ * // No events emitted yet...
1766
+ * // After 100ms of inactivity, subscribers receive single EventBatch
1767
+ * // If operations keep coming, commit happens after maxDebounceTime (10s default)
1768
+ * ```
1769
+ */
1770
+ autoDebounceCommit(
1771
+ timeout: number,
1772
+ options?: { maxDebounceTime?: number },
1773
+ ): void {
1774
+ if (this.txnEventSink !== undefined) {
1775
+ throw new Error(
1776
+ "Cannot enable autoDebounceCommit while transaction is active",
1777
+ );
1778
+ }
1779
+ if (this.debounceState !== undefined) {
1780
+ throw new Error("autoDebounceCommit is already active");
1781
+ }
1782
+
1783
+ const maxDebounceTime = options?.maxDebounceTime ?? 10000;
1784
+
1785
+ this.debounceState = {
1786
+ timeout,
1787
+ maxDebounceTime,
1788
+ timerId: undefined,
1789
+ maxTimerId: undefined,
1790
+ pendingEvents: [],
1791
+ };
1792
+ }
1793
+
1794
+ /**
1795
+ * Disable auto-debounce mode and emit any pending events immediately.
1796
+ * No-op if autoDebounceCommit is not active.
1797
+ */
1798
+ disableAutoDebounceCommit(): void {
1799
+ if (this.debounceState === undefined) {
1800
+ return;
1801
+ }
1802
+
1803
+ const { timerId, maxTimerId, pendingEvents } = this.debounceState;
1804
+ if (timerId !== undefined) {
1805
+ clearTimeout(timerId);
1806
+ }
1807
+ if (maxTimerId !== undefined) {
1808
+ clearTimeout(maxTimerId);
1809
+ }
1810
+ this.debounceState = undefined;
1811
+
1812
+ if (pendingEvents.length > 0) {
1813
+ this.emitEvents("local", pendingEvents);
1814
+ }
1815
+ }
1816
+
1817
+ /**
1818
+ * Force immediate emission of any pending debounced events.
1819
+ * Does not disable auto-debounce mode - new operations will continue to be debounced.
1820
+ * No-op if autoDebounceCommit is not active or no events are pending.
1821
+ */
1822
+ commit(): void {
1823
+ if (this.debounceState === undefined) {
1824
+ return;
1825
+ }
1826
+
1827
+ const { timerId, maxTimerId, pendingEvents } = this.debounceState;
1828
+ if (timerId !== undefined) {
1829
+ clearTimeout(timerId);
1830
+ this.debounceState.timerId = undefined;
1831
+ }
1832
+ if (maxTimerId !== undefined) {
1833
+ clearTimeout(maxTimerId);
1834
+ this.debounceState.maxTimerId = undefined;
1835
+ }
1836
+
1837
+ if (pendingEvents.length > 0) {
1838
+ this.emitEvents("local", pendingEvents);
1839
+ this.debounceState.pendingEvents = [];
1840
+ }
1841
+ }
1842
+
1843
+ /**
1844
+ * Check if auto-debounce mode is currently active.
1845
+ */
1846
+ isAutoDebounceActive(): boolean {
1847
+ return this.debounceState !== undefined;
1848
+ }
1600
1849
  }
1601
1850
 
1602
1851
  export type {