@livestore/common 0.4.0-dev.1 → 0.4.0-dev.10

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 (253) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/ClientSessionLeaderThreadProxy.d.ts +7 -2
  3. package/dist/ClientSessionLeaderThreadProxy.d.ts.map +1 -1
  4. package/dist/ClientSessionLeaderThreadProxy.js.map +1 -1
  5. package/dist/adapter-types.d.ts +9 -3
  6. package/dist/adapter-types.d.ts.map +1 -1
  7. package/dist/adapter-types.js.map +1 -1
  8. package/dist/devtools/devtools-messages-client-session.d.ts +21 -21
  9. package/dist/devtools/devtools-messages-common.d.ts +7 -14
  10. package/dist/devtools/devtools-messages-common.d.ts.map +1 -1
  11. package/dist/devtools/devtools-messages-common.js +1 -6
  12. package/dist/devtools/devtools-messages-common.js.map +1 -1
  13. package/dist/devtools/devtools-messages-leader.d.ts +27 -25
  14. package/dist/devtools/devtools-messages-leader.d.ts.map +1 -1
  15. package/dist/errors.d.ts +47 -5
  16. package/dist/errors.d.ts.map +1 -1
  17. package/dist/errors.js +22 -3
  18. package/dist/errors.js.map +1 -1
  19. package/dist/leader-thread/LeaderSyncProcessor.d.ts +7 -3
  20. package/dist/leader-thread/LeaderSyncProcessor.d.ts.map +1 -1
  21. package/dist/leader-thread/LeaderSyncProcessor.js +122 -49
  22. package/dist/leader-thread/LeaderSyncProcessor.js.map +1 -1
  23. package/dist/leader-thread/eventlog.d.ts +4 -10
  24. package/dist/leader-thread/eventlog.d.ts.map +1 -1
  25. package/dist/leader-thread/eventlog.js +4 -6
  26. package/dist/leader-thread/eventlog.js.map +1 -1
  27. package/dist/leader-thread/leader-worker-devtools.d.ts +1 -1
  28. package/dist/leader-thread/leader-worker-devtools.js +6 -2
  29. package/dist/leader-thread/leader-worker-devtools.js.map +1 -1
  30. package/dist/leader-thread/make-leader-thread-layer.d.ts +1 -2
  31. package/dist/leader-thread/make-leader-thread-layer.d.ts.map +1 -1
  32. package/dist/leader-thread/make-leader-thread-layer.js +68 -19
  33. package/dist/leader-thread/make-leader-thread-layer.js.map +1 -1
  34. package/dist/leader-thread/make-leader-thread-layer.test.d.ts +2 -0
  35. package/dist/leader-thread/make-leader-thread-layer.test.d.ts.map +1 -0
  36. package/dist/leader-thread/make-leader-thread-layer.test.js +32 -0
  37. package/dist/leader-thread/make-leader-thread-layer.test.js.map +1 -0
  38. package/dist/leader-thread/materialize-event.d.ts +2 -2
  39. package/dist/leader-thread/materialize-event.d.ts.map +1 -1
  40. package/dist/leader-thread/materialize-event.js +23 -9
  41. package/dist/leader-thread/materialize-event.js.map +1 -1
  42. package/dist/leader-thread/recreate-db.d.ts +2 -3
  43. package/dist/leader-thread/recreate-db.d.ts.map +1 -1
  44. package/dist/leader-thread/recreate-db.js +1 -1
  45. package/dist/leader-thread/recreate-db.js.map +1 -1
  46. package/dist/leader-thread/shutdown-channel.d.ts +2 -2
  47. package/dist/leader-thread/shutdown-channel.d.ts.map +1 -1
  48. package/dist/leader-thread/shutdown-channel.js +2 -2
  49. package/dist/leader-thread/shutdown-channel.js.map +1 -1
  50. package/dist/leader-thread/types.d.ts +7 -5
  51. package/dist/leader-thread/types.d.ts.map +1 -1
  52. package/dist/leader-thread/types.js.map +1 -1
  53. package/dist/materializer-helper.d.ts +1 -1
  54. package/dist/materializer-helper.d.ts.map +1 -1
  55. package/dist/materializer-helper.js +20 -4
  56. package/dist/materializer-helper.js.map +1 -1
  57. package/dist/rematerialize-from-eventlog.d.ts +1 -1
  58. package/dist/rematerialize-from-eventlog.d.ts.map +1 -1
  59. package/dist/rematerialize-from-eventlog.js +25 -16
  60. package/dist/rematerialize-from-eventlog.js.map +1 -1
  61. package/dist/schema/EventDef.d.ts +3 -0
  62. package/dist/schema/EventDef.d.ts.map +1 -1
  63. package/dist/schema/EventDef.js.map +1 -1
  64. package/dist/schema/LiveStoreEvent.d.ts +1 -1
  65. package/dist/schema/LiveStoreEvent.d.ts.map +1 -1
  66. package/dist/schema/LiveStoreEvent.js +1 -2
  67. package/dist/schema/LiveStoreEvent.js.map +1 -1
  68. package/dist/schema/mod.d.ts +2 -0
  69. package/dist/schema/mod.d.ts.map +1 -1
  70. package/dist/schema/mod.js +1 -0
  71. package/dist/schema/mod.js.map +1 -1
  72. package/dist/schema/schema.d.ts +15 -0
  73. package/dist/schema/schema.d.ts.map +1 -1
  74. package/dist/schema/schema.js +26 -1
  75. package/dist/schema/schema.js.map +1 -1
  76. package/dist/schema/state/sqlite/client-document-def.d.ts +35 -5
  77. package/dist/schema/state/sqlite/client-document-def.d.ts.map +1 -1
  78. package/dist/schema/state/sqlite/client-document-def.js +95 -4
  79. package/dist/schema/state/sqlite/client-document-def.js.map +1 -1
  80. package/dist/schema/state/sqlite/client-document-def.test.js +16 -0
  81. package/dist/schema/state/sqlite/client-document-def.test.js.map +1 -1
  82. package/dist/schema/state/sqlite/column-annotations.d.ts.map +1 -1
  83. package/dist/schema/state/sqlite/column-annotations.js +14 -6
  84. package/dist/schema/state/sqlite/column-annotations.js.map +1 -1
  85. package/dist/schema/state/sqlite/column-def.d.ts +6 -2
  86. package/dist/schema/state/sqlite/column-def.d.ts.map +1 -1
  87. package/dist/schema/state/sqlite/column-def.js +122 -185
  88. package/dist/schema/state/sqlite/column-def.js.map +1 -1
  89. package/dist/schema/state/sqlite/column-def.test.js +116 -73
  90. package/dist/schema/state/sqlite/column-def.test.js.map +1 -1
  91. package/dist/schema/state/sqlite/db-schema/ast/sqlite.d.ts +2 -1
  92. package/dist/schema/state/sqlite/db-schema/ast/sqlite.d.ts.map +1 -1
  93. package/dist/schema/state/sqlite/db-schema/ast/sqlite.js +23 -6
  94. package/dist/schema/state/sqlite/db-schema/ast/sqlite.js.map +1 -1
  95. package/dist/schema/state/sqlite/db-schema/dsl/mod.d.ts.map +1 -1
  96. package/dist/schema/state/sqlite/db-schema/dsl/mod.js +2 -1
  97. package/dist/schema/state/sqlite/db-schema/dsl/mod.js.map +1 -1
  98. package/dist/schema/state/sqlite/mod.d.ts +1 -1
  99. package/dist/schema/state/sqlite/mod.d.ts.map +1 -1
  100. package/dist/schema/state/sqlite/mod.js +1 -1
  101. package/dist/schema/state/sqlite/mod.js.map +1 -1
  102. package/dist/schema/state/sqlite/query-builder/api.d.ts +5 -2
  103. package/dist/schema/state/sqlite/query-builder/api.d.ts.map +1 -1
  104. package/dist/schema/state/sqlite/query-builder/impl.d.ts.map +1 -1
  105. package/dist/schema/state/sqlite/query-builder/impl.js +6 -2
  106. package/dist/schema/state/sqlite/query-builder/impl.js.map +1 -1
  107. package/dist/schema/state/sqlite/query-builder/impl.test.js +137 -2
  108. package/dist/schema/state/sqlite/query-builder/impl.test.js.map +1 -1
  109. package/dist/schema/state/sqlite/system-tables.d.ts +42 -6
  110. package/dist/schema/state/sqlite/system-tables.d.ts.map +1 -1
  111. package/dist/schema/state/sqlite/system-tables.js +2 -0
  112. package/dist/schema/state/sqlite/system-tables.js.map +1 -1
  113. package/dist/schema/state/sqlite/table-def.d.ts +4 -4
  114. package/dist/schema/state/sqlite/table-def.d.ts.map +1 -1
  115. package/dist/schema/state/sqlite/table-def.js +2 -2
  116. package/dist/schema/state/sqlite/table-def.js.map +1 -1
  117. package/dist/schema/state/sqlite/table-def.test.js +51 -2
  118. package/dist/schema/state/sqlite/table-def.test.js.map +1 -1
  119. package/dist/schema/unknown-events.d.ts +47 -0
  120. package/dist/schema/unknown-events.d.ts.map +1 -0
  121. package/dist/schema/unknown-events.js +69 -0
  122. package/dist/schema/unknown-events.js.map +1 -0
  123. package/dist/sql-queries/sql-query-builder.d.ts.map +1 -1
  124. package/dist/sql-queries/sql-query-builder.js +2 -1
  125. package/dist/sql-queries/sql-query-builder.js.map +1 -1
  126. package/dist/sync/ClientSessionSyncProcessor.d.ts +9 -11
  127. package/dist/sync/ClientSessionSyncProcessor.d.ts.map +1 -1
  128. package/dist/sync/ClientSessionSyncProcessor.js +35 -33
  129. package/dist/sync/ClientSessionSyncProcessor.js.map +1 -1
  130. package/dist/sync/errors.d.ts +61 -0
  131. package/dist/sync/errors.d.ts.map +1 -0
  132. package/dist/sync/errors.js +36 -0
  133. package/dist/sync/errors.js.map +1 -0
  134. package/dist/sync/index.d.ts +3 -0
  135. package/dist/sync/index.d.ts.map +1 -1
  136. package/dist/sync/index.js +3 -0
  137. package/dist/sync/index.js.map +1 -1
  138. package/dist/sync/mock-sync-backend.d.ts +23 -0
  139. package/dist/sync/mock-sync-backend.d.ts.map +1 -0
  140. package/dist/sync/mock-sync-backend.js +114 -0
  141. package/dist/sync/mock-sync-backend.js.map +1 -0
  142. package/dist/sync/next/compact-events.d.ts.map +1 -1
  143. package/dist/sync/next/compact-events.js +4 -5
  144. package/dist/sync/next/compact-events.js.map +1 -1
  145. package/dist/sync/next/facts.d.ts.map +1 -1
  146. package/dist/sync/next/facts.js +1 -2
  147. package/dist/sync/next/facts.js.map +1 -1
  148. package/dist/sync/next/history-dag-common.d.ts +50 -11
  149. package/dist/sync/next/history-dag-common.d.ts.map +1 -1
  150. package/dist/sync/next/history-dag-common.js +193 -4
  151. package/dist/sync/next/history-dag-common.js.map +1 -1
  152. package/dist/sync/next/history-dag.d.ts.map +1 -1
  153. package/dist/sync/next/history-dag.js +3 -1
  154. package/dist/sync/next/history-dag.js.map +1 -1
  155. package/dist/sync/sync-backend-kv.d.ts +7 -0
  156. package/dist/sync/sync-backend-kv.d.ts.map +1 -0
  157. package/dist/sync/sync-backend-kv.js +18 -0
  158. package/dist/sync/sync-backend-kv.js.map +1 -0
  159. package/dist/sync/sync-backend.d.ts +105 -0
  160. package/dist/sync/sync-backend.d.ts.map +1 -0
  161. package/dist/sync/sync-backend.js +61 -0
  162. package/dist/sync/sync-backend.js.map +1 -0
  163. package/dist/sync/sync.d.ts +6 -84
  164. package/dist/sync/sync.d.ts.map +1 -1
  165. package/dist/sync/sync.js +2 -27
  166. package/dist/sync/sync.js.map +1 -1
  167. package/dist/sync/transport-chunking.d.ts +36 -0
  168. package/dist/sync/transport-chunking.d.ts.map +1 -0
  169. package/dist/sync/transport-chunking.js +56 -0
  170. package/dist/sync/transport-chunking.js.map +1 -0
  171. package/dist/sync/validate-push-payload.d.ts +1 -1
  172. package/dist/sync/validate-push-payload.d.ts.map +1 -1
  173. package/dist/sync/validate-push-payload.js +6 -6
  174. package/dist/sync/validate-push-payload.js.map +1 -1
  175. package/dist/testing/event-factory.d.ts +68 -0
  176. package/dist/testing/event-factory.d.ts.map +1 -0
  177. package/dist/testing/event-factory.js +80 -0
  178. package/dist/testing/event-factory.js.map +1 -0
  179. package/dist/testing/mod.d.ts +2 -0
  180. package/dist/testing/mod.d.ts.map +1 -0
  181. package/dist/testing/mod.js +2 -0
  182. package/dist/testing/mod.js.map +1 -0
  183. package/dist/version.d.ts +2 -2
  184. package/dist/version.d.ts.map +1 -1
  185. package/dist/version.js +2 -2
  186. package/dist/version.js.map +1 -1
  187. package/package.json +7 -8
  188. package/src/ClientSessionLeaderThreadProxy.ts +7 -2
  189. package/src/adapter-types.ts +13 -3
  190. package/src/devtools/devtools-messages-common.ts +1 -8
  191. package/src/errors.ts +33 -4
  192. package/src/leader-thread/LeaderSyncProcessor.ts +179 -57
  193. package/src/leader-thread/eventlog.ts +10 -6
  194. package/src/leader-thread/leader-worker-devtools.ts +6 -2
  195. package/src/leader-thread/make-leader-thread-layer.test.ts +44 -0
  196. package/src/leader-thread/make-leader-thread-layer.ts +137 -26
  197. package/src/leader-thread/materialize-event.ts +34 -9
  198. package/src/leader-thread/recreate-db.ts +11 -3
  199. package/src/leader-thread/shutdown-channel.ts +16 -2
  200. package/src/leader-thread/types.ts +7 -5
  201. package/src/materializer-helper.ts +22 -5
  202. package/src/rematerialize-from-eventlog.ts +33 -23
  203. package/src/schema/EventDef.ts +3 -0
  204. package/src/schema/LiveStoreEvent.ts +1 -2
  205. package/src/schema/mod.ts +2 -0
  206. package/src/schema/schema.ts +37 -1
  207. package/src/schema/state/sqlite/client-document-def.test.ts +17 -0
  208. package/src/schema/state/sqlite/client-document-def.ts +117 -5
  209. package/src/schema/state/sqlite/column-annotations.ts +16 -6
  210. package/src/schema/state/sqlite/column-def.test.ts +150 -93
  211. package/src/schema/state/sqlite/column-def.ts +128 -203
  212. package/src/schema/state/sqlite/db-schema/ast/sqlite.ts +26 -6
  213. package/src/schema/state/sqlite/db-schema/dsl/mod.ts +2 -1
  214. package/src/schema/state/sqlite/mod.ts +1 -0
  215. package/src/schema/state/sqlite/query-builder/api.ts +7 -2
  216. package/src/schema/state/sqlite/query-builder/impl.test.ts +187 -6
  217. package/src/schema/state/sqlite/query-builder/impl.ts +8 -2
  218. package/src/schema/state/sqlite/system-tables.ts +2 -0
  219. package/src/schema/state/sqlite/table-def.test.ts +64 -2
  220. package/src/schema/state/sqlite/table-def.ts +9 -8
  221. package/src/schema/unknown-events.ts +131 -0
  222. package/src/sql-queries/sql-query-builder.ts +2 -1
  223. package/src/sync/ClientSessionSyncProcessor.ts +55 -49
  224. package/src/sync/errors.ts +38 -0
  225. package/src/sync/index.ts +3 -0
  226. package/src/sync/mock-sync-backend.ts +184 -0
  227. package/src/sync/next/compact-events.ts +4 -5
  228. package/src/sync/next/facts.ts +1 -3
  229. package/src/sync/next/history-dag-common.ts +272 -21
  230. package/src/sync/next/history-dag.ts +3 -1
  231. package/src/sync/sync-backend-kv.ts +22 -0
  232. package/src/sync/sync-backend.ts +185 -0
  233. package/src/sync/sync.ts +6 -89
  234. package/src/sync/transport-chunking.ts +90 -0
  235. package/src/sync/validate-push-payload.ts +6 -7
  236. package/src/testing/event-factory.ts +133 -0
  237. package/src/testing/mod.ts +1 -0
  238. package/src/version.ts +2 -2
  239. package/dist/schema-management/migrations.test.d.ts +0 -2
  240. package/dist/schema-management/migrations.test.d.ts.map +0 -1
  241. package/dist/schema-management/migrations.test.js +0 -52
  242. package/dist/schema-management/migrations.test.js.map +0 -1
  243. package/dist/sync/next/graphology.d.ts +0 -8
  244. package/dist/sync/next/graphology.d.ts.map +0 -1
  245. package/dist/sync/next/graphology.js +0 -30
  246. package/dist/sync/next/graphology.js.map +0 -1
  247. package/dist/sync/next/graphology_.d.ts +0 -3
  248. package/dist/sync/next/graphology_.d.ts.map +0 -1
  249. package/dist/sync/next/graphology_.js +0 -3
  250. package/dist/sync/next/graphology_.js.map +0 -1
  251. package/src/sync/next/ambient.d.ts +0 -3
  252. package/src/sync/next/graphology.ts +0 -41
  253. package/src/sync/next/graphology_.ts +0 -2
@@ -2,27 +2,40 @@ import { casesHandled, isNotUndefined, LS_DEV, shouldNeverHappen, TRACE_VERBOSE
2
2
  import type { HttpClient, Runtime, Scope, Tracer } from '@livestore/utils/effect'
3
3
  import {
4
4
  BucketQueue,
5
+ Cause,
5
6
  Deferred,
7
+ Duration,
6
8
  Effect,
7
9
  Exit,
8
10
  FiberHandle,
11
+ Layer,
9
12
  Option,
10
13
  OtelTracer,
11
14
  pipe,
12
15
  Queue,
13
16
  ReadonlyArray,
17
+ Schedule,
14
18
  Stream,
15
19
  Subscribable,
16
20
  SubscriptionRef,
17
21
  } from '@livestore/utils/effect'
18
22
  import type * as otel from '@opentelemetry/api'
19
-
20
- import type { SqliteDb } from '../adapter-types.ts'
21
- import { SyncError, UnexpectedError } from '../adapter-types.ts'
23
+ import {
24
+ type IntentionalShutdownCause,
25
+ type MaterializeError,
26
+ type SqliteDb,
27
+ UnexpectedError,
28
+ } from '../adapter-types.ts'
22
29
  import { makeMaterializerHash } from '../materializer-helper.ts'
23
30
  import type { LiveStoreSchema } from '../schema/mod.ts'
24
- import { EventSequenceNumber, getEventDef, LiveStoreEvent, SystemTables } from '../schema/mod.ts'
25
- import { LeaderAheadError } from '../sync/sync.ts'
31
+ import { EventSequenceNumber, LiveStoreEvent, resolveEventDef, SystemTables } from '../schema/mod.ts'
32
+ import {
33
+ type InvalidPullError,
34
+ type InvalidPushError,
35
+ type IsOfflineError,
36
+ LeaderAheadError,
37
+ type SyncBackend,
38
+ } from '../sync/sync.ts'
26
39
  import * as SyncState from '../sync/syncstate.ts'
27
40
  import { sql } from '../util.ts'
28
41
  import * as Eventlog from './eventlog.ts'
@@ -71,6 +84,7 @@ export const makeLeaderSyncProcessor = ({
71
84
  initialBlockingSyncContext,
72
85
  initialSyncState,
73
86
  onError,
87
+ livePull,
74
88
  params,
75
89
  testing,
76
90
  }: {
@@ -90,6 +104,11 @@ export const makeLeaderSyncProcessor = ({
90
104
  */
91
105
  backendPushBatchSize?: number
92
106
  }
107
+ /**
108
+ * Whether the sync backend should reactively pull new events from the sync backend
109
+ * When `false`, the sync processor will only do an initial pull
110
+ */
111
+ livePull: boolean
93
112
  testing: {
94
113
  delays?: {
95
114
  localPushProcessing?: Effect.Effect<void>
@@ -103,10 +122,8 @@ export const makeLeaderSyncProcessor = ({
103
122
 
104
123
  const syncStateSref = yield* SubscriptionRef.make<SyncState.SyncState | undefined>(undefined)
105
124
 
106
- const isClientEvent = (eventEncoded: LiveStoreEvent.EncodedWithMeta) => {
107
- const { eventDef } = getEventDef(schema, eventEncoded.name)
108
- return eventDef.options.clientOnly
109
- }
125
+ const isClientEvent = (eventEncoded: LiveStoreEvent.EncodedWithMeta) =>
126
+ schema.eventsDefsMap.get(eventEncoded.name)?.options.clientOnly ?? false
110
127
 
111
128
  const connectedClientSessionPullQueues = yield* makePullQueueSet
112
129
 
@@ -180,14 +197,32 @@ export const makeLeaderSyncProcessor = ({
180
197
  const syncState = yield* syncStateSref
181
198
  if (syncState === undefined) return shouldNeverHappen('Not initialized')
182
199
 
183
- const { eventDef } = getEventDef(schema, name)
200
+ const resolution = yield* resolveEventDef(schema, {
201
+ operation: '@livestore/common:LeaderSyncProcessor:pushPartial',
202
+ event: {
203
+ name,
204
+ args,
205
+ clientId,
206
+ sessionId,
207
+ seqNum: syncState.localHead,
208
+ },
209
+ }).pipe(UnexpectedError.mapToUnexpectedError)
210
+
211
+ if (resolution._tag === 'unknown') {
212
+ // Ignore partial pushes for unrecognised events – they are still
213
+ // persisted server-side once a schema update ships.
214
+ return
215
+ }
184
216
 
185
217
  const eventEncoded = new LiveStoreEvent.EncodedWithMeta({
186
218
  name,
187
219
  args,
188
220
  clientId,
189
221
  sessionId,
190
- ...EventSequenceNumber.nextPair({ seqNum: syncState.localHead, isClient: eventDef.options.clientOnly }),
222
+ ...EventSequenceNumber.nextPair({
223
+ seqNum: syncState.localHead,
224
+ isClient: resolution.eventDef.options.clientOnly,
225
+ }),
191
226
  })
192
227
 
193
228
  yield* push([eventEncoded])
@@ -215,8 +250,8 @@ export const makeLeaderSyncProcessor = ({
215
250
  const globalPendingEvents = initialSyncState.pending
216
251
  // Don't sync clientOnly events
217
252
  .filter((eventEncoded) => {
218
- const { eventDef } = getEventDef(schema, eventEncoded.name)
219
- return eventDef.options.clientOnly === false
253
+ const eventDef = schema.eventsDefsMap.get(eventEncoded.name)
254
+ return eventDef === undefined ? true : eventDef.options.clientOnly === false
220
255
  })
221
256
 
222
257
  if (globalPendingEvents.length > 0) {
@@ -224,12 +259,31 @@ export const makeLeaderSyncProcessor = ({
224
259
  }
225
260
  }
226
261
 
227
- const shutdownOnError = (cause: unknown) =>
262
+ const maybeShutdownOnError = (
263
+ cause: Cause.Cause<
264
+ | UnexpectedError
265
+ | IntentionalShutdownCause
266
+ | IsOfflineError
267
+ | InvalidPushError
268
+ | InvalidPullError
269
+ | MaterializeError
270
+ >,
271
+ ) =>
228
272
  Effect.gen(function* () {
229
- if (onError === 'shutdown') {
230
- yield* shutdownChannel.send(UnexpectedError.make({ cause }))
231
- yield* Effect.die(cause)
273
+ if (onError === 'ignore') {
274
+ if (LS_DEV) {
275
+ yield* Effect.logDebug(
276
+ `Ignoring sync error (${cause._tag === 'Fail' ? cause.error._tag : cause._tag})`,
277
+ Cause.pretty(cause),
278
+ )
279
+ }
280
+ return
232
281
  }
282
+
283
+ const errorToSend = Cause.isFailType(cause) ? cause.error : UnexpectedError.make({ cause })
284
+ yield* shutdownChannel.send(errorToSend).pipe(Effect.orDie)
285
+
286
+ return yield* Effect.die(cause)
233
287
  })
234
288
 
235
289
  yield* backgroundApplyLocalPushes({
@@ -246,20 +300,19 @@ export const makeLeaderSyncProcessor = ({
246
300
  testing: {
247
301
  delay: testing?.delays?.localPushProcessing,
248
302
  },
249
- }).pipe(Effect.tapCauseLogPretty, Effect.catchAllCause(shutdownOnError), Effect.forkScoped)
303
+ }).pipe(Effect.catchAllCause(maybeShutdownOnError), Effect.forkScoped)
250
304
 
251
- const backendPushingFiberHandle = yield* FiberHandle.make()
305
+ const backendPushingFiberHandle = yield* FiberHandle.make<void, never>()
252
306
  const backendPushingEffect = backgroundBackendPushing({
253
307
  syncBackendPushQueue,
254
308
  otelSpan,
255
309
  devtoolsLatch: ctxRef.current?.devtoolsLatch,
256
310
  backendPushBatchSize,
257
- }).pipe(Effect.tapCauseLogPretty, Effect.catchAllCause(shutdownOnError))
311
+ }).pipe(Effect.catchAllCause(maybeShutdownOnError))
258
312
 
259
313
  yield* FiberHandle.run(backendPushingFiberHandle, backendPushingEffect)
260
314
 
261
315
  yield* backgroundBackendPulling({
262
- initialBackendHead: initialSyncState.upstreamHead.global,
263
316
  isClientEvent,
264
317
  restartBackendPushing: (filteredRebasedPending) =>
265
318
  Effect.gen(function* () {
@@ -276,13 +329,24 @@ export const makeLeaderSyncProcessor = ({
276
329
  syncStateSref,
277
330
  localPushesLatch,
278
331
  pullLatch,
332
+ livePull,
279
333
  dbState,
280
334
  otelSpan,
281
335
  initialBlockingSyncContext,
282
336
  devtoolsLatch: ctxRef.current?.devtoolsLatch,
283
337
  connectedClientSessionPullQueues,
284
338
  advancePushHead,
285
- }).pipe(Effect.tapCauseLogPretty, Effect.catchAllCause(shutdownOnError), Effect.forkScoped)
339
+ }).pipe(
340
+ Effect.retry({
341
+ // We want to retry pulling if we've lost connection to the sync backend
342
+ while: (cause) => cause._tag === 'IsOfflineError',
343
+ }),
344
+ Effect.catchAllCause(maybeShutdownOnError),
345
+ // Needed to avoid `Fiber terminated with an unhandled error` logs which seem to happen because of the `Effect.retry` above.
346
+ // This might be a bug in Effect. Only seems to happen in the browser.
347
+ Effect.provide(Layer.setUnhandledErrorLogLevel(Option.none())),
348
+ Effect.forkScoped,
349
+ )
286
350
 
287
351
  return { initialLeaderHead: initialSyncState.localHead }
288
352
  }).pipe(Effect.withSpanScoped('@livestore/common:LeaderSyncProcessor:boot'))
@@ -381,7 +445,12 @@ const backgroundApplyLocalPushes = ({
381
445
  // It's important that we filter after we got localPushesLatch, otherwise we might filter with the old generation
382
446
  const [newEvents, deferreds] = pipe(
383
447
  batchItems,
384
- ReadonlyArray.filter(([eventEncoded]) => eventEncoded.seqNum.rebaseGeneration === currentRebaseGeneration),
448
+ ReadonlyArray.filter(
449
+ ([eventEncoded]) =>
450
+ // Keep events that match the current generation or newer. Older generations will
451
+ // be rejected below when their sequence numbers no longer advance the local head.
452
+ eventEncoded.seqNum.rebaseGeneration >= currentRebaseGeneration,
453
+ ),
385
454
  ReadonlyArray.unzip,
386
455
  )
387
456
 
@@ -405,7 +474,7 @@ const backgroundApplyLocalPushes = ({
405
474
  batchSize: newEvents.length,
406
475
  newEvents: TRACE_VERBOSE ? JSON.stringify(newEvents) : undefined,
407
476
  })
408
- return yield* new SyncError({ cause: mergeResult.message })
477
+ return yield* new UnexpectedError({ cause: mergeResult.message })
409
478
  }
410
479
  case 'rebase': {
411
480
  return shouldNeverHappen('The leader thread should never have to rebase due to a local push')
@@ -476,8 +545,8 @@ const backgroundApplyLocalPushes = ({
476
545
 
477
546
  // Don't sync clientOnly events
478
547
  const filteredBatch = mergeResult.newEvents.filter((eventEncoded) => {
479
- const { eventDef } = getEventDef(schema, eventEncoded.name)
480
- return eventDef.options.clientOnly === false
548
+ const eventDef = schema.eventsDefsMap.get(eventEncoded.name)
549
+ return eventDef === undefined ? true : eventDef.options.clientOnly === false
481
550
  })
482
551
 
483
552
  yield* BucketQueue.offerAll(syncBackendPushQueue, filteredBatch)
@@ -496,7 +565,7 @@ type MaterializeEventsBatch = (_: {
496
565
  * Indexes are aligned with `batchItems`
497
566
  */
498
567
  deferreds: ReadonlyArray<Deferred.Deferred<void, LeaderAheadError> | undefined> | undefined
499
- }) => Effect.Effect<void, UnexpectedError, LeaderThreadCtx>
568
+ }) => Effect.Effect<void, MaterializeError, LeaderThreadCtx>
500
569
 
501
570
  // TODO how to handle errors gracefully
502
571
  const materializeEventsBatch: MaterializeEventsBatch = ({ batchItems, deferreds }) =>
@@ -536,24 +605,22 @@ const materializeEventsBatch: MaterializeEventsBatch = ({ batchItems, deferreds
536
605
  attributes: { batchSize: batchItems.length },
537
606
  }),
538
607
  Effect.tapCauseLogPretty,
539
- UnexpectedError.mapToUnexpectedError,
540
608
  )
541
609
 
542
610
  const backgroundBackendPulling = ({
543
- initialBackendHead,
544
611
  isClientEvent,
545
612
  restartBackendPushing,
546
613
  otelSpan,
547
614
  dbState,
548
615
  syncStateSref,
549
616
  localPushesLatch,
617
+ livePull,
550
618
  pullLatch,
551
619
  devtoolsLatch,
552
620
  initialBlockingSyncContext,
553
621
  connectedClientSessionPullQueues,
554
622
  advancePushHead,
555
623
  }: {
556
- initialBackendHead: EventSequenceNumber.GlobalEventSequenceNumber
557
624
  isClientEvent: (eventEncoded: LiveStoreEvent.EncodedWithMeta) => boolean
558
625
  restartBackendPushing: (
559
626
  filteredRebasedPending: ReadonlyArray<LiveStoreEvent.EncodedWithMeta>,
@@ -563,6 +630,7 @@ const backgroundBackendPulling = ({
563
630
  dbState: SqliteDb
564
631
  localPushesLatch: Effect.Latch
565
632
  pullLatch: Effect.Latch
633
+ livePull: boolean
566
634
  devtoolsLatch: Effect.Latch | undefined
567
635
  initialBlockingSyncContext: InitialBlockingSyncContext
568
636
  connectedClientSessionPullQueues: PullQueueSet
@@ -573,7 +641,7 @@ const backgroundBackendPulling = ({
573
641
 
574
642
  if (syncBackend === undefined) return
575
643
 
576
- const onNewPullChunk = (newEvents: LiveStoreEvent.EncodedWithMeta[], remaining: number) =>
644
+ const onNewPullChunk = (newEvents: LiveStoreEvent.EncodedWithMeta[], pageInfo: SyncBackend.PullResPageInfo) =>
577
645
  Effect.gen(function* () {
578
646
  if (newEvents.length === 0) return
579
647
 
@@ -605,7 +673,7 @@ const backgroundBackendPulling = ({
605
673
  newEventsCount: newEvents.length,
606
674
  newEvents: TRACE_VERBOSE ? JSON.stringify(newEvents) : undefined,
607
675
  })
608
- return yield* new SyncError({ cause: mergeResult.message })
676
+ return yield* new UnexpectedError({ cause: mergeResult.message })
609
677
  }
610
678
 
611
679
  const newBackendHead = newEvents.at(-1)!.seqNum
@@ -621,8 +689,8 @@ const backgroundBackendPulling = ({
621
689
  })
622
690
 
623
691
  const globalRebasedPendingEvents = mergeResult.newSyncState.pending.filter((event) => {
624
- const { eventDef } = getEventDef(schema, event.name)
625
- return eventDef.options.clientOnly === false
692
+ const eventDef = schema.eventsDefsMap.get(event.name)
693
+ return eventDef === undefined ? true : eventDef.options.clientOnly === false
626
694
  })
627
695
  yield* restartBackendPushing(globalRebasedPendingEvents)
628
696
 
@@ -644,6 +712,13 @@ const backgroundBackendPulling = ({
644
712
  mergeResult: TRACE_VERBOSE ? JSON.stringify(mergeResult) : undefined,
645
713
  })
646
714
 
715
+ // Ensure push fiber is active after advance by restarting with current pending (non-client) events
716
+ const globalPendingEvents = mergeResult.newSyncState.pending.filter((event) => {
717
+ const eventDef = schema.eventsDefsMap.get(event.name)
718
+ return eventDef === undefined ? true : eventDef.options.clientOnly === false
719
+ })
720
+ yield* restartBackendPushing(globalPendingEvents)
721
+
647
722
  yield* connectedClientSessionPullQueues.offer({
648
723
  payload: SyncState.payloadFromMergeResult(mergeResult),
649
724
  leaderHead: mergeResult.newSyncState.localHead,
@@ -657,7 +732,7 @@ const backgroundBackendPulling = ({
657
732
  EventSequenceNumber.isEqual(event.seqNum, confirmedEvent.seqNum),
658
733
  ),
659
734
  )
660
- yield* Eventlog.updateSyncMetadata(confirmedNewEvents)
735
+ yield* Eventlog.updateSyncMetadata(confirmedNewEvents).pipe(UnexpectedError.mapToUnexpectedError)
661
736
  }
662
737
  }
663
738
 
@@ -671,18 +746,20 @@ const backgroundBackendPulling = ({
671
746
  yield* SubscriptionRef.set(syncStateSref, mergeResult.newSyncState)
672
747
 
673
748
  // Allow local pushes to be processed again
674
- if (remaining === 0) {
749
+ if (pageInfo._tag === 'NoMore') {
675
750
  yield* localPushesLatch.open
676
751
  }
677
752
  })
678
753
 
679
- const cursorInfo = yield* Eventlog.getSyncBackendCursorInfo({ remoteHead: initialBackendHead })
754
+ const syncState = yield* syncStateSref
755
+ if (syncState === undefined) return shouldNeverHappen('Not initialized')
756
+ const cursorInfo = yield* Eventlog.getSyncBackendCursorInfo({ remoteHead: syncState.upstreamHead.global })
680
757
 
681
758
  const hashMaterializerResult = makeMaterializerHash({ schema, dbState })
682
759
 
683
- yield* syncBackend.pull(cursorInfo).pipe(
760
+ yield* syncBackend.pull(cursorInfo, { live: livePull }).pipe(
684
761
  // TODO only take from queue while connected
685
- Stream.tap(({ batch, remaining }) =>
762
+ Stream.tap(({ batch, pageInfo }) =>
686
763
  Effect.gen(function* () {
687
764
  // yield* Effect.spanEvent('batch', {
688
765
  // attributes: {
@@ -690,12 +767,10 @@ const backgroundBackendPulling = ({
690
767
  // batch: TRACE_VERBOSE ? batch : undefined,
691
768
  // },
692
769
  // })
693
-
694
770
  // NOTE we only want to take process events when the sync backend is connected
695
771
  // (e.g. needed for simulating being offline)
696
772
  // TODO remove when there's a better way to handle this in stream above
697
773
  yield* SubscriptionRef.waitUntil(syncBackend.isConnected, (isConnected) => isConnected === true)
698
-
699
774
  yield* onNewPullChunk(
700
775
  batch.map((_) =>
701
776
  LiveStoreEvent.EncodedWithMeta.fromGlobal(_.eventEncoded, {
@@ -706,15 +781,17 @@ const backgroundBackendPulling = ({
706
781
  materializerHashSession: Option.none(),
707
782
  }),
708
783
  ),
709
- remaining,
784
+ pageInfo,
710
785
  )
711
-
712
- yield* initialBlockingSyncContext.update({ processed: batch.length, remaining })
786
+ yield* initialBlockingSyncContext.update({ processed: batch.length, pageInfo })
713
787
  }),
714
788
  ),
715
789
  Stream.runDrain,
716
790
  Effect.interruptible,
717
791
  )
792
+
793
+ // Should only ever happen when livePull is false
794
+ yield* Effect.logDebug('backend-pulling finished', { livePull })
718
795
  }).pipe(Effect.withSpan('@livestore/common:LeaderSyncProcessor:backend-pulling'))
719
796
 
720
797
  const backgroundBackendPushing = ({
@@ -748,17 +825,53 @@ const backgroundBackendPushing = ({
748
825
  batch: TRACE_VERBOSE ? JSON.stringify(queueItems) : undefined,
749
826
  })
750
827
 
751
- // TODO handle push errors (should only happen during concurrent pull+push)
752
- const pushResult = yield* syncBackend.push(queueItems.map((_) => _.toGlobal())).pipe(Effect.either)
828
+ // Push with declarative retry/backoff using Effect schedules
829
+ // - Exponential backoff starting at 1s and doubling (1s, 2s, 4s, 8s, 16s, 30s ...)
830
+ // - Delay clamped at 30s (continues retrying at 30s)
831
+ // - Resets automatically after successful push
832
+ // TODO(metrics): expose counters/gauges for retry attempts and queue health via devtools/metrics
833
+
834
+ // Only retry for transient UnexpectedError cases
835
+ const isRetryable = (err: InvalidPushError | IsOfflineError) =>
836
+ err._tag === 'InvalidPushError' && err.cause._tag === 'LiveStore.UnexpectedError'
837
+
838
+ // Input: InvalidPushError | IsOfflineError, Output: Duration
839
+ const retrySchedule: Schedule.Schedule<Duration.DurationInput, InvalidPushError | IsOfflineError> =
840
+ Schedule.exponential(Duration.seconds(1)).pipe(
841
+ Schedule.andThenEither(Schedule.spaced(Duration.seconds(30))), // clamp at 30 second intervals
842
+ Schedule.compose(Schedule.elapsed),
843
+ Schedule.whileInput(isRetryable),
844
+ )
753
845
 
754
- if (pushResult._tag === 'Left') {
755
- if (LS_DEV) {
756
- yield* Effect.logDebug('handled backend-push-error', { error: pushResult.left.toString() })
846
+ yield* Effect.gen(function* () {
847
+ const iteration = yield* Schedule.CurrentIterationMetadata
848
+
849
+ const pushResult = yield* syncBackend.push(queueItems.map((_) => _.toGlobal())).pipe(Effect.either)
850
+
851
+ const retries = iteration.recurrence
852
+ if (retries > 0 && pushResult._tag === 'Right') {
853
+ otelSpan?.addEvent('backend-push-retry-success', { retries, batchSize: queueItems.length })
757
854
  }
758
- otelSpan?.addEvent('backend-push-error', { error: pushResult.left.toString() })
759
- // wait for interrupt caused by background pulling which will then restart pushing
760
- return yield* Effect.never
761
- }
855
+
856
+ if (pushResult._tag === 'Left') {
857
+ otelSpan?.addEvent('backend-push-error', {
858
+ error: pushResult.left.toString(),
859
+ retries,
860
+ batchSize: queueItems.length,
861
+ })
862
+ const error = pushResult.left
863
+ if (
864
+ error._tag === 'IsOfflineError' ||
865
+ (error._tag === 'InvalidPushError' && error.cause._tag === 'ServerAheadError')
866
+ ) {
867
+ // It's a core part of the sync protocol that the sync backend will emit a new pull chunk alongside the ServerAheadError
868
+ yield* Effect.logDebug('handled backend-push-error (waiting for interupt caused by pull)', { error })
869
+ return yield* Effect.never
870
+ }
871
+
872
+ return yield* error
873
+ }
874
+ }).pipe(Effect.retry(retrySchedule))
762
875
  }
763
876
  }).pipe(Effect.interruptible, Effect.withSpan('@livestore/common:LeaderSyncProcessor:backend-pushing'))
764
877
 
@@ -890,6 +1003,11 @@ const makePullQueueSet = Effect.gen(function* () {
890
1003
  }
891
1004
  })
892
1005
 
1006
+ /**
1007
+ * Validate a client-provided batch before it is admitted to the leader queue.
1008
+ * Ensures the numbers form a strictly increasing chain and that the first
1009
+ * event sits ahead of the current push head.
1010
+ */
893
1011
  const validatePushBatch = (
894
1012
  batch: ReadonlyArray<LiveStoreEvent.EncodedWithMeta>,
895
1013
  pushHead: EventSequenceNumber.EventSequenceNumber,
@@ -899,12 +1017,16 @@ const validatePushBatch = (
899
1017
  return
900
1018
  }
901
1019
 
902
- // Make sure batch is monotonically increasing
1020
+ // Example: session A already enqueued e1…e6 while session B (same client, different
1021
+ // session) still believes the head is e1 and submits [e2, e7, e8]. The numbers look
1022
+ // monotonic from B’s perspective, but we must reject and force B to rebase locally
1023
+ // so the leader never regresses.
903
1024
  for (let i = 1; i < batch.length; i++) {
904
1025
  if (EventSequenceNumber.isGreaterThanOrEqual(batch[i - 1]!.seqNum, batch[i]!.seqNum)) {
905
- shouldNeverHappen(
906
- `Events must be ordered in monotonically ascending order by eventNum. Received: [${batch.map((e) => EventSequenceNumber.toString(e.seqNum)).join(', ')}]`,
907
- )
1026
+ return yield* LeaderAheadError.make({
1027
+ minimumExpectedNum: batch[i - 1]!.seqNum,
1028
+ providedNum: batch[i]!.seqNum,
1029
+ })
908
1030
  }
909
1031
  }
910
1032
 
@@ -123,6 +123,14 @@ export const getBackendHeadFromDb = (dbEventlog: SqliteDb): EventSequenceNumber.
123
123
  export const updateBackendHead = (dbEventlog: SqliteDb, head: EventSequenceNumber.EventSequenceNumber) =>
124
124
  dbEventlog.execute(sql`UPDATE ${SYNC_STATUS_TABLE} SET head = ${head.global}`)
125
125
 
126
+ export const getBackendIdFromDb = (dbEventlog: SqliteDb): Option.Option<string> =>
127
+ Option.fromNullable(
128
+ dbEventlog.select<{ backendId: string | null }>(sql`select backendId from ${SYNC_STATUS_TABLE}`)[0]?.backendId,
129
+ )
130
+
131
+ export const updateBackendId = (dbEventlog: SqliteDb, backendId: string) =>
132
+ dbEventlog.execute(sql`UPDATE ${SYNC_STATUS_TABLE} SET backendId = '${backendId}'`)
133
+
126
134
  export const insertIntoEventlog = (
127
135
  eventEncoded: LiveStoreEvent.EncodedWithMeta,
128
136
  dbEventlog: SqliteDb,
@@ -141,7 +149,7 @@ export const insertIntoEventlog = (
141
149
 
142
150
  if (parentEventExists === false) {
143
151
  shouldNeverHappen(
144
- `Parent mutation ${eventEncoded.parentSeqNum.global},${eventEncoded.parentSeqNum.client} does not exist`,
152
+ `Parent event ${eventEncoded.parentSeqNum.global},${eventEncoded.parentSeqNum.client} does not exist in eventlog`,
145
153
  )
146
154
  }
147
155
  }
@@ -213,11 +221,7 @@ export const getSyncBackendCursorInfo = ({
213
221
  ).pipe(Effect.andThen(Schema.decode(EventlogQuerySchema)), Effect.map(Option.flatten), Effect.orDie)
214
222
 
215
223
  return Option.some({
216
- cursor: {
217
- global: remoteHead,
218
- client: EventSequenceNumber.clientDefault,
219
- rebaseGeneration: EventSequenceNumber.rebaseGenerationDefault,
220
- },
224
+ eventSequenceNumber: remoteHead,
221
225
  metadata: syncMetadataOption,
222
226
  }) satisfies InitialSyncInfo
223
227
  }).pipe(Effect.withSpan('@livestore/common:eventlog:getSyncBackendCursorInfo', { attributes: { remoteHead } }))
@@ -262,7 +262,7 @@ const listenToDevtools = ({
262
262
 
263
263
  if (syncBackend !== undefined) {
264
264
  // TODO consider piggybacking on the existing leader-thread sync-pulling
265
- yield* syncBackend.pull(Option.none()).pipe(
265
+ yield* syncBackend.pull(Option.none(), { live: true }).pipe(
266
266
  Stream.map((_) => _.batch),
267
267
  Stream.flattenIterables,
268
268
  Stream.tap(({ eventEncoded, metadata }) =>
@@ -319,7 +319,11 @@ const listenToDevtools = ({
319
319
  Stream.tap(([isConnected, { latchClosed }]) =>
320
320
  sendMessage(
321
321
  Devtools.Leader.NetworkStatusRes.make({
322
- networkStatus: { isConnected, timestampMs: Date.now(), latchClosed },
322
+ networkStatus: {
323
+ isConnected,
324
+ timestampMs: Date.now(),
325
+ devtools: { latchClosed },
326
+ },
323
327
  subscriptionId,
324
328
  ...reqPayload,
325
329
  requestId: nanoid(10),
@@ -0,0 +1,44 @@
1
+ import { Effect, Stream, SubscriptionRef } from '@livestore/utils/effect'
2
+ import { Vitest } from '@livestore/utils-dev/node-vitest'
3
+
4
+ import { makeMockSyncBackend } from '../sync/mock-sync-backend.ts'
5
+ import type { SyncBackend } from '../sync/sync.ts'
6
+ import { makeNetworkStatusSubscribable } from './make-leader-thread-layer.ts'
7
+ import type { DevtoolsContext } from './types.ts'
8
+
9
+ Vitest.describe('makeNetworkStatusSubscribable', () => {
10
+ Vitest.scopedLive('tracks sync backend connectivity and devtools latch state', () =>
11
+ Effect.gen(function* () {
12
+ const mockBackend = yield* makeMockSyncBackend({ startConnected: false })
13
+ const syncBackend = yield* mockBackend.makeSyncBackend
14
+ const latchStateRef = yield* SubscriptionRef.make<{ latchClosed: boolean }>({ latchClosed: false })
15
+
16
+ const devtoolsContext: DevtoolsContext = {
17
+ enabled: true,
18
+ syncBackendLatch: yield* Effect.makeLatch(true),
19
+ syncBackendLatchState: latchStateRef,
20
+ }
21
+
22
+ const networkStatus = yield* makeNetworkStatusSubscribable({ syncBackend, devtoolsContext })
23
+
24
+ const initial = yield* networkStatus
25
+ Vitest.expect(initial.isConnected).toBe(false)
26
+ Vitest.expect(initial.devtools.latchClosed).toBe(false)
27
+
28
+ const waitFor = (predicate: (status: SyncBackend.NetworkStatus) => boolean) =>
29
+ networkStatus.changes.pipe(Stream.filter(predicate), Stream.runHead, Effect.flatten)
30
+
31
+ const onlineFiber = yield* waitFor((status) => status.isConnected).pipe(Effect.forkScoped)
32
+ yield* mockBackend.connect
33
+ const online = yield* onlineFiber
34
+ Vitest.expect(online.isConnected).toBe(true)
35
+ Vitest.expect(online.timestampMs).toBeGreaterThan(initial.timestampMs)
36
+
37
+ const latchedFiber = yield* waitFor((status) => status.devtools.latchClosed).pipe(Effect.forkScoped)
38
+ yield* SubscriptionRef.set(latchStateRef, { latchClosed: true })
39
+ const latched = yield* latchedFiber
40
+ Vitest.expect(latched.devtools.latchClosed).toBe(true)
41
+ Vitest.expect(latched.timestampMs).toBeGreaterThanOrEqual(online.timestampMs)
42
+ }),
43
+ )
44
+ })