@livestore/common 0.3.0-dev.16 → 0.3.0-dev.18

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 (124) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/adapter-types.d.ts +12 -4
  3. package/dist/adapter-types.d.ts.map +1 -1
  4. package/dist/adapter-types.js +4 -0
  5. package/dist/adapter-types.js.map +1 -1
  6. package/dist/bounded-collections.d.ts +1 -1
  7. package/dist/bounded-collections.d.ts.map +1 -1
  8. package/dist/debug-info.d.ts.map +1 -1
  9. package/dist/derived-mutations.d.ts.map +1 -1
  10. package/dist/devtools/devtools-messages-client-session.d.ts +21 -21
  11. package/dist/devtools/devtools-messages-common.d.ts +6 -6
  12. package/dist/devtools/devtools-messages-common.d.ts.map +1 -1
  13. package/dist/devtools/devtools-messages-leader.d.ts +28 -28
  14. package/dist/devtools/index.d.ts.map +1 -1
  15. package/dist/init-singleton-tables.d.ts.map +1 -1
  16. package/dist/leader-thread/LeaderSyncProcessor.d.ts +3 -1
  17. package/dist/leader-thread/LeaderSyncProcessor.d.ts.map +1 -1
  18. package/dist/leader-thread/LeaderSyncProcessor.js +130 -50
  19. package/dist/leader-thread/LeaderSyncProcessor.js.map +1 -1
  20. package/dist/leader-thread/apply-mutation.d.ts.map +1 -1
  21. package/dist/leader-thread/apply-mutation.js +11 -5
  22. package/dist/leader-thread/apply-mutation.js.map +1 -1
  23. package/dist/leader-thread/connection.d.ts.map +1 -1
  24. package/dist/leader-thread/leader-worker-devtools.d.ts.map +1 -1
  25. package/dist/leader-thread/make-leader-thread-layer.d.ts.map +1 -1
  26. package/dist/leader-thread/make-leader-thread-layer.js +1 -0
  27. package/dist/leader-thread/make-leader-thread-layer.js.map +1 -1
  28. package/dist/leader-thread/mutationlog.d.ts.map +1 -1
  29. package/dist/leader-thread/pull-queue-set.d.ts +3 -3
  30. package/dist/leader-thread/pull-queue-set.d.ts.map +1 -1
  31. package/dist/leader-thread/pull-queue-set.js +9 -0
  32. package/dist/leader-thread/pull-queue-set.js.map +1 -1
  33. package/dist/leader-thread/shutdown-channel.d.ts +2 -5
  34. package/dist/leader-thread/shutdown-channel.d.ts.map +1 -1
  35. package/dist/leader-thread/shutdown-channel.js +2 -4
  36. package/dist/leader-thread/shutdown-channel.js.map +1 -1
  37. package/dist/leader-thread/types.d.ts +7 -2
  38. package/dist/leader-thread/types.d.ts.map +1 -1
  39. package/dist/mutation.d.ts.map +1 -1
  40. package/dist/otel.d.ts.map +1 -1
  41. package/dist/query-builder/api.d.ts.map +1 -1
  42. package/dist/query-builder/impl.d.ts.map +1 -1
  43. package/dist/rehydrate-from-mutationlog.d.ts.map +1 -1
  44. package/dist/rehydrate-from-mutationlog.js +3 -3
  45. package/dist/rehydrate-from-mutationlog.js.map +1 -1
  46. package/dist/schema/EventId.d.ts +8 -0
  47. package/dist/schema/EventId.d.ts.map +1 -1
  48. package/dist/schema/EventId.js +14 -0
  49. package/dist/schema/EventId.js.map +1 -1
  50. package/dist/schema/MutationEvent.d.ts.map +1 -1
  51. package/dist/schema/MutationEvent.js +3 -3
  52. package/dist/schema/MutationEvent.js.map +1 -1
  53. package/dist/schema/db-schema/ast/sqlite.d.ts.map +1 -1
  54. package/dist/schema/db-schema/ast/validate.d.ts.map +1 -1
  55. package/dist/schema/db-schema/dsl/field-defs.d.ts.map +1 -1
  56. package/dist/schema/db-schema/dsl/field-defs.js.map +1 -1
  57. package/dist/schema/db-schema/dsl/mod.d.ts.map +1 -1
  58. package/dist/schema/db-schema/dsl/mod.js.map +1 -1
  59. package/dist/schema/db-schema/hash.d.ts.map +1 -1
  60. package/dist/schema/mutations.d.ts +5 -2
  61. package/dist/schema/mutations.d.ts.map +1 -1
  62. package/dist/schema/mutations.js.map +1 -1
  63. package/dist/schema/schema-helpers.d.ts.map +1 -1
  64. package/dist/schema/schema.d.ts +4 -1
  65. package/dist/schema/schema.d.ts.map +1 -1
  66. package/dist/schema/schema.js +19 -8
  67. package/dist/schema/schema.js.map +1 -1
  68. package/dist/schema/table-def.d.ts +1 -8
  69. package/dist/schema/table-def.d.ts.map +1 -1
  70. package/dist/schema-management/common.d.ts.map +1 -1
  71. package/dist/schema-management/migrations.d.ts.map +1 -1
  72. package/dist/schema-management/validate-mutation-defs.d.ts.map +1 -1
  73. package/dist/schema-management/validate-mutation-defs.js +2 -2
  74. package/dist/schema-management/validate-mutation-defs.js.map +1 -1
  75. package/dist/sql-queries/misc.d.ts.map +1 -1
  76. package/dist/sql-queries/sql-queries.d.ts.map +1 -1
  77. package/dist/sql-queries/sql-query-builder.d.ts.map +1 -1
  78. package/dist/sql-queries/types.d.ts.map +1 -1
  79. package/dist/sync/ClientSessionSyncProcessor.d.ts +11 -1
  80. package/dist/sync/ClientSessionSyncProcessor.d.ts.map +1 -1
  81. package/dist/sync/ClientSessionSyncProcessor.js +51 -19
  82. package/dist/sync/ClientSessionSyncProcessor.js.map +1 -1
  83. package/dist/sync/next/compact-events.d.ts.map +1 -1
  84. package/dist/sync/next/facts.d.ts.map +1 -1
  85. package/dist/sync/next/history-dag.d.ts.map +1 -1
  86. package/dist/sync/next/rebase-events.d.ts.map +1 -1
  87. package/dist/sync/next/test/mutation-fixtures.d.ts +7 -7
  88. package/dist/sync/next/test/mutation-fixtures.d.ts.map +1 -1
  89. package/dist/sync/sync.d.ts +14 -9
  90. package/dist/sync/sync.d.ts.map +1 -1
  91. package/dist/sync/sync.js +7 -3
  92. package/dist/sync/sync.js.map +1 -1
  93. package/dist/sync/syncstate.d.ts +132 -21
  94. package/dist/sync/syncstate.d.ts.map +1 -1
  95. package/dist/sync/syncstate.js +129 -41
  96. package/dist/sync/syncstate.js.map +1 -1
  97. package/dist/sync/syncstate.test.js +19 -7
  98. package/dist/sync/syncstate.test.js.map +1 -1
  99. package/dist/sync/validate-push-payload.d.ts.map +1 -1
  100. package/dist/util.d.ts.map +1 -1
  101. package/dist/version.d.ts +1 -1
  102. package/dist/version.js +1 -1
  103. package/package.json +2 -2
  104. package/src/adapter-types.ts +9 -4
  105. package/src/leader-thread/LeaderSyncProcessor.ts +169 -61
  106. package/src/leader-thread/apply-mutation.ts +21 -5
  107. package/src/leader-thread/make-leader-thread-layer.ts +1 -0
  108. package/src/leader-thread/pull-queue-set.ts +10 -1
  109. package/src/leader-thread/shutdown-channel.ts +2 -4
  110. package/src/leader-thread/types.ts +8 -2
  111. package/src/rehydrate-from-mutationlog.ts +2 -2
  112. package/src/schema/EventId.ts +16 -0
  113. package/src/schema/MutationEvent.ts +3 -3
  114. package/src/schema/db-schema/dsl/field-defs.ts +1 -2
  115. package/src/schema/db-schema/dsl/mod.ts +1 -1
  116. package/src/schema/mutations.ts +4 -1
  117. package/src/schema/schema.ts +20 -8
  118. package/src/schema-management/validate-mutation-defs.ts +2 -2
  119. package/src/sync/ClientSessionSyncProcessor.ts +82 -19
  120. package/src/sync/sync.ts +7 -4
  121. package/src/sync/syncstate.test.ts +32 -14
  122. package/src/sync/syncstate.ts +145 -60
  123. package/src/version.ts +1 -1
  124. package/tmp/pack.tgz +0 -0
@@ -1,4 +1,4 @@
1
- import { isNotUndefined, LS_DEV, shouldNeverHappen, TRACE_VERBOSE } from '@livestore/utils'
1
+ import { casesHandled, isNotUndefined, LS_DEV, shouldNeverHappen, TRACE_VERBOSE } from '@livestore/utils'
2
2
  import type { HttpClient, Scope, Tracer } from '@livestore/utils/effect'
3
3
  import {
4
4
  BucketQueue,
@@ -21,13 +21,14 @@ import { UnexpectedError } from '../adapter-types.js'
21
21
  import type { LiveStoreSchema, SessionChangesetMetaRow } from '../schema/mod.js'
22
22
  import {
23
23
  EventId,
24
+ getMutationDef,
24
25
  MUTATION_LOG_META_TABLE,
25
26
  MutationEvent,
26
27
  mutationLogMetaTable,
27
28
  SESSION_CHANGESET_META_TABLE,
28
29
  } from '../schema/mod.js'
29
30
  import { updateRows } from '../sql-queries/index.js'
30
- import { InvalidPushError } from '../sync/sync.js'
31
+ import { LeaderAheadError } from '../sync/sync.js'
31
32
  import * as SyncState from '../sync/syncstate.js'
32
33
  import { sql } from '../util.js'
33
34
  import { makeApplyMutation } from './apply-mutation.js'
@@ -36,9 +37,12 @@ import { getBackendHeadFromDb, getClientHeadFromDb, getMutationEventsSince, upda
36
37
  import type { InitialBlockingSyncContext, InitialSyncInfo, LeaderSyncProcessor } from './types.js'
37
38
  import { LeaderThreadCtx } from './types.js'
38
39
 
39
- type PushQueueItem = [
40
+ export const BACKEND_PUSH_BATCH_SIZE = 50
41
+
42
+ type LocalPushQueueItem = [
40
43
  mutationEvent: MutationEvent.EncodedWithMeta,
41
- deferred: Deferred.Deferred<void, InvalidPushError> | undefined,
44
+ deferred: Deferred.Deferred<void, LeaderAheadError> | undefined,
45
+ generation: number,
42
46
  ]
43
47
 
44
48
  /**
@@ -68,12 +72,14 @@ export const makeLeaderSyncProcessor = ({
68
72
  schema,
69
73
  dbMissing,
70
74
  dbMutationLog,
75
+ clientId,
71
76
  initialBlockingSyncContext,
72
77
  }: {
73
78
  schema: LiveStoreSchema
74
79
  /** Only used to know whether we can safely query dbMutationLog during setup execution */
75
80
  dbMissing: boolean
76
81
  dbMutationLog: SqliteDb
82
+ clientId: string
77
83
  initialBlockingSyncContext: InitialBlockingSyncContext
78
84
  }): Effect.Effect<LeaderSyncProcessor, UnexpectedError, Scope.Scope> =>
79
85
  Effect.gen(function* () {
@@ -82,10 +88,17 @@ export const makeLeaderSyncProcessor = ({
82
88
  const syncStateSref = yield* SubscriptionRef.make<SyncState.SyncState | undefined>(undefined)
83
89
 
84
90
  const isLocalEvent = (mutationEventEncoded: MutationEvent.EncodedWithMeta) => {
85
- const mutationDef = schema.mutations.get(mutationEventEncoded.mutation)!
91
+ const mutationDef = getMutationDef(schema, mutationEventEncoded.mutation)
86
92
  return mutationDef.options.clientOnly
87
93
  }
88
94
 
95
+ /**
96
+ * 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,
98
+ * even if they would be valid on their own.
99
+ */
100
+ const currentLocalPushGenerationRef = { current: 0 }
101
+
89
102
  // This context depends on data from `boot`, we should find a better implementation to avoid this ref indirection.
90
103
  const ctxRef = {
91
104
  current: undefined as
@@ -97,7 +110,7 @@ export const makeLeaderSyncProcessor = ({
97
110
  },
98
111
  }
99
112
 
100
- const localPushesQueue = yield* BucketQueue.make<PushQueueItem>()
113
+ const localPushesQueue = yield* BucketQueue.make<LocalPushQueueItem>()
101
114
  const localPushesLatch = yield* Effect.makeLatch(true)
102
115
  const pullLatch = yield* Effect.makeLatch(true)
103
116
 
@@ -106,20 +119,36 @@ export const makeLeaderSyncProcessor = ({
106
119
  // TODO validate batch
107
120
  if (newEvents.length === 0) return
108
121
 
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
+
109
135
  const waitForProcessing = options?.waitForProcessing ?? false
136
+ const generation = currentLocalPushGenerationRef.current
110
137
 
111
138
  if (waitForProcessing) {
112
- const deferreds = yield* Effect.forEach(newEvents, () => Deferred.make<void, InvalidPushError>())
139
+ const deferreds = yield* Effect.forEach(newEvents, () => Deferred.make<void, LeaderAheadError>())
113
140
 
114
141
  const items = newEvents.map(
115
- (mutationEventEncoded, i) => [mutationEventEncoded, deferreds[i]] as PushQueueItem,
142
+ (mutationEventEncoded, i) => [mutationEventEncoded, deferreds[i], generation] as LocalPushQueueItem,
116
143
  )
117
144
 
118
145
  yield* BucketQueue.offerAll(localPushesQueue, items)
119
146
 
120
147
  yield* Effect.all(deferreds)
121
148
  } else {
122
- const items = newEvents.map((mutationEventEncoded) => [mutationEventEncoded, undefined] as PushQueueItem)
149
+ const items = newEvents.map(
150
+ (mutationEventEncoded) => [mutationEventEncoded, undefined, generation] as LocalPushQueueItem,
151
+ )
123
152
  yield* BucketQueue.offerAll(localPushesQueue, items)
124
153
  }
125
154
  }).pipe(
@@ -141,9 +170,7 @@ export const makeLeaderSyncProcessor = ({
141
170
  const syncState = yield* syncStateSref
142
171
  if (syncState === undefined) return shouldNeverHappen('Not initialized')
143
172
 
144
- const mutationDef =
145
- schema.mutations.get(partialMutationEvent.mutation) ??
146
- shouldNeverHappen(`Unknown mutation: ${partialMutationEvent.mutation}`)
173
+ const mutationDef = getMutationDef(schema, partialMutationEvent.mutation)
147
174
 
148
175
  const mutationEventEncoded = new MutationEvent.EncodedWithMeta({
149
176
  ...partialMutationEvent,
@@ -153,14 +180,14 @@ export const makeLeaderSyncProcessor = ({
153
180
  })
154
181
 
155
182
  yield* push([mutationEventEncoded])
156
- }).pipe(Effect.catchTag('InvalidPushError', Effect.orDie))
183
+ }).pipe(Effect.catchTag('LeaderAheadError', Effect.orDie))
157
184
 
158
185
  // Starts various background loops
159
186
  const boot: LeaderSyncProcessor['boot'] = ({ dbReady }) =>
160
187
  Effect.gen(function* () {
161
188
  const span = yield* Effect.currentSpan.pipe(Effect.orDie)
162
189
  const otelSpan = yield* OtelTracer.currentOtelSpan.pipe(Effect.catchAll(() => Effect.succeed(undefined)))
163
- const { devtools } = yield* LeaderThreadCtx
190
+ const { devtools, shutdownChannel } = yield* LeaderThreadCtx
164
191
 
165
192
  ctxRef.current = {
166
193
  otelSpan,
@@ -198,13 +225,19 @@ export const makeLeaderSyncProcessor = ({
198
225
  const filteredBatch = pendingMutationEvents
199
226
  // Don't sync clientOnly mutations
200
227
  .filter((mutationEventEncoded) => {
201
- const mutationDef = schema.mutations.get(mutationEventEncoded.mutation)!
228
+ const mutationDef = getMutationDef(schema, mutationEventEncoded.mutation)
202
229
  return mutationDef.options.clientOnly === false
203
230
  })
204
231
 
205
232
  yield* BucketQueue.offerAll(syncBackendQueue, filteredBatch)
206
233
  }
207
234
 
235
+ const shutdownOnError = (cause: unknown) =>
236
+ Effect.gen(function* () {
237
+ yield* shutdownChannel.send(UnexpectedError.make({ cause }))
238
+ yield* Effect.die(cause)
239
+ })
240
+
208
241
  yield* backgroundApplyLocalPushes({
209
242
  localPushesLatch,
210
243
  localPushesQueue,
@@ -214,7 +247,8 @@ export const makeLeaderSyncProcessor = ({
214
247
  schema,
215
248
  isLocalEvent,
216
249
  otelSpan,
217
- }).pipe(Effect.tapCauseLogPretty, Effect.forkScoped)
250
+ currentLocalPushGenerationRef,
251
+ }).pipe(Effect.tapCauseLogPretty, Effect.catchAllCause(shutdownOnError), Effect.forkScoped)
218
252
 
219
253
  const backendPushingFiberHandle = yield* FiberHandle.make()
220
254
 
@@ -225,7 +259,7 @@ export const makeLeaderSyncProcessor = ({
225
259
  syncBackendQueue,
226
260
  otelSpan,
227
261
  devtoolsLatch: ctxRef.current?.devtoolsLatch,
228
- }).pipe(Effect.tapCauseLogPretty),
262
+ }).pipe(Effect.tapCauseLogPretty, Effect.catchAllCause(shutdownOnError)),
229
263
  )
230
264
 
231
265
  yield* backgroundBackendPulling({
@@ -249,7 +283,7 @@ export const makeLeaderSyncProcessor = ({
249
283
  syncBackendQueue,
250
284
  otelSpan,
251
285
  devtoolsLatch: ctxRef.current?.devtoolsLatch,
252
- }).pipe(Effect.tapCauseLogPretty),
286
+ }).pipe(Effect.tapCauseLogPretty, Effect.catchAllCause(shutdownOnError)),
253
287
  )
254
288
  }),
255
289
  syncStateSref,
@@ -258,7 +292,7 @@ export const makeLeaderSyncProcessor = ({
258
292
  otelSpan,
259
293
  initialBlockingSyncContext,
260
294
  devtoolsLatch: ctxRef.current?.devtoolsLatch,
261
- }).pipe(Effect.tapCauseLogPretty, Effect.forkScoped)
295
+ }).pipe(Effect.tapCauseLogPretty, Effect.catchAllCause(shutdownOnError), Effect.forkScoped)
262
296
 
263
297
  return { initialLeaderHead: initialLocalHead }
264
298
  }).pipe(Effect.withSpanScoped('@livestore/common:leader-thread:syncing'))
@@ -287,32 +321,48 @@ const backgroundApplyLocalPushes = ({
287
321
  schema,
288
322
  isLocalEvent,
289
323
  otelSpan,
324
+ currentLocalPushGenerationRef,
290
325
  }: {
291
326
  pullLatch: Effect.Latch
292
327
  localPushesLatch: Effect.Latch
293
- localPushesQueue: BucketQueue.BucketQueue<PushQueueItem>
328
+ localPushesQueue: BucketQueue.BucketQueue<LocalPushQueueItem>
294
329
  syncStateSref: SubscriptionRef.SubscriptionRef<SyncState.SyncState | undefined>
295
330
  syncBackendQueue: BucketQueue.BucketQueue<MutationEvent.EncodedWithMeta>
296
331
  schema: LiveStoreSchema
297
332
  isLocalEvent: (mutationEventEncoded: MutationEvent.EncodedWithMeta) => boolean
298
333
  otelSpan: otel.Span | undefined
334
+ currentLocalPushGenerationRef: { current: number }
299
335
  }) =>
300
336
  Effect.gen(function* () {
301
- const { connectedClientSessionPullQueues } = yield* LeaderThreadCtx
337
+ const { connectedClientSessionPullQueues, clientId } = yield* LeaderThreadCtx
302
338
 
303
339
  const applyMutationItems = yield* makeApplyMutationItems
304
340
 
305
341
  while (true) {
306
342
  // TODO make batch size configurable
307
343
  const batchItems = yield* BucketQueue.takeBetween(localPushesQueue, 1, 10)
308
- const [newEvents, deferreds] = ReadonlyArray.unzip(batchItems)
309
344
 
310
345
  // Wait for the backend pulling to finish
311
346
  yield* localPushesLatch.await
312
347
 
313
- // Prevent the backend pulling from starting until this local push is finished
348
+ // Prevent backend pull processing until this local push is finished
314
349
  yield* pullLatch.close
315
350
 
351
+ // Since the generation might have changed since enqueuing, we need to filter out items with older generation
352
+ // It's important that we filter after we got localPushesLatch, otherwise we might filter with the old generation
353
+ const filteredBatchItems = batchItems
354
+ .filter(([_1, _2, generation]) => generation === currentLocalPushGenerationRef.current)
355
+ .map(([mutationEventEncoded, deferred]) => [mutationEventEncoded, deferred] as const)
356
+
357
+ if (filteredBatchItems.length === 0) {
358
+ // console.log('dropping old-gen batch', currentLocalPushGenerationRef.current)
359
+ // Allow the backend pulling to start
360
+ yield* pullLatch.open
361
+ continue
362
+ }
363
+
364
+ const [newEvents, deferreds] = ReadonlyArray.unzip(filteredBatchItems)
365
+
316
366
  const syncState = yield* syncStateSref
317
367
  if (syncState === undefined) return shouldNeverHappen('Not initialized')
318
368
 
@@ -323,42 +373,82 @@ const backgroundApplyLocalPushes = ({
323
373
  isEqualEvent: MutationEvent.isEqualEncoded,
324
374
  })
325
375
 
326
- if (updateResult._tag === 'rebase') {
327
- return shouldNeverHappen('The leader thread should never have to rebase due to a local push')
328
- } else if (updateResult._tag === 'reject') {
329
- otelSpan?.addEvent('local-push:reject', {
330
- batchSize: newEvents.length,
331
- updateResult: TRACE_VERBOSE ? JSON.stringify(updateResult) : undefined,
332
- })
376
+ switch (updateResult._tag) {
377
+ case 'unexpected-error': {
378
+ otelSpan?.addEvent('local-push:unexpected-error', {
379
+ batchSize: newEvents.length,
380
+ newEvents: TRACE_VERBOSE ? JSON.stringify(newEvents) : undefined,
381
+ })
382
+ return yield* Effect.fail(updateResult.cause)
383
+ }
384
+ case 'rebase': {
385
+ return shouldNeverHappen('The leader thread should never have to rebase due to a local push')
386
+ }
387
+ case 'reject': {
388
+ otelSpan?.addEvent('local-push:reject', {
389
+ batchSize: newEvents.length,
390
+ updateResult: TRACE_VERBOSE ? JSON.stringify(updateResult) : undefined,
391
+ })
392
+
393
+ /*
394
+
395
+ TODO: how to test this?
396
+ */
397
+ currentLocalPushGenerationRef.current++
398
+
399
+ const nextGeneration = currentLocalPushGenerationRef.current
333
400
 
334
- const providedId = newEvents.at(0)!.id
335
- const remainingEvents = yield* BucketQueue.takeAll(localPushesQueue)
336
- const allDeferreds = [...deferreds, ...remainingEvents.map(([_, deferred]) => deferred)].filter(isNotUndefined)
337
- yield* Effect.forEach(allDeferreds, (deferred) =>
338
- Deferred.fail(
339
- deferred,
340
- InvalidPushError.make({
341
- // TODO improve error handling so it differentiates between a push being rejected
342
- // because of itself or because of another push
343
- reason: {
344
- _tag: 'LeaderAhead',
401
+ const providedId = newEvents.at(0)!.id
402
+ // All subsequent pushes with same generation should be rejected as well
403
+ // We're also handling the case where the localPushQueue already contains events
404
+ // from the next generation which we preserve in the queue
405
+ const remainingEventsMatchingGeneration = yield* BucketQueue.takeSplitWhere(
406
+ localPushesQueue,
407
+ (item) => item[2] >= nextGeneration,
408
+ )
409
+
410
+ if ((yield* BucketQueue.size(localPushesQueue)) > 0) {
411
+ console.log('localPushesQueue is not empty', yield* BucketQueue.size(localPushesQueue))
412
+ debugger
413
+ }
414
+
415
+ const allDeferredsToReject = [
416
+ ...deferreds,
417
+ ...remainingEventsMatchingGeneration.map(([_, deferred]) => deferred),
418
+ ].filter(isNotUndefined)
419
+
420
+ yield* Effect.forEach(allDeferredsToReject, (deferred) =>
421
+ Deferred.fail(
422
+ deferred,
423
+ LeaderAheadError.make({
345
424
  minimumExpectedId: updateResult.expectedMinimumId,
346
425
  providedId,
347
- },
348
- }),
349
- ),
350
- )
426
+ // nextGeneration,
427
+ }),
428
+ ),
429
+ )
351
430
 
352
- // Allow the backend pulling to start
353
- yield* pullLatch.open
431
+ // Allow the backend pulling to start
432
+ yield* pullLatch.open
354
433
 
355
- // In this case we're skipping state update and down/upstream processing
356
- // We've cleared the local push queue and are now waiting for new local pushes / backend pulls
357
- continue
434
+ // In this case we're skipping state update and down/upstream processing
435
+ // We've cleared the local push queue and are now waiting for new local pushes / backend pulls
436
+ continue
437
+ }
438
+ case 'advance': {
439
+ break
440
+ }
441
+ default: {
442
+ casesHandled(updateResult)
443
+ }
358
444
  }
359
445
 
360
446
  yield* SubscriptionRef.set(syncStateSref, updateResult.newSyncState)
361
447
 
448
+ if (clientId === 'client-b') {
449
+ // yield* Effect.log('offer upstream-advance due to local-push')
450
+ // debugger
451
+ }
362
452
  yield* connectedClientSessionPullQueues.offer({
363
453
  payload: { _tag: 'upstream-advance', newEvents: updateResult.newEvents },
364
454
  remaining: 0,
@@ -371,7 +461,7 @@ const backgroundApplyLocalPushes = ({
371
461
 
372
462
  // Don't sync clientOnly mutations
373
463
  const filteredBatch = updateResult.newEvents.filter((mutationEventEncoded) => {
374
- const mutationDef = schema.mutations.get(mutationEventEncoded.mutation)!
464
+ const mutationDef = getMutationDef(schema, mutationEventEncoded.mutation)
375
465
  return mutationDef.options.clientOnly === false
376
466
  })
377
467
 
@@ -387,7 +477,7 @@ const backgroundApplyLocalPushes = ({
387
477
  type ApplyMutationItems = (_: {
388
478
  batchItems: ReadonlyArray<MutationEvent.EncodedWithMeta>
389
479
  /** Indexes are aligned with `batchItems` */
390
- deferreds: ReadonlyArray<Deferred.Deferred<void, InvalidPushError> | undefined> | undefined
480
+ deferreds: ReadonlyArray<Deferred.Deferred<void, LeaderAheadError> | undefined> | undefined
391
481
  }) => Effect.Effect<void, UnexpectedError>
392
482
 
393
483
  // TODO how to handle errors gracefully
@@ -466,6 +556,7 @@ const backgroundBackendPulling = ({
466
556
  dbMutationLog,
467
557
  connectedClientSessionPullQueues,
468
558
  schema,
559
+ clientId,
469
560
  } = yield* LeaderThreadCtx
470
561
 
471
562
  if (syncBackend === undefined) return
@@ -503,6 +594,12 @@ const backgroundBackendPulling = ({
503
594
 
504
595
  if (updateResult._tag === 'reject') {
505
596
  return shouldNeverHappen('The leader thread should never reject upstream advances')
597
+ } else if (updateResult._tag === 'unexpected-error') {
598
+ otelSpan?.addEvent('backend-pull:unexpected-error', {
599
+ newEventsCount: newEvents.length,
600
+ newEvents: TRACE_VERBOSE ? JSON.stringify(newEvents) : undefined,
601
+ })
602
+ return yield* Effect.fail(updateResult.cause)
506
603
  }
507
604
 
508
605
  const newBackendHead = newEvents.at(-1)!.id
@@ -518,7 +615,7 @@ const backgroundBackendPulling = ({
518
615
  })
519
616
 
520
617
  const filteredRebasedPending = updateResult.newSyncState.pending.filter((mutationEvent) => {
521
- const mutationDef = schema.mutations.get(mutationEvent.mutation)!
618
+ const mutationDef = getMutationDef(schema, mutationEvent.mutation)
522
619
  return mutationDef.options.clientOnly === false
523
620
  })
524
621
  yield* restartBackendPushing(filteredRebasedPending)
@@ -542,6 +639,9 @@ const backgroundBackendPulling = ({
542
639
  updateResult: TRACE_VERBOSE ? JSON.stringify(updateResult) : undefined,
543
640
  })
544
641
 
642
+ if (clientId === 'client-b') {
643
+ // yield* Effect.log('offer upstream-advance due to pull')
644
+ }
545
645
  yield* connectedClientSessionPullQueues.offer({
546
646
  payload: { _tag: 'upstream-advance', newEvents: updateResult.newEvents, trimRollbackUntil },
547
647
  remaining,
@@ -617,15 +717,23 @@ const rollback = ({
617
717
  }
618
718
  }
619
719
 
620
- // Delete the changeset rows
621
- db.execute(
622
- sql`DELETE FROM ${SESSION_CHANGESET_META_TABLE} WHERE (idGlobal, idClient) IN (${eventIdsToRollback.map((id) => `(${id.global}, ${id.client})`).join(', ')})`,
720
+ const eventIdPairChunks = ReadonlyArray.chunksOf(100)(
721
+ eventIdsToRollback.map((id) => `(${id.global}, ${id.client})`),
623
722
  )
624
723
 
724
+ // Delete the changeset rows
725
+ for (const eventIdPairChunk of eventIdPairChunks) {
726
+ db.execute(
727
+ sql`DELETE FROM ${SESSION_CHANGESET_META_TABLE} WHERE (idGlobal, idClient) IN (${eventIdPairChunk.join(', ')})`,
728
+ )
729
+ }
730
+
625
731
  // Delete the mutation log rows
626
- dbMutationLog.execute(
627
- sql`DELETE FROM ${MUTATION_LOG_META_TABLE} WHERE (idGlobal, idClient) IN (${eventIdsToRollback.map((id) => `(${id.global}, ${id.client})`).join(', ')})`,
628
- )
732
+ for (const eventIdPairChunk of eventIdPairChunks) {
733
+ dbMutationLog.execute(
734
+ sql`DELETE FROM ${MUTATION_LOG_META_TABLE} WHERE (idGlobal, idClient) IN (${eventIdPairChunk.join(', ')})`,
735
+ )
736
+ }
629
737
  }).pipe(
630
738
  Effect.withSpan('@livestore/common:leader-thread:syncing:rollback', {
631
739
  attributes: { count: eventIdsToRollback.length },
@@ -675,7 +783,7 @@ const backgroundBackendPushing = ({
675
783
  yield* SubscriptionRef.waitUntil(syncBackend.isConnected, (isConnected) => isConnected === true)
676
784
 
677
785
  // TODO make batch size configurable
678
- const queueItems = yield* BucketQueue.takeBetween(syncBackendQueue, 1, 50)
786
+ const queueItems = yield* BucketQueue.takeBetween(syncBackendQueue, 1, BACKEND_PUSH_BATCH_SIZE)
679
787
 
680
788
  yield* SubscriptionRef.waitUntil(syncBackend.isConnected, (isConnected) => isConnected === true)
681
789
 
@@ -693,7 +801,7 @@ const backgroundBackendPushing = ({
693
801
 
694
802
  if (pushResult._tag === 'Left') {
695
803
  if (LS_DEV) {
696
- yield* Effect.logDebug('backend-push-error', { error: pushResult.left.toString() })
804
+ yield* Effect.logDebug('handled backend-push-error', { error: pushResult.left.toString() })
697
805
  }
698
806
  otelSpan?.addEvent('backend-push-error', { error: pushResult.left.toString() })
699
807
  // wait for interrupt caused by background pulling which will then restart pushing
@@ -1,10 +1,12 @@
1
- import { memoizeByRef, shouldNeverHappen } from '@livestore/utils'
1
+ import { LS_DEV, memoizeByRef, shouldNeverHappen } from '@livestore/utils'
2
2
  import type { Scope } from '@livestore/utils/effect'
3
3
  import { Effect, Option, Schema } from '@livestore/utils/effect'
4
4
 
5
- import type { SqliteDb, SqliteError, UnexpectedError } from '../index.js'
5
+ import type { PreparedBindValues, SqliteDb, SqliteError, UnexpectedError } from '../index.js'
6
6
  import { getExecArgsFromMutation } from '../mutation.js'
7
7
  import {
8
+ EventId,
9
+ getMutationDef,
8
10
  type LiveStoreSchema,
9
11
  MUTATION_LOG_META_TABLE,
10
12
  type MutationEvent,
@@ -33,7 +35,7 @@ export const makeApplyMutation: Effect.Effect<ApplyMutation, never, Scope.Scope
33
35
  // TODO Running `Schema.hash` can be a bottleneck for larger schemas. There is an opportunity to run this
34
36
  // at build time and lookup the pre-computed hash at runtime.
35
37
  // Also see https://github.com/Effect-TS/effect/issues/2719
36
- [...leaderThreadCtx.schema.mutations.entries()].map(([k, v]) => [k, Schema.hash(v.schema)] as const),
38
+ [...leaderThreadCtx.schema.mutations.map.entries()].map(([k, v]) => [k, Schema.hash(v.schema)] as const),
37
39
  )
38
40
 
39
41
  return (mutationEventEncoded, options) =>
@@ -42,7 +44,7 @@ export const makeApplyMutation: Effect.Effect<ApplyMutation, never, Scope.Scope
42
44
  const skipMutationLog = options?.skipMutationLog ?? false
43
45
 
44
46
  const mutationName = mutationEventEncoded.mutation
45
- const mutationDef = schema.mutations.get(mutationName) ?? shouldNeverHappen(`Unknown mutation: ${mutationName}`)
47
+ const mutationDef = getMutationDef(schema, mutationName)
46
48
 
47
49
  const execArgsArr = getExecArgsFromMutation({
48
50
  mutationDef,
@@ -127,6 +129,20 @@ const insertIntoMutationLog = (
127
129
  const mutationDefSchemaHash =
128
130
  mutationDefSchemaHashMap.get(mutationName) ?? shouldNeverHappen(`Unknown mutation: ${mutationName}`)
129
131
 
132
+ if (LS_DEV && mutationEventEncoded.parentId.global !== EventId.ROOT.global) {
133
+ const parentMutationExists =
134
+ dbMutationLog.select<{ count: number }>(
135
+ `SELECT COUNT(*) as count FROM ${MUTATION_LOG_META_TABLE} WHERE idGlobal = ? AND idClient = ?`,
136
+ [mutationEventEncoded.parentId.global, mutationEventEncoded.parentId.client] as any as PreparedBindValues,
137
+ )[0]!.count === 1
138
+
139
+ if (parentMutationExists === false) {
140
+ shouldNeverHappen(
141
+ `Parent mutation ${mutationEventEncoded.parentId.global},${mutationEventEncoded.parentId.client} does not exist`,
142
+ )
143
+ }
144
+ }
145
+
130
146
  // TODO use prepared statements
131
147
  yield* execSql(
132
148
  dbMutationLog,
@@ -160,7 +176,7 @@ const makeShouldExcludeMutationFromLog = memoizeByRef((schema: LiveStoreSchema)
160
176
  return (mutationName: string, mutationEventEncoded: MutationEvent.AnyEncoded): boolean => {
161
177
  if (mutationLogExclude.has(mutationName)) return true
162
178
 
163
- const mutationDef = schema.mutations.get(mutationName) ?? shouldNeverHappen(`Unknown mutation: ${mutationName}`)
179
+ const mutationDef = getMutationDef(schema, mutationName)
164
180
  const execArgsArr = getExecArgsFromMutation({
165
181
  mutationDef,
166
182
  mutationEvent: { decoded: undefined, encoded: mutationEventEncoded },
@@ -65,6 +65,7 @@ export const makeLeaderThreadLayer = ({
65
65
  dbMissing,
66
66
  dbMutationLog,
67
67
  initialBlockingSyncContext,
68
+ clientId,
68
69
  })
69
70
 
70
71
  const extraIncomingMessagesQueue = yield* Queue.unbounded<Devtools.Leader.MessageToApp>().pipe(
@@ -2,7 +2,7 @@ import { Effect, Queue } from '@livestore/utils/effect'
2
2
 
3
3
  import * as MutationEvent from '../schema/MutationEvent.js'
4
4
  import { getMutationEventsSince } from './mutationlog.js'
5
- import { type PullQueueItem, type PullQueueSet } from './types.js'
5
+ import { LeaderThreadCtx, type PullQueueItem, type PullQueueSet } from './types.js'
6
6
 
7
7
  export const makePullQueueSet = Effect.gen(function* () {
8
8
  const set = new Set<Queue.Queue<PullQueueItem>>()
@@ -46,6 +46,15 @@ export const makePullQueueSet = Effect.gen(function* () {
46
46
  return
47
47
  }
48
48
 
49
+ const { clientId } = yield* LeaderThreadCtx
50
+ if (clientId === 'client-b') {
51
+ // console.log(
52
+ // 'offer',
53
+ // item.payload._tag,
54
+ // item.payload.newEvents.map((_) => _.toJSON()),
55
+ // )
56
+ }
57
+
49
58
  for (const queue of set) {
50
59
  yield* Queue.offer(queue, item)
51
60
  }
@@ -1,11 +1,9 @@
1
1
  import type { WebChannel } from '@livestore/utils/effect'
2
2
  import { Schema } from '@livestore/utils/effect'
3
3
 
4
- import { IntentionalShutdownCause } from '../index.js'
4
+ import { IntentionalShutdownCause, UnexpectedError } from '../index.js'
5
5
 
6
- export class DedicatedWorkerDisconnectBroadcast extends Schema.TaggedStruct('DedicatedWorkerDisconnectBroadcast', {}) {}
7
-
8
- export class All extends Schema.Union(IntentionalShutdownCause, DedicatedWorkerDisconnectBroadcast) {}
6
+ export class All extends Schema.Union(IntentionalShutdownCause, UnexpectedError) {}
9
7
 
10
8
  /**
11
9
  * Used internally by an adapter to shutdown gracefully.
@@ -14,7 +14,7 @@ import { Context, Schema } from '@livestore/utils/effect'
14
14
  import type {
15
15
  BootStatus,
16
16
  Devtools,
17
- InvalidPushError,
17
+ LeaderAheadError,
18
18
  MakeSqliteDb,
19
19
  MigrationsReport,
20
20
  PersistenceInfo,
@@ -126,13 +126,19 @@ export interface LeaderSyncProcessor {
126
126
  /** `batch` needs to follow the same rules as `batch` in `SyncBackend.push` */
127
127
  batch: ReadonlyArray<MutationEvent.EncodedWithMeta>,
128
128
  options?: {
129
+ /**
130
+ * This generation number is used to automatically reject subsequent pushes
131
+ * of a previously rejected push from a client session. This might occur in
132
+ * certain concurrent scenarios.
133
+ */
134
+ // generation: number
129
135
  /**
130
136
  * If true, the effect will only finish when the local push has been processed (i.e. succeeded or was rejected).
131
137
  * @default false
132
138
  */
133
139
  waitForProcessing?: boolean
134
140
  },
135
- ) => Effect.Effect<void, InvalidPushError>
141
+ ) => Effect.Effect<void, LeaderAheadError>
136
142
 
137
143
  pushPartial: (args: {
138
144
  mutationEvent: MutationEvent.PartialAnyEncoded
@@ -4,7 +4,7 @@ import { Chunk, Effect, Option, Schema, Stream } from '@livestore/utils/effect'
4
4
  import { type MigrationOptionsFromMutationLog, type SqliteDb, UnexpectedError } from './adapter-types.js'
5
5
  import { makeApplyMutation } from './leader-thread/apply-mutation.js'
6
6
  import type { LiveStoreSchema, MutationDef, MutationEvent, MutationLogMetaRow } from './schema/mod.js'
7
- import { EventId, MUTATION_LOG_META_TABLE } from './schema/mod.js'
7
+ import { EventId, getMutationDef, MUTATION_LOG_META_TABLE } from './schema/mod.js'
8
8
  import type { PreparedBindValues } from './util.js'
9
9
  import { sql } from './util.js'
10
10
 
@@ -33,7 +33,7 @@ export const rehydrateFromMutationLog = ({
33
33
 
34
34
  const processMutation = (row: MutationLogMetaRow) =>
35
35
  Effect.gen(function* () {
36
- const mutationDef = schema.mutations.get(row.mutation) ?? shouldNeverHappen(`Unknown mutation ${row.mutation}`)
36
+ const mutationDef = getMutationDef(schema, row.mutation)
37
37
 
38
38
  if (migrationOptions.excludeMutations?.has(row.mutation) === true) return
39
39
 
@@ -33,6 +33,22 @@ export const compare = (a: EventId, b: EventId) => {
33
33
  return a.client - b.client
34
34
  }
35
35
 
36
+ /**
37
+ * Convert an event id to a string representation.
38
+ */
39
+ export const toString = (id: EventId) => `(${id.global},${id.client})`
40
+
41
+ /**
42
+ * Convert a string representation of an event id to an event id.
43
+ */
44
+ export const fromString = (str: string): EventId => {
45
+ const [global, client] = str.slice(1, -1).split(',').map(Number)
46
+ if (global === undefined || client === undefined) {
47
+ throw new Error('Invalid event id string')
48
+ }
49
+ return { global, client } as EventId
50
+ }
51
+
36
52
  export const isEqual = (a: EventId, b: EventId) => a.global === b.global && a.client === b.client
37
53
 
38
54
  export type EventIdPair = { id: EventId; parentId: EventId }