@loro-dev/flock-sqlite 0.8.0 → 0.9.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
@@ -1,5 +1,7 @@
1
1
  import { openStore, type UniStoreConnection } from "@loro-dev/unisqlite";
2
2
  import { computeDigest, type DigestRow } from "./digest";
3
+ import { EventBatcher } from "./event-batcher";
4
+ import { LruSet } from "./lru";
3
5
  import {
4
6
  compareBytes,
5
7
  decodeKeyParts,
@@ -7,6 +9,21 @@ import {
7
9
  keyToString,
8
10
  prefixUpperBound,
9
11
  } from "./key-encoding";
12
+ import type {
13
+ FlockCommit,
14
+ FlockWriteRequest,
15
+ RequestId,
16
+ TabId,
17
+ } from "./multi-tab";
18
+ import {
19
+ createBroadcastChannelTransport,
20
+ createDefaultRuntime,
21
+ type FlockRoleProvider,
22
+ type FlockRuntime,
23
+ type FlockTransport,
24
+ type FlockTransportFactory,
25
+ } from "./multi-tab-env";
26
+ import { createWriteCoordinator, type WriteCoordinator } from "./write-coordinator";
10
27
  import type {
11
28
  EntryClock,
12
29
  Event,
@@ -25,6 +42,8 @@ import type {
25
42
  PutHooks,
26
43
  PutWithMetaOptions,
27
44
  EventListener,
45
+ FlockSQLiteRole,
46
+ RoleChangeListener,
28
47
  ScanBound,
29
48
  ScanOptions,
30
49
  ScanRow,
@@ -51,6 +70,13 @@ type KvRow = {
51
70
 
52
71
  type ExportQueryRow = KvRow;
53
72
 
73
+ type PendingEvent = {
74
+ key: KeyPart[];
75
+ payload: ExportPayload;
76
+ source: string;
77
+ clock: EntryClock;
78
+ };
79
+
54
80
  type PutOperation = {
55
81
  key: KeyPart[];
56
82
  payload: ExportPayload;
@@ -58,7 +84,7 @@ type PutOperation = {
58
84
  skipSameValue: boolean;
59
85
  source: string;
60
86
  clock?: EntryClock;
61
- eventSink?: Array<{ key: KeyPart[]; payload: ExportPayload; source: string }>;
87
+ eventSink: PendingEvent[];
62
88
  };
63
89
 
64
90
  type EncodableVersionVectorEntry = {
@@ -585,6 +611,16 @@ export type FlockSQLiteOptions = {
585
611
  peerId?: string;
586
612
  connection?: UniStoreConnection;
587
613
  tablePrefix?: string;
614
+ /**
615
+ * Advanced: Inject multi-tab environment dependencies.
616
+ * Defaults to BroadcastChannel + UniSQLite role + real timers when available.
617
+ */
618
+ multiTab?: {
619
+ transportFactory?: FlockTransportFactory;
620
+ roleProvider?: FlockRoleProvider;
621
+ runtime?: FlockRuntime;
622
+ tabId?: string;
623
+ };
588
624
  };
589
625
 
590
626
  type TableNames = {
@@ -620,6 +656,89 @@ function buildTableNames(prefix: string): TableNames {
620
656
  };
621
657
  }
622
658
 
659
+ function normalizeRole(role: unknown): FlockSQLiteRole | undefined {
660
+ if (role === "host" || role === "participant" || role === "unknown") {
661
+ return role;
662
+ }
663
+ return undefined;
664
+ }
665
+
666
+ function resolveRole(db: UniStoreConnection): FlockSQLiteRole {
667
+ const role = (db as unknown as { getRole?: () => unknown }).getRole?.();
668
+ const normalized = normalizeRole(role);
669
+ if (normalized) {
670
+ return normalized;
671
+ }
672
+
673
+ // Back-compat with older UniSQLite versions that only expose getSQLiteInfo().
674
+ const info = (db as unknown as { getSQLiteInfo?: () => unknown }).getSQLiteInfo?.();
675
+ if (info && typeof info === "object") {
676
+ const { isHost, isReady } = info as { isHost?: unknown; isReady?: unknown };
677
+ if (isHost === true && isReady === true) {
678
+ return "host";
679
+ }
680
+ if (isReady === false) {
681
+ return "unknown";
682
+ }
683
+ if (isReady === true) {
684
+ return "participant";
685
+ }
686
+ }
687
+
688
+ // Non-browser adapters typically behave like a stable host.
689
+ return "host";
690
+ }
691
+
692
+ function createDefaultRoleProvider(db: UniStoreConnection): FlockRoleProvider {
693
+ const subscribeRoleChange = (
694
+ db as unknown as {
695
+ subscribeRoleChange?: (listener: (role: unknown) => void) => () => void;
696
+ }
697
+ ).subscribeRoleChange;
698
+
699
+ return {
700
+ getRole: () => resolveRole(db),
701
+ subscribeRoleChange:
702
+ typeof subscribeRoleChange === "function"
703
+ ? (listener) =>
704
+ subscribeRoleChange.call(db, (role) => {
705
+ const normalized = normalizeRole(role);
706
+ if (!normalized) {
707
+ return;
708
+ }
709
+ listener(normalized);
710
+ })
711
+ : undefined,
712
+ };
713
+ }
714
+
715
+ const MAX_SEEN_COMMITS = 2048;
716
+
717
+ function buildFlockChannelName(path: string, tablePrefix: string): string {
718
+ if (tablePrefix) {
719
+ return `flock:rpc:${path}:${tablePrefix}`;
720
+ }
721
+ return `flock:rpc:${path}`;
722
+ }
723
+
724
+ function resolveTabIdWithRuntime(
725
+ db: UniStoreConnection,
726
+ runtime: FlockRuntime,
727
+ override?: string,
728
+ ): TabId {
729
+ if (typeof override === "string" && override.length > 0) {
730
+ return override;
731
+ }
732
+ const info = (db as unknown as { getSQLiteInfo?: () => unknown }).getSQLiteInfo?.();
733
+ if (info && typeof info === "object") {
734
+ const tabId = (info as { tabId?: unknown }).tabId;
735
+ if (typeof tabId === "string" && tabId.length > 0) {
736
+ return tabId;
737
+ }
738
+ }
739
+ return runtime.randomUUID();
740
+ }
741
+
623
742
  export class FlockSQLite {
624
743
  private db: UniStoreConnection;
625
744
  private peerIdValue: string;
@@ -627,24 +746,11 @@ export class FlockSQLite {
627
746
  private maxHlc: { physicalTime: number; logicalCounter: number };
628
747
  private listeners: Set<EventListener>;
629
748
  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;
749
+ private readonly runtime: FlockRuntime;
750
+ private readonly eventBatcher: EventBatcher<PendingEvent>;
751
+ private readonly coordinator: WriteCoordinator;
752
+ private readonly seenCommitIds: LruSet<string>;
753
+ private closed: boolean;
648
754
 
649
755
  private constructor(
650
756
  db: UniStoreConnection,
@@ -652,6 +758,10 @@ export class FlockSQLite {
652
758
  vv: Map<string, VersionVectorEntry>,
653
759
  maxHlc: { physicalTime: number; logicalCounter: number },
654
760
  tables: TableNames,
761
+ tabId: TabId,
762
+ runtime: FlockRuntime,
763
+ transport: FlockTransport | undefined,
764
+ roleProvider: FlockRoleProvider,
655
765
  ) {
656
766
  this.db = db;
657
767
  this.peerIdValue = peerId;
@@ -659,8 +769,22 @@ export class FlockSQLite {
659
769
  this.maxHlc = maxHlc;
660
770
  this.listeners = new Set();
661
771
  this.tables = tables;
662
- this.txnEventSink = undefined;
663
- this.debounceState = undefined;
772
+ this.runtime = runtime;
773
+ this.eventBatcher = new EventBatcher({
774
+ runtime,
775
+ emit: (source, events) => this.emitEvents(source, events),
776
+ });
777
+ this.seenCommitIds = new LruSet(MAX_SEEN_COMMITS);
778
+ this.coordinator = createWriteCoordinator({
779
+ runtime,
780
+ tabId,
781
+ transport,
782
+ roleProvider,
783
+ ingestCommit: (commit, bufferable) => this.applyCommit(commit, bufferable),
784
+ executeWriteRequest: (payload, origin, requestId) =>
785
+ this.executeWriteRequest(payload, origin, requestId),
786
+ });
787
+ this.closed = false;
664
788
  }
665
789
 
666
790
  static async open(options: FlockSQLiteOptions): Promise<FlockSQLite> {
@@ -670,7 +794,24 @@ export class FlockSQLite {
670
794
  await FlockSQLite.ensureSchema(db, tables);
671
795
  const peerId = await FlockSQLite.resolvePeerId(db, tables, options.peerId);
672
796
  const { vv, maxHlc } = await FlockSQLite.loadVersionState(db, tables);
673
- return new FlockSQLite(db, peerId, vv, maxHlc, tables);
797
+ const runtime = options.multiTab?.runtime ?? createDefaultRuntime();
798
+ const roleProvider = options.multiTab?.roleProvider ?? createDefaultRoleProvider(db);
799
+ const channelName = buildFlockChannelName(options.path, prefix);
800
+ const transportFactory: FlockTransportFactory =
801
+ options.multiTab?.transportFactory ?? createBroadcastChannelTransport;
802
+ const transport = transportFactory(channelName);
803
+ const tabId = resolveTabIdWithRuntime(db, runtime, options.multiTab?.tabId);
804
+ return new FlockSQLite(
805
+ db,
806
+ peerId,
807
+ vv,
808
+ maxHlc,
809
+ tables,
810
+ tabId,
811
+ runtime,
812
+ transport,
813
+ roleProvider,
814
+ );
674
815
  }
675
816
 
676
817
  static async fromJson(
@@ -682,21 +823,114 @@ export class FlockSQLite {
682
823
  }
683
824
 
684
825
  async close(): Promise<void> {
685
- // Commit any pending debounced events
686
- if (this.debounceState !== undefined) {
687
- this.disableAutoDebounceCommit();
826
+ if (this.closed) {
827
+ return;
688
828
  }
829
+ this.closed = true;
830
+ this.coordinator.close();
831
+ this.eventBatcher.close();
689
832
 
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);
833
+ await this.db.close();
834
+ }
835
+
836
+ getRole(): FlockSQLiteRole {
837
+ return this.coordinator.getRole();
838
+ }
839
+
840
+ isHost(): boolean {
841
+ return this.coordinator.isHost();
842
+ }
843
+
844
+ subscribeRoleChange(listener: RoleChangeListener): () => void {
845
+ return this.coordinator.subscribeRoleChange(listener);
846
+ }
847
+
848
+ private dispatchWriteRequest<T>(
849
+ payload: FlockWriteRequest,
850
+ ): Promise<{ commit: FlockCommit; result: T }> {
851
+ return this.coordinator.dispatchWriteRequest(payload);
852
+ }
853
+
854
+ private applyCommit(commit: FlockCommit, bufferable: boolean): void {
855
+ if (this.seenCommitIds.has(commit.commitId)) {
856
+ return;
857
+ }
858
+ this.seenCommitIds.add(commit.commitId);
859
+
860
+ if (commit.meta?.peerId) {
861
+ try {
862
+ this.peerIdValue = normalizePeerId(commit.meta.peerId);
863
+ } catch {
864
+ // Ignore invalid peer ids; they should only come from user input.
696
865
  }
697
866
  }
698
867
 
699
- await this.db.close();
868
+ const pendingEvents: PendingEvent[] = commit.events.map((event) => ({
869
+ key: cloneJson(event.key),
870
+ payload: clonePayload(event.payload),
871
+ source: commit.source,
872
+ clock: { ...event.clock },
873
+ }));
874
+
875
+ for (const event of pendingEvents) {
876
+ this.bumpVersion(event.clock);
877
+ }
878
+
879
+ if (pendingEvents.length === 0) {
880
+ return;
881
+ }
882
+
883
+ this.eventBatcher.handleCommitEvents(
884
+ commit.source,
885
+ pendingEvents,
886
+ bufferable,
887
+ );
888
+ }
889
+
890
+ private async executeWriteRequest(
891
+ payload: FlockWriteRequest,
892
+ origin: TabId,
893
+ requestId: RequestId,
894
+ ): Promise<{ commit: FlockCommit; result: unknown }> {
895
+ const events: PendingEvent[] = [];
896
+ let result: unknown = undefined;
897
+ let meta: FlockCommit["meta"] | undefined;
898
+
899
+ if (payload.kind === "apply") {
900
+ await this.applyOperation({
901
+ key: payload.key,
902
+ payload: payload.payload,
903
+ now: payload.now,
904
+ skipSameValue: payload.skipSameValue,
905
+ source: payload.source,
906
+ eventSink: events,
907
+ });
908
+ } else if (payload.kind === "putMvr") {
909
+ await this.putMvrInternal(payload.key, payload.value, payload.now, events);
910
+ } else if (payload.kind === "import") {
911
+ result = await this.importBundleInternal(payload.bundle, events);
912
+ } else if (payload.kind === "setPeerId") {
913
+ await this.setPeerIdInternal(payload.peerId);
914
+ meta = { peerId: this.peerIdValue };
915
+ } else {
916
+ const neverPayload: never = payload;
917
+ throw new Error(`Unsupported write payload ${String(neverPayload)}`);
918
+ }
919
+
920
+ const commitId = `c:${origin}:${requestId}`;
921
+ const commit: FlockCommit = {
922
+ commitId,
923
+ origin,
924
+ source: payload.source,
925
+ events: events.map((event) => ({
926
+ key: event.key.slice(),
927
+ clock: { ...event.clock },
928
+ payload: clonePayload(event.payload),
929
+ })),
930
+ ...(meta ? { meta } : {}),
931
+ };
932
+
933
+ return { commit, result };
700
934
  }
701
935
 
702
936
  private static async ensureSchema(
@@ -818,7 +1052,7 @@ export class FlockSQLite {
818
1052
  }
819
1053
 
820
1054
  private allocateClock(now?: number): EntryClock {
821
- const timestamp = now ?? Date.now();
1055
+ const timestamp = now ?? this.runtime.now();
822
1056
  let physical = this.maxHlc.physicalTime;
823
1057
  let logical = this.maxHlc.logicalCounter;
824
1058
  if (timestamp > physical) {
@@ -935,58 +1169,21 @@ export class FlockSQLite {
935
1169
  if (usedClock) {
936
1170
  this.bumpVersion(usedClock);
937
1171
  }
938
- if (applied) {
939
- const eventPayload = {
1172
+ if (applied && usedClock) {
1173
+ const eventPayload: PendingEvent = {
940
1174
  key: operation.key.slice(),
941
1175
  payload,
942
1176
  source: operation.source,
1177
+ clock: usedClock,
943
1178
  };
944
- if (operation.eventSink) {
945
- // Explicit event sink provided (e.g., import)
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();
954
- } else {
955
- // Normal: emit immediately
956
- this.emitEvents(operation.source, [eventPayload]);
957
- }
1179
+ operation.eventSink.push(eventPayload);
958
1180
  }
959
1181
  return applied;
960
1182
  }
961
1183
 
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
-
987
1184
  private emitEvents(
988
1185
  source: string,
989
- events: Array<{ key: KeyPart[]; payload: ExportPayload }>,
1186
+ events: PendingEvent[],
990
1187
  ): void {
991
1188
  if (this.listeners.size === 0 || events.length === 0) {
992
1189
  return;
@@ -996,6 +1193,7 @@ export class FlockSQLite {
996
1193
  events: events.map(
997
1194
  (event): Event => ({
998
1195
  key: cloneJson(event.key),
1196
+ clock: { ...event.clock },
999
1197
  value:
1000
1198
  event.payload.data !== undefined
1001
1199
  ? cloneJson(event.payload.data)
@@ -1011,12 +1209,13 @@ export class FlockSQLite {
1011
1209
  }
1012
1210
 
1013
1211
  async put(key: KeyPart[], value: Value, now?: number): Promise<void> {
1014
- await this.applyOperation({
1212
+ await this.dispatchWriteRequest<void>({
1213
+ kind: "apply",
1214
+ source: "local",
1015
1215
  key,
1016
1216
  payload: { data: cloneJson(value) },
1017
1217
  now,
1018
1218
  skipSameValue: true,
1019
- source: "local",
1020
1219
  });
1021
1220
  }
1022
1221
 
@@ -1040,31 +1239,34 @@ export class FlockSQLite {
1040
1239
  if (finalPayload.data === undefined) {
1041
1240
  throw new TypeError("putWithMeta requires a data value");
1042
1241
  }
1043
- await this.applyOperation({
1242
+ await this.dispatchWriteRequest<void>({
1243
+ kind: "apply",
1244
+ source: "local",
1044
1245
  key,
1045
1246
  payload: finalPayload,
1046
1247
  now: options.now,
1047
1248
  skipSameValue: true,
1048
- source: "local",
1049
1249
  });
1050
1250
  return;
1051
1251
  }
1052
- await this.applyOperation({
1252
+ await this.dispatchWriteRequest<void>({
1253
+ kind: "apply",
1254
+ source: "local",
1053
1255
  key,
1054
1256
  payload: basePayload,
1055
1257
  now: options.now,
1056
1258
  skipSameValue: true,
1057
- source: "local",
1058
1259
  });
1059
1260
  }
1060
1261
 
1061
1262
  async delete(key: KeyPart[], now?: number): Promise<void> {
1062
- await this.applyOperation({
1263
+ await this.dispatchWriteRequest<void>({
1264
+ kind: "apply",
1265
+ source: "local",
1063
1266
  key,
1064
1267
  payload: {},
1065
1268
  now,
1066
1269
  skipSameValue: true,
1067
- source: "local",
1068
1270
  });
1069
1271
  }
1070
1272
 
@@ -1073,12 +1275,13 @@ export class FlockSQLite {
1073
1275
  * This will refresh the timestamp.
1074
1276
  */
1075
1277
  async forcePut(key: KeyPart[], value: Value, now?: number): Promise<void> {
1076
- await this.applyOperation({
1278
+ await this.dispatchWriteRequest<void>({
1279
+ kind: "apply",
1280
+ source: "local",
1077
1281
  key,
1078
1282
  payload: { data: cloneJson(value) },
1079
1283
  now,
1080
1284
  skipSameValue: false,
1081
- source: "local",
1082
1285
  });
1083
1286
  }
1084
1287
 
@@ -1106,21 +1309,23 @@ export class FlockSQLite {
1106
1309
  if (finalPayload.data === undefined) {
1107
1310
  throw new TypeError("forcePutWithMeta requires a data value");
1108
1311
  }
1109
- await this.applyOperation({
1312
+ await this.dispatchWriteRequest<void>({
1313
+ kind: "apply",
1314
+ source: "local",
1110
1315
  key,
1111
1316
  payload: finalPayload,
1112
1317
  now: options.now,
1113
1318
  skipSameValue: false,
1114
- source: "local",
1115
1319
  });
1116
1320
  return;
1117
1321
  }
1118
- await this.applyOperation({
1322
+ await this.dispatchWriteRequest<void>({
1323
+ kind: "apply",
1324
+ source: "local",
1119
1325
  key,
1120
1326
  payload: basePayload,
1121
1327
  now: options.now,
1122
1328
  skipSameValue: false,
1123
- source: "local",
1124
1329
  });
1125
1330
  }
1126
1331
 
@@ -1129,12 +1334,13 @@ export class FlockSQLite {
1129
1334
  * This will refresh the timestamp.
1130
1335
  */
1131
1336
  async forceDelete(key: KeyPart[], now?: number): Promise<void> {
1132
- await this.applyOperation({
1337
+ await this.dispatchWriteRequest<void>({
1338
+ kind: "apply",
1339
+ source: "local",
1133
1340
  key,
1134
1341
  payload: {},
1135
1342
  now,
1136
1343
  skipSameValue: false,
1137
- source: "local",
1138
1344
  });
1139
1345
  }
1140
1346
 
@@ -1143,6 +1349,15 @@ export class FlockSQLite {
1143
1349
  }
1144
1350
 
1145
1351
  async setPeerId(peerId: string): Promise<void> {
1352
+ const normalized = normalizePeerId(peerId);
1353
+ await this.dispatchWriteRequest<void>({
1354
+ kind: "setPeerId",
1355
+ source: "meta",
1356
+ peerId: normalized,
1357
+ });
1358
+ }
1359
+
1360
+ private async setPeerIdInternal(peerId: string): Promise<void> {
1146
1361
  const normalized = normalizePeerId(peerId);
1147
1362
  await this.db.exec(`DELETE FROM ${this.tables.meta}`);
1148
1363
  await this.db.run(`INSERT INTO ${this.tables.meta}(peer_id) VALUES (?)`, [
@@ -1209,15 +1424,49 @@ export class FlockSQLite {
1209
1424
  if (value === null || typeof value === "object") {
1210
1425
  throw new TypeError("putMvr only accepts scalar values");
1211
1426
  }
1427
+ await this.dispatchWriteRequest<void>({
1428
+ kind: "putMvr",
1429
+ source: "local",
1430
+ key,
1431
+ value,
1432
+ now,
1433
+ });
1434
+ }
1435
+
1436
+ private async putMvrInternal(
1437
+ key: KeyPart[],
1438
+ value: Value,
1439
+ now: number | undefined,
1440
+ eventSink: PendingEvent[],
1441
+ ): Promise<void> {
1442
+ if (value === null || typeof value === "object") {
1443
+ throw new TypeError("putMvr only accepts scalar values");
1444
+ }
1445
+
1212
1446
  const existing = await this.scan({ prefix: key });
1213
1447
  for (const row of existing) {
1214
1448
  if (row.raw.d === true) {
1215
- await this.delete(row.key, now);
1449
+ await this.applyOperation({
1450
+ key: row.key,
1451
+ payload: {},
1452
+ now,
1453
+ skipSameValue: true,
1454
+ source: "local",
1455
+ eventSink,
1456
+ });
1216
1457
  }
1217
1458
  }
1459
+
1218
1460
  const composite = key.slice();
1219
1461
  composite.push(value);
1220
- await this.put(composite, true, now);
1462
+ await this.applyOperation({
1463
+ key: composite,
1464
+ payload: { data: true },
1465
+ now,
1466
+ skipSameValue: true,
1467
+ source: "local",
1468
+ eventSink,
1469
+ });
1221
1470
  }
1222
1471
 
1223
1472
  private buildScanBounds(options: ScanOptions): {
@@ -1540,34 +1789,15 @@ export class FlockSQLite {
1540
1789
  return this.exportInternal(arg, pruneTombstonesBefore);
1541
1790
  }
1542
1791
 
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
-
1792
+ private async importBundleInternal(
1793
+ bundle: ExportBundle,
1794
+ eventSink: PendingEvent[],
1795
+ ): Promise<ImportReport> {
1561
1796
  if (bundle.version !== 0) {
1562
1797
  throw new TypeError("Unsupported bundle version");
1563
1798
  }
1564
1799
  let accepted = 0;
1565
1800
  const skipped: Array<{ key: KeyPart[]; reason: string }> = [];
1566
- const appliedEvents: Array<{
1567
- key: KeyPart[];
1568
- payload: ExportPayload;
1569
- source: string;
1570
- }> = [];
1571
1801
  for (const [keyString, record] of Object.entries(bundle.entries)) {
1572
1802
  let keyParts: KeyPart[];
1573
1803
  try {
@@ -1589,12 +1819,9 @@ export class FlockSQLite {
1589
1819
  clock,
1590
1820
  skipSameValue: false,
1591
1821
  source: "import",
1592
- eventSink: appliedEvents,
1822
+ eventSink,
1593
1823
  });
1594
1824
  }
1595
- if (appliedEvents.length > 0) {
1596
- this.emitEvents("import", appliedEvents);
1597
- }
1598
1825
  return { accepted, skipped };
1599
1826
  }
1600
1827
 
@@ -1633,13 +1860,24 @@ export class FlockSQLite {
1633
1860
  }
1634
1861
  }
1635
1862
  }
1636
- const baseReport = await this.importInternal(working);
1863
+ this.eventBatcher.beforeImport();
1864
+ const { result: baseReport } = await this.dispatchWriteRequest<ImportReport>({
1865
+ kind: "import",
1866
+ source: "import",
1867
+ bundle: working,
1868
+ });
1637
1869
  return {
1638
1870
  accepted: baseReport.accepted,
1639
1871
  skipped: skipped.concat(baseReport.skipped),
1640
1872
  };
1641
1873
  }
1642
- return this.importInternal(arg);
1874
+ this.eventBatcher.beforeImport();
1875
+ const { result } = await this.dispatchWriteRequest<ImportReport>({
1876
+ kind: "import",
1877
+ source: "import",
1878
+ bundle: arg,
1879
+ });
1880
+ return result;
1643
1881
  }
1644
1882
 
1645
1883
  async importJsonStr(json: string): Promise<ImportReport> {
@@ -1708,39 +1946,14 @@ export class FlockSQLite {
1708
1946
  * ```
1709
1947
  */
1710
1948
  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
- }
1949
+ return this.eventBatcher.txn(callback);
1737
1950
  }
1738
1951
 
1739
1952
  /**
1740
1953
  * Check if a transaction is currently active.
1741
1954
  */
1742
1955
  isInTxn(): boolean {
1743
- return this.txnEventSink !== undefined;
1956
+ return this.eventBatcher.isInTxn();
1744
1957
  }
1745
1958
 
1746
1959
  /**
@@ -1771,24 +1984,7 @@ export class FlockSQLite {
1771
1984
  timeout: number,
1772
1985
  options?: { maxDebounceTime?: number },
1773
1986
  ): 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
- };
1987
+ this.eventBatcher.autoDebounceCommit(timeout, options);
1792
1988
  }
1793
1989
 
1794
1990
  /**
@@ -1796,22 +1992,7 @@ export class FlockSQLite {
1796
1992
  * No-op if autoDebounceCommit is not active.
1797
1993
  */
1798
1994
  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
- }
1995
+ this.eventBatcher.disableAutoDebounceCommit();
1815
1996
  }
1816
1997
 
1817
1998
  /**
@@ -1820,37 +2001,21 @@ export class FlockSQLite {
1820
2001
  * No-op if autoDebounceCommit is not active or no events are pending.
1821
2002
  */
1822
2003
  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
- }
2004
+ this.eventBatcher.commit();
1841
2005
  }
1842
2006
 
1843
2007
  /**
1844
2008
  * Check if auto-debounce mode is currently active.
1845
2009
  */
1846
2010
  isAutoDebounceActive(): boolean {
1847
- return this.debounceState !== undefined;
2011
+ return this.eventBatcher.isAutoDebounceActive();
1848
2012
  }
1849
2013
  }
1850
2014
 
1851
2015
  export type {
1852
2016
  Event,
1853
2017
  EventBatch,
2018
+ FlockSQLiteRole,
1854
2019
  ExportBundle,
1855
2020
  ExportHooks,
1856
2021
  ExportOptions,
@@ -1872,4 +2037,11 @@ export type {
1872
2037
  };
1873
2038
 
1874
2039
  export { FlockSQLite as Flock };
1875
- export type { EventListener };
2040
+ export type { EventListener, RoleChangeListener };
2041
+ export type {
2042
+ FlockRoleProvider,
2043
+ FlockRuntime,
2044
+ FlockTransport,
2045
+ FlockTransportFactory,
2046
+ TimeoutHandle,
2047
+ } from "./multi-tab-env";