@loro-dev/flock-sqlite 0.8.0 → 0.9.1

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 = {
@@ -68,7 +94,7 @@ type EncodableVersionVectorEntry = {
68
94
  counter: number;
69
95
  };
70
96
 
71
- export interface VersionVector extends VersionVectorType {}
97
+ export interface VersionVector extends VersionVectorType { }
72
98
 
73
99
  const textEncoder = new TextEncoder();
74
100
  const textDecoder = new TextDecoder();
@@ -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;
828
+ }
829
+ this.closed = true;
830
+ this.coordinator.close();
831
+ this.eventBatcher.close();
832
+
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;
688
857
  }
858
+ this.seenCommitIds.add(commit.commitId);
689
859
 
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);
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(
@@ -787,9 +1021,9 @@ export class FlockSQLite {
787
1021
  const maxHlc =
788
1022
  first && Number.isFinite(first.physical) && Number.isFinite(first.logical)
789
1023
  ? {
790
- physicalTime: Number(first.physical),
791
- logicalCounter: Number(first.logical),
792
- }
1024
+ physicalTime: Number(first.physical),
1025
+ logicalCounter: Number(first.logical),
1026
+ }
793
1027
  : { physicalTime: 0, logicalCounter: 0 };
794
1028
  return { vv, maxHlc };
795
1029
  }
@@ -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)
@@ -1005,18 +1203,24 @@ export class FlockSQLite {
1005
1203
  }),
1006
1204
  ),
1007
1205
  };
1008
- this.listeners.forEach((listener) => {
1009
- listener(batch);
1010
- });
1206
+ const listeners = Array.from(this.listeners);
1207
+ for (const listener of listeners) {
1208
+ try {
1209
+ listener(batch);
1210
+ } catch (error) {
1211
+ console.error(error);
1212
+ }
1213
+ }
1011
1214
  }
1012
1215
 
1013
1216
  async put(key: KeyPart[], value: Value, now?: number): Promise<void> {
1014
- await this.applyOperation({
1217
+ await this.dispatchWriteRequest<void>({
1218
+ kind: "apply",
1219
+ source: "local",
1015
1220
  key,
1016
1221
  payload: { data: cloneJson(value) },
1017
1222
  now,
1018
1223
  skipSameValue: true,
1019
- source: "local",
1020
1224
  });
1021
1225
  }
1022
1226
 
@@ -1040,31 +1244,34 @@ export class FlockSQLite {
1040
1244
  if (finalPayload.data === undefined) {
1041
1245
  throw new TypeError("putWithMeta requires a data value");
1042
1246
  }
1043
- await this.applyOperation({
1247
+ await this.dispatchWriteRequest<void>({
1248
+ kind: "apply",
1249
+ source: "local",
1044
1250
  key,
1045
1251
  payload: finalPayload,
1046
1252
  now: options.now,
1047
1253
  skipSameValue: true,
1048
- source: "local",
1049
1254
  });
1050
1255
  return;
1051
1256
  }
1052
- await this.applyOperation({
1257
+ await this.dispatchWriteRequest<void>({
1258
+ kind: "apply",
1259
+ source: "local",
1053
1260
  key,
1054
1261
  payload: basePayload,
1055
1262
  now: options.now,
1056
1263
  skipSameValue: true,
1057
- source: "local",
1058
1264
  });
1059
1265
  }
1060
1266
 
1061
1267
  async delete(key: KeyPart[], now?: number): Promise<void> {
1062
- await this.applyOperation({
1268
+ await this.dispatchWriteRequest<void>({
1269
+ kind: "apply",
1270
+ source: "local",
1063
1271
  key,
1064
1272
  payload: {},
1065
1273
  now,
1066
1274
  skipSameValue: true,
1067
- source: "local",
1068
1275
  });
1069
1276
  }
1070
1277
 
@@ -1073,12 +1280,13 @@ export class FlockSQLite {
1073
1280
  * This will refresh the timestamp.
1074
1281
  */
1075
1282
  async forcePut(key: KeyPart[], value: Value, now?: number): Promise<void> {
1076
- await this.applyOperation({
1283
+ await this.dispatchWriteRequest<void>({
1284
+ kind: "apply",
1285
+ source: "local",
1077
1286
  key,
1078
1287
  payload: { data: cloneJson(value) },
1079
1288
  now,
1080
1289
  skipSameValue: false,
1081
- source: "local",
1082
1290
  });
1083
1291
  }
1084
1292
 
@@ -1106,21 +1314,23 @@ export class FlockSQLite {
1106
1314
  if (finalPayload.data === undefined) {
1107
1315
  throw new TypeError("forcePutWithMeta requires a data value");
1108
1316
  }
1109
- await this.applyOperation({
1317
+ await this.dispatchWriteRequest<void>({
1318
+ kind: "apply",
1319
+ source: "local",
1110
1320
  key,
1111
1321
  payload: finalPayload,
1112
1322
  now: options.now,
1113
1323
  skipSameValue: false,
1114
- source: "local",
1115
1324
  });
1116
1325
  return;
1117
1326
  }
1118
- await this.applyOperation({
1327
+ await this.dispatchWriteRequest<void>({
1328
+ kind: "apply",
1329
+ source: "local",
1119
1330
  key,
1120
1331
  payload: basePayload,
1121
1332
  now: options.now,
1122
1333
  skipSameValue: false,
1123
- source: "local",
1124
1334
  });
1125
1335
  }
1126
1336
 
@@ -1129,12 +1339,13 @@ export class FlockSQLite {
1129
1339
  * This will refresh the timestamp.
1130
1340
  */
1131
1341
  async forceDelete(key: KeyPart[], now?: number): Promise<void> {
1132
- await this.applyOperation({
1342
+ await this.dispatchWriteRequest<void>({
1343
+ kind: "apply",
1344
+ source: "local",
1133
1345
  key,
1134
1346
  payload: {},
1135
1347
  now,
1136
1348
  skipSameValue: false,
1137
- source: "local",
1138
1349
  });
1139
1350
  }
1140
1351
 
@@ -1143,6 +1354,15 @@ export class FlockSQLite {
1143
1354
  }
1144
1355
 
1145
1356
  async setPeerId(peerId: string): Promise<void> {
1357
+ const normalized = normalizePeerId(peerId);
1358
+ await this.dispatchWriteRequest<void>({
1359
+ kind: "setPeerId",
1360
+ source: "meta",
1361
+ peerId: normalized,
1362
+ });
1363
+ }
1364
+
1365
+ private async setPeerIdInternal(peerId: string): Promise<void> {
1146
1366
  const normalized = normalizePeerId(peerId);
1147
1367
  await this.db.exec(`DELETE FROM ${this.tables.meta}`);
1148
1368
  await this.db.run(`INSERT INTO ${this.tables.meta}(peer_id) VALUES (?)`, [
@@ -1209,15 +1429,49 @@ export class FlockSQLite {
1209
1429
  if (value === null || typeof value === "object") {
1210
1430
  throw new TypeError("putMvr only accepts scalar values");
1211
1431
  }
1432
+ await this.dispatchWriteRequest<void>({
1433
+ kind: "putMvr",
1434
+ source: "local",
1435
+ key,
1436
+ value,
1437
+ now,
1438
+ });
1439
+ }
1440
+
1441
+ private async putMvrInternal(
1442
+ key: KeyPart[],
1443
+ value: Value,
1444
+ now: number | undefined,
1445
+ eventSink: PendingEvent[],
1446
+ ): Promise<void> {
1447
+ if (value === null || typeof value === "object") {
1448
+ throw new TypeError("putMvr only accepts scalar values");
1449
+ }
1450
+
1212
1451
  const existing = await this.scan({ prefix: key });
1213
1452
  for (const row of existing) {
1214
1453
  if (row.raw.d === true) {
1215
- await this.delete(row.key, now);
1454
+ await this.applyOperation({
1455
+ key: row.key,
1456
+ payload: {},
1457
+ now,
1458
+ skipSameValue: true,
1459
+ source: "local",
1460
+ eventSink,
1461
+ });
1216
1462
  }
1217
1463
  }
1464
+
1218
1465
  const composite = key.slice();
1219
1466
  composite.push(value);
1220
- await this.put(composite, true, now);
1467
+ await this.applyOperation({
1468
+ key: composite,
1469
+ payload: { data: true },
1470
+ now,
1471
+ skipSameValue: true,
1472
+ source: "local",
1473
+ eventSink,
1474
+ });
1221
1475
  }
1222
1476
 
1223
1477
  private buildScanBounds(options: ScanOptions): {
@@ -1306,9 +1560,9 @@ export class FlockSQLite {
1306
1560
  }
1307
1561
  const postFilter = prefixFilter
1308
1562
  ? (
1309
- (pf: Uint8Array) => (bytes: Uint8Array) =>
1310
- keyMatchesPrefix(bytes, pf)
1311
- )(prefixFilter)
1563
+ (pf: Uint8Array) => (bytes: Uint8Array) =>
1564
+ keyMatchesPrefix(bytes, pf)
1565
+ )(prefixFilter)
1312
1566
  : undefined;
1313
1567
  return { where, params, postFilter };
1314
1568
  }
@@ -1540,34 +1794,15 @@ export class FlockSQLite {
1540
1794
  return this.exportInternal(arg, pruneTombstonesBefore);
1541
1795
  }
1542
1796
 
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
-
1797
+ private async importBundleInternal(
1798
+ bundle: ExportBundle,
1799
+ eventSink: PendingEvent[],
1800
+ ): Promise<ImportReport> {
1561
1801
  if (bundle.version !== 0) {
1562
1802
  throw new TypeError("Unsupported bundle version");
1563
1803
  }
1564
1804
  let accepted = 0;
1565
1805
  const skipped: Array<{ key: KeyPart[]; reason: string }> = [];
1566
- const appliedEvents: Array<{
1567
- key: KeyPart[];
1568
- payload: ExportPayload;
1569
- source: string;
1570
- }> = [];
1571
1806
  for (const [keyString, record] of Object.entries(bundle.entries)) {
1572
1807
  let keyParts: KeyPart[];
1573
1808
  try {
@@ -1589,12 +1824,9 @@ export class FlockSQLite {
1589
1824
  clock,
1590
1825
  skipSameValue: false,
1591
1826
  source: "import",
1592
- eventSink: appliedEvents,
1827
+ eventSink,
1593
1828
  });
1594
1829
  }
1595
- if (appliedEvents.length > 0) {
1596
- this.emitEvents("import", appliedEvents);
1597
- }
1598
1830
  return { accepted, skipped };
1599
1831
  }
1600
1832
 
@@ -1633,13 +1865,24 @@ export class FlockSQLite {
1633
1865
  }
1634
1866
  }
1635
1867
  }
1636
- const baseReport = await this.importInternal(working);
1868
+ this.eventBatcher.beforeImport();
1869
+ const { result: baseReport } = await this.dispatchWriteRequest<ImportReport>({
1870
+ kind: "import",
1871
+ source: "import",
1872
+ bundle: working,
1873
+ });
1637
1874
  return {
1638
1875
  accepted: baseReport.accepted,
1639
1876
  skipped: skipped.concat(baseReport.skipped),
1640
1877
  };
1641
1878
  }
1642
- return this.importInternal(arg);
1879
+ this.eventBatcher.beforeImport();
1880
+ const { result } = await this.dispatchWriteRequest<ImportReport>({
1881
+ kind: "import",
1882
+ source: "import",
1883
+ bundle: arg,
1884
+ });
1885
+ return result;
1643
1886
  }
1644
1887
 
1645
1888
  async importJsonStr(json: string): Promise<ImportReport> {
@@ -1708,39 +1951,14 @@ export class FlockSQLite {
1708
1951
  * ```
1709
1952
  */
1710
1953
  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
- }
1954
+ return this.eventBatcher.txn(callback);
1737
1955
  }
1738
1956
 
1739
1957
  /**
1740
1958
  * Check if a transaction is currently active.
1741
1959
  */
1742
1960
  isInTxn(): boolean {
1743
- return this.txnEventSink !== undefined;
1961
+ return this.eventBatcher.isInTxn();
1744
1962
  }
1745
1963
 
1746
1964
  /**
@@ -1771,24 +1989,7 @@ export class FlockSQLite {
1771
1989
  timeout: number,
1772
1990
  options?: { maxDebounceTime?: number },
1773
1991
  ): 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
- };
1992
+ this.eventBatcher.autoDebounceCommit(timeout, options);
1792
1993
  }
1793
1994
 
1794
1995
  /**
@@ -1796,22 +1997,7 @@ export class FlockSQLite {
1796
1997
  * No-op if autoDebounceCommit is not active.
1797
1998
  */
1798
1999
  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
- }
2000
+ this.eventBatcher.disableAutoDebounceCommit();
1815
2001
  }
1816
2002
 
1817
2003
  /**
@@ -1820,37 +2006,21 @@ export class FlockSQLite {
1820
2006
  * No-op if autoDebounceCommit is not active or no events are pending.
1821
2007
  */
1822
2008
  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
- }
2009
+ this.eventBatcher.commit();
1841
2010
  }
1842
2011
 
1843
2012
  /**
1844
2013
  * Check if auto-debounce mode is currently active.
1845
2014
  */
1846
2015
  isAutoDebounceActive(): boolean {
1847
- return this.debounceState !== undefined;
2016
+ return this.eventBatcher.isAutoDebounceActive();
1848
2017
  }
1849
2018
  }
1850
2019
 
1851
2020
  export type {
1852
2021
  Event,
1853
2022
  EventBatch,
2023
+ FlockSQLiteRole,
1854
2024
  ExportBundle,
1855
2025
  ExportHooks,
1856
2026
  ExportOptions,
@@ -1872,4 +2042,11 @@ export type {
1872
2042
  };
1873
2043
 
1874
2044
  export { FlockSQLite as Flock };
1875
- export type { EventListener };
2045
+ export type { EventListener, RoleChangeListener };
2046
+ export type {
2047
+ FlockRoleProvider,
2048
+ FlockRuntime,
2049
+ FlockTransport,
2050
+ FlockTransportFactory,
2051
+ TimeoutHandle,
2052
+ } from "./multi-tab-env";