@loro-dev/flock-sqlite 0.4.0 → 0.5.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.5.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.3.0"
66
66
  },
67
67
  "scripts": {
68
68
  "build": "tsdown",
package/src/index.ts CHANGED
@@ -627,6 +627,22 @@ 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
+ timerId: ReturnType<typeof setTimeout> | undefined;
639
+ pendingEvents: Array<{
640
+ key: KeyPart[];
641
+ payload: ExportPayload;
642
+ source: string;
643
+ }>;
644
+ }
645
+ | undefined;
630
646
 
631
647
  private constructor(
632
648
  db: UniStoreConnection,
@@ -641,6 +657,8 @@ export class FlockSQLite {
641
657
  this.maxHlc = maxHlc;
642
658
  this.listeners = new Set();
643
659
  this.tables = tables;
660
+ this.txnEventSink = undefined;
661
+ this.debounceState = undefined;
644
662
  }
645
663
 
646
664
  static async open(options: FlockSQLiteOptions): Promise<FlockSQLite> {
@@ -662,6 +680,20 @@ export class FlockSQLite {
662
680
  }
663
681
 
664
682
  async close(): Promise<void> {
683
+ // Commit any pending debounced events
684
+ if (this.debounceState !== undefined) {
685
+ this.disableAutoDebounceCommit();
686
+ }
687
+
688
+ // Commit any transaction events (edge case: close during txn)
689
+ if (this.txnEventSink !== undefined) {
690
+ const pending = this.txnEventSink;
691
+ this.txnEventSink = undefined;
692
+ if (pending.length > 0) {
693
+ this.emitEvents("local", pending);
694
+ }
695
+ }
696
+
665
697
  await this.db.close();
666
698
  }
667
699
 
@@ -908,14 +940,37 @@ export class FlockSQLite {
908
940
  source: operation.source,
909
941
  };
910
942
  if (operation.eventSink) {
943
+ // Explicit event sink provided (e.g., import)
911
944
  operation.eventSink.push(eventPayload);
945
+ } else if (this.txnEventSink) {
946
+ // In transaction: accumulate events
947
+ this.txnEventSink.push(eventPayload);
948
+ } else if (this.debounceState) {
949
+ // Debounce active: accumulate and reset timer
950
+ this.debounceState.pendingEvents.push(eventPayload);
951
+ this.resetDebounceTimer();
912
952
  } else {
953
+ // Normal: emit immediately
913
954
  this.emitEvents(operation.source, [eventPayload]);
914
955
  }
915
956
  }
916
957
  return applied;
917
958
  }
918
959
 
960
+ private resetDebounceTimer(): void {
961
+ if (this.debounceState === undefined) {
962
+ return;
963
+ }
964
+
965
+ if (this.debounceState.timerId !== undefined) {
966
+ clearTimeout(this.debounceState.timerId);
967
+ }
968
+
969
+ this.debounceState.timerId = setTimeout(() => {
970
+ this.commit();
971
+ }, this.debounceState.timeout);
972
+ }
973
+
919
974
  private emitEvents(
920
975
  source: string,
921
976
  events: Array<{ key: KeyPart[]; payload: ExportPayload }>,
@@ -1473,6 +1528,23 @@ export class FlockSQLite {
1473
1528
  }
1474
1529
 
1475
1530
  private async importInternal(bundle: ExportBundle): Promise<ImportReport> {
1531
+ // Force commit if in transaction - this is an error condition
1532
+ if (this.txnEventSink !== undefined) {
1533
+ const pending = this.txnEventSink;
1534
+ this.txnEventSink = undefined;
1535
+ if (pending.length > 0) {
1536
+ this.emitEvents("local", pending);
1537
+ }
1538
+ throw new Error(
1539
+ "import called during transaction - transaction was auto-committed",
1540
+ );
1541
+ }
1542
+
1543
+ // Force commit if in debounce mode
1544
+ if (this.debounceState !== undefined) {
1545
+ this.commit();
1546
+ }
1547
+
1476
1548
  if (bundle.version !== 0) {
1477
1549
  throw new TypeError("Unsupported bundle version");
1478
1550
  }
@@ -1597,6 +1669,154 @@ export class FlockSQLite {
1597
1669
  this.listeners.delete(listener);
1598
1670
  };
1599
1671
  }
1672
+
1673
+ /**
1674
+ * Execute operations within a transaction. All put/delete operations inside
1675
+ * the callback will be batched and emitted as a single EventBatch when the
1676
+ * transaction commits successfully.
1677
+ *
1678
+ * If the callback throws or rejects, the transaction is rolled back and no
1679
+ * events are emitted. Note: Database operations are NOT rolled back - only
1680
+ * event emission is affected.
1681
+ *
1682
+ * @param callback - Async function containing put/delete operations
1683
+ * @returns The return value of the callback
1684
+ * @throws Error if nested transaction attempted
1685
+ * @throws Error if called while autoDebounceCommit is active
1686
+ *
1687
+ * @example
1688
+ * ```ts
1689
+ * await flock.txn(async () => {
1690
+ * await flock.put(["a"], 1);
1691
+ * await flock.put(["b"], 2);
1692
+ * await flock.put(["c"], 3);
1693
+ * });
1694
+ * // Subscribers receive a single EventBatch with 3 events
1695
+ * ```
1696
+ */
1697
+ async txn<T>(callback: () => Promise<T>): Promise<T> {
1698
+ if (this.txnEventSink !== undefined) {
1699
+ throw new Error("Nested transactions are not supported");
1700
+ }
1701
+ if (this.debounceState !== undefined) {
1702
+ throw new Error(
1703
+ "Cannot start transaction while autoDebounceCommit is active",
1704
+ );
1705
+ }
1706
+
1707
+ const eventSink: Array<{
1708
+ key: KeyPart[];
1709
+ payload: ExportPayload;
1710
+ source: string;
1711
+ }> = [];
1712
+ this.txnEventSink = eventSink;
1713
+
1714
+ try {
1715
+ const result = await callback();
1716
+ // Commit: emit all accumulated events as single batch
1717
+ if (eventSink.length > 0) {
1718
+ this.emitEvents("local", eventSink);
1719
+ }
1720
+ return result;
1721
+ } finally {
1722
+ this.txnEventSink = undefined;
1723
+ }
1724
+ }
1725
+
1726
+ /**
1727
+ * Check if a transaction is currently active.
1728
+ */
1729
+ isInTxn(): boolean {
1730
+ return this.txnEventSink !== undefined;
1731
+ }
1732
+
1733
+ /**
1734
+ * Enable auto-debounce mode. Events will be accumulated and emitted after
1735
+ * the specified timeout of inactivity. Each new operation resets the timer.
1736
+ *
1737
+ * Use `commit()` to force immediate emission of pending events.
1738
+ * Use `disableAutoDebounceCommit()` to disable and emit pending events.
1739
+ *
1740
+ * Import operations will automatically call `commit()` before proceeding.
1741
+ *
1742
+ * @param timeout - Debounce timeout in milliseconds
1743
+ * @throws Error if called while a transaction is active
1744
+ * @throws Error if autoDebounceCommit is already active
1745
+ *
1746
+ * @example
1747
+ * ```ts
1748
+ * flock.autoDebounceCommit(100);
1749
+ * await flock.put(["a"], 1);
1750
+ * await flock.put(["b"], 2);
1751
+ * // No events emitted yet...
1752
+ * // After 100ms of inactivity, subscribers receive single EventBatch
1753
+ * ```
1754
+ */
1755
+ autoDebounceCommit(timeout: number): void {
1756
+ if (this.txnEventSink !== undefined) {
1757
+ throw new Error(
1758
+ "Cannot enable autoDebounceCommit while transaction is active",
1759
+ );
1760
+ }
1761
+ if (this.debounceState !== undefined) {
1762
+ throw new Error("autoDebounceCommit is already active");
1763
+ }
1764
+
1765
+ this.debounceState = {
1766
+ timeout,
1767
+ timerId: undefined,
1768
+ pendingEvents: [],
1769
+ };
1770
+ }
1771
+
1772
+ /**
1773
+ * Disable auto-debounce mode and emit any pending events immediately.
1774
+ * No-op if autoDebounceCommit is not active.
1775
+ */
1776
+ disableAutoDebounceCommit(): void {
1777
+ if (this.debounceState === undefined) {
1778
+ return;
1779
+ }
1780
+
1781
+ const { timerId, pendingEvents } = this.debounceState;
1782
+ if (timerId !== undefined) {
1783
+ clearTimeout(timerId);
1784
+ }
1785
+ this.debounceState = undefined;
1786
+
1787
+ if (pendingEvents.length > 0) {
1788
+ this.emitEvents("local", pendingEvents);
1789
+ }
1790
+ }
1791
+
1792
+ /**
1793
+ * Force immediate emission of any pending debounced events.
1794
+ * Does not disable auto-debounce mode - new operations will continue to be debounced.
1795
+ * No-op if autoDebounceCommit is not active or no events are pending.
1796
+ */
1797
+ commit(): void {
1798
+ if (this.debounceState === undefined) {
1799
+ return;
1800
+ }
1801
+
1802
+ const { timerId, pendingEvents } = this.debounceState;
1803
+ if (timerId !== undefined) {
1804
+ clearTimeout(timerId);
1805
+ this.debounceState.timerId = undefined;
1806
+ }
1807
+
1808
+ if (pendingEvents.length > 0) {
1809
+ this.emitEvents("local", pendingEvents);
1810
+ this.debounceState.pendingEvents = [];
1811
+ }
1812
+ }
1813
+
1814
+ /**
1815
+ * Check if auto-debounce mode is currently active.
1816
+ */
1817
+ isAutoDebounceActive(): boolean {
1818
+ return this.debounceState !== undefined;
1819
+ }
1600
1820
  }
1601
1821
 
1602
1822
  export type {