@livestore/common 0.4.0-dev.21 → 0.4.0-dev.23

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 (344) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/ClientSessionLeaderThreadProxy.d.ts +16 -9
  3. package/dist/ClientSessionLeaderThreadProxy.d.ts.map +1 -1
  4. package/dist/ClientSessionLeaderThreadProxy.js.map +1 -1
  5. package/dist/WorkerTransportError.d.ts +11 -0
  6. package/dist/WorkerTransportError.d.ts.map +1 -0
  7. package/dist/WorkerTransportError.js +11 -0
  8. package/dist/WorkerTransportError.js.map +1 -0
  9. package/dist/adapter-types.d.ts +26 -3
  10. package/dist/adapter-types.d.ts.map +1 -1
  11. package/dist/adapter-types.js +27 -1
  12. package/dist/adapter-types.js.map +1 -1
  13. package/dist/bounded-collections.d.ts.map +1 -1
  14. package/dist/bounded-collections.js +6 -4
  15. package/dist/bounded-collections.js.map +1 -1
  16. package/dist/debug-info.js +4 -4
  17. package/dist/debug-info.js.map +1 -1
  18. package/dist/devtools/devtools-messages-client-session.d.ts +42 -22
  19. package/dist/devtools/devtools-messages-client-session.d.ts.map +1 -1
  20. package/dist/devtools/devtools-messages-client-session.js +12 -1
  21. package/dist/devtools/devtools-messages-client-session.js.map +1 -1
  22. package/dist/devtools/devtools-messages-common.d.ts +12 -6
  23. package/dist/devtools/devtools-messages-common.d.ts.map +1 -1
  24. package/dist/devtools/devtools-messages-common.js +8 -3
  25. package/dist/devtools/devtools-messages-common.js.map +1 -1
  26. package/dist/devtools/devtools-messages-leader.d.ts +45 -25
  27. package/dist/devtools/devtools-messages-leader.d.ts.map +1 -1
  28. package/dist/devtools/devtools-messages-leader.js +12 -1
  29. package/dist/devtools/devtools-messages-leader.js.map +1 -1
  30. package/dist/devtools/mod.js +1 -1
  31. package/dist/devtools/mod.js.map +1 -1
  32. package/dist/errors.d.ts +15 -15
  33. package/dist/errors.d.ts.map +1 -1
  34. package/dist/errors.js +11 -11
  35. package/dist/errors.js.map +1 -1
  36. package/dist/index.d.ts +2 -0
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +2 -0
  39. package/dist/index.js.map +1 -1
  40. package/dist/leader-thread/LeaderSyncProcessor.d.ts +20 -6
  41. package/dist/leader-thread/LeaderSyncProcessor.d.ts.map +1 -1
  42. package/dist/leader-thread/LeaderSyncProcessor.js +283 -253
  43. package/dist/leader-thread/LeaderSyncProcessor.js.map +1 -1
  44. package/dist/leader-thread/RejectedPushError.d.ts +107 -0
  45. package/dist/leader-thread/RejectedPushError.d.ts.map +1 -0
  46. package/dist/leader-thread/RejectedPushError.js +78 -0
  47. package/dist/leader-thread/RejectedPushError.js.map +1 -0
  48. package/dist/leader-thread/connection.js +1 -1
  49. package/dist/leader-thread/connection.js.map +1 -1
  50. package/dist/leader-thread/eventlog.d.ts.map +1 -1
  51. package/dist/leader-thread/eventlog.js +12 -11
  52. package/dist/leader-thread/eventlog.js.map +1 -1
  53. package/dist/leader-thread/leader-worker-devtools.d.ts +1 -2
  54. package/dist/leader-thread/leader-worker-devtools.d.ts.map +1 -1
  55. package/dist/leader-thread/leader-worker-devtools.js +34 -14
  56. package/dist/leader-thread/leader-worker-devtools.js.map +1 -1
  57. package/dist/leader-thread/make-leader-thread-layer.d.ts +12 -5
  58. package/dist/leader-thread/make-leader-thread-layer.d.ts.map +1 -1
  59. package/dist/leader-thread/make-leader-thread-layer.js +12 -11
  60. package/dist/leader-thread/make-leader-thread-layer.js.map +1 -1
  61. package/dist/leader-thread/make-leader-thread-layer.test.js +1 -1
  62. package/dist/leader-thread/make-leader-thread-layer.test.js.map +1 -1
  63. package/dist/leader-thread/materialize-event.d.ts.map +1 -1
  64. package/dist/leader-thread/materialize-event.js +7 -4
  65. package/dist/leader-thread/materialize-event.js.map +1 -1
  66. package/dist/leader-thread/recreate-db.js +1 -1
  67. package/dist/leader-thread/recreate-db.js.map +1 -1
  68. package/dist/leader-thread/shutdown-channel.d.ts +2 -2
  69. package/dist/leader-thread/shutdown-channel.d.ts.map +1 -1
  70. package/dist/leader-thread/shutdown-channel.js +2 -2
  71. package/dist/leader-thread/shutdown-channel.js.map +1 -1
  72. package/dist/leader-thread/stream-events.d.ts.map +1 -1
  73. package/dist/leader-thread/stream-events.js +4 -3
  74. package/dist/leader-thread/stream-events.js.map +1 -1
  75. package/dist/leader-thread/types.d.ts +7 -6
  76. package/dist/leader-thread/types.d.ts.map +1 -1
  77. package/dist/leader-thread/types.js.map +1 -1
  78. package/dist/logging.js +4 -4
  79. package/dist/logging.js.map +1 -1
  80. package/dist/make-client-session.js +2 -2
  81. package/dist/make-client-session.js.map +1 -1
  82. package/dist/materializer-helper.js +6 -6
  83. package/dist/materializer-helper.js.map +1 -1
  84. package/dist/otel.d.ts +1 -1
  85. package/dist/otel.d.ts.map +1 -1
  86. package/dist/otel.js +2 -2
  87. package/dist/otel.js.map +1 -1
  88. package/dist/rematerialize-from-eventlog.d.ts +1 -1
  89. package/dist/rematerialize-from-eventlog.d.ts.map +1 -1
  90. package/dist/rematerialize-from-eventlog.js +11 -9
  91. package/dist/rematerialize-from-eventlog.js.map +1 -1
  92. package/dist/schema/EventDef/define.d.ts +16 -2
  93. package/dist/schema/EventDef/define.d.ts.map +1 -1
  94. package/dist/schema/EventDef/define.js +5 -4
  95. package/dist/schema/EventDef/define.js.map +1 -1
  96. package/dist/schema/EventDef/deprecated.d.ts +99 -0
  97. package/dist/schema/EventDef/deprecated.d.ts.map +1 -0
  98. package/dist/schema/EventDef/deprecated.js +144 -0
  99. package/dist/schema/EventDef/deprecated.js.map +1 -0
  100. package/dist/schema/EventDef/deprecated.test.d.ts +2 -0
  101. package/dist/schema/EventDef/deprecated.test.d.ts.map +1 -0
  102. package/dist/schema/EventDef/deprecated.test.js +95 -0
  103. package/dist/schema/EventDef/deprecated.test.js.map +1 -0
  104. package/dist/schema/EventDef/event-def.d.ts +4 -0
  105. package/dist/schema/EventDef/event-def.d.ts.map +1 -1
  106. package/dist/schema/EventDef/mod.d.ts +1 -0
  107. package/dist/schema/EventDef/mod.d.ts.map +1 -1
  108. package/dist/schema/EventDef/mod.js +1 -0
  109. package/dist/schema/EventDef/mod.js.map +1 -1
  110. package/dist/schema/EventSequenceNumber/client.d.ts.map +1 -1
  111. package/dist/schema/EventSequenceNumber/client.js +11 -11
  112. package/dist/schema/EventSequenceNumber/client.js.map +1 -1
  113. package/dist/schema/EventSequenceNumber.test.js +1 -1
  114. package/dist/schema/EventSequenceNumber.test.js.map +1 -1
  115. package/dist/schema/LiveStoreEvent/client.d.ts +6 -6
  116. package/dist/schema/LiveStoreEvent/client.d.ts.map +1 -1
  117. package/dist/schema/LiveStoreEvent/client.js +6 -3
  118. package/dist/schema/LiveStoreEvent/client.js.map +1 -1
  119. package/dist/schema/LiveStoreEvent/client.test.d.ts +2 -0
  120. package/dist/schema/LiveStoreEvent/client.test.d.ts.map +1 -0
  121. package/dist/schema/LiveStoreEvent/client.test.js +83 -0
  122. package/dist/schema/LiveStoreEvent/client.test.js.map +1 -0
  123. package/dist/schema/schema.d.ts.map +1 -1
  124. package/dist/schema/schema.js +7 -4
  125. package/dist/schema/schema.js.map +1 -1
  126. package/dist/schema/state/sqlite/client-document-def.d.ts +1 -0
  127. package/dist/schema/state/sqlite/client-document-def.d.ts.map +1 -1
  128. package/dist/schema/state/sqlite/client-document-def.js +34 -13
  129. package/dist/schema/state/sqlite/client-document-def.js.map +1 -1
  130. package/dist/schema/state/sqlite/client-document-def.test.js +121 -2
  131. package/dist/schema/state/sqlite/client-document-def.test.js.map +1 -1
  132. package/dist/schema/state/sqlite/column-annotations.d.ts.map +1 -1
  133. package/dist/schema/state/sqlite/column-annotations.js +1 -1
  134. package/dist/schema/state/sqlite/column-annotations.js.map +1 -1
  135. package/dist/schema/state/sqlite/column-annotations.test.js +1 -1
  136. package/dist/schema/state/sqlite/column-annotations.test.js.map +1 -1
  137. package/dist/schema/state/sqlite/column-def.d.ts.map +1 -1
  138. package/dist/schema/state/sqlite/column-def.js +36 -34
  139. package/dist/schema/state/sqlite/column-def.js.map +1 -1
  140. package/dist/schema/state/sqlite/column-def.test.js +7 -6
  141. package/dist/schema/state/sqlite/column-def.test.js.map +1 -1
  142. package/dist/schema/state/sqlite/column-spec.d.ts.map +1 -1
  143. package/dist/schema/state/sqlite/column-spec.js +8 -8
  144. package/dist/schema/state/sqlite/column-spec.js.map +1 -1
  145. package/dist/schema/state/sqlite/column-spec.test.js +1 -1
  146. package/dist/schema/state/sqlite/column-spec.test.js.map +1 -1
  147. package/dist/schema/state/sqlite/db-schema/ast/sqlite.js +2 -2
  148. package/dist/schema/state/sqlite/db-schema/ast/sqlite.js.map +1 -1
  149. package/dist/schema/state/sqlite/db-schema/dsl/field-defs.d.ts +2 -2
  150. package/dist/schema/state/sqlite/db-schema/dsl/field-defs.d.ts.map +1 -1
  151. package/dist/schema/state/sqlite/db-schema/dsl/field-defs.js +11 -2
  152. package/dist/schema/state/sqlite/db-schema/dsl/field-defs.js.map +1 -1
  153. package/dist/schema/state/sqlite/db-schema/dsl/field-defs.test.js +1 -1
  154. package/dist/schema/state/sqlite/db-schema/dsl/field-defs.test.js.map +1 -1
  155. package/dist/schema/state/sqlite/db-schema/dsl/mod.d.ts +1 -1
  156. package/dist/schema/state/sqlite/db-schema/dsl/mod.d.ts.map +1 -1
  157. package/dist/schema/state/sqlite/db-schema/dsl/mod.js +1 -1
  158. package/dist/schema/state/sqlite/db-schema/dsl/mod.js.map +1 -1
  159. package/dist/schema/state/sqlite/mod.d.ts.map +1 -1
  160. package/dist/schema/state/sqlite/mod.js +3 -5
  161. package/dist/schema/state/sqlite/mod.js.map +1 -1
  162. package/dist/schema/state/sqlite/query-builder/api.d.ts +37 -13
  163. package/dist/schema/state/sqlite/query-builder/api.d.ts.map +1 -1
  164. package/dist/schema/state/sqlite/query-builder/astToSql.d.ts.map +1 -1
  165. package/dist/schema/state/sqlite/query-builder/astToSql.js +77 -7
  166. package/dist/schema/state/sqlite/query-builder/astToSql.js.map +1 -1
  167. package/dist/schema/state/sqlite/query-builder/impl.d.ts +1 -1
  168. package/dist/schema/state/sqlite/query-builder/impl.d.ts.map +1 -1
  169. package/dist/schema/state/sqlite/query-builder/impl.js +28 -14
  170. package/dist/schema/state/sqlite/query-builder/impl.js.map +1 -1
  171. package/dist/schema/state/sqlite/query-builder/impl.test.js +112 -3
  172. package/dist/schema/state/sqlite/query-builder/impl.test.js.map +1 -1
  173. package/dist/schema/state/sqlite/schema-helpers.js +2 -2
  174. package/dist/schema/state/sqlite/schema-helpers.js.map +1 -1
  175. package/dist/schema/state/sqlite/table-def.d.ts +5 -3
  176. package/dist/schema/state/sqlite/table-def.d.ts.map +1 -1
  177. package/dist/schema/state/sqlite/table-def.js +1 -1
  178. package/dist/schema/state/sqlite/table-def.js.map +1 -1
  179. package/dist/schema/state/sqlite/table-def.test.js +57 -4
  180. package/dist/schema/state/sqlite/table-def.test.js.map +1 -1
  181. package/dist/schema/unknown-events.d.ts +1 -1
  182. package/dist/schema/unknown-events.d.ts.map +1 -1
  183. package/dist/schema/unknown-events.js +1 -1
  184. package/dist/schema/unknown-events.js.map +1 -1
  185. package/dist/schema-management/__tests__/migrations-autoincrement-quoting.test.js +1 -1
  186. package/dist/schema-management/__tests__/migrations-autoincrement-quoting.test.js.map +1 -1
  187. package/dist/schema-management/common.js +2 -2
  188. package/dist/schema-management/common.js.map +1 -1
  189. package/dist/schema-management/migrations.js +1 -1
  190. package/dist/schema-management/migrations.js.map +1 -1
  191. package/dist/sql-queries/sql-queries.js +8 -6
  192. package/dist/sql-queries/sql-queries.js.map +1 -1
  193. package/dist/sql-queries/sql-query-builder.d.ts.map +1 -1
  194. package/dist/sql-queries/sql-query-builder.js.map +1 -1
  195. package/dist/sqlite-db-helper.js +3 -3
  196. package/dist/sqlite-db-helper.js.map +1 -1
  197. package/dist/sqlite-types.d.ts +2 -2
  198. package/dist/sqlite-types.d.ts.map +1 -1
  199. package/dist/sqlite-types.js.map +1 -1
  200. package/dist/sync/ClientSessionSyncProcessor.d.ts +8 -9
  201. package/dist/sync/ClientSessionSyncProcessor.d.ts.map +1 -1
  202. package/dist/sync/ClientSessionSyncProcessor.js +93 -107
  203. package/dist/sync/ClientSessionSyncProcessor.js.map +1 -1
  204. package/dist/sync/errors.d.ts +0 -38
  205. package/dist/sync/errors.d.ts.map +1 -1
  206. package/dist/sync/errors.js +3 -20
  207. package/dist/sync/errors.js.map +1 -1
  208. package/dist/sync/mock-sync-backend.d.ts +5 -3
  209. package/dist/sync/mock-sync-backend.d.ts.map +1 -1
  210. package/dist/sync/mock-sync-backend.js +70 -68
  211. package/dist/sync/mock-sync-backend.js.map +1 -1
  212. package/dist/sync/next/compact-events.js +6 -6
  213. package/dist/sync/next/compact-events.js.map +1 -1
  214. package/dist/sync/next/facts.d.ts.map +1 -1
  215. package/dist/sync/next/facts.js +6 -6
  216. package/dist/sync/next/facts.js.map +1 -1
  217. package/dist/sync/next/history-dag-common.d.ts.map +1 -1
  218. package/dist/sync/next/history-dag-common.js +6 -6
  219. package/dist/sync/next/history-dag-common.js.map +1 -1
  220. package/dist/sync/next/history-dag.js +3 -3
  221. package/dist/sync/next/history-dag.js.map +1 -1
  222. package/dist/sync/next/rebase-events.js +1 -1
  223. package/dist/sync/next/rebase-events.js.map +1 -1
  224. package/dist/sync/next/test/compact-events.calculator.test.js +2 -2
  225. package/dist/sync/next/test/compact-events.calculator.test.js.map +1 -1
  226. package/dist/sync/next/test/compact-events.test.d.ts.map +1 -1
  227. package/dist/sync/next/test/compact-events.test.js +2 -2
  228. package/dist/sync/next/test/compact-events.test.js.map +1 -1
  229. package/dist/sync/next/test/event-fixtures.d.ts.map +1 -1
  230. package/dist/sync/next/test/event-fixtures.js +2 -2
  231. package/dist/sync/next/test/event-fixtures.js.map +1 -1
  232. package/dist/sync/sync-backend-kv.d.ts.map +1 -1
  233. package/dist/sync/sync-backend-kv.js.map +1 -1
  234. package/dist/sync/sync-backend.d.ts +3 -3
  235. package/dist/sync/sync-backend.d.ts.map +1 -1
  236. package/dist/sync/sync-backend.js +1 -1
  237. package/dist/sync/sync-backend.js.map +1 -1
  238. package/dist/sync/sync.d.ts +20 -0
  239. package/dist/sync/sync.d.ts.map +1 -1
  240. package/dist/sync/syncstate.d.ts +4 -17
  241. package/dist/sync/syncstate.d.ts.map +1 -1
  242. package/dist/sync/syncstate.js +51 -74
  243. package/dist/sync/syncstate.js.map +1 -1
  244. package/dist/sync/syncstate.test.js +112 -96
  245. package/dist/sync/syncstate.test.js.map +1 -1
  246. package/dist/sync/transport-chunking.js +3 -3
  247. package/dist/sync/transport-chunking.js.map +1 -1
  248. package/dist/sync/validate-push-payload.d.ts +2 -2
  249. package/dist/sync/validate-push-payload.d.ts.map +1 -1
  250. package/dist/sync/validate-push-payload.js +4 -6
  251. package/dist/sync/validate-push-payload.js.map +1 -1
  252. package/dist/util.js +2 -2
  253. package/dist/util.js.map +1 -1
  254. package/dist/version.d.ts +7 -1
  255. package/dist/version.d.ts.map +1 -1
  256. package/dist/version.js +8 -4
  257. package/dist/version.js.map +1 -1
  258. package/package.json +66 -12
  259. package/src/ClientSessionLeaderThreadProxy.ts +16 -9
  260. package/src/WorkerTransportError.ts +12 -0
  261. package/src/adapter-types.ts +39 -3
  262. package/src/bounded-collections.ts +6 -5
  263. package/src/debug-info.ts +4 -4
  264. package/src/devtools/devtools-messages-client-session.ts +12 -0
  265. package/src/devtools/devtools-messages-common.ts +8 -4
  266. package/src/devtools/devtools-messages-leader.ts +12 -0
  267. package/src/devtools/mod.ts +1 -1
  268. package/src/errors.ts +18 -17
  269. package/src/index.ts +2 -0
  270. package/src/leader-thread/LeaderSyncProcessor.ts +417 -347
  271. package/src/leader-thread/RejectedPushError.ts +106 -0
  272. package/src/leader-thread/connection.ts +1 -1
  273. package/src/leader-thread/eventlog.ts +16 -14
  274. package/src/leader-thread/leader-worker-devtools.ts +107 -66
  275. package/src/leader-thread/make-leader-thread-layer.test.ts +1 -1
  276. package/src/leader-thread/make-leader-thread-layer.ts +41 -31
  277. package/src/leader-thread/materialize-event.ts +8 -4
  278. package/src/leader-thread/recreate-db.ts +1 -1
  279. package/src/leader-thread/shutdown-channel.ts +2 -6
  280. package/src/leader-thread/stream-events.ts +10 -5
  281. package/src/leader-thread/types.ts +7 -6
  282. package/src/logging.ts +4 -4
  283. package/src/make-client-session.ts +2 -2
  284. package/src/materializer-helper.ts +9 -9
  285. package/src/otel.ts +3 -2
  286. package/src/rematerialize-from-eventlog.ts +60 -60
  287. package/src/schema/EventDef/define.ts +22 -6
  288. package/src/schema/EventDef/deprecated.test.ts +129 -0
  289. package/src/schema/EventDef/deprecated.ts +175 -0
  290. package/src/schema/EventDef/event-def.ts +5 -0
  291. package/src/schema/EventDef/mod.ts +1 -0
  292. package/src/schema/EventSequenceNumber/client.ts +11 -11
  293. package/src/schema/EventSequenceNumber.test.ts +2 -1
  294. package/src/schema/LiveStoreEvent/client.test.ts +97 -0
  295. package/src/schema/LiveStoreEvent/client.ts +6 -3
  296. package/src/schema/schema.ts +9 -4
  297. package/src/schema/state/sqlite/client-document-def.test.ts +142 -3
  298. package/src/schema/state/sqlite/client-document-def.ts +37 -14
  299. package/src/schema/state/sqlite/column-annotations.test.ts +2 -1
  300. package/src/schema/state/sqlite/column-annotations.ts +2 -1
  301. package/src/schema/state/sqlite/column-def.test.ts +8 -6
  302. package/src/schema/state/sqlite/column-def.ts +41 -36
  303. package/src/schema/state/sqlite/column-spec.test.ts +3 -1
  304. package/src/schema/state/sqlite/column-spec.ts +9 -8
  305. package/src/schema/state/sqlite/db-schema/ast/sqlite.ts +2 -2
  306. package/src/schema/state/sqlite/db-schema/dsl/field-defs.test.ts +2 -1
  307. package/src/schema/state/sqlite/db-schema/dsl/field-defs.ts +13 -4
  308. package/src/schema/state/sqlite/db-schema/dsl/mod.ts +3 -3
  309. package/src/schema/state/sqlite/mod.ts +4 -5
  310. package/src/schema/state/sqlite/query-builder/api.ts +37 -8
  311. package/src/schema/state/sqlite/query-builder/astToSql.ts +87 -7
  312. package/src/schema/state/sqlite/query-builder/impl.test.ts +145 -3
  313. package/src/schema/state/sqlite/query-builder/impl.ts +26 -12
  314. package/src/schema/state/sqlite/schema-helpers.ts +2 -2
  315. package/src/schema/state/sqlite/table-def.test.ts +67 -4
  316. package/src/schema/state/sqlite/table-def.ts +8 -15
  317. package/src/schema/unknown-events.ts +2 -2
  318. package/src/schema-management/__tests__/migrations-autoincrement-quoting.test.ts +3 -1
  319. package/src/schema-management/common.ts +2 -2
  320. package/src/schema-management/migrations.ts +1 -1
  321. package/src/sql-queries/sql-queries.ts +10 -6
  322. package/src/sql-queries/sql-query-builder.ts +1 -0
  323. package/src/sqlite-db-helper.ts +3 -3
  324. package/src/sqlite-types.ts +3 -2
  325. package/src/sync/ClientSessionSyncProcessor.ts +142 -133
  326. package/src/sync/errors.ts +10 -22
  327. package/src/sync/mock-sync-backend.ts +139 -97
  328. package/src/sync/next/compact-events.ts +5 -5
  329. package/src/sync/next/facts.ts +7 -6
  330. package/src/sync/next/history-dag-common.ts +9 -6
  331. package/src/sync/next/history-dag.ts +3 -3
  332. package/src/sync/next/rebase-events.ts +1 -1
  333. package/src/sync/next/test/compact-events.calculator.test.ts +3 -2
  334. package/src/sync/next/test/compact-events.test.ts +4 -3
  335. package/src/sync/next/test/event-fixtures.ts +2 -2
  336. package/src/sync/sync-backend-kv.ts +1 -0
  337. package/src/sync/sync-backend.ts +5 -4
  338. package/src/sync/sync.ts +21 -0
  339. package/src/sync/syncstate.test.ts +513 -435
  340. package/src/sync/syncstate.ts +80 -86
  341. package/src/sync/transport-chunking.ts +3 -3
  342. package/src/sync/validate-push-payload.ts +4 -6
  343. package/src/util.ts +2 -2
  344. package/src/version.ts +8 -4
@@ -1,4 +1,4 @@
1
- import { casesHandled, isNotUndefined, LS_DEV, shouldNeverHappen, TRACE_VERBOSE } from '@livestore/utils'
1
+ import { casesHandled, isNotUndefined, LS_DEV, TRACE_VERBOSE } from '@livestore/utils'
2
2
  import type { HttpClient, Runtime, Scope, Tracer } from '@livestore/utils/effect'
3
3
  import {
4
4
  BucketQueue,
@@ -10,36 +10,37 @@ import {
10
10
  FiberHandle,
11
11
  Layer,
12
12
  Option,
13
- OtelTracer,
14
13
  Queue,
15
14
  ReadonlyArray,
16
15
  Schedule,
16
+ Schema,
17
17
  Stream,
18
18
  Subscribable,
19
19
  SubscriptionRef,
20
20
  } from '@livestore/utils/effect'
21
- import type * as otel from '@opentelemetry/api'
22
- import { type IntentionalShutdownCause, type MaterializeError, type SqliteDb, UnknownError } from '../adapter-types.ts'
21
+
22
+ import { type MaterializeError, type SqliteDb, UnknownError } from '../adapter-types.ts'
23
+ import { IntentionalShutdownCause } from '../errors.ts'
23
24
  import { makeMaterializerHash } from '../materializer-helper.ts'
24
25
  import type { LiveStoreSchema } from '../schema/mod.ts'
25
26
  import { EventSequenceNumber, LiveStoreEvent, resolveEventDef, SystemTables } from '../schema/mod.ts'
26
- import {
27
- type InvalidPullError,
28
- type InvalidPushError,
29
- type IsOfflineError,
30
- LeaderAheadError,
31
- type SyncBackend,
32
- } from '../sync/sync.ts'
27
+ import { EVENTLOG_META_TABLE, SYNC_STATUS_TABLE } from '../schema/state/sqlite/system-tables/eventlog-tables.ts'
28
+ import type { BackendIdMismatchError, IsOfflineError, SyncBackend } from '../sync/sync.ts'
29
+ import { isRejectedPushError, LeaderAheadError, NonMonotonicBatchError, StaleRebaseGenerationError } from './RejectedPushError.ts'
33
30
  import * as SyncState from '../sync/syncstate.ts'
34
31
  import { sql } from '../util.ts'
35
32
  import * as Eventlog from './eventlog.ts'
36
33
  import { rollback } from './materialize-event.ts'
34
+ import type { ShutdownChannel } from './shutdown-channel.ts'
37
35
  import type { InitialBlockingSyncContext, LeaderSyncProcessor } from './types.ts'
38
36
  import { LeaderThreadCtx } from './types.ts'
39
37
 
38
+ /** Serialize value to JSON string for trace attributes */
39
+ const jsonStringify = Schema.encodeSync(Schema.parseJson())
40
+
40
41
  type LocalPushQueueItem = [
41
42
  event: LiveStoreEvent.Client.EncodedWithMeta,
42
- deferred: Deferred.Deferred<void, LeaderAheadError> | undefined,
43
+ deferred: Deferred.Deferred<void, LeaderAheadError | StaleRebaseGenerationError> | undefined,
43
44
  ]
44
45
 
45
46
  /**
@@ -60,11 +61,11 @@ type LocalPushQueueItem = [
60
61
  * - Maintains events in ascending order.
61
62
  * - Uses `Deferred` objects to resolve/reject events based on application success.
62
63
  * - Processes events from the queue, applying events in batches.
63
- * - Controlled by a `Latch` to manage execution flow.
64
- * - The latch closes on pull receipt and re-opens post-pull completion.
64
+ * - Controlled by a mutex (`Semaphore(1)`) to ensure mutual exclusion between local push and backend pull processing.
65
+ * - The backend pull side acquires the mutex before processing and releases it on post-pull completion.
65
66
  * - Processes up to `maxBatchSize` events per cycle.
66
67
  *
67
- * Currently we're advancing the state db and eventlog in lockstep, but we could also decouple this in the future
68
+ * Currently, we're advancing the state db and eventlog in lockstep, but we could also decouple this in the future
68
69
  *
69
70
  * Tricky concurrency scenarios:
70
71
  * - Queued local push batches becoming invalid due to a prior local push item being rejected.
@@ -78,6 +79,7 @@ export const makeLeaderSyncProcessor = ({
78
79
  initialBlockingSyncContext,
79
80
  initialSyncState,
80
81
  onError,
82
+ onBackendIdMismatch,
81
83
  livePull,
82
84
  params,
83
85
  testing,
@@ -87,7 +89,21 @@ export const makeLeaderSyncProcessor = ({
87
89
  initialBlockingSyncContext: InitialBlockingSyncContext
88
90
  /** Initial sync state rehydrated from the persisted eventlog or initial sync state */
89
91
  initialSyncState: SyncState.SyncState
92
+ /**
93
+ * What to do when a failure (any cause) occurs (except `BackendIdMismatchError`).
94
+ *
95
+ * - `'shutdown'`: Send the error to the shutdown channel and terminate the sync processor.
96
+ * - `'ignore'`: Continue running.
97
+ */
90
98
  onError: 'shutdown' | 'ignore'
99
+ /**
100
+ * What to do when the sync backend identity has changed (i.e. the backend was reset).
101
+ *
102
+ * - `'reset'`: Clear local databases (eventlog and state) and send an intentional shutdown signal.
103
+ * - `'shutdown'`: Send a shutdown signal without clearing local storage.
104
+ * - `'ignore'`: Continue running with stale data.
105
+ */
106
+ onBackendIdMismatch: 'reset' | 'shutdown' | 'ignore'
91
107
  params: {
92
108
  /**
93
109
  * Maximum number of local events to process per batch cycle.
@@ -141,7 +157,7 @@ export const makeLeaderSyncProcessor = ({
141
157
  localPushProcessing?: Effect.Effect<void>
142
158
  }
143
159
  }
144
- }): Effect.Effect<LeaderSyncProcessor, UnknownError, Scope.Scope> =>
160
+ }): Effect.Effect<LeaderSyncProcessor, never, Scope.Scope> =>
145
161
  Effect.gen(function* () {
146
162
  const syncBackendPushQueue = yield* BucketQueue.make<LiveStoreEvent.Client.EncodedWithMeta>()
147
163
  const localPushBatchSize = params.localPushBatchSize ?? 10
@@ -159,7 +175,6 @@ export const makeLeaderSyncProcessor = ({
159
175
  current: undefined as
160
176
  | undefined
161
177
  | {
162
- otelSpan: otel.Span | undefined
163
178
  span: Tracer.Span
164
179
  devtoolsLatch: Effect.Latch | undefined
165
180
  runtime: Runtime.Runtime<LeaderThreadCtx>
@@ -167,8 +182,8 @@ export const makeLeaderSyncProcessor = ({
167
182
  }
168
183
 
169
184
  const localPushesQueue = yield* BucketQueue.make<LocalPushQueueItem>()
170
- const localPushesLatch = yield* Effect.makeLatch(true)
171
- const pullLatch = yield* Effect.makeLatch(true)
185
+ // Ensures mutual exclusion between local push and backend pull processing.
186
+ const localPushBackendPullMutex = yield* Effect.makeSemaphore(1)
172
187
 
173
188
  /**
174
189
  * Additionally to the `syncStateSref` we also need the `pushHeadRef` in order to prevent old/duplicate
@@ -197,8 +212,8 @@ export const makeLeaderSyncProcessor = ({
197
212
 
198
213
  const waitForProcessing = options?.waitForProcessing ?? false
199
214
 
200
- if (waitForProcessing) {
201
- const deferreds = yield* Effect.forEach(newEvents, () => Deferred.make<void, LeaderAheadError>())
215
+ if (waitForProcessing === true) {
216
+ const deferreds = yield* Effect.forEach(newEvents, () => Deferred.make<void, LeaderAheadError | StaleRebaseGenerationError>())
202
217
 
203
218
  const items = newEvents.map((eventEncoded, i) => [eventEncoded, deferreds[i]] as LocalPushQueueItem)
204
219
 
@@ -213,16 +228,18 @@ export const makeLeaderSyncProcessor = ({
213
228
  Effect.withSpan('@livestore/common:LeaderSyncProcessor:push', {
214
229
  attributes: {
215
230
  batchSize: newEvents.length,
216
- batch: TRACE_VERBOSE ? newEvents : undefined,
231
+ batch: TRACE_VERBOSE === true ? newEvents : undefined,
217
232
  },
218
- links: ctxRef.current?.span ? [{ _tag: 'SpanLink', span: ctxRef.current.span, attributes: {} }] : undefined,
233
+ links:
234
+ ctxRef.current?.span !== undefined
235
+ ? [{ _tag: 'SpanLink', span: ctxRef.current.span, attributes: {} }]
236
+ : undefined,
219
237
  }),
220
238
  )
221
239
 
222
240
  const pushPartial: LeaderSyncProcessor['pushPartial'] = ({ event: { name, args }, clientId, sessionId }) =>
223
241
  Effect.gen(function* () {
224
- const syncState = yield* syncStateSref
225
- if (syncState === undefined) return shouldNeverHappen('Not initialized')
242
+ const syncState = yield* Effect.fromNullable(yield* syncStateSref).pipe(Effect.orDieDebugger)
226
243
 
227
244
  const resolution = yield* resolveEventDef(schema, {
228
245
  operation: '@livestore/common:LeaderSyncProcessor:pushPartial',
@@ -233,7 +250,7 @@ export const makeLeaderSyncProcessor = ({
233
250
  sessionId,
234
251
  seqNum: syncState.localHead,
235
252
  },
236
- }).pipe(UnknownError.mapToUnknownError)
253
+ })
237
254
 
238
255
  if (resolution._tag === 'unknown') {
239
256
  // Ignore partial pushes for unrecognised events – they are still
@@ -253,19 +270,20 @@ export const makeLeaderSyncProcessor = ({
253
270
  })
254
271
 
255
272
  yield* push([eventEncoded])
256
- }).pipe(Effect.catchTag('LeaderAheadError', Effect.orDie))
273
+ }).pipe(
274
+ // pushPartial constructs the event sequence number internally, so these errors should never happen.
275
+ Effect.catchIf(isRejectedPushError, Effect.die),
276
+ )
257
277
 
258
278
  // Starts various background loops
259
279
  const boot: LeaderSyncProcessor['boot'] = Effect.gen(function* () {
260
280
  const span = yield* Effect.currentSpan.pipe(Effect.orDie)
261
- const otelSpan = yield* OtelTracer.currentOtelSpan.pipe(Effect.catchAll(() => Effect.succeed(undefined)))
262
281
  const { devtools, shutdownChannel } = yield* LeaderThreadCtx
263
282
  const runtime = yield* Effect.runtime<LeaderThreadCtx>()
264
283
 
265
284
  ctxRef.current = {
266
- otelSpan,
267
285
  span,
268
- devtoolsLatch: devtools.enabled ? devtools.syncBackendLatch : undefined,
286
+ devtoolsLatch: devtools.enabled === true ? devtools.syncBackendLatch : undefined,
269
287
  runtime,
270
288
  }
271
289
 
@@ -286,19 +304,18 @@ export const makeLeaderSyncProcessor = ({
286
304
  }
287
305
  }
288
306
 
307
+ const handleBackendIdMismatchError = (error: BackendIdMismatchError) =>
308
+ handleBackendIdMismatch({ error, onBackendIdMismatch, shutdownChannel })
309
+
289
310
  const maybeShutdownOnError = (
290
311
  cause: Cause.Cause<
291
312
  | UnknownError
292
- | IntentionalShutdownCause
293
- | IsOfflineError
294
- | InvalidPushError
295
- | InvalidPullError
296
313
  | MaterializeError
297
314
  >,
298
315
  ) =>
299
316
  Effect.gen(function* () {
300
317
  if (onError === 'ignore') {
301
- if (LS_DEV) {
318
+ if (LS_DEV === true) {
302
319
  yield* Effect.logDebug(
303
320
  `Ignoring sync error (${cause._tag === 'Fail' ? cause.error._tag : cause._tag})`,
304
321
  Cause.pretty(cause),
@@ -307,35 +324,38 @@ export const makeLeaderSyncProcessor = ({
307
324
  return
308
325
  }
309
326
 
310
- const errorToSend = Cause.isFailType(cause) ? cause.error : UnknownError.make({ cause })
327
+ const errorToSend = Cause.isFailType(cause) === true ? cause.error : UnknownError.make({ cause })
311
328
  yield* shutdownChannel.send(errorToSend).pipe(Effect.orDie)
312
329
 
313
- return yield* Effect.die(cause)
330
+ return yield* Effect.failCause(cause).pipe(Effect.orDie)
314
331
  })
315
332
 
316
333
  yield* backgroundApplyLocalPushes({
317
- localPushesLatch,
334
+ localPushBackendPullMutex,
318
335
  localPushesQueue,
319
- pullLatch,
320
336
  syncStateSref,
321
337
  syncBackendPushQueue,
322
338
  schema,
323
339
  isClientEvent,
324
- otelSpan,
325
340
  connectedClientSessionPullQueues,
326
341
  localPushBatchSize,
327
342
  testing: {
328
343
  delay: testing?.delays?.localPushProcessing,
329
344
  },
330
- }).pipe(Effect.catchAllCause(maybeShutdownOnError), Effect.forkScoped)
345
+ }).pipe(
346
+ Effect.catchAllCause(maybeShutdownOnError),
347
+ Effect.forkScoped,
348
+ )
331
349
 
332
350
  const backendPushingFiberHandle = yield* FiberHandle.make<void, never>()
333
351
  const backendPushingEffect = backgroundBackendPushing({
334
352
  syncBackendPushQueue,
335
- otelSpan,
336
353
  devtoolsLatch: ctxRef.current?.devtoolsLatch,
337
354
  backendPushBatchSize,
338
- }).pipe(Effect.catchAllCause(maybeShutdownOnError))
355
+ }).pipe(
356
+ Effect.catchTag('BackendIdMismatchError', handleBackendIdMismatchError),
357
+ Effect.catchAllCause(maybeShutdownOnError),
358
+ )
339
359
 
340
360
  yield* FiberHandle.run(backendPushingFiberHandle, backendPushingEffect)
341
361
 
@@ -354,20 +374,21 @@ export const makeLeaderSyncProcessor = ({
354
374
  yield* FiberHandle.run(backendPushingFiberHandle, backendPushingEffect)
355
375
  }),
356
376
  syncStateSref,
357
- localPushesLatch,
358
- pullLatch,
377
+ localPushBackendPullMutex,
359
378
  livePull,
360
379
  dbState,
361
- otelSpan,
362
380
  initialBlockingSyncContext,
363
381
  devtoolsLatch: ctxRef.current?.devtoolsLatch,
364
382
  connectedClientSessionPullQueues,
365
383
  advancePushHead,
366
384
  }).pipe(
367
385
  Effect.retry({
368
- // We want to retry pulling if we've lost connection to the sync backend
369
- while: (cause) => cause._tag === 'IsOfflineError',
386
+ // Retry pulling when we've lost connection to the sync backend
387
+ // We're using `until` with a refinement instead of `while` to narrow `IsOfflineError` out of the error type.
388
+ // See https://github.com/Effect-TS/effect/issues/6122
389
+ until: (error): error is Exclude<typeof error, IsOfflineError> => error._tag !== 'IsOfflineError',
370
390
  }),
391
+ Effect.catchTag('BackendIdMismatchError', handleBackendIdMismatchError),
371
392
  Effect.catchAllCause(maybeShutdownOnError),
372
393
  // Needed to avoid `Fiber terminated with an unhandled error` logs which seem to happen because of the `Effect.retry` above.
373
394
  // This might be a bug in Effect. Only seems to happen in the browser.
@@ -398,17 +419,16 @@ export const makeLeaderSyncProcessor = ({
398
419
  - full new state db snapshot in the "rebase" case
399
420
  - downside: importing the snapshot is expensive
400
421
  */
401
- const pullQueue: LeaderSyncProcessor['pullQueue'] = ({ cursor }) => {
402
- const runtime = ctxRef.current?.runtime ?? shouldNeverHappen('Not initialized')
403
- return connectedClientSessionPullQueues.makeQueue(cursor).pipe(Effect.provide(runtime))
404
- }
422
+ const pullQueue: LeaderSyncProcessor['pullQueue'] = ({ cursor }) =>
423
+ Effect.fromNullable(ctxRef.current?.runtime).pipe(
424
+ Effect.orDieDebugger,
425
+ Effect.flatMap((runtime) =>
426
+ connectedClientSessionPullQueues.makeQueue(cursor).pipe(Effect.provide(runtime))
427
+ )
428
+ )
405
429
 
406
430
  const syncState = Subscribable.make({
407
- get: Effect.gen(function* () {
408
- const syncState = yield* syncStateSref
409
- if (syncState === undefined) return shouldNeverHappen('Not initialized')
410
- return syncState
411
- }),
431
+ get: syncStateSref.pipe(Effect.flatMap(Effect.fromNullable), Effect.orDieDebugger),
412
432
  changes: syncStateSref.changes.pipe(Stream.filter(isNotUndefined)),
413
433
  })
414
434
 
@@ -423,26 +443,22 @@ export const makeLeaderSyncProcessor = ({
423
443
  })
424
444
 
425
445
  const backgroundApplyLocalPushes = ({
426
- localPushesLatch,
446
+ localPushBackendPullMutex,
427
447
  localPushesQueue,
428
- pullLatch,
429
448
  syncStateSref,
430
449
  syncBackendPushQueue,
431
450
  schema,
432
451
  isClientEvent,
433
- otelSpan,
434
452
  connectedClientSessionPullQueues,
435
453
  localPushBatchSize,
436
454
  testing,
437
455
  }: {
438
- pullLatch: Effect.Latch
439
- localPushesLatch: Effect.Latch
456
+ localPushBackendPullMutex: Effect.Semaphore
440
457
  localPushesQueue: BucketQueue.BucketQueue<LocalPushQueueItem>
441
458
  syncStateSref: SubscriptionRef.SubscriptionRef<SyncState.SyncState | undefined>
442
459
  syncBackendPushQueue: BucketQueue.BucketQueue<LiveStoreEvent.Client.EncodedWithMeta>
443
460
  schema: LiveStoreSchema
444
461
  isClientEvent: (eventEncoded: LiveStoreEvent.Client.EncodedWithMeta) => boolean
445
- otelSpan: otel.Span | undefined
446
462
  connectedClientSessionPullQueues: PullQueueSet
447
463
  localPushBatchSize: number
448
464
  testing: {
@@ -457,26 +473,21 @@ const backgroundApplyLocalPushes = ({
457
473
 
458
474
  const batchItems = yield* BucketQueue.takeBetween(localPushesQueue, 1, localPushBatchSize)
459
475
 
460
- // Wait for the backend pulling to finish
461
- yield* localPushesLatch.await
462
-
463
- // Prevent backend pull processing until this local push is finished
464
- yield* pullLatch.close
465
-
466
- const syncState = yield* syncStateSref
467
- if (syncState === undefined) return shouldNeverHappen('Not initialized')
476
+ // Applies a batch of local pushes, guarded by the localPushBackendPullMutex to ensure mutual exclusion with backend pulling
477
+ yield* Effect.gen(function* () {
478
+ const syncState = yield* Effect.fromNullable(yield* syncStateSref).pipe(Effect.orDieDebugger)
468
479
 
469
- const currentRebaseGeneration = syncState.localHead.rebaseGeneration
480
+ const currentRebaseGeneration = syncState.localHead.rebaseGeneration
470
481
 
471
- // Since the rebase generation might have changed since enqueuing, we need to filter out items with older generation
472
- // It's important that we filter after we got localPushesLatch, otherwise we might filter with the old generation
473
- const [droppedItems, filteredItems] = ReadonlyArray.partition(
474
- batchItems,
475
- ([eventEncoded]) => eventEncoded.seqNum.rebaseGeneration >= currentRebaseGeneration,
476
- )
482
+ // Since the rebase generation might have changed since enqueuing, we need to filter out items with older generation
483
+ // It's important that we filter after acquiring the localPushBackendPullMutex, otherwise we might filter with the old generation
484
+ const [droppedItems, filteredItems] = ReadonlyArray.partition(
485
+ batchItems,
486
+ ([eventEncoded]) => eventEncoded.seqNum.rebaseGeneration >= currentRebaseGeneration,
487
+ )
477
488
 
478
- if (droppedItems.length > 0) {
479
- otelSpan?.addEvent(`push:drop-old-generation`, {
489
+ if (droppedItems.length > 0) {
490
+ yield* Effect.spanEvent(`push:drop-old-generation`, {
480
491
  droppedCount: droppedItems.length,
481
492
  currentRebaseGeneration,
482
493
  })
@@ -487,121 +498,114 @@ const backgroundApplyLocalPushes = ({
487
498
  */
488
499
  yield* Effect.forEach(
489
500
  droppedItems.filter(
490
- (item): item is [LiveStoreEvent.Client.EncodedWithMeta, Deferred.Deferred<void, LeaderAheadError>] =>
491
- item[1] !== undefined,
492
- ),
493
- ([eventEncoded, deferred]) =>
494
- Deferred.fail(
495
- deferred,
496
- LeaderAheadError.make({
497
- minimumExpectedNum: syncState.localHead,
498
- providedNum: eventEncoded.seqNum,
499
- }),
501
+ (item): item is [LiveStoreEvent.Client.EncodedWithMeta, Deferred.Deferred<void, LeaderAheadError | StaleRebaseGenerationError>] =>
502
+ item[1] !== undefined,
500
503
  ),
501
- )
502
- }
504
+ ([eventEncoded, deferred]) =>
505
+ Deferred.fail(
506
+ deferred,
507
+ StaleRebaseGenerationError.make({
508
+ currentRebaseGeneration,
509
+ providedRebaseGeneration: eventEncoded.seqNum.rebaseGeneration,
510
+ sessionId: eventEncoded.sessionId,
511
+ }),
512
+ ),
513
+ )
514
+ }
503
515
 
504
- if (filteredItems.length === 0) {
505
- yield* pullLatch.open
506
- continue
507
- }
516
+ if (filteredItems.length === 0) {
517
+ return
518
+ }
508
519
 
509
- const [newEvents, deferreds] = ReadonlyArray.unzip(filteredItems)
520
+ const [newEvents, deferreds] = ReadonlyArray.unzip(filteredItems)
510
521
 
511
- const mergeResult = SyncState.merge({
512
- syncState,
513
- payload: { _tag: 'local-push', newEvents },
514
- isClientEvent,
515
- isEqualEvent: LiveStoreEvent.Client.isEqualEncoded,
516
- })
522
+ yield* Effect.annotateCurrentSpan({
523
+ 'batchSize': newEvents.length,
524
+ ...(TRACE_VERBOSE === true ? { 'newEvents': jsonStringify(newEvents) } : {}),
525
+ })
517
526
 
518
- switch (mergeResult._tag) {
519
- case 'unknown-error': {
520
- otelSpan?.addEvent(`push:unknown-error`, {
521
- batchSize: newEvents.length,
522
- newEvents: TRACE_VERBOSE ? JSON.stringify(newEvents) : undefined,
523
- })
524
- return yield* new UnknownError({ cause: mergeResult.message })
525
- }
526
- case 'rebase': {
527
- return shouldNeverHappen('The leader thread should never have to rebase due to a local push')
528
- }
529
- case 'reject': {
530
- otelSpan?.addEvent(`push:reject`, {
531
- batchSize: newEvents.length,
532
- mergeResult: TRACE_VERBOSE ? JSON.stringify(mergeResult) : undefined,
533
- })
527
+ const mergeResult = yield* SyncState.merge({
528
+ syncState,
529
+ payload: { _tag: 'local-push', newEvents },
530
+ isClientEvent,
531
+ isEqualEvent: LiveStoreEvent.Client.isEqualEncoded,
532
+ })
534
533
 
535
- // TODO: how to test this?
536
- const nextRebaseGeneration = currentRebaseGeneration + 1
534
+ switch (mergeResult._tag) {
535
+ case 'rebase': {
536
+ return yield* Effect.dieDebugger('The leader thread should never have to rebase due to a local push')
537
+ }
538
+ case 'reject': {
539
+ yield* Effect.spanEvent(`push:reject`, {
540
+ batchSize: newEvents.length,
541
+ ...(TRACE_VERBOSE === true ? { mergeResult: jsonStringify(mergeResult) } : {}),
542
+ })
537
543
 
538
- const providedNum = newEvents.at(0)!.seqNum
539
- // All subsequent pushes with same generation should be rejected as well
540
- // We're also handling the case where the localPushQueue already contains events
541
- // from the next generation which we preserve in the queue
542
- const remainingEventsMatchingGeneration = yield* BucketQueue.takeSplitWhere(
543
- localPushesQueue,
544
- ([eventEncoded]) => eventEncoded.seqNum.rebaseGeneration >= nextRebaseGeneration,
545
- )
544
+ // TODO: how to test this?
545
+ const nextRebaseGeneration = currentRebaseGeneration + 1
546
546
 
547
- // TODO we still need to better understand and handle this scenario
548
- if (LS_DEV && (yield* BucketQueue.size(localPushesQueue)) > 0) {
549
- console.log('localPushesQueue is not empty', yield* BucketQueue.size(localPushesQueue))
550
- // biome-ignore lint/suspicious/noDebugger: debugging
551
- debugger
552
- }
547
+ const providedNum = newEvents.at(0)!.seqNum
548
+ // All subsequent pushes with same generation should be rejected as well
549
+ // We're also handling the case where the localPushQueue already contains events
550
+ // from the next generation which we preserve in the queue
551
+ const remainingEventsMatchingGeneration = yield* BucketQueue.takeSplitWhere(
552
+ localPushesQueue,
553
+ ([eventEncoded]) => eventEncoded.seqNum.rebaseGeneration >= nextRebaseGeneration,
554
+ )
553
555
 
554
- const allDeferredsToReject = [
555
- ...deferreds,
556
- ...remainingEventsMatchingGeneration.map(([_, deferred]) => deferred),
557
- ].filter(isNotUndefined)
556
+ // TODO we still need to better understand and handle this scenario
557
+ if (LS_DEV === true && (yield* BucketQueue.size(localPushesQueue)) > 0) {
558
+ console.log('localPushesQueue is not empty', yield* BucketQueue.size(localPushesQueue))
559
+ // oxlint-disable-next-line eslint(no-debugger) -- intentional breakpoint for unexpected queue state
560
+ debugger
561
+ }
558
562
 
559
- yield* Effect.forEach(allDeferredsToReject, (deferred) =>
560
- Deferred.fail(
561
- deferred,
562
- LeaderAheadError.make({ minimumExpectedNum: mergeResult.expectedMinimumId, providedNum }),
563
- ),
564
- )
563
+ const allDeferredsToReject = [
564
+ ...deferreds,
565
+ ...remainingEventsMatchingGeneration.map(([_, deferred]) => deferred),
566
+ ].filter(isNotUndefined)
565
567
 
566
- // Allow the backend pulling to start
567
- yield* pullLatch.open
568
+ yield* Effect.forEach(allDeferredsToReject, (deferred) =>
569
+ Deferred.fail(
570
+ deferred,
571
+ LeaderAheadError.make({ minimumExpectedNum: mergeResult.expectedMinimumId, providedNum, sessionId: newEvents.at(0)!.sessionId }),
572
+ ),
573
+ )
568
574
 
569
- // In this case we're skipping state update and down/upstream processing
570
- // We've cleared the local push queue and are now waiting for new local pushes / backend pulls
571
- continue
572
- }
573
- case 'advance': {
574
- break
575
- }
576
- default: {
577
- casesHandled(mergeResult)
575
+ // In this case we're skipping state update and down/upstream processing
576
+ // We've cleared the local push queue and are now waiting for new local pushes / backend pulls
577
+ return
578
+ }
579
+ case 'advance': {
580
+ break
581
+ }
582
+ default: {
583
+ casesHandled(mergeResult)
584
+ }
578
585
  }
579
- }
580
-
581
- yield* SubscriptionRef.set(syncStateSref, mergeResult.newSyncState)
582
586
 
583
- yield* connectedClientSessionPullQueues.offer({
584
- payload: SyncState.PayloadUpstreamAdvance.make({ newEvents: mergeResult.newEvents }),
585
- leaderHead: mergeResult.newSyncState.localHead,
586
- })
587
+ yield* SubscriptionRef.set(syncStateSref, mergeResult.newSyncState)
587
588
 
588
- otelSpan?.addEvent(`push:advance`, {
589
- batchSize: newEvents.length,
590
- mergeResult: TRACE_VERBOSE ? JSON.stringify(mergeResult) : undefined,
591
- })
589
+ yield* connectedClientSessionPullQueues.offer({
590
+ payload: SyncState.PayloadUpstreamAdvance.make({ newEvents: mergeResult.newEvents }),
591
+ leaderHead: mergeResult.newSyncState.localHead,
592
+ })
592
593
 
593
- // Don't sync client-local events
594
- const filteredBatch = mergeResult.newEvents.filter((eventEncoded) => {
595
- const eventDef = schema.eventsDefsMap.get(eventEncoded.name)
596
- return eventDef === undefined ? true : eventDef.options.clientOnly === false
597
- })
594
+ yield* Effect.spanEvent(`push:advance`, {
595
+ batchSize: newEvents.length,
596
+ ...(TRACE_VERBOSE === true ? { mergeResult: jsonStringify(mergeResult) } : {}),
597
+ })
598
598
 
599
- yield* BucketQueue.offerAll(syncBackendPushQueue, filteredBatch)
599
+ // Don't sync client-local events
600
+ const filteredBatch = mergeResult.newEvents.filter((eventEncoded) => {
601
+ const eventDef = schema.eventsDefsMap.get(eventEncoded.name)
602
+ return eventDef === undefined ? true : eventDef.options.clientOnly === false
603
+ })
600
604
 
601
- yield* materializeEventsBatch({ batchItems: mergeResult.newEvents, deferreds })
605
+ yield* BucketQueue.offerAll(syncBackendPushQueue, filteredBatch)
602
606
 
603
- // Allow the backend pulling to start
604
- yield* pullLatch.open
607
+ yield* materializeEventsBatch({ batchItems: mergeResult.newEvents, deferreds })
608
+ }).pipe(localPushBackendPullMutex.withPermits(1))
605
609
  }
606
610
  })
607
611
 
@@ -611,7 +615,7 @@ type MaterializeEventsBatch = (_: {
611
615
  * The deferreds are used by the caller to know when the mutation has been processed.
612
616
  * Indexes are aligned with `batchItems`
613
617
  */
614
- deferreds: ReadonlyArray<Deferred.Deferred<void, LeaderAheadError> | undefined> | undefined
618
+ deferreds: ReadonlyArray<Deferred.Deferred<void, LeaderAheadError | StaleRebaseGenerationError> | undefined> | undefined
615
619
  }) => Effect.Effect<void, MaterializeError, LeaderThreadCtx>
616
620
 
617
621
  // TODO how to handle errors gracefully
@@ -625,7 +629,7 @@ const materializeEventsBatch: MaterializeEventsBatch = ({ batchItems, deferreds
625
629
 
626
630
  yield* Effect.addFinalizer((exit) =>
627
631
  Effect.gen(function* () {
628
- if (Exit.isSuccess(exit)) return
632
+ if (Exit.isSuccess(exit) === true) return
629
633
 
630
634
  // Rollback in case of an error
631
635
  db.execute('ROLLBACK', undefined)
@@ -654,15 +658,13 @@ const materializeEventsBatch: MaterializeEventsBatch = ({ batchItems, deferreds
654
658
  Effect.tapCauseLogPretty,
655
659
  )
656
660
 
657
- const backgroundBackendPulling = ({
661
+ const backgroundBackendPulling = Effect.fn('@livestore/common:LeaderSyncProcessor:backend-pulling')(function* ({
658
662
  isClientEvent,
659
663
  restartBackendPushing,
660
- otelSpan,
661
664
  dbState,
662
665
  syncStateSref,
663
- localPushesLatch,
666
+ localPushBackendPullMutex,
664
667
  livePull,
665
- pullLatch,
666
668
  devtoolsLatch,
667
669
  initialBlockingSyncContext,
668
670
  connectedClientSessionPullQueues,
@@ -671,71 +673,79 @@ const backgroundBackendPulling = ({
671
673
  isClientEvent: (eventEncoded: LiveStoreEvent.Client.EncodedWithMeta) => boolean
672
674
  restartBackendPushing: (
673
675
  filteredRebasedPending: ReadonlyArray<LiveStoreEvent.Client.EncodedWithMeta>,
674
- ) => Effect.Effect<void, UnknownError, LeaderThreadCtx | HttpClient.HttpClient>
675
- otelSpan: otel.Span | undefined
676
+ ) => Effect.Effect<void, never, LeaderThreadCtx | HttpClient.HttpClient>
676
677
  syncStateSref: SubscriptionRef.SubscriptionRef<SyncState.SyncState | undefined>
677
678
  dbState: SqliteDb
678
- localPushesLatch: Effect.Latch
679
- pullLatch: Effect.Latch
679
+ localPushBackendPullMutex: Effect.Semaphore
680
680
  livePull: boolean
681
681
  devtoolsLatch: Effect.Latch | undefined
682
682
  initialBlockingSyncContext: InitialBlockingSyncContext
683
683
  connectedClientSessionPullQueues: PullQueueSet
684
684
  advancePushHead: (eventNum: EventSequenceNumber.Client.Composite) => void
685
- }) =>
686
- Effect.gen(function* () {
687
- const { syncBackend, dbState: db, dbEventlog, schema } = yield* LeaderThreadCtx
685
+ }) {
686
+ const { syncBackend, dbState: db, dbEventlog, schema } = yield* LeaderThreadCtx
688
687
 
689
- if (syncBackend === undefined) return
688
+ if (syncBackend === undefined) return
690
689
 
691
- const onNewPullChunk = (
692
- newEvents: LiveStoreEvent.Client.EncodedWithMeta[],
693
- pageInfo: SyncBackend.PullResPageInfo,
694
- ) =>
695
- Effect.gen(function* () {
696
- if (newEvents.length === 0) return
690
+ let pullMutexHeld = false
691
+
692
+ const releasePullMutexIfHeld = Effect.gen(function* () {
693
+ if (pullMutexHeld === false) return
694
+ pullMutexHeld = false
695
+ yield* localPushBackendPullMutex.release(1)
696
+ })
697
+
698
+ const isPullPaginationComplete = (pageInfo: SyncBackend.PullResPageInfo) => pageInfo._tag === 'NoMore'
699
+
700
+ const onNewPullChunk = (newEvents: LiveStoreEvent.Client.EncodedWithMeta[], pageInfo: SyncBackend.PullResPageInfo) =>
701
+ Effect.gen(function* () {
702
+ if (devtoolsLatch !== undefined) {
703
+ yield* devtoolsLatch.await
704
+ }
697
705
 
698
- if (devtoolsLatch !== undefined) {
699
- yield* devtoolsLatch.await
706
+ if (newEvents.length === 0) {
707
+ if (isPullPaginationComplete(pageInfo) === true) {
708
+ yield* releasePullMutexIfHeld
700
709
  }
710
+ return
711
+ }
701
712
 
702
- // Prevent more local pushes from being processed until this pull is finished
703
- yield* localPushesLatch.close
713
+ // Prevent more local pushes from being processed until this pull pagination sequence is finished.
714
+ if (pullMutexHeld === false) {
715
+ yield* localPushBackendPullMutex.take(1)
716
+ pullMutexHeld = true
717
+ }
704
718
 
705
- // Wait for pending local pushes to finish
706
- yield* pullLatch.await
719
+ const chunkExit = yield* Effect.gen(function* () {
720
+ const syncState = yield* Effect.fromNullable(yield* syncStateSref).pipe(Effect.orDieDebugger)
707
721
 
708
- const syncState = yield* syncStateSref
709
- if (syncState === undefined) return shouldNeverHappen('Not initialized')
722
+ yield* Effect.annotateCurrentSpan({
723
+ 'merge.newEventsCount': newEvents.length,
724
+ ...(TRACE_VERBOSE === true ? { 'merge.newEvents': jsonStringify(newEvents) } : {}),
725
+ })
710
726
 
711
- const mergeResult = SyncState.merge({
712
- syncState,
713
- payload: SyncState.PayloadUpstreamAdvance.make({ newEvents }),
714
- isClientEvent,
715
- isEqualEvent: LiveStoreEvent.Client.isEqualEncoded,
716
- ignoreClientEvents: true,
717
- })
727
+ const mergeResult = yield* SyncState.merge({
728
+ syncState,
729
+ payload: SyncState.PayloadUpstreamAdvance.make({ newEvents }),
730
+ isClientEvent,
731
+ isEqualEvent: LiveStoreEvent.Client.isEqualEncoded,
732
+ ignoreClientEvents: true,
733
+ })
718
734
 
719
- if (mergeResult._tag === 'reject') {
720
- return shouldNeverHappen('The leader thread should never reject upstream advances')
721
- } else if (mergeResult._tag === 'unknown-error') {
722
- otelSpan?.addEvent(`pull:unknown-error`, {
723
- newEventsCount: newEvents.length,
724
- newEvents: TRACE_VERBOSE ? JSON.stringify(newEvents) : undefined,
725
- })
726
- return yield* new UnknownError({ cause: mergeResult.message })
727
- }
735
+ if (mergeResult._tag === 'reject') {
736
+ return yield* Effect.dieDebugger('The leader thread should never reject upstream advances')
737
+ }
728
738
 
729
739
  const newBackendHead = newEvents.at(-1)!.seqNum
730
740
 
731
741
  Eventlog.updateBackendHead(dbEventlog, newBackendHead)
732
742
 
733
743
  if (mergeResult._tag === 'rebase') {
734
- otelSpan?.addEvent(`pull:rebase[${mergeResult.newSyncState.localHead.rebaseGeneration}]`, {
735
- newEventsCount: newEvents.length,
736
- newEvents: TRACE_VERBOSE ? JSON.stringify(newEvents) : undefined,
737
- rollbackCount: mergeResult.rollbackEvents.length,
738
- mergeResult: TRACE_VERBOSE ? JSON.stringify(mergeResult) : undefined,
744
+ yield* Effect.spanEvent(`pull:rebase[${mergeResult.newSyncState.localHead.rebaseGeneration}]`, {
745
+ newEventsCount: newEvents.length,
746
+ ...(TRACE_VERBOSE === true ? { newEvents: jsonStringify(newEvents) } : {}),
747
+ rollbackCount: mergeResult.rollbackEvents.length,
748
+ ...(TRACE_VERBOSE === true ? { mergeResult: jsonStringify(mergeResult) } : {}),
739
749
  })
740
750
 
741
751
  const globalRebasedPendingEvents = mergeResult.newSyncState.pending.filter((event) => {
@@ -757,9 +767,9 @@ const backgroundBackendPulling = ({
757
767
  leaderHead: mergeResult.newSyncState.localHead,
758
768
  })
759
769
  } else {
760
- otelSpan?.addEvent(`pull:advance`, {
770
+ yield* Effect.spanEvent(`pull:advance`, {
761
771
  newEventsCount: newEvents.length,
762
- mergeResult: TRACE_VERBOSE ? JSON.stringify(mergeResult) : undefined,
772
+ ...(TRACE_VERBOSE === true ? { mergeResult: jsonStringify(mergeResult) } : {}),
763
773
  })
764
774
 
765
775
  // Ensure push fiber is active after advance by restarting with current pending (non-client) events
@@ -782,7 +792,7 @@ const backgroundBackendPulling = ({
782
792
  EventSequenceNumber.Client.isEqual(event.seqNum, confirmedEvent.seqNum),
783
793
  ),
784
794
  )
785
- yield* Eventlog.updateSyncMetadata(confirmedNewEvents).pipe(UnknownError.mapToUnknownError)
795
+ yield* Eventlog.updateSyncMetadata(confirmedNewEvents).pipe(Effect.orDieDebugger)
786
796
  }
787
797
  }
788
798
 
@@ -794,136 +804,126 @@ const backgroundBackendPulling = ({
794
804
  yield* materializeEventsBatch({ batchItems: mergeResult.newEvents, deferreds: undefined })
795
805
 
796
806
  yield* SubscriptionRef.set(syncStateSref, mergeResult.newSyncState)
807
+ }).pipe(Effect.exit)
797
808
 
798
- // Allow local pushes to be processed again
799
- if (pageInfo._tag === 'NoMore') {
800
- yield* localPushesLatch.open
801
- }
802
- })
809
+ if (Exit.isFailure(chunkExit) === true) {
810
+ yield* releasePullMutexIfHeld
811
+ return yield* Effect.failCause(chunkExit.cause)
812
+ }
803
813
 
804
- const syncState = yield* syncStateSref
805
- if (syncState === undefined) return shouldNeverHappen('Not initialized')
806
- const cursorInfo = yield* Eventlog.getSyncBackendCursorInfo({ remoteHead: syncState.upstreamHead.global })
814
+ if (isPullPaginationComplete(pageInfo) === true) {
815
+ yield* releasePullMutexIfHeld
816
+ }
817
+ })
807
818
 
808
- const hashMaterializerResult = makeMaterializerHash({ schema, dbState })
819
+ const syncState = yield* Effect.fromNullable(yield* syncStateSref).pipe(Effect.orDieDebugger)
820
+ const cursorInfo = yield* Eventlog.getSyncBackendCursorInfo({ remoteHead: syncState.upstreamHead.global })
809
821
 
810
- yield* syncBackend.pull(cursorInfo, { live: livePull }).pipe(
811
- // TODO only take from queue while connected
812
- Stream.tap(({ batch, pageInfo }) =>
813
- Effect.gen(function* () {
814
- // yield* Effect.spanEvent('batch', {
815
- // attributes: {
816
- // batchSize: batch.length,
817
- // batch: TRACE_VERBOSE ? batch : undefined,
818
- // },
819
- // })
820
- // NOTE we only want to take process events when the sync backend is connected
821
- // (e.g. needed for simulating being offline)
822
- // TODO remove when there's a better way to handle this in stream above
823
- yield* SubscriptionRef.waitUntil(syncBackend.isConnected, (isConnected) => isConnected === true)
824
- yield* onNewPullChunk(
825
- batch.map((_) =>
826
- LiveStoreEvent.Client.EncodedWithMeta.fromGlobal(_.eventEncoded, {
827
- syncMetadata: _.metadata,
828
- // TODO we can't really know the materializer result here yet beyond the first event batch item as we need to materialize it one by one first
829
- // This is a bug and needs to be fixed https://github.com/livestorejs/livestore/issues/503#issuecomment-3114533165
830
- materializerHashLeader: hashMaterializerResult(LiveStoreEvent.Global.toClientEncoded(_.eventEncoded)),
831
- materializerHashSession: Option.none(),
832
- }),
833
- ),
834
- pageInfo,
835
- )
836
- yield* initialBlockingSyncContext.update({ processed: batch.length, pageInfo })
837
- }),
838
- ),
839
- Stream.runDrain,
840
- Effect.interruptible,
841
- )
822
+ const hashMaterializerResult = makeMaterializerHash({ schema, dbState })
842
823
 
843
- // Should only ever happen when livePull is false
844
- yield* Effect.logDebug('backend-pulling finished', { livePull })
845
- }).pipe(Effect.withSpan('@livestore/common:LeaderSyncProcessor:backend-pulling'))
824
+ yield* syncBackend.pull(cursorInfo, { live: livePull }).pipe(
825
+ // TODO only take from queue while connected
826
+ Stream.tap(({ batch, pageInfo }) =>
827
+ Effect.gen(function* () {
828
+ // NOTE we only want to take process events when the sync backend is connected
829
+ // (e.g. needed for simulating being offline)
830
+ // TODO remove when there's a better way to handle this in stream above
831
+ yield* SubscriptionRef.waitUntil(syncBackend.isConnected, (isConnected) => isConnected === true)
832
+ yield* onNewPullChunk(
833
+ batch.map((_) =>
834
+ LiveStoreEvent.Client.EncodedWithMeta.fromGlobal(_.eventEncoded, {
835
+ syncMetadata: _.metadata,
836
+ // TODO we can't really know the materializer result here yet beyond the first event batch item as we need to materialize it one by one first
837
+ // This is a bug and needs to be fixed https://github.com/livestorejs/livestore/issues/503#issuecomment-3114533165
838
+ materializerHashLeader: hashMaterializerResult(LiveStoreEvent.Global.toClientEncoded(_.eventEncoded)),
839
+ materializerHashSession: Option.none(),
840
+ }),
841
+ ),
842
+ pageInfo,
843
+ )
844
+ yield* initialBlockingSyncContext.update({ processed: batch.length, pageInfo })
845
+ }),
846
+ ),
847
+ Stream.runDrain,
848
+ Effect.interruptible,
849
+ Effect.ensuring(releasePullMutexIfHeld),
850
+ )
851
+
852
+ // Should only ever happen when livePull is false
853
+ yield* Effect.logDebug('backend-pulling finished', { livePull })
854
+ })
846
855
 
847
- const backgroundBackendPushing = ({
856
+ const backgroundBackendPushing = Effect.fn('@livestore/common:LeaderSyncProcessor:backend-pushing')(function* ({
848
857
  syncBackendPushQueue,
849
- otelSpan,
850
858
  devtoolsLatch,
851
859
  backendPushBatchSize,
852
860
  }: {
853
861
  syncBackendPushQueue: BucketQueue.BucketQueue<LiveStoreEvent.Client.EncodedWithMeta>
854
- otelSpan: otel.Span | undefined
855
862
  devtoolsLatch: Effect.Latch | undefined
856
863
  backendPushBatchSize: number
857
- }) =>
858
- Effect.gen(function* () {
859
- const { syncBackend } = yield* LeaderThreadCtx
860
- if (syncBackend === undefined) return
864
+ }) {
865
+ const { syncBackend } = yield* LeaderThreadCtx
866
+ if (syncBackend === undefined) return
861
867
 
862
- while (true) {
863
- yield* SubscriptionRef.waitUntil(syncBackend.isConnected, (isConnected) => isConnected === true)
868
+ while (true) {
869
+ yield* SubscriptionRef.waitUntil(syncBackend.isConnected, (isConnected) => isConnected === true)
864
870
 
865
- const queueItems = yield* BucketQueue.takeBetween(syncBackendPushQueue, 1, backendPushBatchSize)
871
+ const queueItems = yield* BucketQueue.takeBetween(syncBackendPushQueue, 1, backendPushBatchSize)
866
872
 
867
- yield* SubscriptionRef.waitUntil(syncBackend.isConnected, (isConnected) => isConnected === true)
873
+ yield* SubscriptionRef.waitUntil(syncBackend.isConnected, (isConnected) => isConnected === true)
868
874
 
869
- if (devtoolsLatch !== undefined) {
870
- yield* devtoolsLatch.await
871
- }
875
+ if (devtoolsLatch !== undefined) {
876
+ yield* devtoolsLatch.await
877
+ }
872
878
 
873
- otelSpan?.addEvent('backend-push', {
874
- batchSize: queueItems.length,
875
- batch: TRACE_VERBOSE ? JSON.stringify(queueItems) : undefined,
876
- })
879
+ yield* Effect.spanEvent('backend-push', {
880
+ batchSize: queueItems.length,
881
+ ...(TRACE_VERBOSE === true ? { batch: jsonStringify(queueItems) } : {}),
882
+ })
877
883
 
878
- // Push with declarative retry/backoff using Effect schedules
879
- // - Exponential backoff starting at 1s and doubling (1s, 2s, 4s, 8s, 16s, 30s ...)
880
- // - Delay clamped at 30s (continues retrying at 30s)
881
- // - Resets automatically after successful push
882
- // TODO(metrics): expose counters/gauges for retry attempts and queue health via devtools/metrics
883
-
884
- // Only retry for transient UnknownError cases
885
- const isRetryable = (err: InvalidPushError | IsOfflineError) =>
886
- err._tag === 'InvalidPushError' && err.cause._tag === 'LiveStore.UnknownError'
887
-
888
- // Input: InvalidPushError | IsOfflineError, Output: Duration
889
- const retrySchedule: Schedule.Schedule<Duration.DurationInput, InvalidPushError | IsOfflineError> =
890
- Schedule.exponential(Duration.seconds(1)).pipe(
891
- Schedule.andThenEither(Schedule.spaced(Duration.seconds(30))), // clamp at 30 second intervals
892
- Schedule.compose(Schedule.elapsed),
893
- Schedule.whileInput(isRetryable),
894
- )
884
+ // Push with declarative retry/backoff using Effect schedules
885
+ // - Exponential backoff starting at 1s and doubling (1s, 2s, 4s, 8s, 16s, 30s ...)
886
+ // - Delay clamped at 30s (continues retrying at 30s)
887
+ // - Resets automatically after successful push
888
+ // TODO(metrics): expose counters/gauges for retry attempts and queue health via devtools/metrics
889
+ yield* Effect.gen(function* () {
890
+ const iteration = yield* Schedule.CurrentIterationMetadata
895
891
 
896
- yield* Effect.gen(function* () {
897
- const iteration = yield* Schedule.CurrentIterationMetadata
892
+ const pushResult = yield* syncBackend.push(queueItems.map((_) => _.toGlobal())).pipe(Effect.either)
898
893
 
899
- const pushResult = yield* syncBackend.push(queueItems.map((_) => _.toGlobal())).pipe(Effect.either)
894
+ const retries = iteration.recurrence
895
+ if (retries > 0 && pushResult._tag === 'Right') {
896
+ yield* Effect.spanEvent('backend-push-retry-success', { retries, batchSize: queueItems.length })
897
+ }
900
898
 
901
- const retries = iteration.recurrence
902
- if (retries > 0 && pushResult._tag === 'Right') {
903
- otelSpan?.addEvent('backend-push-retry-success', { retries, batchSize: queueItems.length })
899
+ if (pushResult._tag === 'Left') {
900
+ yield* Effect.spanEvent('backend-push-error', {
901
+ error: pushResult.left.toString(),
902
+ retries,
903
+ batchSize: queueItems.length,
904
+ })
905
+ const error = pushResult.left
906
+ if (error._tag === 'ServerAheadError') {
907
+ // It's a core part of the sync protocol that the sync backend will emit a new pull chunk alongside the ServerAheadError
908
+ yield* Effect.logDebug('handled backend-push-error (waiting for interupt caused by pull)', { error })
909
+ return yield* Effect.never
904
910
  }
905
911
 
906
- if (pushResult._tag === 'Left') {
907
- otelSpan?.addEvent('backend-push-error', {
908
- error: pushResult.left.toString(),
909
- retries,
910
- batchSize: queueItems.length,
911
- })
912
- const error = pushResult.left
913
- if (
914
- error._tag === 'IsOfflineError' ||
915
- (error._tag === 'InvalidPushError' && error.cause._tag === 'ServerAheadError')
916
- ) {
917
- // It's a core part of the sync protocol that the sync backend will emit a new pull chunk alongside the ServerAheadError
918
- yield* Effect.logDebug('handled backend-push-error (waiting for interupt caused by pull)', { error })
919
- return yield* Effect.never
920
- }
921
-
922
- return yield* error
923
- }
924
- }).pipe(Effect.retry(retrySchedule))
925
- }
926
- }).pipe(Effect.interruptible, Effect.withSpan('@livestore/common:LeaderSyncProcessor:backend-pushing'))
912
+ return yield* error
913
+ }
914
+ }).pipe(
915
+ // Retry transient errors
916
+ Effect.retry({
917
+ schedule: Schedule.exponential(Duration.seconds(1)).pipe(
918
+ Schedule.modifyDelay((_, delay) => Duration.min(delay, Duration.seconds(30))) // Cap delay at 30s intervals.
919
+ ),
920
+ while: (error) => error._tag === 'IsOfflineError' || error._tag === 'UnknownError',
921
+ }),
922
+ // This is needed to narrow the Error type. Our retry policy runs indefinitely, but Effect.retry does not narrow the Error type.
923
+ Effect.catchIf((error) => error._tag === 'IsOfflineError' || error._tag === 'UnknownError', Effect.die),
924
+ )
925
+ }
926
+ }, Effect.interruptible)
927
927
 
928
928
  const trimChangesetRows = (db: SqliteDb, newHead: EventSequenceNumber.Client.Composite) => {
929
929
  // Since we're using the session changeset rows to query for the current head,
@@ -936,13 +936,13 @@ interface PullQueueSet {
936
936
  cursor: EventSequenceNumber.Client.Composite,
937
937
  ) => Effect.Effect<
938
938
  Queue.Queue<{ payload: typeof SyncState.PayloadUpstream.Type }>,
939
- UnknownError,
939
+ never,
940
940
  Scope.Scope | LeaderThreadCtx
941
941
  >
942
942
  offer: (item: {
943
943
  payload: typeof SyncState.PayloadUpstream.Type
944
944
  leaderHead: EventSequenceNumber.Client.Composite
945
- }) => Effect.Effect<void, UnknownError>
945
+ }) => Effect.Effect<void, never>
946
946
  }
947
947
 
948
948
  const makePullQueueSet = Effect.gen(function* () {
@@ -1029,7 +1029,7 @@ const makePullQueueSet = Effect.gen(function* () {
1029
1029
  const offer: PullQueueSet['offer'] = (item) =>
1030
1030
  Effect.gen(function* () {
1031
1031
  const seqNumStr = EventSequenceNumber.Client.toString(item.leaderHead)
1032
- if (cachedPayloads.has(seqNumStr)) {
1032
+ if (cachedPayloads.has(seqNumStr) === true) {
1033
1033
  cachedPayloads.get(seqNumStr)!.push(item.payload)
1034
1034
  } else {
1035
1035
  cachedPayloads.set(seqNumStr, [item.payload])
@@ -1067,24 +1067,94 @@ const validatePushBatch = (
1067
1067
  return
1068
1068
  }
1069
1069
 
1070
- // Example: session A already enqueued e1…e6 while session B (same client, different
1071
- // session) still believes the head is e1 and submits [e2, e7, e8]. The numbers look
1072
- // monotonic from B’s perspective, but we must reject and force B to rebase locally
1073
- // so the leader never regresses.
1070
+ // Defensive check: callers should already provide a strictly increasing sequence
1071
+ // of event numbers.
1074
1072
  for (let i = 1; i < batch.length; i++) {
1075
- if (EventSequenceNumber.Client.isGreaterThanOrEqual(batch[i - 1]!.seqNum, batch[i]!.seqNum)) {
1076
- return yield* LeaderAheadError.make({
1077
- minimumExpectedNum: batch[i - 1]!.seqNum,
1078
- providedNum: batch[i]!.seqNum,
1073
+ if (EventSequenceNumber.Client.isGreaterThanOrEqual(batch[i - 1]!.seqNum, batch[i]!.seqNum) === true) {
1074
+ return yield* NonMonotonicBatchError.make({
1075
+ precedingSeqNum: batch[i - 1]!.seqNum,
1076
+ violatingSeqNum: batch[i]!.seqNum,
1077
+ violationIndex: i,
1078
+ sessionId: batch[i]!.sessionId,
1079
1079
  })
1080
1080
  }
1081
1081
  }
1082
1082
 
1083
- // Make sure smallest sequence number is > pushHead
1084
- if (EventSequenceNumber.Client.isGreaterThanOrEqual(pushHead, batch[0]!.seqNum)) {
1083
+ // Reject stale batches whose first event is at or behind the leader's push head.
1084
+ if (EventSequenceNumber.Client.isGreaterThanOrEqual(pushHead, batch[0]!.seqNum) === true) {
1085
1085
  return yield* LeaderAheadError.make({
1086
1086
  minimumExpectedNum: pushHead,
1087
1087
  providedNum: batch[0]!.seqNum,
1088
+ sessionId: batch[0]!.sessionId,
1088
1089
  })
1089
1090
  }
1090
1091
  })
1092
+
1093
+ /**
1094
+ * Handles a BackendIdMismatchError based on the configured behavior.
1095
+ * This occurs when the sync backend has been reset and has a new identity.
1096
+ */
1097
+ const handleBackendIdMismatch = Effect.fn('@livestore/common:LeaderSyncProcessor:handleBackendIdMismatch')(function* ({
1098
+ error,
1099
+ onBackendIdMismatch,
1100
+ shutdownChannel,
1101
+ }: {
1102
+ error: BackendIdMismatchError
1103
+ onBackendIdMismatch: 'reset' | 'shutdown' | 'ignore'
1104
+ shutdownChannel: ShutdownChannel
1105
+ }) {
1106
+ const { dbEventlog, dbState } = yield* LeaderThreadCtx
1107
+
1108
+ if (onBackendIdMismatch === 'reset') {
1109
+ yield* Effect.logWarning(
1110
+ 'Sync backend identity changed (backend was reset). Clearing local storage and shutting down.',
1111
+ error,
1112
+ )
1113
+
1114
+ // Clear local databases so the client can start fresh on next boot
1115
+ yield* clearLocalDatabases({ dbEventlog, dbState })
1116
+
1117
+ // Send shutdown signal with special reason
1118
+ yield* shutdownChannel.send(IntentionalShutdownCause.make({ reason: 'backend-id-mismatch' })).pipe(Effect.orDie)
1119
+
1120
+ return yield* Effect.die(error)
1121
+ }
1122
+
1123
+ if (onBackendIdMismatch === 'shutdown') {
1124
+ yield* Effect.logWarning(
1125
+ 'Sync backend identity changed (backend was reset). Shutting down without clearing local storage.',
1126
+ error,
1127
+ )
1128
+
1129
+ yield* shutdownChannel.send(error).pipe(Effect.orDie)
1130
+
1131
+ return yield* Effect.die(error)
1132
+ }
1133
+
1134
+ // ignore mode
1135
+ if (LS_DEV === true) {
1136
+ yield* Effect.logDebug(
1137
+ 'Ignoring BackendIdMismatchError (sync backend was reset but client continues with stale data)',
1138
+ error,
1139
+ )
1140
+ }
1141
+ })
1142
+
1143
+ /**
1144
+ * Clears local databases (eventlog and state) so the client can start fresh on next boot.
1145
+ * This is used when the sync backend identity has changed (i.e. backend was reset).
1146
+ */
1147
+ const clearLocalDatabases = ({ dbEventlog, dbState }: { dbEventlog: SqliteDb; dbState: SqliteDb }) =>
1148
+ Effect.sync(() => {
1149
+ // Clear eventlog tables
1150
+ dbEventlog.execute(sql`DELETE FROM ${EVENTLOG_META_TABLE}`)
1151
+ dbEventlog.execute(sql`DELETE FROM ${SYNC_STATUS_TABLE}`)
1152
+
1153
+ // Drop all state tables - they'll be recreated on next boot
1154
+ const tables = dbState.select<{ name: string }>(
1155
+ sql`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`,
1156
+ )
1157
+ for (const { name } of tables) {
1158
+ dbState.execute(`DROP TABLE IF EXISTS "${name}"`)
1159
+ }
1160
+ })