@livestore/common 0.3.1-dev.0 → 0.3.2-dev.0

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 (185) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/ClientSessionLeaderThreadProxy.d.ts +35 -0
  3. package/dist/ClientSessionLeaderThreadProxy.d.ts.map +1 -0
  4. package/dist/ClientSessionLeaderThreadProxy.js +6 -0
  5. package/dist/ClientSessionLeaderThreadProxy.js.map +1 -0
  6. package/dist/adapter-types.d.ts +10 -156
  7. package/dist/adapter-types.d.ts.map +1 -1
  8. package/dist/adapter-types.js +5 -49
  9. package/dist/adapter-types.js.map +1 -1
  10. package/dist/defs.d.ts +20 -0
  11. package/dist/defs.d.ts.map +1 -0
  12. package/dist/defs.js +12 -0
  13. package/dist/defs.js.map +1 -0
  14. package/dist/devtools/devtools-messages-client-session.d.ts +23 -21
  15. package/dist/devtools/devtools-messages-client-session.d.ts.map +1 -1
  16. package/dist/devtools/devtools-messages-common.d.ts +6 -6
  17. package/dist/devtools/devtools-messages-leader.d.ts +26 -24
  18. package/dist/devtools/devtools-messages-leader.d.ts.map +1 -1
  19. package/dist/errors.d.ts +50 -0
  20. package/dist/errors.d.ts.map +1 -0
  21. package/dist/errors.js +36 -0
  22. package/dist/errors.js.map +1 -0
  23. package/dist/index.d.ts +1 -0
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +1 -0
  26. package/dist/index.js.map +1 -1
  27. package/dist/leader-thread/LeaderSyncProcessor.d.ts +6 -7
  28. package/dist/leader-thread/LeaderSyncProcessor.d.ts.map +1 -1
  29. package/dist/leader-thread/LeaderSyncProcessor.js +122 -123
  30. package/dist/leader-thread/LeaderSyncProcessor.js.map +1 -1
  31. package/dist/leader-thread/eventlog.d.ts +17 -6
  32. package/dist/leader-thread/eventlog.d.ts.map +1 -1
  33. package/dist/leader-thread/eventlog.js +34 -17
  34. package/dist/leader-thread/eventlog.js.map +1 -1
  35. package/dist/leader-thread/leader-worker-devtools.d.ts.map +1 -1
  36. package/dist/leader-thread/leader-worker-devtools.js +1 -2
  37. package/dist/leader-thread/leader-worker-devtools.js.map +1 -1
  38. package/dist/leader-thread/make-leader-thread-layer.d.ts.map +1 -1
  39. package/dist/leader-thread/make-leader-thread-layer.js +37 -7
  40. package/dist/leader-thread/make-leader-thread-layer.js.map +1 -1
  41. package/dist/leader-thread/materialize-event.d.ts +3 -3
  42. package/dist/leader-thread/materialize-event.d.ts.map +1 -1
  43. package/dist/leader-thread/materialize-event.js +27 -10
  44. package/dist/leader-thread/materialize-event.js.map +1 -1
  45. package/dist/leader-thread/mod.d.ts +2 -0
  46. package/dist/leader-thread/mod.d.ts.map +1 -1
  47. package/dist/leader-thread/mod.js +2 -0
  48. package/dist/leader-thread/mod.js.map +1 -1
  49. package/dist/leader-thread/recreate-db.d.ts +13 -6
  50. package/dist/leader-thread/recreate-db.d.ts.map +1 -1
  51. package/dist/leader-thread/recreate-db.js +1 -3
  52. package/dist/leader-thread/recreate-db.js.map +1 -1
  53. package/dist/leader-thread/types.d.ts +6 -7
  54. package/dist/leader-thread/types.d.ts.map +1 -1
  55. package/dist/make-client-session.d.ts +1 -1
  56. package/dist/make-client-session.d.ts.map +1 -1
  57. package/dist/make-client-session.js +1 -1
  58. package/dist/make-client-session.js.map +1 -1
  59. package/dist/materializer-helper.d.ts +13 -2
  60. package/dist/materializer-helper.d.ts.map +1 -1
  61. package/dist/materializer-helper.js +25 -11
  62. package/dist/materializer-helper.js.map +1 -1
  63. package/dist/rematerialize-from-eventlog.d.ts +1 -1
  64. package/dist/rematerialize-from-eventlog.d.ts.map +1 -1
  65. package/dist/rematerialize-from-eventlog.js +12 -4
  66. package/dist/rematerialize-from-eventlog.js.map +1 -1
  67. package/dist/schema/EventDef.d.ts +8 -3
  68. package/dist/schema/EventDef.d.ts.map +1 -1
  69. package/dist/schema/EventDef.js +5 -2
  70. package/dist/schema/EventDef.js.map +1 -1
  71. package/dist/schema/EventSequenceNumber.d.ts +20 -2
  72. package/dist/schema/EventSequenceNumber.d.ts.map +1 -1
  73. package/dist/schema/EventSequenceNumber.js +71 -19
  74. package/dist/schema/EventSequenceNumber.js.map +1 -1
  75. package/dist/schema/EventSequenceNumber.test.js +88 -3
  76. package/dist/schema/EventSequenceNumber.test.js.map +1 -1
  77. package/dist/schema/LiveStoreEvent.d.ts +56 -8
  78. package/dist/schema/LiveStoreEvent.d.ts.map +1 -1
  79. package/dist/schema/LiveStoreEvent.js +34 -8
  80. package/dist/schema/LiveStoreEvent.js.map +1 -1
  81. package/dist/schema/state/sqlite/db-schema/dsl/field-defs.js +2 -2
  82. package/dist/schema/state/sqlite/db-schema/dsl/field-defs.js.map +1 -1
  83. package/dist/schema/state/sqlite/db-schema/hash.js +3 -1
  84. package/dist/schema/state/sqlite/db-schema/hash.js.map +1 -1
  85. package/dist/schema/state/sqlite/mod.d.ts +1 -1
  86. package/dist/schema/state/sqlite/mod.d.ts.map +1 -1
  87. package/dist/schema/state/sqlite/query-builder/api.d.ts +36 -9
  88. package/dist/schema/state/sqlite/query-builder/api.d.ts.map +1 -1
  89. package/dist/schema/state/sqlite/query-builder/api.js.map +1 -1
  90. package/dist/schema/state/sqlite/query-builder/impl.d.ts.map +1 -1
  91. package/dist/schema/state/sqlite/query-builder/impl.js +16 -11
  92. package/dist/schema/state/sqlite/query-builder/impl.js.map +1 -1
  93. package/dist/schema/state/sqlite/query-builder/impl.test.d.ts +1 -86
  94. package/dist/schema/state/sqlite/query-builder/impl.test.d.ts.map +1 -1
  95. package/dist/schema/state/sqlite/query-builder/impl.test.js +34 -20
  96. package/dist/schema/state/sqlite/query-builder/impl.test.js.map +1 -1
  97. package/dist/schema/state/sqlite/system-tables.d.ts +380 -432
  98. package/dist/schema/state/sqlite/system-tables.d.ts.map +1 -1
  99. package/dist/schema/state/sqlite/system-tables.js +8 -17
  100. package/dist/schema/state/sqlite/system-tables.js.map +1 -1
  101. package/dist/schema/state/sqlite/table-def.d.ts +2 -2
  102. package/dist/schema/state/sqlite/table-def.d.ts.map +1 -1
  103. package/dist/schema-management/migrations.d.ts +3 -1
  104. package/dist/schema-management/migrations.d.ts.map +1 -1
  105. package/dist/schema-management/migrations.js.map +1 -1
  106. package/dist/sql-queries/sql-queries.d.ts.map +1 -1
  107. package/dist/sql-queries/sql-queries.js +2 -0
  108. package/dist/sql-queries/sql-queries.js.map +1 -1
  109. package/dist/sqlite-db-helper.d.ts +7 -0
  110. package/dist/sqlite-db-helper.d.ts.map +1 -0
  111. package/dist/sqlite-db-helper.js +29 -0
  112. package/dist/sqlite-db-helper.js.map +1 -0
  113. package/dist/sqlite-types.d.ts +72 -0
  114. package/dist/sqlite-types.d.ts.map +1 -0
  115. package/dist/sqlite-types.js +5 -0
  116. package/dist/sqlite-types.js.map +1 -0
  117. package/dist/sync/ClientSessionSyncProcessor.d.ts +12 -3
  118. package/dist/sync/ClientSessionSyncProcessor.d.ts.map +1 -1
  119. package/dist/sync/ClientSessionSyncProcessor.js +37 -19
  120. package/dist/sync/ClientSessionSyncProcessor.js.map +1 -1
  121. package/dist/sync/next/graphology.d.ts.map +1 -1
  122. package/dist/sync/next/graphology.js +0 -6
  123. package/dist/sync/next/graphology.js.map +1 -1
  124. package/dist/sync/next/rebase-events.d.ts.map +1 -1
  125. package/dist/sync/next/rebase-events.js +1 -0
  126. package/dist/sync/next/rebase-events.js.map +1 -1
  127. package/dist/sync/next/test/compact-events.test.js +1 -1
  128. package/dist/sync/next/test/compact-events.test.js.map +1 -1
  129. package/dist/sync/next/test/event-fixtures.d.ts.map +1 -1
  130. package/dist/sync/next/test/event-fixtures.js +12 -3
  131. package/dist/sync/next/test/event-fixtures.js.map +1 -1
  132. package/dist/sync/sync.d.ts +2 -0
  133. package/dist/sync/sync.d.ts.map +1 -1
  134. package/dist/sync/sync.js +3 -0
  135. package/dist/sync/sync.js.map +1 -1
  136. package/dist/sync/syncstate.d.ts +13 -4
  137. package/dist/sync/syncstate.d.ts.map +1 -1
  138. package/dist/sync/syncstate.js +23 -10
  139. package/dist/sync/syncstate.js.map +1 -1
  140. package/dist/sync/syncstate.test.js +17 -17
  141. package/dist/sync/syncstate.test.js.map +1 -1
  142. package/dist/version.d.ts +1 -1
  143. package/dist/version.js +1 -1
  144. package/package.json +7 -6
  145. package/src/ClientSessionLeaderThreadProxy.ts +40 -0
  146. package/src/adapter-types.ts +19 -161
  147. package/src/defs.ts +17 -0
  148. package/src/errors.ts +49 -0
  149. package/src/index.ts +1 -0
  150. package/src/leader-thread/LeaderSyncProcessor.ts +157 -181
  151. package/src/leader-thread/eventlog.ts +78 -54
  152. package/src/leader-thread/leader-worker-devtools.ts +1 -2
  153. package/src/leader-thread/make-leader-thread-layer.ts +52 -8
  154. package/src/leader-thread/materialize-event.ts +33 -12
  155. package/src/leader-thread/mod.ts +2 -0
  156. package/src/leader-thread/recreate-db.ts +99 -91
  157. package/src/leader-thread/types.ts +10 -12
  158. package/src/make-client-session.ts +2 -2
  159. package/src/materializer-helper.ts +45 -19
  160. package/src/rematerialize-from-eventlog.ts +12 -4
  161. package/src/schema/EventDef.ts +16 -4
  162. package/src/schema/EventSequenceNumber.test.ts +120 -3
  163. package/src/schema/EventSequenceNumber.ts +95 -23
  164. package/src/schema/LiveStoreEvent.ts +49 -8
  165. package/src/schema/state/sqlite/db-schema/dsl/field-defs.ts +2 -2
  166. package/src/schema/state/sqlite/db-schema/hash.ts +3 -3
  167. package/src/schema/state/sqlite/mod.ts +1 -1
  168. package/src/schema/state/sqlite/query-builder/api.ts +39 -9
  169. package/src/schema/state/sqlite/query-builder/impl.test.ts +60 -20
  170. package/src/schema/state/sqlite/query-builder/impl.ts +15 -12
  171. package/src/schema/state/sqlite/system-tables.ts +9 -22
  172. package/src/schema/state/sqlite/table-def.ts +2 -2
  173. package/src/schema-management/migrations.ts +3 -1
  174. package/src/sql-queries/sql-queries.ts +2 -0
  175. package/src/sqlite-db-helper.ts +41 -0
  176. package/src/sqlite-types.ts +76 -0
  177. package/src/sync/ClientSessionSyncProcessor.ts +51 -28
  178. package/src/sync/next/graphology.ts +0 -6
  179. package/src/sync/next/rebase-events.ts +1 -0
  180. package/src/sync/next/test/compact-events.test.ts +1 -1
  181. package/src/sync/next/test/event-fixtures.ts +12 -3
  182. package/src/sync/sync.ts +3 -0
  183. package/src/sync/syncstate.test.ts +17 -17
  184. package/src/sync/syncstate.ts +31 -10
  185. package/src/version.ts +1 -1
@@ -6,7 +6,9 @@ import {
6
6
  Effect,
7
7
  Exit,
8
8
  FiberHandle,
9
+ Option,
9
10
  OtelTracer,
11
+ pipe,
10
12
  Queue,
11
13
  ReadonlyArray,
12
14
  Stream,
@@ -16,7 +18,8 @@ import {
16
18
  import type * as otel from '@opentelemetry/api'
17
19
 
18
20
  import type { SqliteDb } from '../adapter-types.js'
19
- import { UnexpectedError } from '../adapter-types.js'
21
+ import { SyncError, UnexpectedError } from '../adapter-types.js'
22
+ import { makeMaterializerHash } from '../materializer-helper.js'
20
23
  import type { LiveStoreSchema } from '../schema/mod.js'
21
24
  import { EventSequenceNumber, getEventDef, LiveStoreEvent, SystemTables } from '../schema/mod.js'
22
25
  import { LeaderAheadError } from '../sync/sync.js'
@@ -30,8 +33,6 @@ import { LeaderThreadCtx } from './types.js'
30
33
  type LocalPushQueueItem = [
31
34
  event: LiveStoreEvent.EncodedWithMeta,
32
35
  deferred: Deferred.Deferred<void, LeaderAheadError> | undefined,
33
- /** Used to determine whether the batch has become invalid due to a rejected local push batch */
34
- generation: number,
35
36
  ]
36
37
 
37
38
  /**
@@ -56,32 +57,28 @@ type LocalPushQueueItem = [
56
57
  * - The latch closes on pull receipt and re-opens post-pull completion.
57
58
  * - Processes up to `maxBatchSize` events per cycle.
58
59
  *
59
- * Currently we're advancing the db read model and eventlog in lockstep, but we could also decouple this in the future
60
+ * Currently we're advancing the state db and eventlog in lockstep, but we could also decouple this in the future
60
61
  *
61
62
  * Tricky concurrency scenarios:
62
63
  * - Queued local push batches becoming invalid due to a prior local push item being rejected.
63
64
  * Solution: Introduce a generation number for local push batches which is used to filter out old batches items in case of rejection.
64
65
  *
66
+ * See ClientSessionSyncProcessor for how the leader and session sync processors are similar/different.
65
67
  */
66
68
  export const makeLeaderSyncProcessor = ({
67
69
  schema,
68
- dbEventlogMissing,
69
- dbEventlog,
70
70
  dbState,
71
- dbStateMissing,
72
71
  initialBlockingSyncContext,
72
+ initialSyncState,
73
73
  onError,
74
74
  params,
75
75
  testing,
76
76
  }: {
77
77
  schema: LiveStoreSchema
78
- /** Only used to know whether we can safely query dbEventlog during setup execution */
79
- dbEventlogMissing: boolean
80
- dbEventlog: SqliteDb
81
78
  dbState: SqliteDb
82
- /** Only used to know whether we can safely query dbState during setup execution */
83
- dbStateMissing: boolean
84
79
  initialBlockingSyncContext: InitialBlockingSyncContext
80
+ /** Initial sync state rehydrated from the persisted eventlog or initial sync state */
81
+ initialSyncState: SyncState.SyncState
85
82
  onError: 'shutdown' | 'ignore'
86
83
  params: {
87
84
  /**
@@ -113,18 +110,6 @@ export const makeLeaderSyncProcessor = ({
113
110
 
114
111
  const connectedClientSessionPullQueues = yield* makePullQueueSet
115
112
 
116
- /**
117
- * Tracks generations of queued local push events.
118
- * If a local-push batch is rejected, all subsequent push queue items with the same generation are also rejected,
119
- * even if they would be valid on their own.
120
- */
121
- // TODO get rid of this in favour of the `mergeGeneration` event sequence number field
122
- const currentLocalPushGenerationRef = { current: 0 }
123
-
124
- type MergeCounter = number
125
- const mergeCounterRef = { current: dbStateMissing ? 0 : yield* getMergeCounterFromDb(dbState) }
126
- const mergePayloads = new Map<MergeCounter, typeof SyncState.PayloadUpstream.Type>()
127
-
128
113
  // This context depends on data from `boot`, we should find a better implementation to avoid this ref indirection.
129
114
  const ctxRef = {
130
115
  current: undefined as
@@ -148,7 +133,7 @@ export const makeLeaderSyncProcessor = ({
148
133
  * - leader sync processor takes a bit and hasn't yet taken e1 from the localPushesQueue
149
134
  * - client session B also pushes e1 (which should be rejected)
150
135
  *
151
- * Thus the purpoe of the pushHeadRef is the guard the integrity of the local push queue
136
+ * Thus the purpose of the pushHeadRef is the guard the integrity of the local push queue
152
137
  */
153
138
  const pushHeadRef = { current: EventSequenceNumber.ROOT }
154
139
  const advancePushHead = (eventNum: EventSequenceNumber.EventSequenceNumber) => {
@@ -160,25 +145,24 @@ export const makeLeaderSyncProcessor = ({
160
145
  Effect.gen(function* () {
161
146
  if (newEvents.length === 0) return
162
147
 
148
+ // console.debug('push', newEvents)
149
+
163
150
  yield* validatePushBatch(newEvents, pushHeadRef.current)
164
151
 
165
152
  advancePushHead(newEvents.at(-1)!.seqNum)
166
153
 
167
154
  const waitForProcessing = options?.waitForProcessing ?? false
168
- const generation = currentLocalPushGenerationRef.current
169
155
 
170
156
  if (waitForProcessing) {
171
157
  const deferreds = yield* Effect.forEach(newEvents, () => Deferred.make<void, LeaderAheadError>())
172
158
 
173
- const items = newEvents.map(
174
- (eventEncoded, i) => [eventEncoded, deferreds[i], generation] as LocalPushQueueItem,
175
- )
159
+ const items = newEvents.map((eventEncoded, i) => [eventEncoded, deferreds[i]] as LocalPushQueueItem)
176
160
 
177
161
  yield* BucketQueue.offerAll(localPushesQueue, items)
178
162
 
179
163
  yield* Effect.all(deferreds)
180
164
  } else {
181
- const items = newEvents.map((eventEncoded) => [eventEncoded, undefined, generation] as LocalPushQueueItem)
165
+ const items = newEvents.map((eventEncoded) => [eventEncoded, undefined] as LocalPushQueueItem)
182
166
  yield* BucketQueue.offerAll(localPushesQueue, items)
183
167
  }
184
168
  }).pipe(
@@ -203,7 +187,7 @@ export const makeLeaderSyncProcessor = ({
203
187
  args,
204
188
  clientId,
205
189
  sessionId,
206
- ...EventSequenceNumber.nextPair(syncState.localHead, eventDef.options.clientOnly),
190
+ ...EventSequenceNumber.nextPair({ seqNum: syncState.localHead, isClient: eventDef.options.clientOnly }),
207
191
  })
208
192
 
209
193
  yield* push([eventEncoded])
@@ -223,34 +207,12 @@ export const makeLeaderSyncProcessor = ({
223
207
  runtime,
224
208
  }
225
209
 
226
- const initialLocalHead = dbEventlogMissing ? EventSequenceNumber.ROOT : Eventlog.getClientHeadFromDb(dbEventlog)
227
-
228
- const initialBackendHead = dbEventlogMissing
229
- ? EventSequenceNumber.ROOT.global
230
- : Eventlog.getBackendHeadFromDb(dbEventlog)
231
-
232
- if (initialBackendHead > initialLocalHead.global) {
233
- return shouldNeverHappen(
234
- `During boot the backend head (${initialBackendHead}) should never be greater than the local head (${initialLocalHead.global})`,
235
- )
236
- }
237
-
238
- const pendingEvents = dbEventlogMissing
239
- ? []
240
- : yield* Eventlog.getEventsSince({ global: initialBackendHead, client: EventSequenceNumber.clientDefault })
241
-
242
- const initialSyncState = new SyncState.SyncState({
243
- pending: pendingEvents,
244
- upstreamHead: { global: initialBackendHead, client: EventSequenceNumber.clientDefault },
245
- localHead: initialLocalHead,
246
- })
247
-
248
210
  /** State transitions need to happen atomically, so we use a Ref to track the state */
249
211
  yield* SubscriptionRef.set(syncStateSref, initialSyncState)
250
212
 
251
213
  // Rehydrate sync queue
252
- if (pendingEvents.length > 0) {
253
- const globalPendingEvents = pendingEvents
214
+ if (initialSyncState.pending.length > 0) {
215
+ const globalPendingEvents = initialSyncState.pending
254
216
  // Don't sync clientOnly events
255
217
  .filter((eventEncoded) => {
256
218
  const { eventDef } = getEventDef(schema, eventEncoded.name)
@@ -279,10 +241,7 @@ export const makeLeaderSyncProcessor = ({
279
241
  schema,
280
242
  isClientEvent,
281
243
  otelSpan,
282
- currentLocalPushGenerationRef,
283
244
  connectedClientSessionPullQueues,
284
- mergeCounterRef,
285
- mergePayloads,
286
245
  localPushBatchSize,
287
246
  testing: {
288
247
  delay: testing?.delays?.localPushProcessing,
@@ -300,7 +259,7 @@ export const makeLeaderSyncProcessor = ({
300
259
  yield* FiberHandle.run(backendPushingFiberHandle, backendPushingEffect)
301
260
 
302
261
  yield* backgroundBackendPulling({
303
- initialBackendHead,
262
+ initialBackendHead: initialSyncState.upstreamHead.global,
304
263
  isClientEvent,
305
264
  restartBackendPushing: (filteredRebasedPending) =>
306
265
  Effect.gen(function* () {
@@ -317,16 +276,15 @@ export const makeLeaderSyncProcessor = ({
317
276
  syncStateSref,
318
277
  localPushesLatch,
319
278
  pullLatch,
279
+ dbState,
320
280
  otelSpan,
321
281
  initialBlockingSyncContext,
322
282
  devtoolsLatch: ctxRef.current?.devtoolsLatch,
323
283
  connectedClientSessionPullQueues,
324
- mergeCounterRef,
325
- mergePayloads,
326
284
  advancePushHead,
327
285
  }).pipe(Effect.tapCauseLogPretty, Effect.catchAllCause(shutdownOnError), Effect.forkScoped)
328
286
 
329
- return { initialLeaderHead: initialLocalHead }
287
+ return { initialLeaderHead: initialSyncState.localHead }
330
288
  }).pipe(Effect.withSpanScoped('@livestore/common:LeaderSyncProcessor:boot'))
331
289
 
332
290
  const pull: LeaderSyncProcessor['pull'] = ({ cursor }) =>
@@ -335,34 +293,23 @@ export const makeLeaderSyncProcessor = ({
335
293
  return Stream.fromQueue(queue)
336
294
  }).pipe(Stream.unwrapScoped)
337
295
 
338
- const pullQueue: LeaderSyncProcessor['pullQueue'] = ({ cursor }) => {
339
- const runtime = ctxRef.current?.runtime ?? shouldNeverHappen('Not initialized')
340
- return Effect.gen(function* () {
341
- const queue = yield* connectedClientSessionPullQueues.makeQueue
342
- const payloadsSinceCursor = Array.from(mergePayloads.entries())
343
- .map(([mergeCounter, payload]) => ({ payload, mergeCounter }))
344
- .filter(({ mergeCounter }) => mergeCounter > cursor.mergeCounter)
345
- .toSorted((a, b) => a.mergeCounter - b.mergeCounter)
346
- .map(({ payload, mergeCounter }) => {
347
- if (payload._tag === 'upstream-advance') {
348
- return {
349
- payload: {
350
- _tag: 'upstream-advance' as const,
351
- newEvents: ReadonlyArray.dropWhile(payload.newEvents, (eventEncoded) =>
352
- EventSequenceNumber.isGreaterThanOrEqual(cursor.eventNum, eventEncoded.seqNum),
353
- ),
354
- },
355
- mergeCounter,
356
- }
357
- } else {
358
- return { payload, mergeCounter }
359
- }
360
- })
296
+ /*
297
+ Notes for a potential new `LeaderSyncProcessor.pull` implementation:
361
298
 
362
- yield* queue.offerAll(payloadsSinceCursor)
299
+ - Doesn't take cursor but is "atomically called" in the leader during the snapshot phase
300
+ - TODO: how is this done "atomically" in the web adapter where the snapshot is read optimistically?
301
+ - Would require a new kind of "boot-phase" API which is stream based:
302
+ - initial message: state snapshot + seq num head
303
+ - subsequent messages: sync state payloads
363
304
 
364
- return queue
365
- }).pipe(Effect.provide(runtime))
305
+ - alternative: instead of session pulling sync state payloads from leader, we could send
306
+ - events in the "advance" case
307
+ - full new state db snapshot in the "rebase" case
308
+ - downside: importing the snapshot is expensive
309
+ */
310
+ const pullQueue: LeaderSyncProcessor['pullQueue'] = ({ cursor }) => {
311
+ const runtime = ctxRef.current?.runtime ?? shouldNeverHappen('Not initialized')
312
+ return connectedClientSessionPullQueues.makeQueue(cursor).pipe(Effect.provide(runtime))
366
313
  }
367
314
 
368
315
  const syncState = Subscribable.make({
@@ -381,7 +328,6 @@ export const makeLeaderSyncProcessor = ({
381
328
  pushPartial,
382
329
  boot,
383
330
  syncState,
384
- getMergeCounter: () => mergeCounterRef.current,
385
331
  } satisfies LeaderSyncProcessor
386
332
  })
387
333
 
@@ -394,10 +340,7 @@ const backgroundApplyLocalPushes = ({
394
340
  schema,
395
341
  isClientEvent,
396
342
  otelSpan,
397
- currentLocalPushGenerationRef,
398
343
  connectedClientSessionPullQueues,
399
- mergeCounterRef,
400
- mergePayloads,
401
344
  localPushBatchSize,
402
345
  testing,
403
346
  }: {
@@ -409,10 +352,7 @@ const backgroundApplyLocalPushes = ({
409
352
  schema: LiveStoreSchema
410
353
  isClientEvent: (eventEncoded: LiveStoreEvent.EncodedWithMeta) => boolean
411
354
  otelSpan: otel.Span | undefined
412
- currentLocalPushGenerationRef: { current: number }
413
355
  connectedClientSessionPullQueues: PullQueueSet
414
- mergeCounterRef: { current: number }
415
- mergePayloads: Map<number, typeof SyncState.PayloadUpstream.Type>
416
356
  localPushBatchSize: number
417
357
  testing: {
418
358
  delay: Effect.Effect<void> | undefined
@@ -432,24 +372,26 @@ const backgroundApplyLocalPushes = ({
432
372
  // Prevent backend pull processing until this local push is finished
433
373
  yield* pullLatch.close
434
374
 
435
- // Since the generation might have changed since enqueuing, we need to filter out items with older generation
375
+ const syncState = yield* syncStateSref
376
+ if (syncState === undefined) return shouldNeverHappen('Not initialized')
377
+
378
+ const currentRebaseGeneration = syncState.localHead.rebaseGeneration
379
+
380
+ // Since the rebase generation might have changed since enqueuing, we need to filter out items with older generation
436
381
  // It's important that we filter after we got localPushesLatch, otherwise we might filter with the old generation
437
- const filteredBatchItems = batchItems
438
- .filter(([_1, _2, generation]) => generation === currentLocalPushGenerationRef.current)
439
- .map(([eventEncoded, deferred]) => [eventEncoded, deferred] as const)
382
+ const [newEvents, deferreds] = pipe(
383
+ batchItems,
384
+ ReadonlyArray.filter(([eventEncoded]) => eventEncoded.seqNum.rebaseGeneration === currentRebaseGeneration),
385
+ ReadonlyArray.unzip,
386
+ )
440
387
 
441
- if (filteredBatchItems.length === 0) {
388
+ if (newEvents.length === 0) {
442
389
  // console.log('dropping old-gen batch', currentLocalPushGenerationRef.current)
443
390
  // Allow the backend pulling to start
444
391
  yield* pullLatch.open
445
392
  continue
446
393
  }
447
394
 
448
- const [newEvents, deferreds] = ReadonlyArray.unzip(filteredBatchItems)
449
-
450
- const syncState = yield* syncStateSref
451
- if (syncState === undefined) return shouldNeverHappen('Not initialized')
452
-
453
395
  const mergeResult = SyncState.merge({
454
396
  syncState,
455
397
  payload: { _tag: 'local-push', newEvents },
@@ -457,29 +399,25 @@ const backgroundApplyLocalPushes = ({
457
399
  isEqualEvent: LiveStoreEvent.isEqualEncoded,
458
400
  })
459
401
 
460
- const mergeCounter = yield* incrementMergeCounter(mergeCounterRef)
461
-
462
402
  switch (mergeResult._tag) {
463
403
  case 'unexpected-error': {
464
- otelSpan?.addEvent(`[${mergeCounter}]:push:unexpected-error`, {
404
+ otelSpan?.addEvent(`push:unexpected-error`, {
465
405
  batchSize: newEvents.length,
466
406
  newEvents: TRACE_VERBOSE ? JSON.stringify(newEvents) : undefined,
467
407
  })
468
- return yield* Effect.fail(mergeResult.cause)
408
+ return yield* new SyncError({ cause: mergeResult.message })
469
409
  }
470
410
  case 'rebase': {
471
411
  return shouldNeverHappen('The leader thread should never have to rebase due to a local push')
472
412
  }
473
413
  case 'reject': {
474
- otelSpan?.addEvent(`[${mergeCounter}]:push:reject`, {
414
+ otelSpan?.addEvent(`push:reject`, {
475
415
  batchSize: newEvents.length,
476
416
  mergeResult: TRACE_VERBOSE ? JSON.stringify(mergeResult) : undefined,
477
417
  })
478
418
 
479
419
  // TODO: how to test this?
480
- currentLocalPushGenerationRef.current++
481
-
482
- const nextGeneration = currentLocalPushGenerationRef.current
420
+ const nextRebaseGeneration = currentRebaseGeneration + 1
483
421
 
484
422
  const providedNum = newEvents.at(0)!.seqNum
485
423
  // All subsequent pushes with same generation should be rejected as well
@@ -487,12 +425,13 @@ const backgroundApplyLocalPushes = ({
487
425
  // from the next generation which we preserve in the queue
488
426
  const remainingEventsMatchingGeneration = yield* BucketQueue.takeSplitWhere(
489
427
  localPushesQueue,
490
- (item) => item[2] >= nextGeneration,
428
+ ([eventEncoded]) => eventEncoded.seqNum.rebaseGeneration >= nextRebaseGeneration,
491
429
  )
492
430
 
493
431
  // TODO we still need to better understand and handle this scenario
494
432
  if (LS_DEV && (yield* BucketQueue.size(localPushesQueue)) > 0) {
495
433
  console.log('localPushesQueue is not empty', yield* BucketQueue.size(localPushesQueue))
434
+ // biome-ignore lint/suspicious/noDebugger: debugging
496
435
  debugger
497
436
  }
498
437
 
@@ -504,11 +443,7 @@ const backgroundApplyLocalPushes = ({
504
443
  yield* Effect.forEach(allDeferredsToReject, (deferred) =>
505
444
  Deferred.fail(
506
445
  deferred,
507
- LeaderAheadError.make({
508
- minimumExpectedNum: mergeResult.expectedMinimumId,
509
- providedNum,
510
- // nextGeneration,
511
- }),
446
+ LeaderAheadError.make({ minimumExpectedNum: mergeResult.expectedMinimumId, providedNum }),
512
447
  ),
513
448
  )
514
449
 
@@ -531,11 +466,10 @@ const backgroundApplyLocalPushes = ({
531
466
 
532
467
  yield* connectedClientSessionPullQueues.offer({
533
468
  payload: SyncState.PayloadUpstreamAdvance.make({ newEvents: mergeResult.newEvents }),
534
- mergeCounter,
469
+ leaderHead: mergeResult.newSyncState.localHead,
535
470
  })
536
- mergePayloads.set(mergeCounter, SyncState.PayloadUpstreamAdvance.make({ newEvents: mergeResult.newEvents }))
537
471
 
538
- otelSpan?.addEvent(`[${mergeCounter}]:push:advance`, {
472
+ otelSpan?.addEvent(`push:advance`, {
539
473
  batchSize: newEvents.length,
540
474
  mergeResult: TRACE_VERBOSE ? JSON.stringify(mergeResult) : undefined,
541
475
  })
@@ -584,8 +518,9 @@ const materializeEventsBatch: MaterializeEventsBatch = ({ batchItems, deferreds
584
518
  )
585
519
 
586
520
  for (let i = 0; i < batchItems.length; i++) {
587
- const { sessionChangeset } = yield* materializeEvent(batchItems[i]!)
521
+ const { sessionChangeset, hash } = yield* materializeEvent(batchItems[i]!)
588
522
  batchItems[i]!.meta.sessionChangeset = sessionChangeset
523
+ batchItems[i]!.meta.materializerHashLeader = hash
589
524
 
590
525
  if (deferreds?.[i] !== undefined) {
591
526
  yield* Deferred.succeed(deferreds[i]!, void 0)
@@ -609,14 +544,13 @@ const backgroundBackendPulling = ({
609
544
  isClientEvent,
610
545
  restartBackendPushing,
611
546
  otelSpan,
547
+ dbState,
612
548
  syncStateSref,
613
549
  localPushesLatch,
614
550
  pullLatch,
615
551
  devtoolsLatch,
616
552
  initialBlockingSyncContext,
617
553
  connectedClientSessionPullQueues,
618
- mergeCounterRef,
619
- mergePayloads,
620
554
  advancePushHead,
621
555
  }: {
622
556
  initialBackendHead: EventSequenceNumber.GlobalEventSequenceNumber
@@ -626,13 +560,12 @@ const backgroundBackendPulling = ({
626
560
  ) => Effect.Effect<void, UnexpectedError, LeaderThreadCtx | HttpClient.HttpClient>
627
561
  otelSpan: otel.Span | undefined
628
562
  syncStateSref: SubscriptionRef.SubscriptionRef<SyncState.SyncState | undefined>
563
+ dbState: SqliteDb
629
564
  localPushesLatch: Effect.Latch
630
565
  pullLatch: Effect.Latch
631
566
  devtoolsLatch: Effect.Latch | undefined
632
567
  initialBlockingSyncContext: InitialBlockingSyncContext
633
568
  connectedClientSessionPullQueues: PullQueueSet
634
- mergeCounterRef: { current: number }
635
- mergePayloads: Map<number, typeof SyncState.PayloadUpstream.Type>
636
569
  advancePushHead: (eventNum: EventSequenceNumber.EventSequenceNumber) => void
637
570
  }) =>
638
571
  Effect.gen(function* () {
@@ -665,16 +598,14 @@ const backgroundBackendPulling = ({
665
598
  ignoreClientEvents: true,
666
599
  })
667
600
 
668
- const mergeCounter = yield* incrementMergeCounter(mergeCounterRef)
669
-
670
601
  if (mergeResult._tag === 'reject') {
671
602
  return shouldNeverHappen('The leader thread should never reject upstream advances')
672
603
  } else if (mergeResult._tag === 'unexpected-error') {
673
- otelSpan?.addEvent(`[${mergeCounter}]:pull:unexpected-error`, {
604
+ otelSpan?.addEvent(`pull:unexpected-error`, {
674
605
  newEventsCount: newEvents.length,
675
606
  newEvents: TRACE_VERBOSE ? JSON.stringify(newEvents) : undefined,
676
607
  })
677
- return yield* Effect.fail(mergeResult.cause)
608
+ return yield* new SyncError({ cause: mergeResult.message })
678
609
  }
679
610
 
680
611
  const newBackendHead = newEvents.at(-1)!.seqNum
@@ -682,7 +613,7 @@ const backgroundBackendPulling = ({
682
613
  Eventlog.updateBackendHead(dbEventlog, newBackendHead)
683
614
 
684
615
  if (mergeResult._tag === 'rebase') {
685
- otelSpan?.addEvent(`[${mergeCounter}]:pull:rebase`, {
616
+ otelSpan?.addEvent(`pull:rebase[${mergeResult.newSyncState.localHead.rebaseGeneration}]`, {
686
617
  newEventsCount: newEvents.length,
687
618
  newEvents: TRACE_VERBOSE ? JSON.stringify(newEvents) : undefined,
688
619
  rollbackCount: mergeResult.rollbackEvents.length,
@@ -704,30 +635,19 @@ const backgroundBackendPulling = ({
704
635
  }
705
636
 
706
637
  yield* connectedClientSessionPullQueues.offer({
707
- payload: SyncState.PayloadUpstreamRebase.make({
708
- newEvents: mergeResult.newEvents,
709
- rollbackEvents: mergeResult.rollbackEvents,
710
- }),
711
- mergeCounter,
638
+ payload: SyncState.payloadFromMergeResult(mergeResult),
639
+ leaderHead: mergeResult.newSyncState.localHead,
712
640
  })
713
- mergePayloads.set(
714
- mergeCounter,
715
- SyncState.PayloadUpstreamRebase.make({
716
- newEvents: mergeResult.newEvents,
717
- rollbackEvents: mergeResult.rollbackEvents,
718
- }),
719
- )
720
641
  } else {
721
- otelSpan?.addEvent(`[${mergeCounter}]:pull:advance`, {
642
+ otelSpan?.addEvent(`pull:advance`, {
722
643
  newEventsCount: newEvents.length,
723
644
  mergeResult: TRACE_VERBOSE ? JSON.stringify(mergeResult) : undefined,
724
645
  })
725
646
 
726
647
  yield* connectedClientSessionPullQueues.offer({
727
- payload: SyncState.PayloadUpstreamAdvance.make({ newEvents: mergeResult.newEvents }),
728
- mergeCounter,
648
+ payload: SyncState.payloadFromMergeResult(mergeResult),
649
+ leaderHead: mergeResult.newSyncState.localHead,
729
650
  })
730
- mergePayloads.set(mergeCounter, SyncState.PayloadUpstreamAdvance.make({ newEvents: mergeResult.newEvents }))
731
651
 
732
652
  if (mergeResult.confirmedEvents.length > 0) {
733
653
  // `mergeResult.confirmedEvents` don't contain the correct sync metadata, so we need to use
@@ -756,7 +676,9 @@ const backgroundBackendPulling = ({
756
676
  }
757
677
  })
758
678
 
759
- const cursorInfo = yield* Eventlog.getSyncBackendCursorInfo(initialBackendHead)
679
+ const cursorInfo = yield* Eventlog.getSyncBackendCursorInfo({ remoteHead: initialBackendHead })
680
+
681
+ const hashMaterializerResult = makeMaterializerHash({ schema, dbState })
760
682
 
761
683
  yield* syncBackend.pull(cursorInfo).pipe(
762
684
  // TODO only take from queue while connected
@@ -775,7 +697,13 @@ const backgroundBackendPulling = ({
775
697
  yield* SubscriptionRef.waitUntil(syncBackend.isConnected, (isConnected) => isConnected === true)
776
698
 
777
699
  yield* onNewPullChunk(
778
- batch.map((_) => LiveStoreEvent.EncodedWithMeta.fromGlobal(_.eventEncoded, _.metadata)),
700
+ batch.map((_) =>
701
+ LiveStoreEvent.EncodedWithMeta.fromGlobal(_.eventEncoded, {
702
+ syncMetadata: _.metadata,
703
+ materializerHashLeader: hashMaterializerResult(_.eventEncoded),
704
+ materializerHashSession: Option.none(),
705
+ }),
706
+ ),
779
707
  remaining,
780
708
  )
781
709
 
@@ -839,19 +767,25 @@ const trimChangesetRows = (db: SqliteDb, newHead: EventSequenceNumber.EventSeque
839
767
  }
840
768
 
841
769
  interface PullQueueSet {
842
- makeQueue: Effect.Effect<
843
- Queue.Queue<{ payload: typeof SyncState.PayloadUpstream.Type; mergeCounter: number }>,
770
+ makeQueue: (
771
+ cursor: EventSequenceNumber.EventSequenceNumber,
772
+ ) => Effect.Effect<
773
+ Queue.Queue<{ payload: typeof SyncState.PayloadUpstream.Type }>,
844
774
  UnexpectedError,
845
775
  Scope.Scope | LeaderThreadCtx
846
776
  >
847
777
  offer: (item: {
848
778
  payload: typeof SyncState.PayloadUpstream.Type
849
- mergeCounter: number
779
+ leaderHead: EventSequenceNumber.EventSequenceNumber
850
780
  }) => Effect.Effect<void, UnexpectedError>
851
781
  }
852
782
 
853
783
  const makePullQueueSet = Effect.gen(function* () {
854
- const set = new Set<Queue.Queue<{ payload: typeof SyncState.PayloadUpstream.Type; mergeCounter: number }>>()
784
+ const set = new Set<Queue.Queue<{ payload: typeof SyncState.PayloadUpstream.Type }>>()
785
+
786
+ type StringifiedSeqNum = string
787
+ // NOTE this could grow unbounded for long running sessions
788
+ const cachedPayloads = new Map<StringifiedSeqNum, (typeof SyncState.PayloadUpstream.Type)[]>()
855
789
 
856
790
  yield* Effect.addFinalizer(() =>
857
791
  Effect.gen(function* () {
@@ -863,21 +797,81 @@ const makePullQueueSet = Effect.gen(function* () {
863
797
  }),
864
798
  )
865
799
 
866
- const makeQueue: PullQueueSet['makeQueue'] = Effect.gen(function* () {
867
- const queue = yield* Queue.unbounded<{
868
- payload: typeof SyncState.PayloadUpstream.Type
869
- mergeCounter: number
870
- }>().pipe(Effect.acquireRelease(Queue.shutdown))
800
+ const makeQueue: PullQueueSet['makeQueue'] = (cursor) =>
801
+ Effect.gen(function* () {
802
+ const queue = yield* Queue.unbounded<{
803
+ payload: typeof SyncState.PayloadUpstream.Type
804
+ }>().pipe(Effect.acquireRelease(Queue.shutdown))
871
805
 
872
- yield* Effect.addFinalizer(() => Effect.sync(() => set.delete(queue)))
806
+ yield* Effect.addFinalizer(() => Effect.sync(() => set.delete(queue)))
873
807
 
874
- set.add(queue)
808
+ const payloadsSinceCursor = Array.from(cachedPayloads.entries())
809
+ .flatMap(([seqNumStr, payloads]) =>
810
+ payloads.map((payload) => ({ payload, seqNum: EventSequenceNumber.fromString(seqNumStr) })),
811
+ )
812
+ .filter(({ seqNum }) => EventSequenceNumber.isGreaterThan(seqNum, cursor))
813
+ .toSorted((a, b) => EventSequenceNumber.compare(a.seqNum, b.seqNum))
814
+ .map(({ payload }) => {
815
+ if (payload._tag === 'upstream-advance') {
816
+ return {
817
+ payload: {
818
+ _tag: 'upstream-advance' as const,
819
+ newEvents: ReadonlyArray.dropWhile(payload.newEvents, (eventEncoded) =>
820
+ EventSequenceNumber.isGreaterThanOrEqual(cursor, eventEncoded.seqNum),
821
+ ),
822
+ },
823
+ }
824
+ } else {
825
+ return { payload }
826
+ }
827
+ })
875
828
 
876
- return queue
877
- })
829
+ // console.debug(
830
+ // 'seeding new queue',
831
+ // {
832
+ // cursor,
833
+ // },
834
+ // '\n mergePayloads',
835
+ // ...Array.from(cachedPayloads.entries())
836
+ // .flatMap(([seqNumStr, payloads]) =>
837
+ // payloads.map((payload) => ({ payload, seqNum: EventSequenceNumber.fromString(seqNumStr) })),
838
+ // )
839
+ // .map(({ payload, seqNum }) => [
840
+ // seqNum,
841
+ // payload._tag,
842
+ // 'newEvents',
843
+ // ...payload.newEvents.map((_) => _.toJSON()),
844
+ // 'rollbackEvents',
845
+ // ...(payload._tag === 'upstream-rebase' ? payload.rollbackEvents.map((_) => _.toJSON()) : []),
846
+ // ]),
847
+ // '\n payloadsSinceCursor',
848
+ // ...payloadsSinceCursor.map(({ payload }) => [
849
+ // payload._tag,
850
+ // 'newEvents',
851
+ // ...payload.newEvents.map((_) => _.toJSON()),
852
+ // 'rollbackEvents',
853
+ // ...(payload._tag === 'upstream-rebase' ? payload.rollbackEvents.map((_) => _.toJSON()) : []),
854
+ // ]),
855
+ // )
856
+
857
+ yield* queue.offerAll(payloadsSinceCursor)
858
+
859
+ set.add(queue)
860
+
861
+ return queue
862
+ })
878
863
 
879
864
  const offer: PullQueueSet['offer'] = (item) =>
880
865
  Effect.gen(function* () {
866
+ const seqNumStr = EventSequenceNumber.toString(item.leaderHead)
867
+ if (cachedPayloads.has(seqNumStr)) {
868
+ cachedPayloads.get(seqNumStr)!.push(item.payload)
869
+ } else {
870
+ cachedPayloads.set(seqNumStr, [item.payload])
871
+ }
872
+
873
+ // console.debug(`offering to ${set.size} queues`, item.leaderHead, JSON.stringify(item.payload, null, 2))
874
+
881
875
  // Short-circuit if the payload is an empty upstream advance
882
876
  if (item.payload._tag === 'upstream-advance' && item.payload.newEvents.length === 0) {
883
877
  return
@@ -894,24 +888,6 @@ const makePullQueueSet = Effect.gen(function* () {
894
888
  }
895
889
  })
896
890
 
897
- const incrementMergeCounter = (mergeCounterRef: { current: number }) =>
898
- Effect.gen(function* () {
899
- const { dbState } = yield* LeaderThreadCtx
900
- mergeCounterRef.current++
901
- dbState.execute(
902
- sql`INSERT OR REPLACE INTO ${SystemTables.LEADER_MERGE_COUNTER_TABLE} (id, mergeCounter) VALUES (0, ${mergeCounterRef.current})`,
903
- )
904
- return mergeCounterRef.current
905
- })
906
-
907
- const getMergeCounterFromDb = (dbState: SqliteDb) =>
908
- Effect.gen(function* () {
909
- const result = dbState.select<{ mergeCounter: number }>(
910
- sql`SELECT mergeCounter FROM ${SystemTables.LEADER_MERGE_COUNTER_TABLE} WHERE id = 0`,
911
- )
912
- return result[0]?.mergeCounter ?? 0
913
- })
914
-
915
891
  const validatePushBatch = (
916
892
  batch: ReadonlyArray<LiveStoreEvent.EncodedWithMeta>,
917
893
  pushHead: EventSequenceNumber.EventSequenceNumber,