@livestore/common 0.3.0-dev.8 → 0.3.0-dev.9

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 (36) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/devtools/devtools-messages.d.ts +39 -39
  3. package/dist/leader-thread/LeaderSyncProcessor.d.ts +16 -26
  4. package/dist/leader-thread/LeaderSyncProcessor.d.ts.map +1 -1
  5. package/dist/leader-thread/LeaderSyncProcessor.js +146 -157
  6. package/dist/leader-thread/LeaderSyncProcessor.js.map +1 -1
  7. package/dist/leader-thread/apply-mutation.js +1 -1
  8. package/dist/leader-thread/apply-mutation.js.map +1 -1
  9. package/dist/leader-thread/pull-queue-set.d.ts.map +1 -1
  10. package/dist/leader-thread/types.d.ts +9 -3
  11. package/dist/leader-thread/types.d.ts.map +1 -1
  12. package/dist/leader-thread/types.js.map +1 -1
  13. package/dist/schema/MutationEvent.d.ts +6 -5
  14. package/dist/schema/MutationEvent.d.ts.map +1 -1
  15. package/dist/schema/MutationEvent.js.map +1 -1
  16. package/dist/sync/ClientSessionSyncProcessor.d.ts +1 -1
  17. package/dist/sync/ClientSessionSyncProcessor.d.ts.map +1 -1
  18. package/dist/sync/ClientSessionSyncProcessor.js +5 -3
  19. package/dist/sync/ClientSessionSyncProcessor.js.map +1 -1
  20. package/dist/sync/syncstate.d.ts +39 -17
  21. package/dist/sync/syncstate.d.ts.map +1 -1
  22. package/dist/sync/syncstate.js +56 -12
  23. package/dist/sync/syncstate.js.map +1 -1
  24. package/dist/sync/syncstate.test.js +110 -51
  25. package/dist/sync/syncstate.test.js.map +1 -1
  26. package/dist/version.d.ts +1 -1
  27. package/dist/version.js +1 -1
  28. package/package.json +3 -3
  29. package/src/leader-thread/LeaderSyncProcessor.ts +196 -205
  30. package/src/leader-thread/apply-mutation.ts +1 -1
  31. package/src/leader-thread/types.ts +10 -2
  32. package/src/schema/MutationEvent.ts +5 -1
  33. package/src/sync/ClientSessionSyncProcessor.ts +6 -4
  34. package/src/sync/syncstate.test.ts +110 -51
  35. package/src/sync/syncstate.ts +21 -19
  36. package/src/version.ts +1 -1
@@ -1,18 +1,17 @@
1
- import { shouldNeverHappen, TRACE_VERBOSE } from '@livestore/utils'
1
+ import { isNotUndefined, shouldNeverHappen, TRACE_VERBOSE } from '@livestore/utils'
2
2
  import type { HttpClient, Scope } from '@livestore/utils/effect'
3
3
  import {
4
4
  BucketQueue,
5
5
  Deferred,
6
6
  Effect,
7
7
  Exit,
8
- Fiber,
9
8
  FiberHandle,
10
9
  Option,
11
10
  OtelTracer,
12
11
  ReadonlyArray,
13
- Ref,
14
12
  Schema,
15
13
  Stream,
14
+ Subscribable,
16
15
  SubscriptionRef,
17
16
  } from '@livestore/utils/effect'
18
17
  import type * as otel from '@opentelemetry/api'
@@ -37,58 +36,28 @@ import { getBackendHeadFromDb, getLocalHeadFromDb, getMutationEventsSince, updat
37
36
  import type { InitialBlockingSyncContext, InitialSyncInfo, LeaderSyncProcessor } from './types.js'
38
37
  import { LeaderThreadCtx } from './types.js'
39
38
 
40
- type ProcessorStateInit = {
41
- _tag: 'init'
42
- }
43
-
44
- type ProcessorStateInSync = {
45
- _tag: 'in-sync'
46
- syncState: SyncState.SyncState
47
- }
48
-
49
- type ProcessorStateApplyingSyncStateAdvance = {
50
- _tag: 'applying-syncstate-advance'
51
- origin: 'pull' | 'push'
52
- syncState: SyncState.SyncState
53
- // TODO re-introduce this
54
- // proccesHead: EventId
55
- fiber: Fiber.RuntimeFiber<void, UnexpectedError>
56
- }
57
-
58
- type ProcessorState = ProcessorStateInit | ProcessorStateInSync | ProcessorStateApplyingSyncStateAdvance
59
-
60
39
  /**
61
- * The general idea of the sync processor is to "follow the sync state"
62
- * and apply/rollback mutations as needed to the read model and mutation log.
63
- * The leader sync processor is also responsible for
64
- * - broadcasting mutations to client sessions via the pull queues.
65
- * - pushing mutations to the sync backend
40
+ * The LeaderSyncProcessor manages synchronization of mutations between
41
+ * the local state and the sync backend, ensuring efficient and orderly processing.
66
42
  *
67
- * In the leader sync processor, pulling always has precedence over pushing.
43
+ * In the LeaderSyncProcessor, pulling always has precedence over pushing.
68
44
  *
69
- * External events:
70
- * - Mutation pushed from client session
71
- * - Mutation pushed from devtools (via pushPartial)
72
- * - Mutation pulled from sync backend
45
+ * Responsibilities:
46
+ * - Queueing incoming local mutations in a localPushMailbox.
47
+ * - Broadcasting mutations to client sessions via pull queues.
48
+ * - Pushing mutations to the sync backend.
73
49
  *
74
- * The machine can be in the following states:
75
- * - in-sync: fully synced with remote, now idling
76
- * - applying-syncstate-advance (with pointer to current progress in case of rebase interrupt)
50
+ * Notes:
77
51
  *
78
- * Transitions:
79
- * - in-sync -> applying-syncstate-advance
80
- * - applying-syncstate-advance -> in-sync
81
- * - applying-syncstate-advance -> applying-syncstate-advance (need to interrupt previous operation)
52
+ * local push processing:
53
+ * - localPushMailbox:
54
+ * - Maintains events in ascending order.
55
+ * - Uses `Deferred` objects to resolve/reject events based on application success.
56
+ * - Processes events from the mailbox, applying mutations in batches.
57
+ * - Controlled by a `Latch` to manage execution flow.
58
+ * - The latch closes on pull receipt and re-opens post-pull completion.
59
+ * - Processes up to `maxBatchSize` events per cycle.
82
60
  *
83
- * Queuing vs interrupting behaviour:
84
- * - Operations caused by pull can never be interrupted
85
- * - Incoming pull can interrupt current push
86
- * - Incoming pull needs to wait to previous pull to finish
87
- * - Incoming push needs to wait to previous push to finish
88
- *
89
- * Backend pushing:
90
- * - continously push to backend
91
- * - only interrupted and restarted on rebase
92
61
  */
93
62
  export const makeLeaderSyncProcessor = ({
94
63
  schema,
@@ -105,9 +74,7 @@ export const makeLeaderSyncProcessor = ({
105
74
  Effect.gen(function* () {
106
75
  const syncBackendQueue = yield* BucketQueue.make<MutationEvent.EncodedWithMeta>()
107
76
 
108
- const stateRef = yield* Ref.make<ProcessorState>({ _tag: 'init' })
109
-
110
- const semaphore = yield* Effect.makeSemaphore(1)
77
+ const syncStateSref = yield* SubscriptionRef.make<SyncState.SyncState | undefined>(undefined)
111
78
 
112
79
  const isLocalEvent = (mutationEventEncoded: MutationEvent.EncodedWithMeta) => {
113
80
  const mutationDef = schema.mutations.get(mutationEventEncoded.mutation)!
@@ -115,105 +82,35 @@ export const makeLeaderSyncProcessor = ({
115
82
  }
116
83
 
117
84
  const spanRef = { current: undefined as otel.Span | undefined }
118
- const applyMutationItemsRef = { current: undefined as ApplyMutationItems | undefined }
119
-
120
- // TODO get rid of counters once Effect semaphore ordering is fixed
121
- let counterRef = 0
122
- let expectedCounter = 0
123
85
 
124
- /*
125
- TODO: refactor
126
- - Pushes go directly into a Mailbox
127
- - Have a worker fiber that takes from the mailbox (wouldn't need a semaphore)
128
- */
86
+ const localPushesQueue = yield* BucketQueue.make<MutationEvent.EncodedWithMeta>()
87
+ const localPushesLatch = yield* Effect.makeLatch(true)
88
+ const pullLatch = yield* Effect.makeLatch(true)
129
89
 
130
- const waitForSyncState = (counter: number): Effect.Effect<ProcessorStateInSync> =>
90
+ const push: LeaderSyncProcessor['push'] = (newEvents, options) =>
131
91
  Effect.gen(function* () {
132
- // console.log('waitForSyncState: waiting for semaphore', counter)
133
- yield* semaphore.take(1)
134
- // NOTE this is a workaround to ensure the semaphore take-order is respected
135
- // TODO this needs to be fixed upstream in Effect
136
- if (counter !== expectedCounter) {
137
- console.log(
138
- `waitForSyncState: counter mismatch (expected: ${expectedCounter}, got: ${counter}), releasing semaphore`,
139
- )
140
- yield* semaphore.release(1)
141
- yield* Effect.yieldNow()
142
- // Retrying...
143
- return yield* waitForSyncState(counter)
144
- }
145
- // console.log('waitForSyncState: took semaphore', counter)
146
- const state = yield* Ref.get(stateRef)
147
- if (state._tag !== 'in-sync') {
148
- return shouldNeverHappen('Expected to be in-sync but got ' + state._tag)
149
- }
150
- expectedCounter = counter + 1
151
- return state
152
- }).pipe(Effect.withSpan(`@livestore/common:leader-thread:syncing:waitForSyncState(${counter})`))
153
-
154
- const push = (newEvents: ReadonlyArray<MutationEvent.EncodedWithMeta>) =>
155
- Effect.gen(function* () {
156
- const counter = counterRef
157
- counterRef++
158
92
  // TODO validate batch
159
93
  if (newEvents.length === 0) return
160
94
 
161
- const { connectedClientSessionPullQueues } = yield* LeaderThreadCtx
162
-
163
- // TODO if there are multiple pending pushes, we should batch them together
164
- const state = yield* waitForSyncState(counter)
95
+ const waitForProcessing = options?.waitForProcessing ?? false
165
96
 
166
- const updateResult = SyncState.updateSyncState({
167
- syncState: state.syncState,
168
- payload: { _tag: 'local-push', newEvents },
169
- isLocalEvent,
170
- isEqualEvent: MutationEvent.isEqualEncoded,
171
- })
97
+ const deferreds = waitForProcessing
98
+ ? yield* Effect.forEach(newEvents, () => Deferred.make<void, InvalidPushError>())
99
+ : newEvents.map((_) => undefined)
172
100
 
173
- if (updateResult._tag === 'rebase') {
174
- return shouldNeverHappen('The leader thread should never have to rebase due to a local push')
175
- } else if (updateResult._tag === 'reject') {
176
- return yield* Effect.fail(
177
- InvalidPushError.make({
178
- reason: {
179
- _tag: 'LeaderAhead',
180
- minimumExpectedId: updateResult.expectedMinimumId,
181
- providedId: newEvents.at(0)!.id,
182
- },
101
+ // TODO validate batch ordering
102
+ const mappedEvents = newEvents.map(
103
+ (mutationEventEncoded, i) =>
104
+ new MutationEvent.EncodedWithMeta({
105
+ ...mutationEventEncoded,
106
+ meta: { deferred: deferreds[i] },
183
107
  }),
184
- )
185
- }
186
-
187
- const fiber = yield* applyMutationItemsRef.current!({ batchItems: updateResult.newEvents }).pipe(Effect.fork)
188
-
189
- yield* Ref.set(stateRef, {
190
- _tag: 'applying-syncstate-advance',
191
- origin: 'push',
192
- syncState: updateResult.newSyncState,
193
- fiber,
194
- })
195
-
196
- // console.log('setRef:applying-syncstate-advance after push', counter)
197
-
198
- yield* connectedClientSessionPullQueues.offer({
199
- payload: { _tag: 'upstream-advance', newEvents: updateResult.newEvents },
200
- remaining: 0,
201
- })
202
-
203
- spanRef.current?.addEvent('local-push', {
204
- batchSize: newEvents.length,
205
- updateResult: TRACE_VERBOSE ? JSON.stringify(updateResult) : undefined,
206
- })
207
-
208
- // Don't sync localOnly mutations
209
- const filteredBatch = updateResult.newEvents.filter((mutationEventEncoded) => {
210
- const mutationDef = schema.mutations.get(mutationEventEncoded.mutation)!
211
- return mutationDef.options.localOnly === false
212
- })
213
-
214
- yield* BucketQueue.offerAll(syncBackendQueue, filteredBatch)
108
+ )
109
+ yield* BucketQueue.offerAll(localPushesQueue, mappedEvents)
215
110
 
216
- yield* fiber // Waiting for the mutation to be applied
111
+ if (waitForProcessing) {
112
+ yield* Effect.all(deferreds as ReadonlyArray<Deferred.Deferred<void, InvalidPushError>>)
113
+ }
217
114
  }).pipe(
218
115
  Effect.withSpan('@livestore/common:leader-thread:syncing:local-push', {
219
116
  attributes: {
@@ -228,8 +125,8 @@ export const makeLeaderSyncProcessor = ({
228
125
 
229
126
  const pushPartial: LeaderSyncProcessor['pushPartial'] = (mutationEventEncoded_) =>
230
127
  Effect.gen(function* () {
231
- const state = yield* Ref.get(stateRef)
232
- if (state._tag === 'init') return shouldNeverHappen('Not initialized')
128
+ const syncState = yield* syncStateSref
129
+ if (syncState === undefined) return shouldNeverHappen('Not initialized')
233
130
 
234
131
  const mutationDef =
235
132
  schema.mutations.get(mutationEventEncoded_.mutation) ??
@@ -237,7 +134,7 @@ export const makeLeaderSyncProcessor = ({
237
134
 
238
135
  const mutationEventEncoded = new MutationEvent.EncodedWithMeta({
239
136
  ...mutationEventEncoded_,
240
- ...EventId.nextPair(state.syncState.localHead, mutationDef.options.localOnly),
137
+ ...EventId.nextPair(syncState.localHead, mutationDef.options.localOnly),
241
138
  })
242
139
 
243
140
  yield* push([mutationEventEncoded])
@@ -263,18 +160,16 @@ export const makeLeaderSyncProcessor = ({
263
160
  local: EventId.localDefault,
264
161
  }).pipe(Effect.map(ReadonlyArray.map((_) => new MutationEvent.EncodedWithMeta(_))))
265
162
 
266
- const initialSyncState = {
163
+ const initialSyncState = new SyncState.SyncState({
267
164
  pending: pendingMutationEvents,
268
165
  // On the leader we don't need a rollback tail beyond `pending` items
269
166
  rollbackTail: [],
270
167
  upstreamHead: { global: initialBackendHead, local: EventId.localDefault },
271
168
  localHead: initialLocalHead,
272
- } as SyncState.SyncState
169
+ })
273
170
 
274
171
  /** State transitions need to happen atomically, so we use a Ref to track the state */
275
- yield* Ref.set(stateRef, { _tag: 'in-sync', syncState: initialSyncState })
276
-
277
- applyMutationItemsRef.current = yield* makeApplyMutationItems({ stateRef, semaphore })
172
+ yield* SubscriptionRef.set(syncStateSref, initialSyncState)
278
173
 
279
174
  // Rehydrate sync queue
280
175
  if (pendingMutationEvents.length > 0) {
@@ -288,6 +183,17 @@ export const makeLeaderSyncProcessor = ({
288
183
  yield* BucketQueue.offerAll(syncBackendQueue, filteredBatch)
289
184
  }
290
185
 
186
+ yield* backgroundApplyLocalPushes({
187
+ localPushesLatch,
188
+ localPushesQueue,
189
+ pullLatch,
190
+ syncStateSref,
191
+ syncBackendQueue,
192
+ schema,
193
+ isLocalEvent,
194
+ span,
195
+ }).pipe(Effect.tapCauseLogPretty, Effect.forkScoped)
196
+
291
197
  const backendPushingFiberHandle = yield* FiberHandle.make()
292
198
 
293
199
  yield* FiberHandle.run(
@@ -314,9 +220,9 @@ export const makeLeaderSyncProcessor = ({
314
220
  backgroundBackendPushing({ dbReady, syncBackendQueue, span }).pipe(Effect.tapCauseLogPretty),
315
221
  )
316
222
  }),
317
- applyMutationItemsRef,
318
- stateRef,
319
- semaphore,
223
+ syncStateSref,
224
+ localPushesLatch,
225
+ pullLatch,
320
226
  span,
321
227
  initialBlockingSyncContext,
322
228
  }).pipe(Effect.tapCauseLogPretty, Effect.forkScoped)
@@ -326,26 +232,130 @@ export const makeLeaderSyncProcessor = ({
326
232
  push,
327
233
  pushPartial,
328
234
  boot,
329
- syncState: Effect.gen(function* () {
330
- const state = yield* Ref.get(stateRef)
331
- if (state._tag === 'init') return shouldNeverHappen('Not initialized')
332
- return state.syncState
235
+ syncState: Subscribable.make({
236
+ get: Effect.gen(function* () {
237
+ const syncState = yield* syncStateSref
238
+ if (syncState === undefined) return shouldNeverHappen('Not initialized')
239
+ return syncState
240
+ }),
241
+ changes: syncStateSref.changes.pipe(Stream.filter(isNotUndefined)),
333
242
  }),
334
243
  } satisfies LeaderSyncProcessor
335
244
  })
336
245
 
246
+ const backgroundApplyLocalPushes = ({
247
+ localPushesLatch,
248
+ localPushesQueue,
249
+ pullLatch,
250
+ syncStateSref,
251
+ syncBackendQueue,
252
+ schema,
253
+ isLocalEvent,
254
+ span,
255
+ }: {
256
+ pullLatch: Effect.Latch
257
+ localPushesLatch: Effect.Latch
258
+ localPushesQueue: BucketQueue.BucketQueue<MutationEvent.EncodedWithMeta>
259
+ syncStateSref: SubscriptionRef.SubscriptionRef<SyncState.SyncState | undefined>
260
+ syncBackendQueue: BucketQueue.BucketQueue<MutationEvent.EncodedWithMeta>
261
+ schema: LiveStoreSchema
262
+ isLocalEvent: (mutationEventEncoded: MutationEvent.EncodedWithMeta) => boolean
263
+ span: otel.Span | undefined
264
+ }) =>
265
+ Effect.gen(function* () {
266
+ const { connectedClientSessionPullQueues } = yield* LeaderThreadCtx
267
+
268
+ const applyMutationItems = yield* makeApplyMutationItems
269
+
270
+ while (true) {
271
+ // TODO make this configurable
272
+ const newEvents = yield* BucketQueue.takeBetween(localPushesQueue, 1, 10)
273
+
274
+ // Wait for the backend pulling to finish
275
+ yield* localPushesLatch.await
276
+
277
+ // Prevent the backend pulling from starting until this local push is finished
278
+ yield* pullLatch.close
279
+
280
+ const syncState = yield* syncStateSref
281
+ if (syncState === undefined) return shouldNeverHappen('Not initialized')
282
+
283
+ const updateResult = SyncState.updateSyncState({
284
+ syncState,
285
+ payload: { _tag: 'local-push', newEvents },
286
+ isLocalEvent,
287
+ isEqualEvent: MutationEvent.isEqualEncoded,
288
+ })
289
+
290
+ if (updateResult._tag === 'rebase') {
291
+ return shouldNeverHappen('The leader thread should never have to rebase due to a local push')
292
+ } else if (updateResult._tag === 'reject') {
293
+ span?.addEvent('local-push:reject', {
294
+ batchSize: newEvents.length,
295
+ updateResult: TRACE_VERBOSE ? JSON.stringify(updateResult) : undefined,
296
+ })
297
+
298
+ const providedId = newEvents.at(0)!.id
299
+ const remainingEvents = yield* BucketQueue.takeAll(localPushesQueue)
300
+ const allEvents = [...newEvents, ...remainingEvents]
301
+ yield* Effect.forEach(allEvents, (mutationEventEncoded) =>
302
+ mutationEventEncoded.meta.deferred
303
+ ? Deferred.fail(
304
+ mutationEventEncoded.meta.deferred,
305
+ InvalidPushError.make({
306
+ // TODO improve error handling so it differentiates between a push being rejected
307
+ // because of itself or because of another push
308
+ reason: {
309
+ _tag: 'LeaderAhead',
310
+ minimumExpectedId: updateResult.expectedMinimumId,
311
+ providedId,
312
+ },
313
+ }),
314
+ )
315
+ : Effect.void,
316
+ )
317
+
318
+ // Allow the backend pulling to start
319
+ yield* pullLatch.open
320
+
321
+ // In this case we're skipping state update and down/upstream processing
322
+ // We've cleared the local push queue and are now waiting for new local pushes / backend pulls
323
+ continue
324
+ }
325
+
326
+ yield* SubscriptionRef.set(syncStateSref, updateResult.newSyncState)
327
+
328
+ yield* connectedClientSessionPullQueues.offer({
329
+ payload: { _tag: 'upstream-advance', newEvents: updateResult.newEvents },
330
+ remaining: 0,
331
+ })
332
+
333
+ span?.addEvent('local-push', {
334
+ batchSize: newEvents.length,
335
+ updateResult: TRACE_VERBOSE ? JSON.stringify(updateResult) : undefined,
336
+ })
337
+
338
+ // Don't sync localOnly mutations
339
+ const filteredBatch = updateResult.newEvents.filter((mutationEventEncoded) => {
340
+ const mutationDef = schema.mutations.get(mutationEventEncoded.mutation)!
341
+ return mutationDef.options.localOnly === false
342
+ })
343
+
344
+ yield* BucketQueue.offerAll(syncBackendQueue, filteredBatch)
345
+
346
+ yield* applyMutationItems({ batchItems: newEvents })
347
+
348
+ // Allow the backend pulling to start
349
+ yield* pullLatch.open
350
+ }
351
+ })
352
+
337
353
  type ApplyMutationItems = (_: {
338
354
  batchItems: ReadonlyArray<MutationEvent.EncodedWithMeta>
339
355
  }) => Effect.Effect<void, UnexpectedError>
340
356
 
341
357
  // TODO how to handle errors gracefully
342
- const makeApplyMutationItems = ({
343
- stateRef,
344
- semaphore,
345
- }: {
346
- stateRef: Ref.Ref<ProcessorState>
347
- semaphore: Effect.Semaphore
348
- }): Effect.Effect<ApplyMutationItems, UnexpectedError, LeaderThreadCtx | Scope.Scope> =>
358
+ const makeApplyMutationItems: Effect.Effect<ApplyMutationItems, UnexpectedError, LeaderThreadCtx | Scope.Scope> =
349
359
  Effect.gen(function* () {
350
360
  const leaderThreadCtx = yield* LeaderThreadCtx
351
361
  const { db, dbLog } = leaderThreadCtx
@@ -354,12 +364,6 @@ const makeApplyMutationItems = ({
354
364
 
355
365
  return ({ batchItems }) =>
356
366
  Effect.gen(function* () {
357
- const state = yield* Ref.get(stateRef)
358
- if (state._tag !== 'applying-syncstate-advance') {
359
- // console.log('applyMutationItems: counter', counter)
360
- return shouldNeverHappen(`Expected to be applying-syncstate-advance but got ${state._tag}`)
361
- }
362
-
363
367
  db.execute('BEGIN TRANSACTION', undefined) // Start the transaction
364
368
  dbLog.execute('BEGIN TRANSACTION', undefined) // Start the transaction
365
369
 
@@ -381,20 +385,12 @@ const makeApplyMutationItems = ({
381
385
  if (meta?.deferred) {
382
386
  yield* Deferred.succeed(meta.deferred, void 0)
383
387
  }
384
-
385
- // TODO re-introduce this
386
- // if (i < batchItems.length - 1) {
387
- // yield* Ref.set(stateRef, { ...state, proccesHead: batchItems[i + 1]!.id })
388
- // }
389
388
  }
390
389
 
391
390
  db.execute('COMMIT', undefined) // Commit the transaction
392
391
  dbLog.execute('COMMIT', undefined) // Commit the transaction
393
-
394
- yield* Ref.set(stateRef, { _tag: 'in-sync', syncState: state.syncState })
395
- // console.log('setRef:sync after applyMutationItems', counter)
396
- yield* semaphore.release(1)
397
392
  }).pipe(
393
+ Effect.uninterruptible,
398
394
  Effect.scoped,
399
395
  Effect.withSpan('@livestore/common:leader-thread:syncing:applyMutationItems', {
400
396
  attributes: { count: batchItems.length },
@@ -410,9 +406,9 @@ const backgroundBackendPulling = ({
410
406
  isLocalEvent,
411
407
  restartBackendPushing,
412
408
  span,
413
- stateRef,
414
- applyMutationItemsRef,
415
- semaphore,
409
+ syncStateSref,
410
+ localPushesLatch,
411
+ pullLatch,
416
412
  initialBlockingSyncContext,
417
413
  }: {
418
414
  dbReady: Deferred.Deferred<void>
@@ -422,9 +418,9 @@ const backgroundBackendPulling = ({
422
418
  filteredRebasedPending: ReadonlyArray<MutationEvent.EncodedWithMeta>,
423
419
  ) => Effect.Effect<void, UnexpectedError, LeaderThreadCtx | HttpClient.HttpClient>
424
420
  span: otel.Span | undefined
425
- stateRef: Ref.Ref<ProcessorState>
426
- applyMutationItemsRef: { current: ApplyMutationItems | undefined }
427
- semaphore: Effect.Semaphore
421
+ syncStateSref: SubscriptionRef.SubscriptionRef<SyncState.SyncState | undefined>
422
+ localPushesLatch: Effect.Latch
423
+ pullLatch: Effect.Latch
428
424
  initialBlockingSyncContext: InitialBlockingSyncContext
429
425
  }) =>
430
426
  Effect.gen(function* () {
@@ -434,30 +430,25 @@ const backgroundBackendPulling = ({
434
430
 
435
431
  const cursorInfo = yield* getCursorInfo(initialBackendHead)
436
432
 
433
+ const applyMutationItems = yield* makeApplyMutationItems
434
+
437
435
  const onNewPullChunk = (newEvents: MutationEvent.EncodedWithMeta[], remaining: number) =>
438
436
  Effect.gen(function* () {
439
437
  if (newEvents.length === 0) return
440
438
 
441
- const state = yield* Ref.get(stateRef)
442
- if (state._tag === 'init') return shouldNeverHappen('Not initialized')
439
+ // Prevent more local pushes from being processed until this pull is finished
440
+ yield* localPushesLatch.close
443
441
 
444
- // const counter = state.counter + 1
442
+ // Wait for pending local pushes to finish
443
+ yield* pullLatch.await
445
444
 
446
- if (state._tag === 'applying-syncstate-advance') {
447
- if (state.origin === 'push') {
448
- yield* Fiber.interrupt(state.fiber)
449
- // In theory we should force-take the semaphore here, but as it's still taken,
450
- // it's already in the right state we want it to be in
451
- } else {
452
- // Wait for previous advance to finish
453
- yield* semaphore.take(1)
454
- }
455
- }
445
+ const syncState = yield* syncStateSref
446
+ if (syncState === undefined) return shouldNeverHappen('Not initialized')
456
447
 
457
448
  const trimRollbackUntil = newEvents.at(-1)!.id
458
449
 
459
450
  const updateResult = SyncState.updateSyncState({
460
- syncState: state.syncState,
451
+ syncState,
461
452
  payload: { _tag: 'upstream-advance', newEvents, trimRollbackUntil },
462
453
  isLocalEvent,
463
454
  isEqualEvent: MutationEvent.isEqualEncoded,
@@ -513,14 +504,14 @@ const backgroundBackendPulling = ({
513
504
 
514
505
  trimChangesetRows(db, newBackendHead)
515
506
 
516
- const fiber = yield* applyMutationItemsRef.current!({ batchItems: updateResult.newEvents }).pipe(Effect.fork)
507
+ yield* applyMutationItems({ batchItems: updateResult.newEvents })
517
508
 
518
- yield* Ref.set(stateRef, {
519
- _tag: 'applying-syncstate-advance',
520
- origin: 'pull',
521
- syncState: updateResult.newSyncState,
522
- fiber,
523
- })
509
+ yield* SubscriptionRef.set(syncStateSref, updateResult.newSyncState)
510
+
511
+ if (remaining === 0) {
512
+ // Allow local pushes to be processed again
513
+ yield* localPushesLatch.open
514
+ }
524
515
  })
525
516
 
526
517
  yield* syncBackend.pull(cursorInfo).pipe(
@@ -101,7 +101,7 @@ export const makeApplyMutation: Effect.Effect<ApplyMutation, never, Scope.Scope
101
101
  attributes: {
102
102
  mutationName: mutationEventEncoded.mutation,
103
103
  mutationId: mutationEventEncoded.id,
104
- 'span.label': mutationEventEncoded.mutation,
104
+ 'span.label': `(${mutationEventEncoded.id.global},${mutationEventEncoded.id.local}) ${mutationEventEncoded.mutation}`,
105
105
  },
106
106
  }),
107
107
  // Effect.logDuration('@livestore/common:leader-thread:applyMutation'),
@@ -6,6 +6,7 @@ import type {
6
6
  Option,
7
7
  Queue,
8
8
  Scope,
9
+ Subscribable,
9
10
  SubscriptionRef,
10
11
  WebChannel,
11
12
  } from '@livestore/utils/effect'
@@ -111,13 +112,20 @@ export interface LeaderSyncProcessor {
111
112
  push: (
112
113
  /** `batch` needs to follow the same rules as `batch` in `SyncBackend.push` */
113
114
  batch: ReadonlyArray<MutationEvent.EncodedWithMeta>,
114
- ) => Effect.Effect<void, UnexpectedError | InvalidPushError, HttpClient.HttpClient | LeaderThreadCtx>
115
+ options?: {
116
+ /**
117
+ * If true, the effect will only finish when the local push has been processed (i.e. succeeded or was rejected).
118
+ * @default false
119
+ */
120
+ waitForProcessing?: boolean
121
+ },
122
+ ) => Effect.Effect<void, InvalidPushError>
115
123
 
116
124
  pushPartial: (mutationEvent: MutationEvent.PartialAnyEncoded) => Effect.Effect<void, UnexpectedError, LeaderThreadCtx>
117
125
  boot: (args: {
118
126
  dbReady: Deferred.Deferred<void>
119
127
  }) => Effect.Effect<void, UnexpectedError, LeaderThreadCtx | Scope.Scope | HttpClient.HttpClient>
120
- syncState: Effect.Effect<SyncState.SyncState, UnexpectedError>
128
+ syncState: Subscribable.Subscribable<SyncState.SyncState>
121
129
  }
122
130
 
123
131
  export interface PullQueueSet {
@@ -2,6 +2,7 @@ import { memoizeByRef } from '@livestore/utils'
2
2
  import type { Deferred } from '@livestore/utils/effect'
3
3
  import { Schema } from '@livestore/utils/effect'
4
4
 
5
+ import type { InvalidPushError } from '../sync/sync.js'
5
6
  import * as EventId from './EventId.js'
6
7
  import type { MutationDef, MutationDefRecord } from './mutations.js'
7
8
  import type { LiveStoreSchema } from './schema.js'
@@ -138,7 +139,10 @@ export class EncodedWithMeta extends Schema.Class<EncodedWithMeta>('MutationEven
138
139
  id: EventId.EventId,
139
140
  parentId: EventId.EventId,
140
141
  meta: Schema.optionalWith(
141
- Schema.Any as Schema.Schema<{ deferred?: Deferred.Deferred<void>; sessionChangeset?: Uint8Array }>,
142
+ Schema.Any as Schema.Schema<{
143
+ deferred?: Deferred.Deferred<void, InvalidPushError>
144
+ sessionChangeset?: Uint8Array
145
+ }>,
142
146
  { default: () => ({}) },
143
147
  ),
144
148
  }) {
@@ -7,8 +7,7 @@ import type { ClientSessionLeaderThreadProxy, UnexpectedError } from '../adapter
7
7
  import * as EventId from '../schema/EventId.js'
8
8
  import { type LiveStoreSchema } from '../schema/mod.js'
9
9
  import * as MutationEvent from '../schema/MutationEvent.js'
10
- import type { SyncState } from './syncstate.js'
11
- import { updateSyncState } from './syncstate.js'
10
+ import { SyncState, updateSyncState } from './syncstate.js'
12
11
 
13
12
  /**
14
13
  * Rebase behaviour:
@@ -47,13 +46,13 @@ export const makeClientSessionSyncProcessor = ({
47
46
  const mutationEventSchema = MutationEvent.makeMutationEventSchemaMemo(schema)
48
47
 
49
48
  const syncStateRef = {
50
- current: {
49
+ current: new SyncState({
51
50
  localHead: initialLeaderHead,
52
51
  upstreamHead: initialLeaderHead,
53
52
  pending: [],
54
53
  // TODO init rollbackTail from leader to be ready for backend rebasing
55
54
  rollbackTail: [],
56
- } as SyncState,
55
+ }),
57
56
  }
58
57
 
59
58
  const isLocalEvent = (mutationEventEncoded: MutationEvent.EncodedWithMeta) => {
@@ -103,6 +102,7 @@ export const makeClientSessionSyncProcessor = ({
103
102
  mutationEvent.meta.sessionChangeset = res.sessionChangeset
104
103
  }
105
104
 
105
+ // console.debug('pushToLeader', encodedMutationEvents.length, ...encodedMutationEvents.map((_) => _.toJSON()))
106
106
  pushToLeader(encodedMutationEvents)
107
107
 
108
108
  return { writeTables }
@@ -154,6 +154,8 @@ export const makeClientSessionSyncProcessor = ({
154
154
  event.meta.sessionChangeset = undefined
155
155
  }
156
156
  }
157
+
158
+ pushToLeader(updateResult.newSyncState.pending)
157
159
  } else {
158
160
  span.addEvent('pull:advance', {
159
161
  payloadTag: payload._tag,