@livestore/common 0.0.0-snapshot-f6ec49b1a18859aad769f0a0d8edf8bae231ed07 → 0.0.0-snapshot-2ef046b02334f52613d31dbe06af53487685edc0

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.
Files changed (102) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/adapter-types.d.ts +7 -12
  3. package/dist/adapter-types.d.ts.map +1 -1
  4. package/dist/adapter-types.js +1 -7
  5. package/dist/adapter-types.js.map +1 -1
  6. package/dist/devtools/devtools-messages-client-session.d.ts +21 -21
  7. package/dist/devtools/devtools-messages-common.d.ts +13 -6
  8. package/dist/devtools/devtools-messages-common.d.ts.map +1 -1
  9. package/dist/devtools/devtools-messages-common.js +6 -0
  10. package/dist/devtools/devtools-messages-common.js.map +1 -1
  11. package/dist/devtools/devtools-messages-leader.d.ts +25 -25
  12. package/dist/devtools/devtools-messages-leader.d.ts.map +1 -1
  13. package/dist/devtools/devtools-messages-leader.js +1 -2
  14. package/dist/devtools/devtools-messages-leader.js.map +1 -1
  15. package/dist/leader-thread/LeaderSyncProcessor.d.ts +16 -6
  16. package/dist/leader-thread/LeaderSyncProcessor.d.ts.map +1 -1
  17. package/dist/leader-thread/LeaderSyncProcessor.js +227 -215
  18. package/dist/leader-thread/LeaderSyncProcessor.js.map +1 -1
  19. package/dist/leader-thread/apply-mutation.d.ts +14 -9
  20. package/dist/leader-thread/apply-mutation.d.ts.map +1 -1
  21. package/dist/leader-thread/apply-mutation.js +43 -36
  22. package/dist/leader-thread/apply-mutation.js.map +1 -1
  23. package/dist/leader-thread/leader-worker-devtools.d.ts.map +1 -1
  24. package/dist/leader-thread/leader-worker-devtools.js +2 -5
  25. package/dist/leader-thread/leader-worker-devtools.js.map +1 -1
  26. package/dist/leader-thread/make-leader-thread-layer.d.ts.map +1 -1
  27. package/dist/leader-thread/make-leader-thread-layer.js +22 -33
  28. package/dist/leader-thread/make-leader-thread-layer.js.map +1 -1
  29. package/dist/leader-thread/mod.d.ts +1 -1
  30. package/dist/leader-thread/mod.d.ts.map +1 -1
  31. package/dist/leader-thread/mod.js +1 -1
  32. package/dist/leader-thread/mod.js.map +1 -1
  33. package/dist/leader-thread/mutationlog.d.ts +20 -3
  34. package/dist/leader-thread/mutationlog.d.ts.map +1 -1
  35. package/dist/leader-thread/mutationlog.js +106 -12
  36. package/dist/leader-thread/mutationlog.js.map +1 -1
  37. package/dist/leader-thread/recreate-db.d.ts.map +1 -1
  38. package/dist/leader-thread/recreate-db.js +4 -3
  39. package/dist/leader-thread/recreate-db.js.map +1 -1
  40. package/dist/leader-thread/types.d.ts +35 -19
  41. package/dist/leader-thread/types.d.ts.map +1 -1
  42. package/dist/leader-thread/types.js.map +1 -1
  43. package/dist/rehydrate-from-mutationlog.d.ts +5 -4
  44. package/dist/rehydrate-from-mutationlog.d.ts.map +1 -1
  45. package/dist/rehydrate-from-mutationlog.js +7 -9
  46. package/dist/rehydrate-from-mutationlog.js.map +1 -1
  47. package/dist/schema/EventId.d.ts +4 -0
  48. package/dist/schema/EventId.d.ts.map +1 -1
  49. package/dist/schema/EventId.js +7 -1
  50. package/dist/schema/EventId.js.map +1 -1
  51. package/dist/schema/MutationEvent.d.ts +87 -18
  52. package/dist/schema/MutationEvent.d.ts.map +1 -1
  53. package/dist/schema/MutationEvent.js +35 -6
  54. package/dist/schema/MutationEvent.js.map +1 -1
  55. package/dist/schema/schema.js +1 -1
  56. package/dist/schema/schema.js.map +1 -1
  57. package/dist/schema/system-tables.d.ts +67 -0
  58. package/dist/schema/system-tables.d.ts.map +1 -1
  59. package/dist/schema/system-tables.js +12 -1
  60. package/dist/schema/system-tables.js.map +1 -1
  61. package/dist/sync/ClientSessionSyncProcessor.d.ts +11 -1
  62. package/dist/sync/ClientSessionSyncProcessor.d.ts.map +1 -1
  63. package/dist/sync/ClientSessionSyncProcessor.js +54 -47
  64. package/dist/sync/ClientSessionSyncProcessor.js.map +1 -1
  65. package/dist/sync/sync.d.ts +16 -5
  66. package/dist/sync/sync.d.ts.map +1 -1
  67. package/dist/sync/sync.js.map +1 -1
  68. package/dist/sync/syncstate.d.ts +81 -83
  69. package/dist/sync/syncstate.d.ts.map +1 -1
  70. package/dist/sync/syncstate.js +159 -125
  71. package/dist/sync/syncstate.js.map +1 -1
  72. package/dist/sync/syncstate.test.js +97 -138
  73. package/dist/sync/syncstate.test.js.map +1 -1
  74. package/dist/version.d.ts +1 -1
  75. package/dist/version.js +1 -1
  76. package/package.json +2 -2
  77. package/src/adapter-types.ts +5 -12
  78. package/src/devtools/devtools-messages-common.ts +9 -0
  79. package/src/devtools/devtools-messages-leader.ts +1 -2
  80. package/src/leader-thread/LeaderSyncProcessor.ts +398 -370
  81. package/src/leader-thread/apply-mutation.ts +81 -71
  82. package/src/leader-thread/leader-worker-devtools.ts +3 -8
  83. package/src/leader-thread/make-leader-thread-layer.ts +27 -41
  84. package/src/leader-thread/mod.ts +1 -1
  85. package/src/leader-thread/mutationlog.ts +167 -13
  86. package/src/leader-thread/recreate-db.ts +4 -3
  87. package/src/leader-thread/types.ts +34 -23
  88. package/src/rehydrate-from-mutationlog.ts +12 -12
  89. package/src/schema/EventId.ts +8 -1
  90. package/src/schema/MutationEvent.ts +42 -10
  91. package/src/schema/schema.ts +1 -1
  92. package/src/schema/system-tables.ts +20 -1
  93. package/src/sync/ClientSessionSyncProcessor.ts +64 -50
  94. package/src/sync/sync.ts +16 -9
  95. package/src/sync/syncstate.test.ts +173 -217
  96. package/src/sync/syncstate.ts +184 -151
  97. package/src/version.ts +1 -1
  98. package/dist/leader-thread/pull-queue-set.d.ts +0 -7
  99. package/dist/leader-thread/pull-queue-set.d.ts.map +0 -1
  100. package/dist/leader-thread/pull-queue-set.js +0 -48
  101. package/dist/leader-thread/pull-queue-set.js.map +0 -1
  102. package/src/leader-thread/pull-queue-set.ts +0 -67
@@ -1,15 +1,14 @@
1
1
  import { casesHandled, isNotUndefined, LS_DEV, shouldNeverHappen, TRACE_VERBOSE } from '@livestore/utils'
2
- import type { HttpClient, Scope, Tracer } from '@livestore/utils/effect'
2
+ import type { HttpClient, Runtime, Scope, Tracer } from '@livestore/utils/effect'
3
3
  import {
4
4
  BucketQueue,
5
5
  Deferred,
6
6
  Effect,
7
7
  Exit,
8
8
  FiberHandle,
9
- Option,
10
9
  OtelTracer,
10
+ Queue,
11
11
  ReadonlyArray,
12
- Schema,
13
12
  Stream,
14
13
  Subscribable,
15
14
  SubscriptionRef,
@@ -18,30 +17,29 @@ import type * as otel from '@opentelemetry/api'
18
17
 
19
18
  import type { SqliteDb } from '../adapter-types.js'
20
19
  import { UnexpectedError } from '../adapter-types.js'
21
- import type { LiveStoreSchema, SessionChangesetMetaRow } from '../schema/mod.js'
20
+ import type { LiveStoreSchema } from '../schema/mod.js'
22
21
  import {
23
22
  EventId,
24
23
  getMutationDef,
25
- MUTATION_LOG_META_TABLE,
24
+ LEADER_MERGE_COUNTER_TABLE,
26
25
  MutationEvent,
27
- mutationLogMetaTable,
28
26
  SESSION_CHANGESET_META_TABLE,
29
27
  } from '../schema/mod.js'
30
- import { updateRows } from '../sql-queries/index.js'
31
28
  import { LeaderAheadError } from '../sync/sync.js'
32
29
  import * as SyncState from '../sync/syncstate.js'
33
30
  import { sql } from '../util.js'
34
- import { makeApplyMutation } from './apply-mutation.js'
35
- import { execSql } from './connection.js'
36
- import { getBackendHeadFromDb, getClientHeadFromDb, getMutationEventsSince, updateBackendHead } from './mutationlog.js'
37
- import type { InitialBlockingSyncContext, InitialSyncInfo, LeaderSyncProcessor } from './types.js'
31
+ import { rollback } from './apply-mutation.js'
32
+ import * as Mutationlog from './mutationlog.js'
33
+ import type { InitialBlockingSyncContext, LeaderSyncProcessor } from './types.js'
38
34
  import { LeaderThreadCtx } from './types.js'
39
35
 
40
36
  export const BACKEND_PUSH_BATCH_SIZE = 50
37
+ export const LOCAL_PUSH_BATCH_SIZE = 10
41
38
 
42
39
  type LocalPushQueueItem = [
43
40
  mutationEvent: MutationEvent.EncodedWithMeta,
44
41
  deferred: Deferred.Deferred<void, LeaderAheadError> | undefined,
42
+ /** Used to determine whether the batch has become invalid due to a rejected local push batch */
45
43
  generation: number,
46
44
  ]
47
45
 
@@ -52,53 +50,69 @@ type LocalPushQueueItem = [
52
50
  * In the LeaderSyncProcessor, pulling always has precedence over pushing.
53
51
  *
54
52
  * Responsibilities:
55
- * - Queueing incoming local mutations in a localPushMailbox.
53
+ * - Queueing incoming local mutations in a localPushesQueue.
56
54
  * - Broadcasting mutations to client sessions via pull queues.
57
55
  * - Pushing mutations to the sync backend.
58
56
  *
59
57
  * Notes:
60
58
  *
61
59
  * local push processing:
62
- * - localPushMailbox:
60
+ * - localPushesQueue:
63
61
  * - Maintains events in ascending order.
64
62
  * - Uses `Deferred` objects to resolve/reject events based on application success.
65
- * - Processes events from the mailbox, applying mutations in batches.
63
+ * - Processes events from the queue, applying mutations in batches.
66
64
  * - Controlled by a `Latch` to manage execution flow.
67
65
  * - The latch closes on pull receipt and re-opens post-pull completion.
68
66
  * - Processes up to `maxBatchSize` events per cycle.
69
67
  *
68
+ * Currently we're advancing the db read model and mutation log in lockstep, but we could also decouple this in the future
69
+ *
70
+ * Tricky concurrency scenarios:
71
+ * - Queued local push batches becoming invalid due to a prior local push item being rejected.
72
+ * Solution: Introduce a generation number for local push batches which is used to filter out old batches items in case of rejection.
73
+ *
70
74
  */
71
75
  export const makeLeaderSyncProcessor = ({
72
76
  schema,
73
- dbMissing,
77
+ dbMutationLogMissing,
74
78
  dbMutationLog,
75
- clientId,
79
+ dbReadModel,
80
+ dbReadModelMissing,
76
81
  initialBlockingSyncContext,
82
+ onError,
77
83
  }: {
78
84
  schema: LiveStoreSchema
79
85
  /** Only used to know whether we can safely query dbMutationLog during setup execution */
80
- dbMissing: boolean
86
+ dbMutationLogMissing: boolean
81
87
  dbMutationLog: SqliteDb
82
- clientId: string
88
+ dbReadModel: SqliteDb
89
+ /** Only used to know whether we can safely query dbReadModel during setup execution */
90
+ dbReadModelMissing: boolean
83
91
  initialBlockingSyncContext: InitialBlockingSyncContext
92
+ onError: 'shutdown' | 'ignore'
84
93
  }): Effect.Effect<LeaderSyncProcessor, UnexpectedError, Scope.Scope> =>
85
94
  Effect.gen(function* () {
86
- const syncBackendQueue = yield* BucketQueue.make<MutationEvent.EncodedWithMeta>()
95
+ const syncBackendPushQueue = yield* BucketQueue.make<MutationEvent.EncodedWithMeta>()
87
96
 
88
97
  const syncStateSref = yield* SubscriptionRef.make<SyncState.SyncState | undefined>(undefined)
89
98
 
90
- const isLocalEvent = (mutationEventEncoded: MutationEvent.EncodedWithMeta) => {
99
+ const isClientEvent = (mutationEventEncoded: MutationEvent.EncodedWithMeta) => {
91
100
  const mutationDef = getMutationDef(schema, mutationEventEncoded.mutation)
92
101
  return mutationDef.options.clientOnly
93
102
  }
94
103
 
104
+ const connectedClientSessionPullQueues = yield* makePullQueueSet
105
+
95
106
  /**
96
107
  * Tracks generations of queued local push events.
97
- * If a batch is rejected, all subsequent push queue items with the same generation are also rejected,
108
+ * If a local-push batch is rejected, all subsequent push queue items with the same generation are also rejected,
98
109
  * even if they would be valid on their own.
99
110
  */
100
111
  const currentLocalPushGenerationRef = { current: 0 }
101
112
 
113
+ const mergeCounterRef = { current: dbReadModelMissing ? 0 : yield* getMergeCounterFromDb(dbReadModel) }
114
+ const mergePayloads = new Map<number, typeof SyncState.PayloadUpstream.Type>()
115
+
102
116
  // This context depends on data from `boot`, we should find a better implementation to avoid this ref indirection.
103
117
  const ctxRef = {
104
118
  current: undefined as
@@ -107,6 +121,7 @@ export const makeLeaderSyncProcessor = ({
107
121
  otelSpan: otel.Span | undefined
108
122
  span: Tracer.Span
109
123
  devtoolsLatch: Effect.Latch | undefined
124
+ runtime: Runtime.Runtime<LeaderThreadCtx>
110
125
  },
111
126
  }
112
127
 
@@ -114,24 +129,12 @@ export const makeLeaderSyncProcessor = ({
114
129
  const localPushesLatch = yield* Effect.makeLatch(true)
115
130
  const pullLatch = yield* Effect.makeLatch(true)
116
131
 
132
+ // NOTE: New events are only pushed to sync backend after successful local push processing
117
133
  const push: LeaderSyncProcessor['push'] = (newEvents, options) =>
118
134
  Effect.gen(function* () {
119
135
  // TODO validate batch
120
136
  if (newEvents.length === 0) return
121
137
 
122
- // if (options.generation < currentLocalPushGenerationRef.current) {
123
- // debugger
124
- // // We can safely drop this batch as it's from a previous push generation
125
- // return
126
- // }
127
-
128
- if (clientId === 'client-b') {
129
- // console.log(
130
- // 'push from client session',
131
- // newEvents.map((item) => item.toJSON()),
132
- // )
133
- }
134
-
135
138
  const waitForProcessing = options?.waitForProcessing ?? false
136
139
  const generation = currentLocalPushGenerationRef.current
137
140
 
@@ -152,7 +155,7 @@ export const makeLeaderSyncProcessor = ({
152
155
  yield* BucketQueue.offerAll(localPushesQueue, items)
153
156
  }
154
157
  }).pipe(
155
- Effect.withSpan('@livestore/common:leader-thread:syncing:local-push', {
158
+ Effect.withSpan('@livestore/common:LeaderSyncProcessor:local-push', {
156
159
  attributes: {
157
160
  batchSize: newEvents.length,
158
161
  batch: TRACE_VERBOSE ? newEvents : undefined,
@@ -162,7 +165,7 @@ export const makeLeaderSyncProcessor = ({
162
165
  )
163
166
 
164
167
  const pushPartial: LeaderSyncProcessor['pushPartial'] = ({
165
- mutationEvent: partialMutationEvent,
168
+ mutationEvent: { mutation, args },
166
169
  clientId,
167
170
  sessionId,
168
171
  }) =>
@@ -170,10 +173,11 @@ export const makeLeaderSyncProcessor = ({
170
173
  const syncState = yield* syncStateSref
171
174
  if (syncState === undefined) return shouldNeverHappen('Not initialized')
172
175
 
173
- const mutationDef = getMutationDef(schema, partialMutationEvent.mutation)
176
+ const mutationDef = getMutationDef(schema, mutation)
174
177
 
175
178
  const mutationEventEncoded = new MutationEvent.EncodedWithMeta({
176
- ...partialMutationEvent,
179
+ mutation,
180
+ args,
177
181
  clientId,
178
182
  sessionId,
179
183
  ...EventId.nextPair(syncState.localHead, mutationDef.options.clientOnly),
@@ -183,132 +187,166 @@ export const makeLeaderSyncProcessor = ({
183
187
  }).pipe(Effect.catchTag('LeaderAheadError', Effect.orDie))
184
188
 
185
189
  // Starts various background loops
186
- const boot: LeaderSyncProcessor['boot'] = ({ dbReady }) =>
187
- Effect.gen(function* () {
188
- const span = yield* Effect.currentSpan.pipe(Effect.orDie)
189
- const otelSpan = yield* OtelTracer.currentOtelSpan.pipe(Effect.catchAll(() => Effect.succeed(undefined)))
190
- const { devtools, shutdownChannel } = yield* LeaderThreadCtx
190
+ const boot: LeaderSyncProcessor['boot'] = Effect.gen(function* () {
191
+ const span = yield* Effect.currentSpan.pipe(Effect.orDie)
192
+ const otelSpan = yield* OtelTracer.currentOtelSpan.pipe(Effect.catchAll(() => Effect.succeed(undefined)))
193
+ const { devtools, shutdownChannel } = yield* LeaderThreadCtx
194
+ const runtime = yield* Effect.runtime<LeaderThreadCtx>()
195
+
196
+ ctxRef.current = {
197
+ otelSpan,
198
+ span,
199
+ devtoolsLatch: devtools.enabled ? devtools.syncBackendLatch : undefined,
200
+ runtime,
201
+ }
191
202
 
192
- ctxRef.current = {
193
- otelSpan,
194
- span,
195
- devtoolsLatch: devtools.enabled ? devtools.syncBackendLatch : undefined,
196
- }
203
+ const initialBackendHead = dbMutationLogMissing
204
+ ? EventId.ROOT.global
205
+ : Mutationlog.getBackendHeadFromDb(dbMutationLog)
206
+ const initialLocalHead = dbMutationLogMissing ? EventId.ROOT : Mutationlog.getClientHeadFromDb(dbMutationLog)
197
207
 
198
- const initialBackendHead = dbMissing ? EventId.ROOT.global : getBackendHeadFromDb(dbMutationLog)
199
- const initialLocalHead = dbMissing ? EventId.ROOT : getClientHeadFromDb(dbMutationLog)
208
+ if (initialBackendHead > initialLocalHead.global) {
209
+ return shouldNeverHappen(
210
+ `During boot the backend head (${initialBackendHead}) should never be greater than the local head (${initialLocalHead.global})`,
211
+ )
212
+ }
200
213
 
201
- if (initialBackendHead > initialLocalHead.global) {
202
- return shouldNeverHappen(
203
- `During boot the backend head (${initialBackendHead}) should never be greater than the local head (${initialLocalHead.global})`,
204
- )
205
- }
214
+ const pendingMutationEvents = dbMutationLogMissing
215
+ ? []
216
+ : yield* Mutationlog.getMutationEventsSince({ global: initialBackendHead, client: EventId.clientDefault })
206
217
 
207
- const pendingMutationEvents = yield* getMutationEventsSince({
208
- global: initialBackendHead,
209
- client: EventId.clientDefault,
210
- }).pipe(Effect.map(ReadonlyArray.map((_) => new MutationEvent.EncodedWithMeta(_))))
211
-
212
- const initialSyncState = new SyncState.SyncState({
213
- pending: pendingMutationEvents,
214
- // On the leader we don't need a rollback tail beyond `pending` items
215
- rollbackTail: [],
216
- upstreamHead: { global: initialBackendHead, client: EventId.clientDefault },
217
- localHead: initialLocalHead,
218
- })
218
+ const initialSyncState = new SyncState.SyncState({
219
+ pending: pendingMutationEvents,
220
+ upstreamHead: { global: initialBackendHead, client: EventId.clientDefault },
221
+ localHead: initialLocalHead,
222
+ })
219
223
 
220
- /** State transitions need to happen atomically, so we use a Ref to track the state */
221
- yield* SubscriptionRef.set(syncStateSref, initialSyncState)
224
+ /** State transitions need to happen atomically, so we use a Ref to track the state */
225
+ yield* SubscriptionRef.set(syncStateSref, initialSyncState)
222
226
 
223
- // Rehydrate sync queue
224
- if (pendingMutationEvents.length > 0) {
225
- const filteredBatch = pendingMutationEvents
226
- // Don't sync clientOnly mutations
227
- .filter((mutationEventEncoded) => {
228
- const mutationDef = getMutationDef(schema, mutationEventEncoded.mutation)
229
- return mutationDef.options.clientOnly === false
230
- })
227
+ // Rehydrate sync queue
228
+ if (pendingMutationEvents.length > 0) {
229
+ const globalPendingMutationEvents = pendingMutationEvents
230
+ // Don't sync clientOnly mutations
231
+ .filter((mutationEventEncoded) => {
232
+ const mutationDef = getMutationDef(schema, mutationEventEncoded.mutation)
233
+ return mutationDef.options.clientOnly === false
234
+ })
231
235
 
232
- yield* BucketQueue.offerAll(syncBackendQueue, filteredBatch)
236
+ if (globalPendingMutationEvents.length > 0) {
237
+ yield* BucketQueue.offerAll(syncBackendPushQueue, globalPendingMutationEvents)
233
238
  }
239
+ }
234
240
 
235
- const shutdownOnError = (cause: unknown) =>
236
- Effect.gen(function* () {
241
+ const shutdownOnError = (cause: unknown) =>
242
+ Effect.gen(function* () {
243
+ if (onError === 'shutdown') {
237
244
  yield* shutdownChannel.send(UnexpectedError.make({ cause }))
238
245
  yield* Effect.die(cause)
239
- })
240
-
241
- yield* backgroundApplyLocalPushes({
242
- localPushesLatch,
243
- localPushesQueue,
244
- pullLatch,
245
- syncStateSref,
246
- syncBackendQueue,
247
- schema,
248
- isLocalEvent,
249
- otelSpan,
250
- currentLocalPushGenerationRef,
251
- }).pipe(Effect.tapCauseLogPretty, Effect.catchAllCause(shutdownOnError), Effect.forkScoped)
252
-
253
- const backendPushingFiberHandle = yield* FiberHandle.make()
254
-
255
- yield* FiberHandle.run(
256
- backendPushingFiberHandle,
257
- backgroundBackendPushing({
258
- dbReady,
259
- syncBackendQueue,
260
- otelSpan,
261
- devtoolsLatch: ctxRef.current?.devtoolsLatch,
262
- }).pipe(Effect.tapCauseLogPretty, Effect.catchAllCause(shutdownOnError)),
263
- )
246
+ }
247
+ })
264
248
 
265
- yield* backgroundBackendPulling({
266
- dbReady,
267
- initialBackendHead,
268
- isLocalEvent,
269
- restartBackendPushing: (filteredRebasedPending) =>
270
- Effect.gen(function* () {
271
- // Stop current pushing fiber
272
- yield* FiberHandle.clear(backendPushingFiberHandle)
273
-
274
- // Reset the sync queue
275
- yield* BucketQueue.clear(syncBackendQueue)
276
- yield* BucketQueue.offerAll(syncBackendQueue, filteredRebasedPending)
277
-
278
- // Restart pushing fiber
279
- yield* FiberHandle.run(
280
- backendPushingFiberHandle,
281
- backgroundBackendPushing({
282
- dbReady,
283
- syncBackendQueue,
284
- otelSpan,
285
- devtoolsLatch: ctxRef.current?.devtoolsLatch,
286
- }).pipe(Effect.tapCauseLogPretty, Effect.catchAllCause(shutdownOnError)),
287
- )
288
- }),
289
- syncStateSref,
290
- localPushesLatch,
291
- pullLatch,
249
+ yield* backgroundApplyLocalPushes({
250
+ localPushesLatch,
251
+ localPushesQueue,
252
+ pullLatch,
253
+ syncStateSref,
254
+ syncBackendPushQueue,
255
+ schema,
256
+ isClientEvent,
257
+ otelSpan,
258
+ currentLocalPushGenerationRef,
259
+ connectedClientSessionPullQueues,
260
+ mergeCounterRef,
261
+ mergePayloads,
262
+ }).pipe(Effect.tapCauseLogPretty, Effect.catchAllCause(shutdownOnError), Effect.forkScoped)
263
+
264
+ const backendPushingFiberHandle = yield* FiberHandle.make()
265
+
266
+ yield* FiberHandle.run(
267
+ backendPushingFiberHandle,
268
+ backgroundBackendPushing({
269
+ syncBackendPushQueue,
292
270
  otelSpan,
293
- initialBlockingSyncContext,
294
271
  devtoolsLatch: ctxRef.current?.devtoolsLatch,
295
- }).pipe(Effect.tapCauseLogPretty, Effect.catchAllCause(shutdownOnError), Effect.forkScoped)
272
+ }).pipe(Effect.tapCauseLogPretty, Effect.catchAllCause(shutdownOnError)),
273
+ )
274
+
275
+ yield* backgroundBackendPulling({
276
+ initialBackendHead,
277
+ isClientEvent,
278
+ restartBackendPushing: (filteredRebasedPending) =>
279
+ Effect.gen(function* () {
280
+ // Stop current pushing fiber
281
+ yield* FiberHandle.clear(backendPushingFiberHandle)
282
+
283
+ // Reset the sync backend push queue
284
+ yield* BucketQueue.clear(syncBackendPushQueue)
285
+ yield* BucketQueue.offerAll(syncBackendPushQueue, filteredRebasedPending)
286
+
287
+ // Restart pushing fiber
288
+ yield* FiberHandle.run(
289
+ backendPushingFiberHandle,
290
+ backgroundBackendPushing({
291
+ syncBackendPushQueue,
292
+ otelSpan,
293
+ devtoolsLatch: ctxRef.current?.devtoolsLatch,
294
+ }).pipe(Effect.tapCauseLogPretty, Effect.catchAllCause(shutdownOnError)),
295
+ )
296
+ }),
297
+ syncStateSref,
298
+ localPushesLatch,
299
+ pullLatch,
300
+ otelSpan,
301
+ initialBlockingSyncContext,
302
+ devtoolsLatch: ctxRef.current?.devtoolsLatch,
303
+ connectedClientSessionPullQueues,
304
+ mergeCounterRef,
305
+ mergePayloads,
306
+ }).pipe(Effect.tapCauseLogPretty, Effect.catchAllCause(shutdownOnError), Effect.forkScoped)
307
+
308
+ return { initialLeaderHead: initialLocalHead }
309
+ }).pipe(Effect.withSpanScoped('@livestore/common:LeaderSyncProcessor:boot'))
310
+
311
+ const pull: LeaderSyncProcessor['pull'] = ({ cursor }) => {
312
+ return Effect.gen(function* () {
313
+ const queue = yield* pullQueue({ cursor })
314
+ return Stream.fromQueue(queue)
315
+ }).pipe(Stream.unwrapScoped)
316
+ }
317
+
318
+ const pullQueue: LeaderSyncProcessor['pullQueue'] = ({ cursor }) => {
319
+ const runtime = ctxRef.current?.runtime ?? shouldNeverHappen('Not initialized')
320
+ return Effect.gen(function* () {
321
+ const queue = yield* connectedClientSessionPullQueues.makeQueue(cursor)
322
+ const payloadsSinceCursor = Array.from(mergePayloads.entries())
323
+ .map(([mergeCounter, payload]) => ({ payload, mergeCounter }))
324
+ .filter(({ mergeCounter }) => mergeCounter > cursor)
325
+ .toSorted((a, b) => a.mergeCounter - b.mergeCounter)
296
326
 
297
- return { initialLeaderHead: initialLocalHead }
298
- }).pipe(Effect.withSpanScoped('@livestore/common:leader-thread:syncing'))
327
+ yield* queue.offerAll(payloadsSinceCursor)
328
+
329
+ return queue
330
+ }).pipe(Effect.provide(runtime))
331
+ }
332
+
333
+ const syncState = Subscribable.make({
334
+ get: Effect.gen(function* () {
335
+ const syncState = yield* syncStateSref
336
+ if (syncState === undefined) return shouldNeverHappen('Not initialized')
337
+ return syncState
338
+ }),
339
+ changes: syncStateSref.changes.pipe(Stream.filter(isNotUndefined)),
340
+ })
299
341
 
300
342
  return {
343
+ pull,
344
+ pullQueue,
301
345
  push,
302
346
  pushPartial,
303
347
  boot,
304
- syncState: Subscribable.make({
305
- get: Effect.gen(function* () {
306
- const syncState = yield* syncStateSref
307
- if (syncState === undefined) return shouldNeverHappen('Not initialized')
308
- return syncState
309
- }),
310
- changes: syncStateSref.changes.pipe(Stream.filter(isNotUndefined)),
311
- }),
348
+ syncState,
349
+ getMergeCounter: () => mergeCounterRef.current,
312
350
  } satisfies LeaderSyncProcessor
313
351
  })
314
352
 
@@ -317,30 +355,32 @@ const backgroundApplyLocalPushes = ({
317
355
  localPushesQueue,
318
356
  pullLatch,
319
357
  syncStateSref,
320
- syncBackendQueue,
358
+ syncBackendPushQueue,
321
359
  schema,
322
- isLocalEvent,
360
+ isClientEvent,
323
361
  otelSpan,
324
362
  currentLocalPushGenerationRef,
363
+ connectedClientSessionPullQueues,
364
+ mergeCounterRef,
365
+ mergePayloads,
325
366
  }: {
326
367
  pullLatch: Effect.Latch
327
368
  localPushesLatch: Effect.Latch
328
369
  localPushesQueue: BucketQueue.BucketQueue<LocalPushQueueItem>
329
370
  syncStateSref: SubscriptionRef.SubscriptionRef<SyncState.SyncState | undefined>
330
- syncBackendQueue: BucketQueue.BucketQueue<MutationEvent.EncodedWithMeta>
371
+ syncBackendPushQueue: BucketQueue.BucketQueue<MutationEvent.EncodedWithMeta>
331
372
  schema: LiveStoreSchema
332
- isLocalEvent: (mutationEventEncoded: MutationEvent.EncodedWithMeta) => boolean
373
+ isClientEvent: (mutationEventEncoded: MutationEvent.EncodedWithMeta) => boolean
333
374
  otelSpan: otel.Span | undefined
334
375
  currentLocalPushGenerationRef: { current: number }
376
+ connectedClientSessionPullQueues: PullQueueSet
377
+ mergeCounterRef: { current: number }
378
+ mergePayloads: Map<number, typeof SyncState.PayloadUpstream.Type>
335
379
  }) =>
336
380
  Effect.gen(function* () {
337
- const { connectedClientSessionPullQueues, clientId } = yield* LeaderThreadCtx
338
-
339
- const applyMutationItems = yield* makeApplyMutationItems
340
-
341
381
  while (true) {
342
382
  // TODO make batch size configurable
343
- const batchItems = yield* BucketQueue.takeBetween(localPushesQueue, 1, 10)
383
+ const batchItems = yield* BucketQueue.takeBetween(localPushesQueue, 1, LOCAL_PUSH_BATCH_SIZE)
344
384
 
345
385
  // Wait for the backend pulling to finish
346
386
  yield* localPushesLatch.await
@@ -366,34 +406,33 @@ const backgroundApplyLocalPushes = ({
366
406
  const syncState = yield* syncStateSref
367
407
  if (syncState === undefined) return shouldNeverHappen('Not initialized')
368
408
 
369
- const updateResult = SyncState.updateSyncState({
409
+ const mergeResult = SyncState.merge({
370
410
  syncState,
371
411
  payload: { _tag: 'local-push', newEvents },
372
- isLocalEvent,
412
+ isClientEvent,
373
413
  isEqualEvent: MutationEvent.isEqualEncoded,
374
414
  })
375
415
 
376
- switch (updateResult._tag) {
416
+ const mergeCounter = yield* incrementMergeCounter(mergeCounterRef)
417
+
418
+ switch (mergeResult._tag) {
377
419
  case 'unexpected-error': {
378
- otelSpan?.addEvent('local-push:unexpected-error', {
420
+ otelSpan?.addEvent(`merge[${mergeCounter}]:local-push:unexpected-error`, {
379
421
  batchSize: newEvents.length,
380
422
  newEvents: TRACE_VERBOSE ? JSON.stringify(newEvents) : undefined,
381
423
  })
382
- return yield* Effect.fail(updateResult.cause)
424
+ return yield* Effect.fail(mergeResult.cause)
383
425
  }
384
426
  case 'rebase': {
385
427
  return shouldNeverHappen('The leader thread should never have to rebase due to a local push')
386
428
  }
387
429
  case 'reject': {
388
- otelSpan?.addEvent('local-push:reject', {
430
+ otelSpan?.addEvent(`merge[${mergeCounter}]:local-push:reject`, {
389
431
  batchSize: newEvents.length,
390
- updateResult: TRACE_VERBOSE ? JSON.stringify(updateResult) : undefined,
432
+ mergeResult: TRACE_VERBOSE ? JSON.stringify(mergeResult) : undefined,
391
433
  })
392
434
 
393
- /*
394
-
395
- TODO: how to test this?
396
- */
435
+ // TODO: how to test this?
397
436
  currentLocalPushGenerationRef.current++
398
437
 
399
438
  const nextGeneration = currentLocalPushGenerationRef.current
@@ -407,7 +446,8 @@ const backgroundApplyLocalPushes = ({
407
446
  (item) => item[2] >= nextGeneration,
408
447
  )
409
448
 
410
- if ((yield* BucketQueue.size(localPushesQueue)) > 0) {
449
+ // TODO we still need to better understand and handle this scenario
450
+ if (LS_DEV && (yield* BucketQueue.size(localPushesQueue)) > 0) {
411
451
  console.log('localPushesQueue is not empty', yield* BucketQueue.size(localPushesQueue))
412
452
  debugger
413
453
  }
@@ -421,7 +461,7 @@ const backgroundApplyLocalPushes = ({
421
461
  Deferred.fail(
422
462
  deferred,
423
463
  LeaderAheadError.make({
424
- minimumExpectedId: updateResult.expectedMinimumId,
464
+ minimumExpectedId: mergeResult.expectedMinimumId,
425
465
  providedId,
426
466
  // nextGeneration,
427
467
  }),
@@ -439,95 +479,90 @@ const backgroundApplyLocalPushes = ({
439
479
  break
440
480
  }
441
481
  default: {
442
- casesHandled(updateResult)
482
+ casesHandled(mergeResult)
443
483
  }
444
484
  }
445
485
 
446
- yield* SubscriptionRef.set(syncStateSref, updateResult.newSyncState)
486
+ yield* SubscriptionRef.set(syncStateSref, mergeResult.newSyncState)
447
487
 
448
- if (clientId === 'client-b') {
449
- // yield* Effect.log('offer upstream-advance due to local-push')
450
- // debugger
451
- }
452
488
  yield* connectedClientSessionPullQueues.offer({
453
- payload: { _tag: 'upstream-advance', newEvents: updateResult.newEvents },
454
- remaining: 0,
489
+ payload: SyncState.PayloadUpstreamAdvance.make({ newEvents: mergeResult.newEvents }),
490
+ mergeCounter,
455
491
  })
492
+ mergePayloads.set(mergeCounter, SyncState.PayloadUpstreamAdvance.make({ newEvents: mergeResult.newEvents }))
456
493
 
457
- otelSpan?.addEvent('local-push', {
494
+ otelSpan?.addEvent(`merge[${mergeCounter}]:local-push:advance`, {
458
495
  batchSize: newEvents.length,
459
- updateResult: TRACE_VERBOSE ? JSON.stringify(updateResult) : undefined,
496
+ mergeResult: TRACE_VERBOSE ? JSON.stringify(mergeResult) : undefined,
460
497
  })
461
498
 
462
499
  // Don't sync clientOnly mutations
463
- const filteredBatch = updateResult.newEvents.filter((mutationEventEncoded) => {
500
+ const filteredBatch = mergeResult.newEvents.filter((mutationEventEncoded) => {
464
501
  const mutationDef = getMutationDef(schema, mutationEventEncoded.mutation)
465
502
  return mutationDef.options.clientOnly === false
466
503
  })
467
504
 
468
- yield* BucketQueue.offerAll(syncBackendQueue, filteredBatch)
505
+ yield* BucketQueue.offerAll(syncBackendPushQueue, filteredBatch)
469
506
 
470
- yield* applyMutationItems({ batchItems: newEvents, deferreds })
507
+ yield* applyMutationsBatch({ batchItems: newEvents, deferreds })
471
508
 
472
509
  // Allow the backend pulling to start
473
510
  yield* pullLatch.open
474
511
  }
475
512
  })
476
513
 
477
- type ApplyMutationItems = (_: {
514
+ type ApplyMutationsBatch = (_: {
478
515
  batchItems: ReadonlyArray<MutationEvent.EncodedWithMeta>
479
- /** Indexes are aligned with `batchItems` */
516
+ /**
517
+ * The deferreds are used by the caller to know when the mutation has been processed.
518
+ * Indexes are aligned with `batchItems`
519
+ */
480
520
  deferreds: ReadonlyArray<Deferred.Deferred<void, LeaderAheadError> | undefined> | undefined
481
- }) => Effect.Effect<void, UnexpectedError>
521
+ }) => Effect.Effect<void, UnexpectedError, LeaderThreadCtx>
482
522
 
483
523
  // TODO how to handle errors gracefully
484
- const makeApplyMutationItems: Effect.Effect<ApplyMutationItems, UnexpectedError, LeaderThreadCtx | Scope.Scope> =
524
+ const applyMutationsBatch: ApplyMutationsBatch = ({ batchItems, deferreds }) =>
485
525
  Effect.gen(function* () {
486
- const leaderThreadCtx = yield* LeaderThreadCtx
487
- const { dbReadModel: db, dbMutationLog } = leaderThreadCtx
526
+ const { dbReadModel: db, dbMutationLog, applyMutation } = yield* LeaderThreadCtx
488
527
 
489
- const applyMutation = yield* makeApplyMutation
528
+ // NOTE We always start a transaction to ensure consistency between db and mutation log (even for single-item batches)
529
+ db.execute('BEGIN TRANSACTION', undefined) // Start the transaction
530
+ dbMutationLog.execute('BEGIN TRANSACTION', undefined) // Start the transaction
490
531
 
491
- return ({ batchItems, deferreds }) =>
532
+ yield* Effect.addFinalizer((exit) =>
492
533
  Effect.gen(function* () {
493
- db.execute('BEGIN TRANSACTION', undefined) // Start the transaction
494
- dbMutationLog.execute('BEGIN TRANSACTION', undefined) // Start the transaction
534
+ if (Exit.isSuccess(exit)) return
495
535
 
496
- yield* Effect.addFinalizer((exit) =>
497
- Effect.gen(function* () {
498
- if (Exit.isSuccess(exit)) return
499
-
500
- // Rollback in case of an error
501
- db.execute('ROLLBACK', undefined)
502
- dbMutationLog.execute('ROLLBACK', undefined)
503
- }),
504
- )
536
+ // Rollback in case of an error
537
+ db.execute('ROLLBACK', undefined)
538
+ dbMutationLog.execute('ROLLBACK', undefined)
539
+ }),
540
+ )
505
541
 
506
- for (let i = 0; i < batchItems.length; i++) {
507
- yield* applyMutation(batchItems[i]!)
542
+ for (let i = 0; i < batchItems.length; i++) {
543
+ const { sessionChangeset } = yield* applyMutation(batchItems[i]!)
544
+ batchItems[i]!.meta.sessionChangeset = sessionChangeset
508
545
 
509
- if (deferreds?.[i] !== undefined) {
510
- yield* Deferred.succeed(deferreds[i]!, void 0)
511
- }
512
- }
546
+ if (deferreds?.[i] !== undefined) {
547
+ yield* Deferred.succeed(deferreds[i]!, void 0)
548
+ }
549
+ }
513
550
 
514
- db.execute('COMMIT', undefined) // Commit the transaction
515
- dbMutationLog.execute('COMMIT', undefined) // Commit the transaction
516
- }).pipe(
517
- Effect.uninterruptible,
518
- Effect.scoped,
519
- Effect.withSpan('@livestore/common:leader-thread:syncing:applyMutationItems', {
520
- attributes: { count: batchItems.length },
521
- }),
522
- Effect.tapCauseLogPretty,
523
- UnexpectedError.mapToUnexpectedError,
524
- )
525
- })
551
+ db.execute('COMMIT', undefined) // Commit the transaction
552
+ dbMutationLog.execute('COMMIT', undefined) // Commit the transaction
553
+ }).pipe(
554
+ Effect.uninterruptible,
555
+ Effect.scoped,
556
+ Effect.withSpan('@livestore/common:LeaderSyncProcessor:applyMutationItems', {
557
+ attributes: { batchSize: batchItems.length },
558
+ }),
559
+ Effect.tapCauseLogPretty,
560
+ UnexpectedError.mapToUnexpectedError,
561
+ )
526
562
 
527
563
  const backgroundBackendPulling = ({
528
- dbReady,
529
564
  initialBackendHead,
530
- isLocalEvent,
565
+ isClientEvent,
531
566
  restartBackendPushing,
532
567
  otelSpan,
533
568
  syncStateSref,
@@ -535,10 +570,12 @@ const backgroundBackendPulling = ({
535
570
  pullLatch,
536
571
  devtoolsLatch,
537
572
  initialBlockingSyncContext,
573
+ connectedClientSessionPullQueues,
574
+ mergeCounterRef,
575
+ mergePayloads,
538
576
  }: {
539
- dbReady: Deferred.Deferred<void>
540
577
  initialBackendHead: EventId.GlobalEventId
541
- isLocalEvent: (mutationEventEncoded: MutationEvent.EncodedWithMeta) => boolean
578
+ isClientEvent: (mutationEventEncoded: MutationEvent.EncodedWithMeta) => boolean
542
579
  restartBackendPushing: (
543
580
  filteredRebasedPending: ReadonlyArray<MutationEvent.EncodedWithMeta>,
544
581
  ) => Effect.Effect<void, UnexpectedError, LeaderThreadCtx | HttpClient.HttpClient>
@@ -548,23 +585,15 @@ const backgroundBackendPulling = ({
548
585
  pullLatch: Effect.Latch
549
586
  devtoolsLatch: Effect.Latch | undefined
550
587
  initialBlockingSyncContext: InitialBlockingSyncContext
588
+ connectedClientSessionPullQueues: PullQueueSet
589
+ mergeCounterRef: { current: number }
590
+ mergePayloads: Map<number, typeof SyncState.PayloadUpstream.Type>
551
591
  }) =>
552
592
  Effect.gen(function* () {
553
- const {
554
- syncBackend,
555
- dbReadModel: db,
556
- dbMutationLog,
557
- connectedClientSessionPullQueues,
558
- schema,
559
- clientId,
560
- } = yield* LeaderThreadCtx
593
+ const { syncBackend, dbReadModel: db, dbMutationLog, schema } = yield* LeaderThreadCtx
561
594
 
562
595
  if (syncBackend === undefined) return
563
596
 
564
- const cursorInfo = yield* getCursorInfo(initialBackendHead)
565
-
566
- const applyMutationItems = yield* makeApplyMutationItems
567
-
568
597
  const onNewPullChunk = (newEvents: MutationEvent.EncodedWithMeta[], remaining: number) =>
569
598
  Effect.gen(function* () {
570
599
  if (newEvents.length === 0) return
@@ -582,84 +611,101 @@ const backgroundBackendPulling = ({
582
611
  const syncState = yield* syncStateSref
583
612
  if (syncState === undefined) return shouldNeverHappen('Not initialized')
584
613
 
585
- const trimRollbackUntil = newEvents.at(-1)!.id
586
-
587
- const updateResult = SyncState.updateSyncState({
614
+ const mergeResult = SyncState.merge({
588
615
  syncState,
589
- payload: { _tag: 'upstream-advance', newEvents, trimRollbackUntil },
590
- isLocalEvent,
616
+ payload: SyncState.PayloadUpstreamAdvance.make({ newEvents }),
617
+ isClientEvent,
591
618
  isEqualEvent: MutationEvent.isEqualEncoded,
592
- ignoreLocalEvents: true,
619
+ ignoreClientEvents: true,
593
620
  })
594
621
 
595
- if (updateResult._tag === 'reject') {
622
+ const mergeCounter = yield* incrementMergeCounter(mergeCounterRef)
623
+
624
+ if (mergeResult._tag === 'reject') {
596
625
  return shouldNeverHappen('The leader thread should never reject upstream advances')
597
- } else if (updateResult._tag === 'unexpected-error') {
598
- otelSpan?.addEvent('backend-pull:unexpected-error', {
626
+ } else if (mergeResult._tag === 'unexpected-error') {
627
+ otelSpan?.addEvent(`merge[${mergeCounter}]:backend-pull:unexpected-error`, {
599
628
  newEventsCount: newEvents.length,
600
629
  newEvents: TRACE_VERBOSE ? JSON.stringify(newEvents) : undefined,
601
630
  })
602
- return yield* Effect.fail(updateResult.cause)
631
+ return yield* Effect.fail(mergeResult.cause)
603
632
  }
604
633
 
605
634
  const newBackendHead = newEvents.at(-1)!.id
606
635
 
607
- updateBackendHead(dbMutationLog, newBackendHead)
636
+ Mutationlog.updateBackendHead(dbMutationLog, newBackendHead)
608
637
 
609
- if (updateResult._tag === 'rebase') {
610
- otelSpan?.addEvent('backend-pull:rebase', {
638
+ if (mergeResult._tag === 'rebase') {
639
+ otelSpan?.addEvent(`merge[${mergeCounter}]:backend-pull:rebase`, {
611
640
  newEventsCount: newEvents.length,
612
641
  newEvents: TRACE_VERBOSE ? JSON.stringify(newEvents) : undefined,
613
- rollbackCount: updateResult.eventsToRollback.length,
614
- updateResult: TRACE_VERBOSE ? JSON.stringify(updateResult) : undefined,
642
+ rollbackCount: mergeResult.rollbackEvents.length,
643
+ mergeResult: TRACE_VERBOSE ? JSON.stringify(mergeResult) : undefined,
615
644
  })
616
645
 
617
- const filteredRebasedPending = updateResult.newSyncState.pending.filter((mutationEvent) => {
646
+ const globalRebasedPendingEvents = mergeResult.newSyncState.pending.filter((mutationEvent) => {
618
647
  const mutationDef = getMutationDef(schema, mutationEvent.mutation)
619
648
  return mutationDef.options.clientOnly === false
620
649
  })
621
- yield* restartBackendPushing(filteredRebasedPending)
650
+ yield* restartBackendPushing(globalRebasedPendingEvents)
622
651
 
623
- if (updateResult.eventsToRollback.length > 0) {
624
- yield* rollback({ db, dbMutationLog, eventIdsToRollback: updateResult.eventsToRollback.map((_) => _.id) })
652
+ if (mergeResult.rollbackEvents.length > 0) {
653
+ yield* rollback({ db, dbMutationLog, eventIdsToRollback: mergeResult.rollbackEvents.map((_) => _.id) })
625
654
  }
626
655
 
627
656
  yield* connectedClientSessionPullQueues.offer({
628
- payload: {
629
- _tag: 'upstream-rebase',
630
- newEvents: updateResult.newEvents,
631
- rollbackUntil: updateResult.eventsToRollback.at(0)!.id,
632
- trimRollbackUntil,
633
- },
634
- remaining,
657
+ payload: SyncState.PayloadUpstreamRebase.make({
658
+ newEvents: mergeResult.newEvents,
659
+ rollbackEvents: mergeResult.rollbackEvents,
660
+ }),
661
+ mergeCounter,
635
662
  })
663
+ mergePayloads.set(
664
+ mergeCounter,
665
+ SyncState.PayloadUpstreamRebase.make({
666
+ newEvents: mergeResult.newEvents,
667
+ rollbackEvents: mergeResult.rollbackEvents,
668
+ }),
669
+ )
636
670
  } else {
637
- otelSpan?.addEvent('backend-pull:advance', {
671
+ otelSpan?.addEvent(`merge[${mergeCounter}]:backend-pull:advance`, {
638
672
  newEventsCount: newEvents.length,
639
- updateResult: TRACE_VERBOSE ? JSON.stringify(updateResult) : undefined,
673
+ mergeResult: TRACE_VERBOSE ? JSON.stringify(mergeResult) : undefined,
640
674
  })
641
675
 
642
- if (clientId === 'client-b') {
643
- // yield* Effect.log('offer upstream-advance due to pull')
644
- }
645
676
  yield* connectedClientSessionPullQueues.offer({
646
- payload: { _tag: 'upstream-advance', newEvents: updateResult.newEvents, trimRollbackUntil },
647
- remaining,
677
+ payload: SyncState.PayloadUpstreamAdvance.make({ newEvents: mergeResult.newEvents }),
678
+ mergeCounter,
648
679
  })
680
+ mergePayloads.set(mergeCounter, SyncState.PayloadUpstreamAdvance.make({ newEvents: mergeResult.newEvents }))
681
+
682
+ if (mergeResult.confirmedEvents.length > 0) {
683
+ // `mergeResult.confirmedEvents` don't contain the correct sync metadata, so we need to use
684
+ // `newEvents` instead which we filter via `mergeResult.confirmedEvents`
685
+ const confirmedNewEvents = newEvents.filter((mutationEvent) =>
686
+ mergeResult.confirmedEvents.some((confirmedEvent) =>
687
+ EventId.isEqual(mutationEvent.id, confirmedEvent.id),
688
+ ),
689
+ )
690
+ yield* Mutationlog.updateSyncMetadata(confirmedNewEvents)
691
+ }
649
692
  }
650
693
 
694
+ // Removes the changeset rows which are no longer needed as we'll never have to rollback beyond this point
651
695
  trimChangesetRows(db, newBackendHead)
652
696
 
653
- yield* applyMutationItems({ batchItems: updateResult.newEvents, deferreds: undefined })
697
+ yield* applyMutationsBatch({ batchItems: mergeResult.newEvents, deferreds: undefined })
654
698
 
655
- yield* SubscriptionRef.set(syncStateSref, updateResult.newSyncState)
699
+ yield* SubscriptionRef.set(syncStateSref, mergeResult.newSyncState)
656
700
 
701
+ // Allow local pushes to be processed again
657
702
  if (remaining === 0) {
658
- // Allow local pushes to be processed again
659
703
  yield* localPushesLatch.open
660
704
  }
661
705
  })
662
706
 
707
+ const cursorInfo = yield* Mutationlog.getSyncBackendCursorInfo(initialBackendHead)
708
+
663
709
  yield* syncBackend.pull(cursorInfo).pipe(
664
710
  // TODO only take from queue while connected
665
711
  Stream.tap(({ batch, remaining }) =>
@@ -671,16 +717,13 @@ const backgroundBackendPulling = ({
671
717
  // },
672
718
  // })
673
719
 
674
- // Wait for the db to be initially created
675
- yield* dbReady
676
-
677
720
  // NOTE we only want to take process mutations when the sync backend is connected
678
721
  // (e.g. needed for simulating being offline)
679
722
  // TODO remove when there's a better way to handle this in stream above
680
723
  yield* SubscriptionRef.waitUntil(syncBackend.isConnected, (isConnected) => isConnected === true)
681
724
 
682
725
  yield* onNewPullChunk(
683
- batch.map((_) => MutationEvent.EncodedWithMeta.fromGlobal(_.mutationEventEncoded)),
726
+ batch.map((_) => MutationEvent.EncodedWithMeta.fromGlobal(_.mutationEventEncoded, _.metadata)),
684
727
  remaining,
685
728
  )
686
729
 
@@ -690,102 +733,26 @@ const backgroundBackendPulling = ({
690
733
  Stream.runDrain,
691
734
  Effect.interruptible,
692
735
  )
693
- }).pipe(Effect.withSpan('@livestore/common:leader-thread:syncing:backend-pulling'))
694
-
695
- const rollback = ({
696
- db,
697
- dbMutationLog,
698
- eventIdsToRollback,
699
- }: {
700
- db: SqliteDb
701
- dbMutationLog: SqliteDb
702
- eventIdsToRollback: EventId.EventId[]
703
- }) =>
704
- Effect.gen(function* () {
705
- const rollbackEvents = db
706
- .select<SessionChangesetMetaRow>(
707
- sql`SELECT * FROM ${SESSION_CHANGESET_META_TABLE} WHERE (idGlobal, idClient) IN (${eventIdsToRollback.map((id) => `(${id.global}, ${id.client})`).join(', ')})`,
708
- )
709
- .map((_) => ({ id: { global: _.idGlobal, client: _.idClient }, changeset: _.changeset, debug: _.debug }))
710
- .sort((a, b) => EventId.compare(a.id, b.id))
711
- // TODO bring back `.toSorted` once Expo supports it
712
- // .toSorted((a, b) => EventId.compare(a.id, b.id))
713
-
714
- // Apply changesets in reverse order
715
- for (let i = rollbackEvents.length - 1; i >= 0; i--) {
716
- const { changeset } = rollbackEvents[i]!
717
- if (changeset !== null) {
718
- db.makeChangeset(changeset).invert().apply()
719
- }
720
- }
721
-
722
- const eventIdPairChunks = ReadonlyArray.chunksOf(100)(
723
- eventIdsToRollback.map((id) => `(${id.global}, ${id.client})`),
724
- )
725
-
726
- // Delete the changeset rows
727
- for (const eventIdPairChunk of eventIdPairChunks) {
728
- db.execute(
729
- sql`DELETE FROM ${SESSION_CHANGESET_META_TABLE} WHERE (idGlobal, idClient) IN (${eventIdPairChunk.join(', ')})`,
730
- )
731
- }
732
-
733
- // Delete the mutation log rows
734
- for (const eventIdPairChunk of eventIdPairChunks) {
735
- dbMutationLog.execute(
736
- sql`DELETE FROM ${MUTATION_LOG_META_TABLE} WHERE (idGlobal, idClient) IN (${eventIdPairChunk.join(', ')})`,
737
- )
738
- }
739
- }).pipe(
740
- Effect.withSpan('@livestore/common:leader-thread:syncing:rollback', {
741
- attributes: { count: eventIdsToRollback.length },
742
- }),
743
- )
744
-
745
- const getCursorInfo = (remoteHead: EventId.GlobalEventId) =>
746
- Effect.gen(function* () {
747
- const { dbMutationLog } = yield* LeaderThreadCtx
748
-
749
- if (remoteHead === EventId.ROOT.global) return Option.none()
750
-
751
- const MutationlogQuerySchema = Schema.Struct({
752
- syncMetadataJson: Schema.parseJson(Schema.Option(Schema.JsonValue)),
753
- }).pipe(Schema.pluck('syncMetadataJson'), Schema.Array, Schema.head)
754
-
755
- const syncMetadataOption = yield* Effect.sync(() =>
756
- dbMutationLog.select<{ syncMetadataJson: string }>(
757
- sql`SELECT syncMetadataJson FROM ${MUTATION_LOG_META_TABLE} WHERE idGlobal = ${remoteHead} ORDER BY idClient ASC LIMIT 1`,
758
- ),
759
- ).pipe(Effect.andThen(Schema.decode(MutationlogQuerySchema)), Effect.map(Option.flatten), Effect.orDie)
760
-
761
- return Option.some({
762
- cursor: { global: remoteHead, client: EventId.clientDefault },
763
- metadata: syncMetadataOption,
764
- }) satisfies InitialSyncInfo
765
- }).pipe(Effect.withSpan('@livestore/common:leader-thread:syncing:getCursorInfo', { attributes: { remoteHead } }))
736
+ }).pipe(Effect.withSpan('@livestore/common:LeaderSyncProcessor:backend-pulling'))
766
737
 
767
738
  const backgroundBackendPushing = ({
768
- dbReady,
769
- syncBackendQueue,
739
+ syncBackendPushQueue,
770
740
  otelSpan,
771
741
  devtoolsLatch,
772
742
  }: {
773
- dbReady: Deferred.Deferred<void>
774
- syncBackendQueue: BucketQueue.BucketQueue<MutationEvent.EncodedWithMeta>
743
+ syncBackendPushQueue: BucketQueue.BucketQueue<MutationEvent.EncodedWithMeta>
775
744
  otelSpan: otel.Span | undefined
776
745
  devtoolsLatch: Effect.Latch | undefined
777
746
  }) =>
778
747
  Effect.gen(function* () {
779
- const { syncBackend, dbMutationLog } = yield* LeaderThreadCtx
748
+ const { syncBackend } = yield* LeaderThreadCtx
780
749
  if (syncBackend === undefined) return
781
750
 
782
- yield* dbReady
783
-
784
751
  while (true) {
785
752
  yield* SubscriptionRef.waitUntil(syncBackend.isConnected, (isConnected) => isConnected === true)
786
753
 
787
754
  // TODO make batch size configurable
788
- const queueItems = yield* BucketQueue.takeBetween(syncBackendQueue, 1, BACKEND_PUSH_BATCH_SIZE)
755
+ const queueItems = yield* BucketQueue.takeBetween(syncBackendPushQueue, 1, BACKEND_PUSH_BATCH_SIZE)
789
756
 
790
757
  yield* SubscriptionRef.waitUntil(syncBackend.isConnected, (isConnected) => isConnected === true)
791
758
 
@@ -809,27 +776,88 @@ const backgroundBackendPushing = ({
809
776
  // wait for interrupt caused by background pulling which will then restart pushing
810
777
  return yield* Effect.never
811
778
  }
812
-
813
- const { metadata } = pushResult.right
814
-
815
- // TODO try to do this in a single query
816
- for (let i = 0; i < queueItems.length; i++) {
817
- const mutationEventEncoded = queueItems[i]!
818
- yield* execSql(
819
- dbMutationLog,
820
- ...updateRows({
821
- tableName: MUTATION_LOG_META_TABLE,
822
- columns: mutationLogMetaTable.sqliteDef.columns,
823
- where: { idGlobal: mutationEventEncoded.id.global, idClient: mutationEventEncoded.id.client },
824
- updateValues: { syncMetadataJson: metadata[i]! },
825
- }),
826
- )
827
- }
828
779
  }
829
- }).pipe(Effect.interruptible, Effect.withSpan('@livestore/common:leader-thread:syncing:backend-pushing'))
780
+ }).pipe(Effect.interruptible, Effect.withSpan('@livestore/common:LeaderSyncProcessor:backend-pushing'))
830
781
 
831
782
  const trimChangesetRows = (db: SqliteDb, newHead: EventId.EventId) => {
832
783
  // Since we're using the session changeset rows to query for the current head,
833
784
  // we're keeping at least one row for the current head, and thus are using `<` instead of `<=`
834
785
  db.execute(sql`DELETE FROM ${SESSION_CHANGESET_META_TABLE} WHERE idGlobal < ${newHead.global}`)
835
786
  }
787
+
788
+ interface PullQueueSet {
789
+ makeQueue: (
790
+ cursor: number,
791
+ ) => Effect.Effect<
792
+ Queue.Queue<{ payload: typeof SyncState.PayloadUpstream.Type; mergeCounter: number }>,
793
+ UnexpectedError,
794
+ Scope.Scope | LeaderThreadCtx
795
+ >
796
+ offer: (item: {
797
+ payload: typeof SyncState.PayloadUpstream.Type
798
+ mergeCounter: number
799
+ }) => Effect.Effect<void, UnexpectedError>
800
+ }
801
+
802
+ const makePullQueueSet = Effect.gen(function* () {
803
+ const set = new Set<Queue.Queue<{ payload: typeof SyncState.PayloadUpstream.Type; mergeCounter: number }>>()
804
+
805
+ yield* Effect.addFinalizer(() =>
806
+ Effect.gen(function* () {
807
+ for (const queue of set) {
808
+ yield* Queue.shutdown(queue)
809
+ }
810
+
811
+ set.clear()
812
+ }),
813
+ )
814
+
815
+ const makeQueue: PullQueueSet['makeQueue'] = () =>
816
+ Effect.gen(function* () {
817
+ const queue = yield* Queue.unbounded<{
818
+ payload: typeof SyncState.PayloadUpstream.Type
819
+ mergeCounter: number
820
+ }>().pipe(Effect.acquireRelease(Queue.shutdown))
821
+
822
+ yield* Effect.addFinalizer(() => Effect.sync(() => set.delete(queue)))
823
+
824
+ set.add(queue)
825
+
826
+ return queue
827
+ })
828
+
829
+ const offer: PullQueueSet['offer'] = (item) =>
830
+ Effect.gen(function* () {
831
+ // Short-circuit if the payload is an empty upstream advance
832
+ if (item.payload._tag === 'upstream-advance' && item.payload.newEvents.length === 0) {
833
+ return
834
+ }
835
+
836
+ for (const queue of set) {
837
+ yield* Queue.offer(queue, item)
838
+ }
839
+ })
840
+
841
+ return {
842
+ makeQueue,
843
+ offer,
844
+ }
845
+ })
846
+
847
+ const incrementMergeCounter = (mergeCounterRef: { current: number }) =>
848
+ Effect.gen(function* () {
849
+ const { dbReadModel } = yield* LeaderThreadCtx
850
+ mergeCounterRef.current++
851
+ dbReadModel.execute(
852
+ sql`INSERT OR REPLACE INTO ${LEADER_MERGE_COUNTER_TABLE} (id, mergeCounter) VALUES (0, ${mergeCounterRef.current})`,
853
+ )
854
+ return mergeCounterRef.current
855
+ })
856
+
857
+ const getMergeCounterFromDb = (dbReadModel: SqliteDb) =>
858
+ Effect.gen(function* () {
859
+ const result = dbReadModel.select<{ mergeCounter: number }>(
860
+ sql`SELECT mergeCounter FROM ${LEADER_MERGE_COUNTER_TABLE} WHERE id = 0`,
861
+ )
862
+ return result[0]?.mergeCounter ?? 0
863
+ })