@livestore/common 0.3.0-dev.7 → 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 (67) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/devtools/devtools-messages.d.ts +47 -47
  3. package/dist/index.d.ts +0 -4
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/leader-thread/LeaderSyncProcessor.d.ts +37 -0
  6. package/dist/leader-thread/LeaderSyncProcessor.d.ts.map +1 -0
  7. package/dist/leader-thread/LeaderSyncProcessor.js +421 -0
  8. package/dist/leader-thread/LeaderSyncProcessor.js.map +1 -0
  9. package/dist/leader-thread/apply-mutation.js +1 -1
  10. package/dist/leader-thread/apply-mutation.js.map +1 -1
  11. package/dist/leader-thread/leader-sync-processor.d.ts +2 -2
  12. package/dist/leader-thread/leader-sync-processor.d.ts.map +1 -1
  13. package/dist/leader-thread/leader-sync-processor.js.map +1 -1
  14. package/dist/leader-thread/leader-worker-devtools.d.ts +1 -1
  15. package/dist/leader-thread/make-leader-thread-layer.d.ts +5 -6
  16. package/dist/leader-thread/make-leader-thread-layer.d.ts.map +1 -1
  17. package/dist/leader-thread/make-leader-thread-layer.js +7 -4
  18. package/dist/leader-thread/make-leader-thread-layer.js.map +1 -1
  19. package/dist/leader-thread/pull-queue-set.d.ts.map +1 -1
  20. package/dist/leader-thread/types.d.ts +11 -5
  21. package/dist/leader-thread/types.d.ts.map +1 -1
  22. package/dist/leader-thread/types.js.map +1 -1
  23. package/dist/schema/EventId.test.d.ts +2 -0
  24. package/dist/schema/EventId.test.d.ts.map +1 -0
  25. package/dist/schema/EventId.test.js +11 -0
  26. package/dist/schema/EventId.test.js.map +1 -0
  27. package/dist/schema/MutationEvent.d.ts +6 -5
  28. package/dist/schema/MutationEvent.d.ts.map +1 -1
  29. package/dist/schema/MutationEvent.js +2 -2
  30. package/dist/schema/MutationEvent.js.map +1 -1
  31. package/dist/schema/MutationEvent.test.d.ts +2 -0
  32. package/dist/schema/MutationEvent.test.d.ts.map +1 -0
  33. package/dist/schema/MutationEvent.test.js +2 -0
  34. package/dist/schema/MutationEvent.test.js.map +1 -0
  35. package/dist/sync/ClientSessionSyncProcessor.d.ts +45 -0
  36. package/dist/sync/ClientSessionSyncProcessor.d.ts.map +1 -0
  37. package/dist/sync/ClientSessionSyncProcessor.js +133 -0
  38. package/dist/sync/ClientSessionSyncProcessor.js.map +1 -0
  39. package/dist/sync/index.d.ts +1 -1
  40. package/dist/sync/index.d.ts.map +1 -1
  41. package/dist/sync/index.js +1 -1
  42. package/dist/sync/index.js.map +1 -1
  43. package/dist/sync/sync.d.ts +15 -5
  44. package/dist/sync/sync.d.ts.map +1 -1
  45. package/dist/sync/sync.js.map +1 -1
  46. package/dist/sync/syncstate.d.ts +39 -17
  47. package/dist/sync/syncstate.d.ts.map +1 -1
  48. package/dist/sync/syncstate.js +56 -12
  49. package/dist/sync/syncstate.js.map +1 -1
  50. package/dist/sync/syncstate.test.js +123 -63
  51. package/dist/sync/syncstate.test.js.map +1 -1
  52. package/dist/version.d.ts +1 -1
  53. package/dist/version.js +1 -1
  54. package/package.json +3 -3
  55. package/src/index.ts +0 -6
  56. package/src/leader-thread/{leader-sync-processor.ts → LeaderSyncProcessor.ts} +205 -212
  57. package/src/leader-thread/apply-mutation.ts +1 -1
  58. package/src/leader-thread/make-leader-thread-layer.ts +9 -8
  59. package/src/leader-thread/types.ts +12 -4
  60. package/src/schema/EventId.test.ts +12 -0
  61. package/src/schema/MutationEvent.ts +7 -3
  62. package/src/sync/{client-session-sync-processor.ts → ClientSessionSyncProcessor.ts} +6 -5
  63. package/src/sync/index.ts +1 -1
  64. package/src/sync/sync.ts +15 -4
  65. package/src/sync/syncstate.test.ts +123 -63
  66. package/src/sync/syncstate.ts +21 -19
  67. 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'
@@ -34,61 +33,31 @@ import { sql } from '../util.js'
34
33
  import { makeApplyMutation } from './apply-mutation.js'
35
34
  import { execSql } from './connection.js'
36
35
  import { getBackendHeadFromDb, getLocalHeadFromDb, getMutationEventsSince, updateBackendHead } from './mutationlog.js'
37
- import type { InitialBlockingSyncContext, InitialSyncInfo, SyncProcessor } from './types.js'
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,
@@ -101,13 +70,11 @@ export const makeLeaderSyncProcessor = ({
101
70
  dbMissing: boolean
102
71
  dbLog: SynchronousDatabase
103
72
  initialBlockingSyncContext: InitialBlockingSyncContext
104
- }): Effect.Effect<SyncProcessor, UnexpectedError, Scope.Scope> =>
73
+ }): Effect.Effect<LeaderSyncProcessor, UnexpectedError, Scope.Scope> =>
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: {
@@ -226,10 +123,10 @@ export const makeLeaderSyncProcessor = ({
226
123
  }),
227
124
  )
228
125
 
229
- const pushPartial: SyncProcessor['pushPartial'] = (mutationEventEncoded_) =>
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,14 +134,14 @@ 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])
244
141
  }).pipe(Effect.catchTag('InvalidPushError', Effect.orDie))
245
142
 
246
143
  // Starts various background loops
247
- const boot: SyncProcessor['boot'] = ({ dbReady }) =>
144
+ const boot: LeaderSyncProcessor['boot'] = ({ dbReady }) =>
248
145
  Effect.gen(function* () {
249
146
  const span = yield* OtelTracer.currentOtelSpan.pipe(Effect.catchAll(() => Effect.succeed(undefined)))
250
147
  spanRef.current = span
@@ -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,12 +232,122 @@ 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
- } satisfies SyncProcessor
243
+ } satisfies LeaderSyncProcessor
244
+ })
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
+ }
335
351
  })
336
352
 
337
353
  type ApplyMutationItems = (_: {
@@ -339,13 +355,7 @@ type ApplyMutationItems = (_: {
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,22 +385,16 @@ 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
- Effect.withSpan('@livestore/common:leader-thread:syncing:applyMutationItems'),
395
+ Effect.withSpan('@livestore/common:leader-thread:syncing:applyMutationItems', {
396
+ attributes: { count: batchItems.length },
397
+ }),
400
398
  Effect.tapCauseLogPretty,
401
399
  UnexpectedError.mapToUnexpectedError,
402
400
  )
@@ -408,9 +406,9 @@ const backgroundBackendPulling = ({
408
406
  isLocalEvent,
409
407
  restartBackendPushing,
410
408
  span,
411
- stateRef,
412
- applyMutationItemsRef,
413
- semaphore,
409
+ syncStateSref,
410
+ localPushesLatch,
411
+ pullLatch,
414
412
  initialBlockingSyncContext,
415
413
  }: {
416
414
  dbReady: Deferred.Deferred<void>
@@ -420,9 +418,9 @@ const backgroundBackendPulling = ({
420
418
  filteredRebasedPending: ReadonlyArray<MutationEvent.EncodedWithMeta>,
421
419
  ) => Effect.Effect<void, UnexpectedError, LeaderThreadCtx | HttpClient.HttpClient>
422
420
  span: otel.Span | undefined
423
- stateRef: Ref.Ref<ProcessorState>
424
- applyMutationItemsRef: { current: ApplyMutationItems | undefined }
425
- semaphore: Effect.Semaphore
421
+ syncStateSref: SubscriptionRef.SubscriptionRef<SyncState.SyncState | undefined>
422
+ localPushesLatch: Effect.Latch
423
+ pullLatch: Effect.Latch
426
424
  initialBlockingSyncContext: InitialBlockingSyncContext
427
425
  }) =>
428
426
  Effect.gen(function* () {
@@ -432,30 +430,25 @@ const backgroundBackendPulling = ({
432
430
 
433
431
  const cursorInfo = yield* getCursorInfo(initialBackendHead)
434
432
 
433
+ const applyMutationItems = yield* makeApplyMutationItems
434
+
435
435
  const onNewPullChunk = (newEvents: MutationEvent.EncodedWithMeta[], remaining: number) =>
436
436
  Effect.gen(function* () {
437
437
  if (newEvents.length === 0) return
438
438
 
439
- const state = yield* Ref.get(stateRef)
440
- 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
441
441
 
442
- // const counter = state.counter + 1
442
+ // Wait for pending local pushes to finish
443
+ yield* pullLatch.await
443
444
 
444
- if (state._tag === 'applying-syncstate-advance') {
445
- if (state.origin === 'push') {
446
- yield* Fiber.interrupt(state.fiber)
447
- // In theory we should force-take the semaphore here, but as it's still taken,
448
- // it's already in the right state we want it to be in
449
- } else {
450
- // Wait for previous advance to finish
451
- yield* semaphore.take(1)
452
- }
453
- }
445
+ const syncState = yield* syncStateSref
446
+ if (syncState === undefined) return shouldNeverHappen('Not initialized')
454
447
 
455
448
  const trimRollbackUntil = newEvents.at(-1)!.id
456
449
 
457
450
  const updateResult = SyncState.updateSyncState({
458
- syncState: state.syncState,
451
+ syncState,
459
452
  payload: { _tag: 'upstream-advance', newEvents, trimRollbackUntil },
460
453
  isLocalEvent,
461
454
  isEqualEvent: MutationEvent.isEqualEncoded,
@@ -511,14 +504,14 @@ const backgroundBackendPulling = ({
511
504
 
512
505
  trimChangesetRows(db, newBackendHead)
513
506
 
514
- const fiber = yield* applyMutationItemsRef.current!({ batchItems: updateResult.newEvents }).pipe(Effect.fork)
507
+ yield* applyMutationItems({ batchItems: updateResult.newEvents })
515
508
 
516
- yield* Ref.set(stateRef, {
517
- _tag: 'applying-syncstate-advance',
518
- origin: 'pull',
519
- syncState: updateResult.newSyncState,
520
- fiber,
521
- })
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
+ }
522
515
  })
523
516
 
524
517
  yield* syncBackend.pull(cursorInfo).pipe(
@@ -648,7 +641,7 @@ const backgroundBackendPushing = ({
648
641
 
649
642
  if (pushResult._tag === 'Left') {
650
643
  span?.addEvent('backend-push-error', { error: pushResult.left.toString() })
651
- // wait for interrupt and restarting of pushing
644
+ // wait for interrupt caused by background pulling which will then restart pushing
652
645
  return yield* Effect.never
653
646
  }
654
647
 
@@ -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'),
@@ -7,11 +7,11 @@ import type * as Devtools from '../devtools/index.js'
7
7
  import type { LiveStoreSchema } from '../schema/mod.js'
8
8
  import { EventId, MutationEvent, mutationLogMetaTable, SYNC_STATUS_TABLE, syncStatusTable } from '../schema/mod.js'
9
9
  import { migrateTable } from '../schema-management/migrations.js'
10
- import type { InvalidPullError, IsOfflineError, SyncBackend } from '../sync/sync.js'
10
+ import type { InvalidPullError, IsOfflineError, SyncOptions } from '../sync/sync.js'
11
11
  import { sql } from '../util.js'
12
12
  import { execSql } from './connection.js'
13
- import { makeLeaderSyncProcessor } from './leader-sync-processor.js'
14
13
  import { bootDevtools } from './leader-worker-devtools.js'
14
+ import { makeLeaderSyncProcessor } from './LeaderSyncProcessor.js'
15
15
  import { makePullQueueSet } from './pull-queue-set.js'
16
16
  import { recreateDb } from './recreate-db.js'
17
17
  import type { ShutdownChannel } from './shutdown-channel.js'
@@ -23,22 +23,20 @@ export const makeLeaderThreadLayer = ({
23
23
  storeId,
24
24
  clientId,
25
25
  makeSyncDb,
26
- makeSyncBackend,
26
+ syncOptions,
27
27
  db,
28
28
  dbLog,
29
29
  devtoolsOptions,
30
- initialSyncOptions = { _tag: 'Skip' },
31
30
  shutdownChannel,
32
31
  }: {
33
32
  storeId: string
34
33
  clientId: string
35
34
  schema: LiveStoreSchema
36
35
  makeSyncDb: MakeSynchronousDatabase
37
- makeSyncBackend: Effect.Effect<SyncBackend, UnexpectedError, Scope.Scope> | undefined
36
+ syncOptions: SyncOptions | undefined
38
37
  db: SynchronousDatabase
39
38
  dbLog: SynchronousDatabase
40
39
  devtoolsOptions: DevtoolsOptions
41
- initialSyncOptions: InitialSyncOptions | undefined
42
40
  shutdownChannel: ShutdownChannel
43
41
  }): Layer.Layer<LeaderThreadCtx, UnexpectedError, Scope.Scope | HttpClient.HttpClient> =>
44
42
  Effect.gen(function* () {
@@ -48,9 +46,12 @@ export const makeLeaderThreadLayer = ({
48
46
  // Either happens on initial boot or if schema changes
49
47
  const dbMissing = db.select<{ count: number }>(sql`select count(*) as count from sqlite_master`)[0]!.count === 0
50
48
 
51
- const syncBackend = makeSyncBackend === undefined ? undefined : yield* makeSyncBackend
49
+ const syncBackend = syncOptions === undefined ? undefined : yield* syncOptions.makeBackend({ storeId, clientId })
52
50
 
53
- const initialBlockingSyncContext = yield* makeInitialBlockingSyncContext({ initialSyncOptions, bootStatusQueue })
51
+ const initialBlockingSyncContext = yield* makeInitialBlockingSyncContext({
52
+ initialSyncOptions: syncOptions?.initialSyncOptions ?? { _tag: 'Skip' },
53
+ bootStatusQueue,
54
+ })
54
55
 
55
56
  const syncProcessor = yield* makeLeaderSyncProcessor({ schema, dbMissing, dbLog, initialBlockingSyncContext })
56
57