@livestore/common 0.4.0-dev.9 → 0.5.0-dev.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (580) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/ClientSessionLeaderThreadProxy.d.ts +27 -12
  3. package/dist/ClientSessionLeaderThreadProxy.d.ts.map +1 -1
  4. package/dist/ClientSessionLeaderThreadProxy.js.map +1 -1
  5. package/dist/MaterializationJournal.d.ts +44 -0
  6. package/dist/MaterializationJournal.d.ts.map +1 -0
  7. package/dist/MaterializationJournal.js +112 -0
  8. package/dist/MaterializationJournal.js.map +1 -0
  9. package/dist/StateHead.d.ts +26 -0
  10. package/dist/StateHead.d.ts.map +1 -0
  11. package/dist/StateHead.js +50 -0
  12. package/dist/StateHead.js.map +1 -0
  13. package/dist/WorkerTransportError.d.ts +11 -0
  14. package/dist/WorkerTransportError.d.ts.map +1 -0
  15. package/dist/WorkerTransportError.js +11 -0
  16. package/dist/WorkerTransportError.js.map +1 -0
  17. package/dist/__tests__/fixture.d.ts +1 -1
  18. package/dist/__tests__/fixture.d.ts.map +1 -1
  19. package/dist/__tests__/fixture.js +3 -3
  20. package/dist/__tests__/fixture.js.map +1 -1
  21. package/dist/adapter-types.d.ts +59 -57
  22. package/dist/adapter-types.d.ts.map +1 -1
  23. package/dist/adapter-types.js +31 -22
  24. package/dist/adapter-types.js.map +1 -1
  25. package/dist/bounded-collections.d.ts.map +1 -1
  26. package/dist/bounded-collections.js +6 -4
  27. package/dist/bounded-collections.js.map +1 -1
  28. package/dist/debug-info.d.ts +56 -56
  29. package/dist/debug-info.d.ts.map +1 -1
  30. package/dist/debug-info.js +46 -32
  31. package/dist/debug-info.js.map +1 -1
  32. package/dist/defs.d.ts +9 -9
  33. package/dist/defs.js +2 -2
  34. package/dist/devtools/devtools-compatibility.test.d.ts +2 -0
  35. package/dist/devtools/devtools-compatibility.test.d.ts.map +1 -0
  36. package/dist/devtools/devtools-compatibility.test.js +16 -0
  37. package/dist/devtools/devtools-compatibility.test.js.map +1 -0
  38. package/dist/devtools/devtools-messages-client-session.d.ts +531 -312
  39. package/dist/devtools/devtools-messages-client-session.d.ts.map +1 -1
  40. package/dist/devtools/devtools-messages-client-session.js +86 -67
  41. package/dist/devtools/devtools-messages-client-session.js.map +1 -1
  42. package/dist/devtools/devtools-messages-common.d.ts +32 -35
  43. package/dist/devtools/devtools-messages-common.d.ts.map +1 -1
  44. package/dist/devtools/devtools-messages-common.js +12 -14
  45. package/dist/devtools/devtools-messages-common.js.map +1 -1
  46. package/dist/devtools/devtools-messages-leader.d.ts +417 -320
  47. package/dist/devtools/devtools-messages-leader.d.ts.map +1 -1
  48. package/dist/devtools/devtools-messages-leader.js +117 -87
  49. package/dist/devtools/devtools-messages-leader.js.map +1 -1
  50. package/dist/devtools/devtools-sessioninfo.d.ts +28 -17
  51. package/dist/devtools/devtools-sessioninfo.d.ts.map +1 -1
  52. package/dist/devtools/devtools-sessioninfo.js +12 -6
  53. package/dist/devtools/devtools-sessioninfo.js.map +1 -1
  54. package/dist/devtools/mod.d.ts +17 -7
  55. package/dist/devtools/mod.d.ts.map +1 -1
  56. package/dist/devtools/mod.js +19 -8
  57. package/dist/devtools/mod.js.map +1 -1
  58. package/dist/errors.d.ts +52 -46
  59. package/dist/errors.d.ts.map +1 -1
  60. package/dist/errors.js +31 -21
  61. package/dist/errors.js.map +1 -1
  62. package/dist/index.d.ts +7 -1
  63. package/dist/index.d.ts.map +1 -1
  64. package/dist/index.js +7 -1
  65. package/dist/index.js.map +1 -1
  66. package/dist/leader-thread/LeaderSyncProcessor.d.ts +116 -21
  67. package/dist/leader-thread/LeaderSyncProcessor.d.ts.map +1 -1
  68. package/dist/leader-thread/LeaderSyncProcessor.js +596 -470
  69. package/dist/leader-thread/LeaderSyncProcessor.js.map +1 -1
  70. package/dist/leader-thread/RejectedPushError.d.ts +138 -0
  71. package/dist/leader-thread/RejectedPushError.d.ts.map +1 -0
  72. package/dist/leader-thread/RejectedPushError.js +97 -0
  73. package/dist/leader-thread/RejectedPushError.js.map +1 -0
  74. package/dist/leader-thread/connection.d.ts +1 -0
  75. package/dist/leader-thread/connection.d.ts.map +1 -1
  76. package/dist/leader-thread/connection.js +10 -9
  77. package/dist/leader-thread/connection.js.map +1 -1
  78. package/dist/leader-thread/eventlog.d.ts +20 -15
  79. package/dist/leader-thread/eventlog.d.ts.map +1 -1
  80. package/dist/leader-thread/eventlog.js +80 -20
  81. package/dist/leader-thread/eventlog.js.map +1 -1
  82. package/dist/leader-thread/leader-worker-devtools.d.ts +1 -2
  83. package/dist/leader-thread/leader-worker-devtools.d.ts.map +1 -1
  84. package/dist/leader-thread/leader-worker-devtools.js +110 -62
  85. package/dist/leader-thread/leader-worker-devtools.js.map +1 -1
  86. package/dist/leader-thread/make-leader-thread-layer.d.ts +19 -7
  87. package/dist/leader-thread/make-leader-thread-layer.d.ts.map +1 -1
  88. package/dist/leader-thread/make-leader-thread-layer.js +67 -45
  89. package/dist/leader-thread/make-leader-thread-layer.js.map +1 -1
  90. package/dist/leader-thread/make-leader-thread-layer.test.d.ts +2 -0
  91. package/dist/leader-thread/make-leader-thread-layer.test.d.ts.map +1 -0
  92. package/dist/leader-thread/make-leader-thread-layer.test.js +32 -0
  93. package/dist/leader-thread/make-leader-thread-layer.test.js.map +1 -0
  94. package/dist/leader-thread/materialize-event.d.ts +3 -2
  95. package/dist/leader-thread/materialize-event.d.ts.map +1 -1
  96. package/dist/leader-thread/materialize-event.js +32 -9
  97. package/dist/leader-thread/materialize-event.js.map +1 -1
  98. package/dist/leader-thread/mod.d.ts +1 -0
  99. package/dist/leader-thread/mod.d.ts.map +1 -1
  100. package/dist/leader-thread/mod.js +1 -0
  101. package/dist/leader-thread/mod.js.map +1 -1
  102. package/dist/leader-thread/recreate-db.d.ts +2 -2
  103. package/dist/leader-thread/recreate-db.d.ts.map +1 -1
  104. package/dist/leader-thread/recreate-db.js +19 -40
  105. package/dist/leader-thread/recreate-db.js.map +1 -1
  106. package/dist/leader-thread/shutdown-channel.d.ts +3 -7
  107. package/dist/leader-thread/shutdown-channel.d.ts.map +1 -1
  108. package/dist/leader-thread/shutdown-channel.js +2 -3
  109. package/dist/leader-thread/shutdown-channel.js.map +1 -1
  110. package/dist/leader-thread/stream-events.d.ts +53 -0
  111. package/dist/leader-thread/stream-events.d.ts.map +1 -0
  112. package/dist/leader-thread/stream-events.js +164 -0
  113. package/dist/leader-thread/stream-events.js.map +1 -0
  114. package/dist/leader-thread/types.d.ts +92 -51
  115. package/dist/leader-thread/types.d.ts.map +1 -1
  116. package/dist/leader-thread/types.js +17 -4
  117. package/dist/leader-thread/types.js.map +1 -1
  118. package/dist/logging.d.ts +15 -0
  119. package/dist/logging.d.ts.map +1 -0
  120. package/dist/logging.js +2 -0
  121. package/dist/logging.js.map +1 -0
  122. package/dist/make-client-session.d.ts +6 -5
  123. package/dist/make-client-session.d.ts.map +1 -1
  124. package/dist/make-client-session.js +10 -7
  125. package/dist/make-client-session.js.map +1 -1
  126. package/dist/materializer-helper.d.ts +6 -7
  127. package/dist/materializer-helper.d.ts.map +1 -1
  128. package/dist/materializer-helper.js +22 -40
  129. package/dist/materializer-helper.js.map +1 -1
  130. package/dist/otel.d.ts +4 -4
  131. package/dist/otel.d.ts.map +1 -1
  132. package/dist/otel.js +8 -8
  133. package/dist/otel.js.map +1 -1
  134. package/dist/rematerialize-from-eventlog.d.ts +3 -3
  135. package/dist/rematerialize-from-eventlog.d.ts.map +1 -1
  136. package/dist/rematerialize-from-eventlog.js +52 -39
  137. package/dist/rematerialize-from-eventlog.js.map +1 -1
  138. package/dist/schema/EventDef/define.d.ts +161 -0
  139. package/dist/schema/EventDef/define.d.ts.map +1 -0
  140. package/dist/schema/EventDef/define.js +140 -0
  141. package/dist/schema/EventDef/define.js.map +1 -0
  142. package/dist/schema/EventDef/define.test.d.ts +2 -0
  143. package/dist/schema/EventDef/define.test.d.ts.map +1 -0
  144. package/dist/schema/EventDef/define.test.js +23 -0
  145. package/dist/schema/EventDef/define.test.js.map +1 -0
  146. package/dist/schema/EventDef/deprecated.d.ts +94 -0
  147. package/dist/schema/EventDef/deprecated.d.ts.map +1 -0
  148. package/dist/schema/EventDef/deprecated.js +143 -0
  149. package/dist/schema/EventDef/deprecated.js.map +1 -0
  150. package/dist/schema/EventDef/deprecated.test.d.ts +2 -0
  151. package/dist/schema/EventDef/deprecated.test.d.ts.map +1 -0
  152. package/dist/schema/EventDef/deprecated.test.js +95 -0
  153. package/dist/schema/EventDef/deprecated.test.js.map +1 -0
  154. package/dist/schema/EventDef/event-def.d.ts +110 -0
  155. package/dist/schema/EventDef/event-def.d.ts.map +1 -0
  156. package/dist/schema/EventDef/event-def.js +2 -0
  157. package/dist/schema/EventDef/event-def.js.map +1 -0
  158. package/dist/schema/EventDef/facts.d.ts +118 -0
  159. package/dist/schema/EventDef/facts.d.ts.map +1 -0
  160. package/dist/schema/EventDef/facts.js +53 -0
  161. package/dist/schema/EventDef/facts.js.map +1 -0
  162. package/dist/schema/EventDef/materializer.d.ts +155 -0
  163. package/dist/schema/EventDef/materializer.d.ts.map +1 -0
  164. package/dist/schema/EventDef/materializer.js +83 -0
  165. package/dist/schema/EventDef/materializer.js.map +1 -0
  166. package/dist/schema/EventDef/mod.d.ts +6 -0
  167. package/dist/schema/EventDef/mod.d.ts.map +1 -0
  168. package/dist/schema/EventDef/mod.js +6 -0
  169. package/dist/schema/EventDef/mod.js.map +1 -0
  170. package/dist/schema/EventSequenceNumber/client.d.ts +136 -0
  171. package/dist/schema/EventSequenceNumber/client.d.ts.map +1 -0
  172. package/dist/schema/EventSequenceNumber/client.js +192 -0
  173. package/dist/schema/EventSequenceNumber/client.js.map +1 -0
  174. package/dist/schema/EventSequenceNumber/global.d.ts +15 -0
  175. package/dist/schema/EventSequenceNumber/global.d.ts.map +1 -0
  176. package/dist/schema/EventSequenceNumber/global.js +14 -0
  177. package/dist/schema/EventSequenceNumber/global.js.map +1 -0
  178. package/dist/schema/EventSequenceNumber/mod.d.ts +37 -0
  179. package/dist/schema/EventSequenceNumber/mod.d.ts.map +1 -0
  180. package/dist/schema/EventSequenceNumber/mod.js +37 -0
  181. package/dist/schema/EventSequenceNumber/mod.js.map +1 -0
  182. package/dist/schema/EventSequenceNumber.test.js +44 -44
  183. package/dist/schema/EventSequenceNumber.test.js.map +1 -1
  184. package/dist/schema/LiveStoreEvent/client.d.ts +168 -0
  185. package/dist/schema/LiveStoreEvent/client.d.ts.map +1 -0
  186. package/dist/schema/LiveStoreEvent/client.js +179 -0
  187. package/dist/schema/LiveStoreEvent/client.js.map +1 -0
  188. package/dist/schema/LiveStoreEvent/client.test.d.ts +2 -0
  189. package/dist/schema/LiveStoreEvent/client.test.d.ts.map +1 -0
  190. package/dist/schema/LiveStoreEvent/client.test.js +120 -0
  191. package/dist/schema/LiveStoreEvent/client.test.js.map +1 -0
  192. package/dist/schema/LiveStoreEvent/for-event-def.d.ts +52 -0
  193. package/dist/schema/LiveStoreEvent/for-event-def.d.ts.map +1 -0
  194. package/dist/schema/LiveStoreEvent/for-event-def.js +2 -0
  195. package/dist/schema/LiveStoreEvent/for-event-def.js.map +1 -0
  196. package/dist/schema/LiveStoreEvent/global.d.ts +36 -0
  197. package/dist/schema/LiveStoreEvent/global.d.ts.map +1 -0
  198. package/dist/schema/LiveStoreEvent/global.js +31 -0
  199. package/dist/schema/LiveStoreEvent/global.js.map +1 -0
  200. package/dist/schema/LiveStoreEvent/input.d.ts +46 -0
  201. package/dist/schema/LiveStoreEvent/input.d.ts.map +1 -0
  202. package/dist/schema/LiveStoreEvent/input.js +26 -0
  203. package/dist/schema/LiveStoreEvent/input.js.map +1 -0
  204. package/dist/schema/LiveStoreEvent/mod.d.ts +5 -0
  205. package/dist/schema/LiveStoreEvent/mod.d.ts.map +1 -0
  206. package/dist/schema/LiveStoreEvent/mod.js +5 -0
  207. package/dist/schema/LiveStoreEvent/mod.js.map +1 -0
  208. package/dist/schema/events.d.ts +1 -1
  209. package/dist/schema/events.d.ts.map +1 -1
  210. package/dist/schema/events.js +1 -1
  211. package/dist/schema/events.js.map +1 -1
  212. package/dist/schema/mod.d.ts +7 -4
  213. package/dist/schema/mod.d.ts.map +1 -1
  214. package/dist/schema/mod.js +6 -4
  215. package/dist/schema/mod.js.map +1 -1
  216. package/dist/schema/schema.d.ts +16 -1
  217. package/dist/schema/schema.d.ts.map +1 -1
  218. package/dist/schema/schema.js +34 -5
  219. package/dist/schema/schema.js.map +1 -1
  220. package/dist/schema/state/sqlite/client-document-def.d.ts +14 -18
  221. package/dist/schema/state/sqlite/client-document-def.d.ts.map +1 -1
  222. package/dist/schema/state/sqlite/client-document-def.js +54 -30
  223. package/dist/schema/state/sqlite/client-document-def.js.map +1 -1
  224. package/dist/schema/state/sqlite/client-document-def.test.js +146 -6
  225. package/dist/schema/state/sqlite/client-document-def.test.js.map +1 -1
  226. package/dist/schema/state/sqlite/column-annotations.d.ts +13 -13
  227. package/dist/schema/state/sqlite/column-annotations.d.ts.map +1 -1
  228. package/dist/schema/state/sqlite/column-annotations.js +13 -15
  229. package/dist/schema/state/sqlite/column-annotations.js.map +1 -1
  230. package/dist/schema/state/sqlite/column-annotations.test.js +19 -43
  231. package/dist/schema/state/sqlite/column-annotations.test.js.map +1 -1
  232. package/dist/schema/state/sqlite/column-def.d.ts +1 -1
  233. package/dist/schema/state/sqlite/column-def.d.ts.map +1 -1
  234. package/dist/schema/state/sqlite/column-def.js +132 -57
  235. package/dist/schema/state/sqlite/column-def.js.map +1 -1
  236. package/dist/schema/state/sqlite/column-def.test.js +155 -70
  237. package/dist/schema/state/sqlite/column-def.test.js.map +1 -1
  238. package/dist/schema/state/sqlite/column-spec.d.ts.map +1 -1
  239. package/dist/schema/state/sqlite/column-spec.js +30 -12
  240. package/dist/schema/state/sqlite/column-spec.js.map +1 -1
  241. package/dist/schema/state/sqlite/column-spec.test.js +26 -17
  242. package/dist/schema/state/sqlite/column-spec.test.js.map +1 -1
  243. package/dist/schema/state/sqlite/db-schema/ast/sqlite.d.ts +2 -2
  244. package/dist/schema/state/sqlite/db-schema/ast/sqlite.d.ts.map +1 -1
  245. package/dist/schema/state/sqlite/db-schema/ast/sqlite.js +15 -6
  246. package/dist/schema/state/sqlite/db-schema/ast/sqlite.js.map +1 -1
  247. package/dist/schema/state/sqlite/db-schema/dsl/field-defs.d.ts +25 -19
  248. package/dist/schema/state/sqlite/db-schema/dsl/field-defs.d.ts.map +1 -1
  249. package/dist/schema/state/sqlite/db-schema/dsl/field-defs.js +22 -16
  250. package/dist/schema/state/sqlite/db-schema/dsl/field-defs.js.map +1 -1
  251. package/dist/schema/state/sqlite/db-schema/dsl/field-defs.test.js +28 -17
  252. package/dist/schema/state/sqlite/db-schema/dsl/field-defs.test.js.map +1 -1
  253. package/dist/schema/state/sqlite/db-schema/dsl/mod.d.ts +14 -12
  254. package/dist/schema/state/sqlite/db-schema/dsl/mod.d.ts.map +1 -1
  255. package/dist/schema/state/sqlite/db-schema/dsl/mod.js +6 -4
  256. package/dist/schema/state/sqlite/db-schema/dsl/mod.js.map +1 -1
  257. package/dist/schema/state/sqlite/mod.d.ts +3 -5
  258. package/dist/schema/state/sqlite/mod.d.ts.map +1 -1
  259. package/dist/schema/state/sqlite/mod.js +6 -8
  260. package/dist/schema/state/sqlite/mod.js.map +1 -1
  261. package/dist/schema/state/sqlite/persistence.d.ts +11 -0
  262. package/dist/schema/state/sqlite/persistence.d.ts.map +1 -0
  263. package/dist/schema/state/sqlite/persistence.js +10 -0
  264. package/dist/schema/state/sqlite/persistence.js.map +1 -0
  265. package/dist/schema/state/sqlite/query-builder/api.d.ts +58 -29
  266. package/dist/schema/state/sqlite/query-builder/api.d.ts.map +1 -1
  267. package/dist/schema/state/sqlite/query-builder/astToSql.d.ts.map +1 -1
  268. package/dist/schema/state/sqlite/query-builder/astToSql.js +105 -24
  269. package/dist/schema/state/sqlite/query-builder/astToSql.js.map +1 -1
  270. package/dist/schema/state/sqlite/query-builder/impl.d.ts +4 -2
  271. package/dist/schema/state/sqlite/query-builder/impl.d.ts.map +1 -1
  272. package/dist/schema/state/sqlite/query-builder/impl.js +42 -27
  273. package/dist/schema/state/sqlite/query-builder/impl.js.map +1 -1
  274. package/dist/schema/state/sqlite/query-builder/impl.test.js +402 -498
  275. package/dist/schema/state/sqlite/query-builder/impl.test.js.map +1 -1
  276. package/dist/schema/state/sqlite/schema-helpers.d.ts +2 -2
  277. package/dist/schema/state/sqlite/schema-helpers.d.ts.map +1 -1
  278. package/dist/schema/state/sqlite/schema-helpers.js +24 -14
  279. package/dist/schema/state/sqlite/schema-helpers.js.map +1 -1
  280. package/dist/schema/state/sqlite/schema-helpers.test.d.ts +2 -0
  281. package/dist/schema/state/sqlite/schema-helpers.test.d.ts.map +1 -0
  282. package/dist/schema/state/sqlite/schema-helpers.test.js +36 -0
  283. package/dist/schema/state/sqlite/schema-helpers.test.js.map +1 -0
  284. package/dist/schema/state/sqlite/system-tables/eventlog-tables.d.ts +515 -0
  285. package/dist/schema/state/sqlite/system-tables/eventlog-tables.d.ts.map +1 -0
  286. package/dist/schema/state/sqlite/system-tables/eventlog-tables.js +54 -0
  287. package/dist/schema/state/sqlite/system-tables/eventlog-tables.js.map +1 -0
  288. package/dist/schema/state/sqlite/system-tables/mod.d.ts +3 -0
  289. package/dist/schema/state/sqlite/system-tables/mod.d.ts.map +1 -0
  290. package/dist/schema/state/sqlite/system-tables/mod.js +3 -0
  291. package/dist/schema/state/sqlite/system-tables/mod.js.map +1 -0
  292. package/dist/schema/state/sqlite/system-tables/state-tables.d.ts +678 -0
  293. package/dist/schema/state/sqlite/system-tables/state-tables.d.ts.map +1 -0
  294. package/dist/schema/state/sqlite/system-tables/state-tables.js +87 -0
  295. package/dist/schema/state/sqlite/system-tables/state-tables.js.map +1 -0
  296. package/dist/schema/state/sqlite/table-def.d.ts +19 -20
  297. package/dist/schema/state/sqlite/table-def.d.ts.map +1 -1
  298. package/dist/schema/state/sqlite/table-def.js +54 -4
  299. package/dist/schema/state/sqlite/table-def.js.map +1 -1
  300. package/dist/schema/state/sqlite/table-def.test.js +226 -20
  301. package/dist/schema/state/sqlite/table-def.test.js.map +1 -1
  302. package/dist/schema/unknown-events.d.ts +47 -0
  303. package/dist/schema/unknown-events.d.ts.map +1 -0
  304. package/dist/schema/unknown-events.js +70 -0
  305. package/dist/schema/unknown-events.js.map +1 -0
  306. package/dist/schema-management/__tests__/migrations-autoincrement-quoting.test.d.ts +2 -0
  307. package/dist/schema-management/__tests__/migrations-autoincrement-quoting.test.d.ts.map +1 -0
  308. package/dist/schema-management/__tests__/migrations-autoincrement-quoting.test.js +73 -0
  309. package/dist/schema-management/__tests__/migrations-autoincrement-quoting.test.js.map +1 -0
  310. package/dist/schema-management/common.js +2 -2
  311. package/dist/schema-management/common.js.map +1 -1
  312. package/dist/schema-management/migrations.d.ts +32 -2
  313. package/dist/schema-management/migrations.d.ts.map +1 -1
  314. package/dist/schema-management/migrations.js +38 -6
  315. package/dist/schema-management/migrations.js.map +1 -1
  316. package/dist/schema-management/validate-schema.d.ts +3 -3
  317. package/dist/schema-management/validate-schema.d.ts.map +1 -1
  318. package/dist/schema-management/validate-schema.js +2 -2
  319. package/dist/schema-management/validate-schema.js.map +1 -1
  320. package/dist/session-id-symbol.d.ts +29 -0
  321. package/dist/session-id-symbol.d.ts.map +1 -0
  322. package/dist/session-id-symbol.js +44 -0
  323. package/dist/session-id-symbol.js.map +1 -0
  324. package/dist/sql-queries/sql-queries.d.ts.map +1 -1
  325. package/dist/sql-queries/sql-queries.js +26 -16
  326. package/dist/sql-queries/sql-queries.js.map +1 -1
  327. package/dist/sql-queries/sql-query-builder.d.ts.map +1 -1
  328. package/dist/sql-queries/sql-query-builder.js.map +1 -1
  329. package/dist/sql-queries/types.d.ts +6 -7
  330. package/dist/sql-queries/types.d.ts.map +1 -1
  331. package/dist/sql-queries/types.js.map +1 -1
  332. package/dist/sqlite-db-helper.d.ts +21 -1
  333. package/dist/sqlite-db-helper.d.ts.map +1 -1
  334. package/dist/sqlite-db-helper.js +40 -5
  335. package/dist/sqlite-db-helper.js.map +1 -1
  336. package/dist/sqlite-types.d.ts +8 -11
  337. package/dist/sqlite-types.d.ts.map +1 -1
  338. package/dist/sqlite-types.js +3 -3
  339. package/dist/sqlite-types.js.map +1 -1
  340. package/dist/sync/ClientSessionSyncProcessor.d.ts +47 -25
  341. package/dist/sync/ClientSessionSyncProcessor.d.ts.map +1 -1
  342. package/dist/sync/ClientSessionSyncProcessor.js +269 -154
  343. package/dist/sync/ClientSessionSyncProcessor.js.map +1 -1
  344. package/dist/sync/errors.d.ts +12 -51
  345. package/dist/sync/errors.d.ts.map +1 -1
  346. package/dist/sync/errors.js +7 -24
  347. package/dist/sync/errors.js.map +1 -1
  348. package/dist/sync/index.d.ts +2 -0
  349. package/dist/sync/index.d.ts.map +1 -1
  350. package/dist/sync/index.js +2 -0
  351. package/dist/sync/index.js.map +1 -1
  352. package/dist/sync/mock-sync-backend.d.ts +11 -10
  353. package/dist/sync/mock-sync-backend.d.ts.map +1 -1
  354. package/dist/sync/mock-sync-backend.js +97 -74
  355. package/dist/sync/mock-sync-backend.js.map +1 -1
  356. package/dist/sync/mock-sync-backend.test.d.ts +2 -0
  357. package/dist/sync/mock-sync-backend.test.d.ts.map +1 -0
  358. package/dist/sync/mock-sync-backend.test.js +49 -0
  359. package/dist/sync/mock-sync-backend.test.js.map +1 -0
  360. package/dist/sync/next/compact-events.d.ts.map +1 -1
  361. package/dist/sync/next/compact-events.js +11 -12
  362. package/dist/sync/next/compact-events.js.map +1 -1
  363. package/dist/sync/next/facts.d.ts +5 -5
  364. package/dist/sync/next/facts.d.ts.map +1 -1
  365. package/dist/sync/next/facts.js +7 -8
  366. package/dist/sync/next/facts.js.map +1 -1
  367. package/dist/sync/next/history-dag-common.d.ts +58 -15
  368. package/dist/sync/next/history-dag-common.d.ts.map +1 -1
  369. package/dist/sync/next/history-dag-common.js +219 -9
  370. package/dist/sync/next/history-dag-common.js.map +1 -1
  371. package/dist/sync/next/history-dag.d.ts.map +1 -1
  372. package/dist/sync/next/history-dag.js +12 -11
  373. package/dist/sync/next/history-dag.js.map +1 -1
  374. package/dist/sync/next/rebase-events.d.ts +5 -5
  375. package/dist/sync/next/rebase-events.d.ts.map +1 -1
  376. package/dist/sync/next/rebase-events.js +6 -6
  377. package/dist/sync/next/rebase-events.js.map +1 -1
  378. package/dist/sync/next/test/compact-events.calculator.test.js +4 -4
  379. package/dist/sync/next/test/compact-events.calculator.test.js.map +1 -1
  380. package/dist/sync/next/test/compact-events.test.d.ts.map +1 -1
  381. package/dist/sync/next/test/compact-events.test.js +2 -2
  382. package/dist/sync/next/test/compact-events.test.js.map +1 -1
  383. package/dist/sync/next/test/event-fixtures.d.ts +9 -9
  384. package/dist/sync/next/test/event-fixtures.d.ts.map +1 -1
  385. package/dist/sync/next/test/event-fixtures.js +12 -12
  386. package/dist/sync/next/test/event-fixtures.js.map +1 -1
  387. package/dist/sync/next/test/history-dag-common.test.d.ts +2 -0
  388. package/dist/sync/next/test/history-dag-common.test.d.ts.map +1 -0
  389. package/dist/sync/next/test/history-dag-common.test.js +73 -0
  390. package/dist/sync/next/test/history-dag-common.test.js.map +1 -0
  391. package/dist/sync/sync-backend-kv.d.ts +3 -3
  392. package/dist/sync/sync-backend-kv.d.ts.map +1 -1
  393. package/dist/sync/sync-backend-kv.js +6 -4
  394. package/dist/sync/sync-backend-kv.js.map +1 -1
  395. package/dist/sync/sync-backend.d.ts +41 -21
  396. package/dist/sync/sync-backend.d.ts.map +1 -1
  397. package/dist/sync/sync-backend.js +45 -4
  398. package/dist/sync/sync-backend.js.map +1 -1
  399. package/dist/sync/sync.d.ts +23 -2
  400. package/dist/sync/sync.d.ts.map +1 -1
  401. package/dist/sync/syncstate.d.ts +115 -191
  402. package/dist/sync/syncstate.d.ts.map +1 -1
  403. package/dist/sync/syncstate.js +108 -129
  404. package/dist/sync/syncstate.js.map +1 -1
  405. package/dist/sync/syncstate.test.js +289 -161
  406. package/dist/sync/syncstate.test.js.map +1 -1
  407. package/dist/sync/transport-chunking.d.ts +35 -0
  408. package/dist/sync/transport-chunking.d.ts.map +1 -0
  409. package/dist/sync/transport-chunking.js +47 -0
  410. package/dist/sync/transport-chunking.js.map +1 -0
  411. package/dist/sync/validate-push-payload.d.ts +2 -2
  412. package/dist/sync/validate-push-payload.d.ts.map +1 -1
  413. package/dist/sync/validate-push-payload.js +4 -6
  414. package/dist/sync/validate-push-payload.js.map +1 -1
  415. package/dist/testing/event-factory.d.ts +3 -3
  416. package/dist/testing/event-factory.d.ts.map +1 -1
  417. package/dist/testing/event-factory.js +5 -7
  418. package/dist/testing/event-factory.js.map +1 -1
  419. package/dist/util.d.ts +11 -4
  420. package/dist/util.d.ts.map +1 -1
  421. package/dist/util.js +21 -4
  422. package/dist/util.js.map +1 -1
  423. package/dist/version.d.ts +24 -5
  424. package/dist/version.d.ts.map +1 -1
  425. package/dist/version.js +25 -8
  426. package/dist/version.js.map +1 -1
  427. package/package.json +50 -16
  428. package/src/ClientSessionLeaderThreadProxy.ts +27 -12
  429. package/src/MaterializationJournal.ts +184 -0
  430. package/src/StateHead.ts +80 -0
  431. package/src/WorkerTransportError.ts +12 -0
  432. package/src/__tests__/fixture.ts +3 -3
  433. package/src/adapter-types.ts +62 -56
  434. package/src/bounded-collections.ts +6 -5
  435. package/src/debug-info.ts +54 -43
  436. package/src/defs.ts +2 -2
  437. package/src/devtools/devtools-compatibility.test.ts +19 -0
  438. package/src/devtools/devtools-messages-client-session.ts +72 -63
  439. package/src/devtools/devtools-messages-common.ts +18 -22
  440. package/src/devtools/devtools-messages-leader.ts +89 -68
  441. package/src/devtools/devtools-sessioninfo.ts +17 -12
  442. package/src/devtools/mod.ts +15 -7
  443. package/src/errors.ts +58 -38
  444. package/src/index.ts +11 -1
  445. package/src/leader-thread/LeaderSyncProcessor.ts +927 -674
  446. package/src/leader-thread/RejectedPushError.ts +119 -0
  447. package/src/leader-thread/connection.ts +13 -9
  448. package/src/leader-thread/eventlog.ts +114 -41
  449. package/src/leader-thread/leader-worker-devtools.ts +223 -124
  450. package/src/leader-thread/make-leader-thread-layer.test.ts +44 -0
  451. package/src/leader-thread/make-leader-thread-layer.ts +170 -82
  452. package/src/leader-thread/materialize-event.ts +51 -16
  453. package/src/leader-thread/mod.ts +1 -0
  454. package/src/leader-thread/recreate-db.ts +26 -61
  455. package/src/leader-thread/shutdown-channel.ts +3 -18
  456. package/src/leader-thread/stream-events.ts +209 -0
  457. package/src/leader-thread/types.ts +77 -65
  458. package/src/logging.ts +15 -0
  459. package/src/make-client-session.ts +16 -10
  460. package/src/materializer-helper.ts +31 -49
  461. package/src/otel.ts +15 -13
  462. package/src/rematerialize-from-eventlog.ts +106 -93
  463. package/src/schema/EventDef/define.test.ts +28 -0
  464. package/src/schema/EventDef/define.ts +218 -0
  465. package/src/schema/EventDef/deprecated.test.ts +122 -0
  466. package/src/schema/EventDef/deprecated.ts +172 -0
  467. package/src/schema/EventDef/event-def.ts +123 -0
  468. package/src/schema/EventDef/facts.ts +135 -0
  469. package/src/schema/EventDef/materializer.ts +172 -0
  470. package/src/schema/EventDef/mod.ts +5 -0
  471. package/src/schema/EventSequenceNumber/client.ts +256 -0
  472. package/src/schema/EventSequenceNumber/global.ts +19 -0
  473. package/src/schema/EventSequenceNumber/mod.ts +37 -0
  474. package/src/schema/EventSequenceNumber.test.ts +72 -53
  475. package/src/schema/LiveStoreEvent/client.test.ts +142 -0
  476. package/src/schema/LiveStoreEvent/client.ts +236 -0
  477. package/src/schema/LiveStoreEvent/for-event-def.ts +60 -0
  478. package/src/schema/LiveStoreEvent/global.ts +45 -0
  479. package/src/schema/LiveStoreEvent/input.ts +63 -0
  480. package/src/schema/LiveStoreEvent/mod.ts +4 -0
  481. package/src/schema/events.ts +1 -1
  482. package/src/schema/mod.ts +7 -4
  483. package/src/schema/schema.ts +48 -6
  484. package/src/schema/state/sqlite/client-document-def.test.ts +172 -14
  485. package/src/schema/state/sqlite/client-document-def.ts +126 -98
  486. package/src/schema/state/sqlite/column-annotations.test.ts +27 -45
  487. package/src/schema/state/sqlite/column-annotations.ts +27 -30
  488. package/src/schema/state/sqlite/column-def.test.ts +186 -77
  489. package/src/schema/state/sqlite/column-def.ts +169 -62
  490. package/src/schema/state/sqlite/column-spec.test.ts +34 -19
  491. package/src/schema/state/sqlite/column-spec.ts +38 -11
  492. package/src/schema/state/sqlite/db-schema/ast/sqlite.ts +17 -7
  493. package/src/schema/state/sqlite/db-schema/dsl/__snapshots__/field-defs.test.ts.snap +123 -63
  494. package/src/schema/state/sqlite/db-schema/dsl/field-defs.test.ts +37 -17
  495. package/src/schema/state/sqlite/db-schema/dsl/field-defs.ts +65 -45
  496. package/src/schema/state/sqlite/db-schema/dsl/mod.ts +48 -40
  497. package/src/schema/state/sqlite/mod.ts +9 -12
  498. package/src/schema/state/sqlite/persistence.ts +11 -0
  499. package/src/schema/state/sqlite/query-builder/api.ts +61 -23
  500. package/src/schema/state/sqlite/query-builder/astToSql.ts +117 -23
  501. package/src/schema/state/sqlite/query-builder/impl.test.ts +489 -551
  502. package/src/schema/state/sqlite/query-builder/impl.ts +43 -26
  503. package/src/schema/state/sqlite/schema-helpers.test.ts +44 -0
  504. package/src/schema/state/sqlite/schema-helpers.ts +30 -22
  505. package/src/schema/state/sqlite/system-tables/eventlog-tables.ts +64 -0
  506. package/src/schema/state/sqlite/system-tables/mod.ts +2 -0
  507. package/src/schema/state/sqlite/system-tables/state-tables.ts +109 -0
  508. package/src/schema/state/sqlite/table-def.test.ts +295 -46
  509. package/src/schema/state/sqlite/table-def.ts +109 -64
  510. package/src/schema/unknown-events.ts +132 -0
  511. package/src/schema-management/__tests__/migrations-autoincrement-quoting.test.ts +88 -0
  512. package/src/schema-management/common.ts +2 -2
  513. package/src/schema-management/migrations.ts +42 -9
  514. package/src/schema-management/validate-schema.ts +3 -3
  515. package/src/session-id-symbol.ts +59 -0
  516. package/src/sql-queries/sql-queries.ts +26 -18
  517. package/src/sql-queries/sql-query-builder.ts +1 -0
  518. package/src/sql-queries/types.ts +6 -7
  519. package/src/sqlite-db-helper.ts +67 -5
  520. package/src/sqlite-types.ts +9 -11
  521. package/src/sync/ClientSessionSyncProcessor.ts +388 -194
  522. package/src/sync/errors.ts +18 -28
  523. package/src/sync/index.ts +2 -0
  524. package/src/sync/mock-sync-backend.test.ts +78 -0
  525. package/src/sync/mock-sync-backend.ts +196 -108
  526. package/src/sync/next/compact-events.ts +10 -11
  527. package/src/sync/next/facts.ts +13 -14
  528. package/src/sync/next/history-dag-common.ts +306 -26
  529. package/src/sync/next/history-dag.ts +20 -13
  530. package/src/sync/next/rebase-events.ts +12 -12
  531. package/src/sync/next/test/compact-events.calculator.test.ts +5 -4
  532. package/src/sync/next/test/compact-events.test.ts +4 -3
  533. package/src/sync/next/test/event-fixtures.ts +14 -14
  534. package/src/sync/next/test/history-dag-common.test.ts +87 -0
  535. package/src/sync/sync-backend-kv.ts +7 -4
  536. package/src/sync/sync-backend.ts +75 -26
  537. package/src/sync/sync.ts +24 -2
  538. package/src/sync/syncstate.test.ts +657 -442
  539. package/src/sync/syncstate.ts +175 -160
  540. package/src/sync/transport-chunking.ts +85 -0
  541. package/src/sync/validate-push-payload.ts +6 -8
  542. package/src/testing/event-factory.ts +10 -12
  543. package/src/util.ts +20 -11
  544. package/src/version.ts +32 -8
  545. package/dist/schema/EventDef.d.ts +0 -126
  546. package/dist/schema/EventDef.d.ts.map +0 -1
  547. package/dist/schema/EventDef.js +0 -46
  548. package/dist/schema/EventDef.js.map +0 -1
  549. package/dist/schema/EventSequenceNumber.d.ts +0 -80
  550. package/dist/schema/EventSequenceNumber.d.ts.map +0 -1
  551. package/dist/schema/EventSequenceNumber.js +0 -139
  552. package/dist/schema/EventSequenceNumber.js.map +0 -1
  553. package/dist/schema/LiveStoreEvent.d.ts +0 -306
  554. package/dist/schema/LiveStoreEvent.d.ts.map +0 -1
  555. package/dist/schema/LiveStoreEvent.js +0 -147
  556. package/dist/schema/LiveStoreEvent.js.map +0 -1
  557. package/dist/schema/state/sqlite/system-tables.d.ts +0 -976
  558. package/dist/schema/state/sqlite/system-tables.d.ts.map +0 -1
  559. package/dist/schema/state/sqlite/system-tables.js +0 -81
  560. package/dist/schema/state/sqlite/system-tables.js.map +0 -1
  561. package/dist/sql-queries/misc.d.ts +0 -2
  562. package/dist/sql-queries/misc.d.ts.map +0 -1
  563. package/dist/sql-queries/misc.js +0 -2
  564. package/dist/sql-queries/misc.js.map +0 -1
  565. package/dist/sync/next/graphology.d.ts +0 -8
  566. package/dist/sync/next/graphology.d.ts.map +0 -1
  567. package/dist/sync/next/graphology.js +0 -30
  568. package/dist/sync/next/graphology.js.map +0 -1
  569. package/dist/sync/next/graphology_.d.ts +0 -3
  570. package/dist/sync/next/graphology_.d.ts.map +0 -1
  571. package/dist/sync/next/graphology_.js +0 -3
  572. package/dist/sync/next/graphology_.js.map +0 -1
  573. package/src/schema/EventDef.ts +0 -222
  574. package/src/schema/EventSequenceNumber.ts +0 -199
  575. package/src/schema/LiveStoreEvent.ts +0 -286
  576. package/src/schema/state/sqlite/system-tables.ts +0 -106
  577. package/src/sql-queries/misc.ts +0 -2
  578. package/src/sync/next/ambient.d.ts +0 -3
  579. package/src/sync/next/graphology.ts +0 -41
  580. package/src/sync/next/graphology_.ts +0 -2
@@ -1,8 +1,11 @@
1
- import { casesHandled, isNotUndefined, LS_DEV, shouldNeverHappen, TRACE_VERBOSE } from '@livestore/utils'
2
- import type { HttpClient, Runtime, Scope, Tracer } from '@livestore/utils/effect'
1
+ import { casesHandled, LS_DEV, TRACE_VERBOSE } from '@livestore/utils'
3
2
  import {
4
- BucketQueue,
3
+ type HttpClient,
4
+ type Latch,
5
+ type Scope,
6
+ type Tracer,
5
7
  Cause,
8
+ Context,
6
9
  Deferred,
7
10
  Duration,
8
11
  Effect,
@@ -10,43 +13,47 @@ import {
10
13
  FiberHandle,
11
14
  Layer,
12
15
  Option,
13
- OtelTracer,
14
- pipe,
16
+ Predicate,
15
17
  Queue,
16
18
  ReadonlyArray,
19
+ References,
20
+ Result,
17
21
  Schedule,
22
+ Schema,
23
+ Semaphore,
18
24
  Stream,
19
25
  Subscribable,
20
26
  SubscriptionRef,
27
+ TxQueue,
21
28
  } from '@livestore/utils/effect'
22
- import type * as otel from '@opentelemetry/api'
23
- import {
24
- type IntentionalShutdownCause,
25
- type MaterializeError,
26
- type SqliteDb,
27
- UnexpectedError,
28
- } from '../adapter-types.ts'
29
+
30
+ import { MaterializeError, type SqliteDb, UnknownError } from '../adapter-types.ts'
31
+ import type { UnknownEventError } from '../errors.ts'
32
+ import { IntentionalShutdownCause } from '../errors.ts'
29
33
  import { makeMaterializerHash } from '../materializer-helper.ts'
30
34
  import type { LiveStoreSchema } from '../schema/mod.ts'
31
- import { EventSequenceNumber, getEventDef, LiveStoreEvent, 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'
35
+ import { EventSequenceNumber, LiveStoreEvent, resolveEventDef, SystemTables } from '../schema/mod.ts'
36
+ import { EVENTLOG_META_TABLE, SYNC_STATUS_TABLE } from '../schema/state/sqlite/system-tables/eventlog-tables.ts'
37
+ import * as StateHead from '../StateHead.ts'
38
+ import type { BackendIdMismatchError, IsOfflineError, SyncBackend } from '../sync/sync.ts'
39
39
  import * as SyncState from '../sync/syncstate.ts'
40
40
  import { sql } from '../util.ts'
41
41
  import * as Eventlog from './eventlog.ts'
42
42
  import { rollback } from './materialize-event.ts'
43
- import type { InitialBlockingSyncContext, LeaderSyncProcessor } from './types.ts'
43
+ import {
44
+ isRejectedPushError,
45
+ LeaderAheadError,
46
+ NonContiguousBatchError,
47
+ NonMonotonicBatchError,
48
+ type RejectedPushError,
49
+ StaleRebaseGenerationError,
50
+ } from './RejectedPushError.ts'
51
+ import type { ShutdownChannel } from './shutdown-channel.ts'
52
+ import type { InitialBlockingSyncContext } from './types.ts'
44
53
  import { LeaderThreadCtx } from './types.ts'
45
54
 
46
- type LocalPushQueueItem = [
47
- event: LiveStoreEvent.EncodedWithMeta,
48
- deferred: Deferred.Deferred<void, LeaderAheadError> | undefined,
49
- ]
55
+ export const TypeId = '~@livestore/common/LeaderSyncProcessor' as const
56
+ export type TypeId = typeof TypeId
50
57
 
51
58
  /**
52
59
  * The LeaderSyncProcessor manages synchronization of events between
@@ -66,11 +73,11 @@ type LocalPushQueueItem = [
66
73
  * - Maintains events in ascending order.
67
74
  * - Uses `Deferred` objects to resolve/reject events based on application success.
68
75
  * - Processes events from the queue, applying events in batches.
69
- * - Controlled by a `Latch` to manage execution flow.
70
- * - The latch closes on pull receipt and re-opens post-pull completion.
76
+ * - Controlled by a mutex (`Semaphore(1)`) to ensure mutual exclusion between local push and backend pull processing.
77
+ * - The backend pull side acquires the mutex before processing and releases it on post-pull completion.
71
78
  * - Processes up to `maxBatchSize` events per cycle.
72
79
  *
73
- * Currently we're advancing the state db and eventlog in lockstep, but we could also decouple this in the future
80
+ * Currently, we're advancing the state db and eventlog in lockstep, but we could also decouple this in the future
74
81
  *
75
82
  * Tricky concurrency scenarios:
76
83
  * - Queued local push batches becoming invalid due to a prior local push item being rejected.
@@ -78,660 +85,526 @@ type LocalPushQueueItem = [
78
85
  *
79
86
  * See ClientSessionSyncProcessor for how the leader and session sync processors are similar/different.
80
87
  */
81
- export const makeLeaderSyncProcessor = ({
82
- schema,
83
- dbState,
84
- initialBlockingSyncContext,
85
- initialSyncState,
86
- onError,
87
- livePull,
88
- params,
89
- testing,
90
- }: {
91
- schema: LiveStoreSchema
92
- dbState: SqliteDb
93
- initialBlockingSyncContext: InitialBlockingSyncContext
88
+ export class LeaderSyncProcessor extends Context.Service<LeaderSyncProcessor, Service>()(
89
+ '@livestore/common/LeaderSyncProcessor',
90
+ ) {}
91
+
92
+ export interface Service {
93
+ readonly [TypeId]: TypeId
94
+ /** Used by client sessions to subscribe to upstream sync state changes */
95
+ readonly pull: (args: {
96
+ cursor: EventSequenceNumber.Client.Composite
97
+ }) => Stream.Stream<{ payload: typeof SyncState.PayloadUpstream.Type }>
98
+ /** The `pullQueue` API can be used instead of `pull` when more convenient */
99
+ readonly pullQueue: (args: {
100
+ cursor: EventSequenceNumber.Client.Composite
101
+ }) => Effect.Effect<Queue.Queue<{ payload: typeof SyncState.PayloadUpstream.Type }>, never, Scope.Scope>
102
+
103
+ /**
104
+ * Used by client sessions to push events to the leader thread.
105
+ * The effect only finishes when the local push has been processed (i.e. succeeded or was rejected).
106
+ * This doesn't mean the events have been pushed to the sync backend.
107
+ */
108
+ readonly push: (
109
+ /** `batch` needs to follow the same rules as `batch` in `SyncBackend.push` */
110
+ batch: ReadonlyArray<LiveStoreEvent.Client.EncodedWithMeta>,
111
+ ) => Effect.Effect<void, RejectedPushError>
112
+
113
+ /** Currently only used by devtools which don't provide their own event numbers */
114
+ readonly pushPartial: (args: {
115
+ event: LiveStoreEvent.Input.Encoded
116
+ clientId: string
117
+ sessionId: string
118
+ }) => Effect.Effect<void, UnknownEventError>
119
+
120
+ readonly boot: Effect.Effect<
121
+ { initialLeaderHead: EventSequenceNumber.Client.Composite },
122
+ never,
123
+ LeaderThreadCtx | Scope.Scope | HttpClient.HttpClient
124
+ >
125
+ readonly syncState: Subscribable.Subscribable<SyncState.SyncState>
126
+ }
127
+
128
+ interface Options {
129
+ readonly schema: LiveStoreSchema
130
+ readonly dbState: SqliteDb
131
+ readonly initialBlockingSyncContext: InitialBlockingSyncContext
94
132
  /** Initial sync state rehydrated from the persisted eventlog or initial sync state */
95
- initialSyncState: SyncState.SyncState
96
- onError: 'shutdown' | 'ignore'
97
- params: {
133
+ readonly initialSyncState: SyncState.SyncState
134
+ /**
135
+ * What to do when a failure (any cause) occurs (except `BackendIdMismatchError`).
136
+ *
137
+ * - `'shutdown'`: Send the error to the shutdown channel and terminate the sync processor.
138
+ * - `'ignore'`: Continue running.
139
+ */
140
+ readonly onError: 'shutdown' | 'ignore'
141
+ /**
142
+ * What to do when the sync backend identity has changed (i.e. the backend was reset).
143
+ *
144
+ * - `'reset'`: Clear local databases (eventlog and state) and send an intentional shutdown signal.
145
+ * - `'shutdown'`: Send a shutdown signal without clearing local storage.
146
+ * - `'ignore'`: Continue running with stale data.
147
+ */
148
+ readonly onBackendIdMismatch: 'reset' | 'shutdown' | 'ignore'
149
+ readonly params: {
98
150
  /**
151
+ * Maximum number of local events to process per batch cycle.
152
+ *
153
+ * This controls how many events from client sessions are applied to the local state
154
+ * in a single iteration before yielding to allow potential backend pulls.
155
+ *
156
+ * **Trade-offs:**
157
+ * - **Lower values (1-5):** More responsive to remote updates since pull processing can
158
+ * interleave more frequently. Better for high-conflict scenarios where rebases are common.
159
+ * Slightly higher per-event overhead due to more frequent transaction commits.
160
+ *
161
+ * - **Higher values (10-50+):** Better throughput for bulk local writes as more events are
162
+ * batched into a single transaction. However, may delay remote update processing and
163
+ * increase rebase complexity if many local events queue up during a slow pull.
164
+ *
165
+ * - **Very high values (100+):** Risk of starvation for pull processing if local pushes
166
+ * arrive continuously. May cause larger rollbacks during rebases. Not recommended
167
+ * unless you have a write-heavy workload with minimal remote synchronization.
168
+ *
99
169
  * @default 10
100
170
  */
101
- localPushBatchSize?: number
171
+ readonly localPushBatchSize?: number
102
172
  /**
173
+ * Maximum number of events to push to the sync backend per batch.
174
+ *
175
+ * This controls how many events are sent in a single push request to the remote server.
176
+ *
177
+ * **Trade-offs:**
178
+ * - **Lower values (1-10):** Lower latency for each push operation. Faster feedback on
179
+ * push success/failure. Slightly higher network overhead due to more requests.
180
+ *
181
+ * - **Higher values (50-100):** Better network efficiency by amortizing request overhead.
182
+ * Preferred for high-throughput scenarios. May increase latency to first confirmation.
183
+ *
184
+ * - **Very high values (200+):** Risk of hitting server request size limits or timeouts.
185
+ * A single failed request loses the entire batch (will be retried). May cause memory
186
+ * pressure if events accumulate faster than they can be pushed.
187
+ *
103
188
  * @default 50
104
189
  */
105
- backendPushBatchSize?: number
190
+ readonly backendPushBatchSize?: number
106
191
  }
107
192
  /**
108
193
  * Whether the sync backend should reactively pull new events from the sync backend
109
194
  * When `false`, the sync processor will only do an initial pull
110
195
  */
111
- livePull: boolean
112
- testing: {
113
- delays?: {
114
- localPushProcessing?: Effect.Effect<void>
196
+ readonly livePull: boolean
197
+ readonly testing: {
198
+ readonly delays?: {
199
+ readonly localPushProcessing?: Effect.Effect<void>
115
200
  }
116
- }
117
- }): Effect.Effect<LeaderSyncProcessor, UnexpectedError, Scope.Scope> =>
118
- Effect.gen(function* () {
119
- const syncBackendPushQueue = yield* BucketQueue.make<LiveStoreEvent.EncodedWithMeta>()
120
- const localPushBatchSize = params.localPushBatchSize ?? 1
121
- const backendPushBatchSize = params.backendPushBatchSize ?? 2
122
-
123
- const syncStateSref = yield* SubscriptionRef.make<SyncState.SyncState | undefined>(undefined)
124
-
125
- const isClientEvent = (eventEncoded: LiveStoreEvent.EncodedWithMeta) => {
126
- const { eventDef } = getEventDef(schema, eventEncoded.name)
127
- return eventDef.options.clientOnly
201
+ readonly hooks?: {
202
+ readonly localPushAdmitted?: (events: ReadonlyArray<LiveStoreEvent.Client.EncodedWithMeta>) => Effect.Effect<void>
128
203
  }
204
+ }
205
+ }
129
206
 
130
- const connectedClientSessionPullQueues = yield* makePullQueueSet
131
-
132
- // This context depends on data from `boot`, we should find a better implementation to avoid this ref indirection.
133
- const ctxRef = {
134
- current: undefined as
135
- | undefined
136
- | {
137
- otelSpan: otel.Span | undefined
138
- span: Tracer.Span
139
- devtoolsLatch: Effect.Latch | undefined
140
- runtime: Runtime.Runtime<LeaderThreadCtx>
141
- },
142
- }
207
+ export const make = Effect.fnUntraced(function* ({
208
+ schema,
209
+ dbState,
210
+ initialBlockingSyncContext,
211
+ initialSyncState,
212
+ onError,
213
+ onBackendIdMismatch,
214
+ livePull,
215
+ params,
216
+ testing,
217
+ }: Options) {
218
+ const stateHead = yield* StateHead.StateHead
219
+ const syncBackendPushQueue = yield* TxQueue.unbounded<LiveStoreEvent.Client.EncodedWithMeta>()
220
+ const localPushBatchSize = params.localPushBatchSize ?? 10
221
+ const backendPushBatchSize = params.backendPushBatchSize ?? 50
222
+
223
+ const syncStateSref = yield* SubscriptionRef.make<SyncState.SyncState | undefined>(undefined)
224
+
225
+ const isClientOnlyEvent = (eventEncoded: LiveStoreEvent.Client.EncodedWithMeta) =>
226
+ schema.eventsDefsMap.get(eventEncoded.name)?.options.clientOnly ?? false
227
+
228
+ const connectedClientSessionPullQueues = yield* makePullQueueSet
229
+
230
+ // This context depends on data from `boot`, we should find a better implementation to avoid this ref indirection.
231
+ const ctxRef = {
232
+ current: undefined as
233
+ | undefined
234
+ | {
235
+ span: Tracer.Span
236
+ devtoolsLatch: Latch.Latch | undefined
237
+ services: Context.Context<LeaderThreadCtx>
238
+ },
239
+ }
143
240
 
144
- const localPushesQueue = yield* BucketQueue.make<LocalPushQueueItem>()
145
- const localPushesLatch = yield* Effect.makeLatch(true)
146
- const pullLatch = yield* Effect.makeLatch(true)
241
+ type LocalPushQueueItem = [
242
+ event: LiveStoreEvent.Client.EncodedWithMeta,
243
+ deferred: Deferred.Deferred<void, LeaderAheadError | StaleRebaseGenerationError>,
244
+ ]
245
+ const localPushesQueue = yield* TxQueue.unbounded<LocalPushQueueItem>()
246
+ // Reservations cover admitted pushes from validation until they are applied or rejected. The Set's
247
+ // insertion order mirrors admission order, so its last relevant item is the optimistic sequence head.
248
+ const reservedLocalPushItems = new Set<LocalPushQueueItem>()
249
+ // Ensures mutual exclusion between local push and backend pull processing.
250
+ const localPushBackendPullMutex = yield* Semaphore.make(1)
251
+ // Serializes validation, queue admission, and prefix-fence reconciliation.
252
+ const pushAdmissionSemaphore = yield* Semaphore.make(1)
147
253
 
148
- /**
149
- * Additionally to the `syncStateSref` we also need the `pushHeadRef` in order to prevent old/duplicate
150
- * events from being pushed in a scenario like this:
151
- * - client session A pushes e1
152
- * - leader sync processor takes a bit and hasn't yet taken e1 from the localPushesQueue
153
- * - client session B also pushes e1 (which should be rejected)
154
- *
155
- * Thus the purpose of the pushHeadRef is the guard the integrity of the local push queue
156
- */
157
- const pushHeadRef = { current: EventSequenceNumber.ROOT }
158
- const advancePushHead = (eventNum: EventSequenceNumber.EventSequenceNumber) => {
159
- pushHeadRef.current = EventSequenceNumber.max(pushHeadRef.current, eventNum)
160
- }
254
+ /**
255
+ * Admission fence for local pushes. Unlike `syncState.localHead`, this advances as soon as an event
256
+ * is queued, so another session cannot claim the same sequence number while that event is waiting
257
+ * to be applied. With authoritative head e0 and reserved pushes e1/e2, this points to e2.
258
+ *
259
+ * All reads and writes are protected by `pushAdmissionSemaphore` so validation and reservation are
260
+ * one atomic operation from the perspective of concurrent sessions and backend pulls.
261
+ */
262
+ const pushHeadRef = { current: initialSyncState.localHead }
161
263
 
162
- // NOTE: New events are only pushed to sync backend after successful local push processing
163
- const push: LeaderSyncProcessor['push'] = (newEvents, options) =>
264
+ /**
265
+ * A backend pull may advance or rebase authoritative history while local pushes remain reserved.
266
+ * Keep the fence at the newest reservation that is still valid for that history; if none remains,
267
+ * fall back to the authoritative head. Stale reservations are released later by the queue worker.
268
+ */
269
+ const reconcilePushHead = (authoritativeHead: EventSequenceNumber.Client.Composite) =>
270
+ pushAdmissionSemaphore.withPermits(1)(
164
271
  Effect.gen(function* () {
165
- if (newEvents.length === 0) return
166
-
167
- // console.debug('push', newEvents)
168
-
169
- yield* validatePushBatch(newEvents, pushHeadRef.current)
170
-
171
- advancePushHead(newEvents.at(-1)!.seqNum)
172
-
173
- const waitForProcessing = options?.waitForProcessing ?? false
174
-
175
- if (waitForProcessing) {
176
- const deferreds = yield* Effect.forEach(newEvents, () => Deferred.make<void, LeaderAheadError>())
177
-
178
- const items = newEvents.map((eventEncoded, i) => [eventEncoded, deferreds[i]] as LocalPushQueueItem)
179
-
180
- yield* BucketQueue.offerAll(localPushesQueue, items)
181
-
182
- yield* Effect.all(deferreds)
183
- } else {
184
- const items = newEvents.map((eventEncoded) => [eventEncoded, undefined] as LocalPushQueueItem)
185
- yield* BucketQueue.offerAll(localPushesQueue, items)
186
- }
187
- }).pipe(
188
- Effect.withSpan('@livestore/common:LeaderSyncProcessor:push', {
189
- attributes: {
190
- batchSize: newEvents.length,
191
- batch: TRACE_VERBOSE ? newEvents : undefined,
192
- },
193
- links: ctxRef.current?.span ? [{ _tag: 'SpanLink', span: ctxRef.current.span, attributes: {} }] : undefined,
194
- }),
195
- )
196
-
197
- const pushPartial: LeaderSyncProcessor['pushPartial'] = ({ event: { name, args }, clientId, sessionId }) =>
272
+ const latestCurrentGenerationItem = [...reservedLocalPushItems].findLast(
273
+ ([event]) => event.seqNum.rebaseGeneration >= authoritativeHead.rebaseGeneration,
274
+ )
275
+ pushHeadRef.current = latestCurrentGenerationItem?.[0].seqNum ?? authoritativeHead
276
+ }).pipe(Effect.uninterruptible),
277
+ )
278
+ /**
279
+ * Stop completed, rejected, or stale queue items from extending the admission fence, then rebuild
280
+ * the fence from any pushes that are still reserved.
281
+ */
282
+ const releasePushReservations = (
283
+ items: ReadonlyArray<LocalPushQueueItem>,
284
+ authoritativeHead: EventSequenceNumber.Client.Composite,
285
+ ) =>
286
+ pushAdmissionSemaphore.withPermits(1)(
198
287
  Effect.gen(function* () {
199
- const syncState = yield* syncStateSref
200
- if (syncState === undefined) return shouldNeverHappen('Not initialized')
201
-
202
- const { eventDef } = getEventDef(schema, name)
288
+ for (const item of items) reservedLocalPushItems.delete(item)
289
+ const latestCurrentGenerationItem = [...reservedLocalPushItems].findLast(
290
+ ([event]) => event.seqNum.rebaseGeneration >= authoritativeHead.rebaseGeneration,
291
+ )
292
+ pushHeadRef.current = latestCurrentGenerationItem?.[0].seqNum ?? authoritativeHead
293
+ }).pipe(Effect.uninterruptible),
294
+ )
203
295
 
204
- const eventEncoded = new LiveStoreEvent.EncodedWithMeta({
205
- name,
206
- args,
207
- clientId,
208
- sessionId,
209
- ...EventSequenceNumber.nextPair({ seqNum: syncState.localHead, isClient: eventDef.options.clientOnly }),
210
- })
296
+ const backgroundApplyLocalPushes = Effect.gen(function* () {
297
+ while (true) {
298
+ if (testing.delays?.localPushProcessing !== undefined) {
299
+ yield* testing.delays.localPushProcessing.pipe(Effect.withSpan('localPushProcessingDelay'))
300
+ }
211
301
 
212
- yield* push([eventEncoded])
213
- }).pipe(Effect.catchTag('LeaderAheadError', Effect.orDie))
302
+ const batchItems = yield* TxQueue.takeBetween(localPushesQueue, 1, localPushBatchSize)
214
303
 
215
- // Starts various background loops
216
- const boot: LeaderSyncProcessor['boot'] = Effect.gen(function* () {
217
- const span = yield* Effect.currentSpan.pipe(Effect.orDie)
218
- const otelSpan = yield* OtelTracer.currentOtelSpan.pipe(Effect.catchAll(() => Effect.succeed(undefined)))
219
- const { devtools, shutdownChannel } = yield* LeaderThreadCtx
220
- const runtime = yield* Effect.runtime<LeaderThreadCtx>()
304
+ // Applies a batch of local pushes, guarded by the localPushBackendPullMutex to ensure mutual exclusion with backend pulling
305
+ yield* Effect.gen(function* () {
306
+ const syncState = yield* Effect.fromNullishOr(yield* SubscriptionRef.get(syncStateSref)).pipe(
307
+ Effect.orDieDebugger,
308
+ )
221
309
 
222
- ctxRef.current = {
223
- otelSpan,
224
- span,
225
- devtoolsLatch: devtools.enabled ? devtools.syncBackendLatch : undefined,
226
- runtime,
227
- }
310
+ const currentRebaseGeneration = syncState.localHead.rebaseGeneration
228
311
 
229
- /** State transitions need to happen atomically, so we use a Ref to track the state */
230
- yield* SubscriptionRef.set(syncStateSref, initialSyncState)
312
+ // Since the rebase generation might have changed since enqueuing, we need to filter out items with older generation
313
+ // It's important that we filter after acquiring the localPushBackendPullMutex, otherwise we might filter with the old generation
314
+ const [droppedItems, filteredItems] = ReadonlyArray.partition(batchItems, (batchItem) =>
315
+ batchItem[0].seqNum.rebaseGeneration >= currentRebaseGeneration
316
+ ? Result.succeed(batchItem)
317
+ : Result.fail(batchItem),
318
+ )
231
319
 
232
- // Rehydrate sync queue
233
- if (initialSyncState.pending.length > 0) {
234
- const globalPendingEvents = initialSyncState.pending
235
- // Don't sync clientOnly events
236
- .filter((eventEncoded) => {
237
- const { eventDef } = getEventDef(schema, eventEncoded.name)
238
- return eventDef.options.clientOnly === false
320
+ if (droppedItems.length > 0) {
321
+ yield* Effect.spanEvent(`push:drop-old-generation`, {
322
+ droppedCount: droppedItems.length,
323
+ currentRebaseGeneration,
239
324
  })
240
325
 
241
- if (globalPendingEvents.length > 0) {
242
- yield* BucketQueue.offerAll(syncBackendPushQueue, globalPendingEvents)
326
+ yield* Effect.forEach(droppedItems, ([eventEncoded, deferred]) =>
327
+ Deferred.fail(
328
+ deferred,
329
+ StaleRebaseGenerationError.make({
330
+ currentRebaseGeneration,
331
+ providedRebaseGeneration: eventEncoded.seqNum.rebaseGeneration,
332
+ sessionId: eventEncoded.sessionId,
333
+ }),
334
+ ),
335
+ )
336
+
337
+ yield* releasePushReservations(droppedItems, syncState.localHead)
243
338
  }
244
- }
245
339
 
246
- const maybeShutdownOnError = (
247
- cause: Cause.Cause<
248
- | UnexpectedError
249
- | IntentionalShutdownCause
250
- | IsOfflineError
251
- | InvalidPushError
252
- | InvalidPullError
253
- | MaterializeError
254
- >,
255
- ) =>
256
- Effect.gen(function* () {
257
- if (onError === 'ignore') {
258
- if (LS_DEV) {
259
- yield* Effect.logDebug(
260
- `Ignoring sync error (${cause._tag === 'Fail' ? cause.error._tag : cause._tag})`,
261
- Cause.pretty(cause),
262
- )
263
- }
264
- return
265
- }
340
+ if (filteredItems.length === 0) {
341
+ return
342
+ }
266
343
 
267
- const errorToSend = Cause.isFailType(cause) ? cause.error : UnexpectedError.make({ cause })
268
- yield* shutdownChannel.send(errorToSend).pipe(Effect.orDie)
344
+ const [newEvents, deferreds] = ReadonlyArray.unzip(filteredItems)
269
345
 
270
- return yield* Effect.die(cause)
346
+ yield* Effect.annotateCurrentSpan({
347
+ batchSize: newEvents.length,
348
+ ...(TRACE_VERBOSE === true ? { newEvents: jsonStringify(newEvents) } : {}),
271
349
  })
272
350
 
273
- yield* backgroundApplyLocalPushes({
274
- localPushesLatch,
275
- localPushesQueue,
276
- pullLatch,
277
- syncStateSref,
278
- syncBackendPushQueue,
279
- schema,
280
- isClientEvent,
281
- otelSpan,
282
- connectedClientSessionPullQueues,
283
- localPushBatchSize,
284
- testing: {
285
- delay: testing?.delays?.localPushProcessing,
286
- },
287
- }).pipe(Effect.catchAllCause(maybeShutdownOnError), Effect.forkScoped)
288
-
289
- const backendPushingFiberHandle = yield* FiberHandle.make<void, never>()
290
- const backendPushingEffect = backgroundBackendPushing({
291
- syncBackendPushQueue,
292
- otelSpan,
293
- devtoolsLatch: ctxRef.current?.devtoolsLatch,
294
- backendPushBatchSize,
295
- }).pipe(Effect.catchAllCause(maybeShutdownOnError))
296
-
297
- yield* FiberHandle.run(backendPushingFiberHandle, backendPushingEffect)
298
-
299
- yield* backgroundBackendPulling({
300
- isClientEvent,
301
- restartBackendPushing: (filteredRebasedPending) =>
302
- Effect.gen(function* () {
303
- // Stop current pushing fiber
304
- yield* FiberHandle.clear(backendPushingFiberHandle)
305
-
306
- // Reset the sync backend push queue
307
- yield* BucketQueue.clear(syncBackendPushQueue)
308
- yield* BucketQueue.offerAll(syncBackendPushQueue, filteredRebasedPending)
309
-
310
- // Restart pushing fiber
311
- yield* FiberHandle.run(backendPushingFiberHandle, backendPushingEffect)
312
- }),
313
- syncStateSref,
314
- localPushesLatch,
315
- pullLatch,
316
- livePull,
317
- dbState,
318
- otelSpan,
319
- initialBlockingSyncContext,
320
- devtoolsLatch: ctxRef.current?.devtoolsLatch,
321
- connectedClientSessionPullQueues,
322
- advancePushHead,
323
- }).pipe(
324
- Effect.retry({
325
- // We want to retry pulling if we've lost connection to the sync backend
326
- while: (cause) => cause._tag === 'IsOfflineError',
327
- }),
328
- Effect.catchAllCause(maybeShutdownOnError),
329
- // Needed to avoid `Fiber terminated with an unhandled error` logs which seem to happen because of the `Effect.retry` above.
330
- // This might be a bug in Effect. Only seems to happen in the browser.
331
- Effect.provide(Layer.setUnhandledErrorLogLevel(Option.none())),
332
- Effect.forkScoped,
333
- )
334
-
335
- return { initialLeaderHead: initialSyncState.localHead }
336
- }).pipe(Effect.withSpanScoped('@livestore/common:LeaderSyncProcessor:boot'))
337
-
338
- const pull: LeaderSyncProcessor['pull'] = ({ cursor }) =>
339
- Effect.gen(function* () {
340
- const queue = yield* pullQueue({ cursor })
341
- return Stream.fromQueue(queue)
342
- }).pipe(Stream.unwrapScoped)
343
-
344
- /*
345
- Notes for a potential new `LeaderSyncProcessor.pull` implementation:
346
-
347
- - Doesn't take cursor but is "atomically called" in the leader during the snapshot phase
348
- - TODO: how is this done "atomically" in the web adapter where the snapshot is read optimistically?
349
- - Would require a new kind of "boot-phase" API which is stream based:
350
- - initial message: state snapshot + seq num head
351
- - subsequent messages: sync state payloads
352
-
353
- - alternative: instead of session pulling sync state payloads from leader, we could send
354
- - events in the "advance" case
355
- - full new state db snapshot in the "rebase" case
356
- - downside: importing the snapshot is expensive
357
- */
358
- const pullQueue: LeaderSyncProcessor['pullQueue'] = ({ cursor }) => {
359
- const runtime = ctxRef.current?.runtime ?? shouldNeverHappen('Not initialized')
360
- return connectedClientSessionPullQueues.makeQueue(cursor).pipe(Effect.provide(runtime))
361
- }
362
-
363
- const syncState = Subscribable.make({
364
- get: Effect.gen(function* () {
365
- const syncState = yield* syncStateSref
366
- if (syncState === undefined) return shouldNeverHappen('Not initialized')
367
- return syncState
368
- }),
369
- changes: syncStateSref.changes.pipe(Stream.filter(isNotUndefined)),
370
- })
371
-
372
- return {
373
- pull,
374
- pullQueue,
375
- push,
376
- pushPartial,
377
- boot,
378
- syncState,
379
- } satisfies LeaderSyncProcessor
380
- })
381
-
382
- const backgroundApplyLocalPushes = ({
383
- localPushesLatch,
384
- localPushesQueue,
385
- pullLatch,
386
- syncStateSref,
387
- syncBackendPushQueue,
388
- schema,
389
- isClientEvent,
390
- otelSpan,
391
- connectedClientSessionPullQueues,
392
- localPushBatchSize,
393
- testing,
394
- }: {
395
- pullLatch: Effect.Latch
396
- localPushesLatch: Effect.Latch
397
- localPushesQueue: BucketQueue.BucketQueue<LocalPushQueueItem>
398
- syncStateSref: SubscriptionRef.SubscriptionRef<SyncState.SyncState | undefined>
399
- syncBackendPushQueue: BucketQueue.BucketQueue<LiveStoreEvent.EncodedWithMeta>
400
- schema: LiveStoreSchema
401
- isClientEvent: (eventEncoded: LiveStoreEvent.EncodedWithMeta) => boolean
402
- otelSpan: otel.Span | undefined
403
- connectedClientSessionPullQueues: PullQueueSet
404
- localPushBatchSize: number
405
- testing: {
406
- delay: Effect.Effect<void> | undefined
407
- }
408
- }) =>
409
- Effect.gen(function* () {
410
- while (true) {
411
- if (testing.delay !== undefined) {
412
- yield* testing.delay.pipe(Effect.withSpan('localPushProcessingDelay'))
413
- }
414
-
415
- const batchItems = yield* BucketQueue.takeBetween(localPushesQueue, 1, localPushBatchSize)
416
-
417
- // Wait for the backend pulling to finish
418
- yield* localPushesLatch.await
419
-
420
- // Prevent backend pull processing until this local push is finished
421
- yield* pullLatch.close
422
-
423
- const syncState = yield* syncStateSref
424
- if (syncState === undefined) return shouldNeverHappen('Not initialized')
351
+ const mergeResult = yield* SyncState.merge({
352
+ syncState,
353
+ payload: { _tag: 'local-push', newEvents },
354
+ isClientOnlyEvent,
355
+ isEqualEvent: LiveStoreEvent.Client.isEqualEncoded,
356
+ })
425
357
 
426
- const currentRebaseGeneration = syncState.localHead.rebaseGeneration
358
+ switch (mergeResult._tag) {
359
+ case 'rebase': {
360
+ return yield* Effect.dieDebugger('The leader thread should never have to rebase due to a local push')
361
+ }
362
+ case 'reject': {
363
+ yield* Effect.spanEvent(`push:reject`, {
364
+ batchSize: newEvents.length,
365
+ ...(TRACE_VERBOSE === true ? { mergeResult: jsonStringify(mergeResult) } : {}),
366
+ })
427
367
 
428
- // Since the rebase generation might have changed since enqueuing, we need to filter out items with older generation
429
- // It's important that we filter after we got localPushesLatch, otherwise we might filter with the old generation
430
- const [newEvents, deferreds] = pipe(
431
- batchItems,
432
- ReadonlyArray.filter(([eventEncoded]) => eventEncoded.seqNum.rebaseGeneration === currentRebaseGeneration),
433
- ReadonlyArray.unzip,
434
- )
368
+ // TODO: how to test this?
369
+ const nextRebaseGeneration = currentRebaseGeneration + 1
435
370
 
436
- if (newEvents.length === 0) {
437
- // console.log('dropping old-gen batch', currentLocalPushGenerationRef.current)
438
- // Allow the backend pulling to start
439
- yield* pullLatch.open
440
- continue
441
- }
371
+ const providedNum = newEvents.at(0)!.seqNum
372
+ // All subsequent pushes with same generation should be rejected as well
373
+ // We're also handling the case where the localPushQueue already contains events
374
+ // from the next generation which we preserve in the queue
375
+ const remainingEventsMatchingGeneration = yield* takePrefixUntil(
376
+ localPushesQueue,
377
+ ([eventEncoded]) => eventEncoded.seqNum.rebaseGeneration >= nextRebaseGeneration,
378
+ )
442
379
 
443
- const mergeResult = SyncState.merge({
444
- syncState,
445
- payload: { _tag: 'local-push', newEvents },
446
- isClientEvent,
447
- isEqualEvent: LiveStoreEvent.isEqualEncoded,
448
- })
380
+ // TODO we still need to better understand and handle this scenario
381
+ const remainingLocalPushes = yield* snapshotTxQueue(localPushesQueue)
382
+ if (LS_DEV === true && remainingLocalPushes.length > 0) {
383
+ console.log('localPushesQueue is not empty', remainingLocalPushes.length)
384
+ // oxlint-disable-next-line eslint(no-debugger) -- intentional breakpoint for unexpected queue state
385
+ debugger
386
+ }
449
387
 
450
- switch (mergeResult._tag) {
451
- case 'unexpected-error': {
452
- otelSpan?.addEvent(`push:unexpected-error`, {
453
- batchSize: newEvents.length,
454
- newEvents: TRACE_VERBOSE ? JSON.stringify(newEvents) : undefined,
455
- })
456
- return yield* new UnexpectedError({ cause: mergeResult.message })
457
- }
458
- case 'rebase': {
459
- return shouldNeverHappen('The leader thread should never have to rebase due to a local push')
460
- }
461
- case 'reject': {
462
- otelSpan?.addEvent(`push:reject`, {
463
- batchSize: newEvents.length,
464
- mergeResult: TRACE_VERBOSE ? JSON.stringify(mergeResult) : undefined,
465
- })
388
+ const allDeferredsToReject = [
389
+ ...deferreds,
390
+ ...remainingEventsMatchingGeneration.map(([_, deferred]) => deferred),
391
+ ]
466
392
 
467
- // TODO: how to test this?
468
- const nextRebaseGeneration = currentRebaseGeneration + 1
393
+ // The rejected batch and its drained same-generation suffix will never advance leader state,
394
+ // so release their sequence-number reservations before clients retry from the authoritative head.
395
+ yield* releasePushReservations(
396
+ [...filteredItems, ...remainingEventsMatchingGeneration],
397
+ syncState.localHead,
398
+ )
469
399
 
470
- const providedNum = newEvents.at(0)!.seqNum
471
- // All subsequent pushes with same generation should be rejected as well
472
- // We're also handling the case where the localPushQueue already contains events
473
- // from the next generation which we preserve in the queue
474
- const remainingEventsMatchingGeneration = yield* BucketQueue.takeSplitWhere(
475
- localPushesQueue,
476
- ([eventEncoded]) => eventEncoded.seqNum.rebaseGeneration >= nextRebaseGeneration,
477
- )
400
+ yield* Effect.forEach(allDeferredsToReject, (deferred) =>
401
+ Deferred.fail(
402
+ deferred,
403
+ LeaderAheadError.make({
404
+ minimumExpectedNum: mergeResult.expectedMinimumId,
405
+ providedNum,
406
+ sessionId: newEvents.at(0)!.sessionId,
407
+ }),
408
+ ),
409
+ )
478
410
 
479
- // TODO we still need to better understand and handle this scenario
480
- if (LS_DEV && (yield* BucketQueue.size(localPushesQueue)) > 0) {
481
- console.log('localPushesQueue is not empty', yield* BucketQueue.size(localPushesQueue))
482
- // biome-ignore lint/suspicious/noDebugger: debugging
483
- debugger
411
+ // In this case we're skipping state update and down/upstream processing
412
+ // We've cleared the local push queue and are now waiting for new local pushes / backend pulls
413
+ return
414
+ }
415
+ case 'advance': {
416
+ break
417
+ }
418
+ default: {
419
+ casesHandled(mergeResult)
484
420
  }
485
-
486
- const allDeferredsToReject = [
487
- ...deferreds,
488
- ...remainingEventsMatchingGeneration.map(([_, deferred]) => deferred),
489
- ].filter(isNotUndefined)
490
-
491
- yield* Effect.forEach(allDeferredsToReject, (deferred) =>
492
- Deferred.fail(
493
- deferred,
494
- LeaderAheadError.make({ minimumExpectedNum: mergeResult.expectedMinimumId, providedNum }),
495
- ),
496
- )
497
-
498
- // Allow the backend pulling to start
499
- yield* pullLatch.open
500
-
501
- // In this case we're skipping state update and down/upstream processing
502
- // We've cleared the local push queue and are now waiting for new local pushes / backend pulls
503
- continue
504
- }
505
- case 'advance': {
506
- break
507
- }
508
- default: {
509
- casesHandled(mergeResult)
510
421
  }
511
- }
512
-
513
- yield* SubscriptionRef.set(syncStateSref, mergeResult.newSyncState)
514
-
515
- yield* connectedClientSessionPullQueues.offer({
516
- payload: SyncState.PayloadUpstreamAdvance.make({ newEvents: mergeResult.newEvents }),
517
- leaderHead: mergeResult.newSyncState.localHead,
518
- })
519
-
520
- otelSpan?.addEvent(`push:advance`, {
521
- batchSize: newEvents.length,
522
- mergeResult: TRACE_VERBOSE ? JSON.stringify(mergeResult) : undefined,
523
- })
524
422
 
525
- // Don't sync clientOnly events
526
- const filteredBatch = mergeResult.newEvents.filter((eventEncoded) => {
527
- const { eventDef } = getEventDef(schema, eventEncoded.name)
528
- return eventDef.options.clientOnly === false
529
- })
530
-
531
- yield* BucketQueue.offerAll(syncBackendPushQueue, filteredBatch)
423
+ // For a local-push advance, `newEvents` and the appended pending suffix describe the same logical
424
+ // events but serve different roles and may be distinct instances. Materialize the retained pending
425
+ // instances so their rollback metadata remains available if a later backend event causes a rebase.
426
+ const acceptedPendingEvents = mergeResult.newSyncState.pending.slice(syncState.pending.length)
427
+ if (acceptedPendingEvents.length !== mergeResult.newEvents.length) {
428
+ return yield* Effect.dieDebugger('Local push events must be retained in pending state')
429
+ }
532
430
 
533
- yield* materializeEventsBatch({ batchItems: mergeResult.newEvents, deferreds })
431
+ yield* materializeEventsBatch({ batchItems: acceptedPendingEvents })
534
432
 
535
- // Allow the backend pulling to start
536
- yield* pullLatch.open
537
- }
538
- })
539
-
540
- type MaterializeEventsBatch = (_: {
541
- batchItems: ReadonlyArray<LiveStoreEvent.EncodedWithMeta>
542
- /**
543
- * The deferreds are used by the caller to know when the mutation has been processed.
544
- * Indexes are aligned with `batchItems`
545
- */
546
- deferreds: ReadonlyArray<Deferred.Deferred<void, LeaderAheadError> | undefined> | undefined
547
- }) => Effect.Effect<void, MaterializeError, LeaderThreadCtx>
433
+ yield* SubscriptionRef.set(syncStateSref, mergeResult.newSyncState)
548
434
 
549
- // TODO how to handle errors gracefully
550
- const materializeEventsBatch: MaterializeEventsBatch = ({ batchItems, deferreds }) =>
551
- Effect.gen(function* () {
552
- const { dbState: db, dbEventlog, materializeEvent } = yield* LeaderThreadCtx
435
+ yield* connectedClientSessionPullQueues.offer({
436
+ payload: SyncState.PayloadUpstreamAdvance.make({ newEvents: acceptedPendingEvents }),
437
+ leaderHead: mergeResult.newSyncState.localHead,
438
+ })
553
439
 
554
- // NOTE We always start a transaction to ensure consistency between db and eventlog (even for single-item batches)
555
- db.execute('BEGIN TRANSACTION', undefined) // Start the transaction
556
- dbEventlog.execute('BEGIN TRANSACTION', undefined) // Start the transaction
440
+ yield* Effect.spanEvent(`push:advance`, {
441
+ batchSize: newEvents.length,
442
+ ...(TRACE_VERBOSE === true ? { mergeResult: jsonStringify(mergeResult) } : {}),
443
+ })
557
444
 
558
- yield* Effect.addFinalizer((exit) =>
559
- Effect.gen(function* () {
560
- if (Exit.isSuccess(exit)) return
445
+ // Don't sync client-only events
446
+ const globalOrUnknownEvents = acceptedPendingEvents.filter((e) => !isClientOnlyEvent(e))
561
447
 
562
- // Rollback in case of an error
563
- db.execute('ROLLBACK', undefined)
564
- dbEventlog.execute('ROLLBACK', undefined)
565
- }),
566
- )
448
+ yield* TxQueue.offerAll(syncBackendPushQueue, globalOrUnknownEvents)
567
449
 
568
- for (let i = 0; i < batchItems.length; i++) {
569
- const { sessionChangeset, hash } = yield* materializeEvent(batchItems[i]!)
570
- batchItems[i]!.meta.sessionChangeset = sessionChangeset
571
- batchItems[i]!.meta.materializerHashLeader = hash
450
+ yield* releasePushReservations(filteredItems, mergeResult.newSyncState.localHead)
572
451
 
573
- if (deferreds?.[i] !== undefined) {
574
- yield* Deferred.succeed(deferreds[i]!, void 0)
575
- }
452
+ // A push is acknowledged only after the complete batch is materialized, published in
453
+ // leader sync state, exposed to sessions, and queued for backend propagation.
454
+ yield* Effect.forEach(deferreds, (deferred) => Deferred.succeed(deferred, void 0))
455
+ }).pipe(localPushBackendPullMutex.withPermits(1))
576
456
  }
457
+ })
577
458
 
578
- db.execute('COMMIT', undefined) // Commit the transaction
579
- dbEventlog.execute('COMMIT', undefined) // Commit the transaction
580
- }).pipe(
581
- Effect.uninterruptible,
582
- Effect.scoped,
583
- Effect.withSpan('@livestore/common:LeaderSyncProcessor:materializeEventItems', {
584
- attributes: { batchSize: batchItems.length },
585
- }),
586
- Effect.tapCauseLogPretty,
587
- )
588
-
589
- const backgroundBackendPulling = ({
590
- isClientEvent,
591
- restartBackendPushing,
592
- otelSpan,
593
- dbState,
594
- syncStateSref,
595
- localPushesLatch,
596
- livePull,
597
- pullLatch,
598
- devtoolsLatch,
599
- initialBlockingSyncContext,
600
- connectedClientSessionPullQueues,
601
- advancePushHead,
602
- }: {
603
- isClientEvent: (eventEncoded: LiveStoreEvent.EncodedWithMeta) => boolean
604
- restartBackendPushing: (
605
- filteredRebasedPending: ReadonlyArray<LiveStoreEvent.EncodedWithMeta>,
606
- ) => Effect.Effect<void, UnexpectedError, LeaderThreadCtx | HttpClient.HttpClient>
607
- otelSpan: otel.Span | undefined
608
- syncStateSref: SubscriptionRef.SubscriptionRef<SyncState.SyncState | undefined>
609
- dbState: SqliteDb
610
- localPushesLatch: Effect.Latch
611
- pullLatch: Effect.Latch
612
- livePull: boolean
613
- devtoolsLatch: Effect.Latch | undefined
614
- initialBlockingSyncContext: InitialBlockingSyncContext
615
- connectedClientSessionPullQueues: PullQueueSet
616
- advancePushHead: (eventNum: EventSequenceNumber.EventSequenceNumber) => void
617
- }) =>
618
- Effect.gen(function* () {
459
+ const backgroundBackendPulling = Effect.fn('@livestore/common:LeaderSyncProcessor:backend-pulling')(function* ({
460
+ restartBackendPushing,
461
+ }: {
462
+ restartBackendPushing: (
463
+ filteredRebasedPending: ReadonlyArray<LiveStoreEvent.Client.EncodedWithMeta>,
464
+ ) => Effect.Effect<void, never, LeaderThreadCtx | HttpClient.HttpClient>
465
+ }) {
619
466
  const { syncBackend, dbState: db, dbEventlog, schema } = yield* LeaderThreadCtx
620
467
 
621
468
  if (syncBackend === undefined) return
622
469
 
623
- const onNewPullChunk = (newEvents: LiveStoreEvent.EncodedWithMeta[], pageInfo: SyncBackend.PullResPageInfo) =>
624
- Effect.gen(function* () {
625
- if (newEvents.length === 0) return
626
-
627
- if (devtoolsLatch !== undefined) {
628
- yield* devtoolsLatch.await
629
- }
630
-
631
- // Prevent more local pushes from being processed until this pull is finished
632
- yield* localPushesLatch.close
470
+ let pullMutexHeld = false
633
471
 
634
- // Wait for pending local pushes to finish
635
- yield* pullLatch.await
472
+ const releasePullMutexIfHeld = Effect.gen(function* () {
473
+ if (pullMutexHeld === false) return
474
+ pullMutexHeld = false
475
+ yield* localPushBackendPullMutex.release(1)
476
+ })
636
477
 
637
- const syncState = yield* syncStateSref
638
- if (syncState === undefined) return shouldNeverHappen('Not initialized')
478
+ const isPullPaginationComplete = (pageInfo: SyncBackend.PullResPageInfo) => pageInfo._tag === 'NoMore'
639
479
 
640
- const mergeResult = SyncState.merge({
641
- syncState,
642
- payload: SyncState.PayloadUpstreamAdvance.make({ newEvents }),
643
- isClientEvent,
644
- isEqualEvent: LiveStoreEvent.isEqualEncoded,
645
- ignoreClientEvents: true,
646
- })
480
+ const onNewPullChunk = (
481
+ newEvents: LiveStoreEvent.Client.EncodedWithMeta[],
482
+ pageInfo: SyncBackend.PullResPageInfo,
483
+ ) =>
484
+ Effect.gen(function* () {
485
+ if (ctxRef.current?.devtoolsLatch !== undefined) {
486
+ yield* ctxRef.current.devtoolsLatch.await
487
+ }
647
488
 
648
- if (mergeResult._tag === 'reject') {
649
- return shouldNeverHappen('The leader thread should never reject upstream advances')
650
- } else if (mergeResult._tag === 'unexpected-error') {
651
- otelSpan?.addEvent(`pull:unexpected-error`, {
652
- newEventsCount: newEvents.length,
653
- newEvents: TRACE_VERBOSE ? JSON.stringify(newEvents) : undefined,
654
- })
655
- return yield* new UnexpectedError({ cause: mergeResult.message })
489
+ if (newEvents.length === 0) {
490
+ if (isPullPaginationComplete(pageInfo) === true) {
491
+ yield* releasePullMutexIfHeld
492
+ }
493
+ return
656
494
  }
657
495
 
658
- const newBackendHead = newEvents.at(-1)!.seqNum
496
+ // Prevent more local pushes from being processed until this pull pagination sequence is finished.
497
+ if (pullMutexHeld === false) {
498
+ yield* localPushBackendPullMutex.take(1)
499
+ pullMutexHeld = true
500
+ }
659
501
 
660
- Eventlog.updateBackendHead(dbEventlog, newBackendHead)
502
+ const chunkExit = yield* Effect.gen(function* () {
503
+ const syncState = yield* Effect.fromNullishOr(yield* SubscriptionRef.get(syncStateSref)).pipe(
504
+ Effect.orDieDebugger,
505
+ )
661
506
 
662
- if (mergeResult._tag === 'rebase') {
663
- otelSpan?.addEvent(`pull:rebase[${mergeResult.newSyncState.localHead.rebaseGeneration}]`, {
664
- newEventsCount: newEvents.length,
665
- newEvents: TRACE_VERBOSE ? JSON.stringify(newEvents) : undefined,
666
- rollbackCount: mergeResult.rollbackEvents.length,
667
- mergeResult: TRACE_VERBOSE ? JSON.stringify(mergeResult) : undefined,
507
+ yield* Effect.annotateCurrentSpan({
508
+ 'merge.newEventsCount': newEvents.length,
509
+ ...(TRACE_VERBOSE === true ? { 'merge.newEvents': jsonStringify(newEvents) } : {}),
668
510
  })
669
511
 
670
- const globalRebasedPendingEvents = mergeResult.newSyncState.pending.filter((event) => {
671
- const { eventDef } = getEventDef(schema, event.name)
672
- return eventDef.options.clientOnly === false
512
+ const mergeResult = yield* SyncState.merge({
513
+ syncState,
514
+ payload: SyncState.PayloadUpstreamAdvance.make({ newEvents }),
515
+ isClientOnlyEvent,
516
+ isEqualEvent: LiveStoreEvent.Client.isEqualEncoded,
517
+ ignoreClientOnlyEvents: true,
673
518
  })
674
- yield* restartBackendPushing(globalRebasedPendingEvents)
675
519
 
676
- if (mergeResult.rollbackEvents.length > 0) {
677
- yield* rollback({
678
- dbState: db,
679
- dbEventlog,
680
- eventNumsToRollback: mergeResult.rollbackEvents.map((_) => _.seqNum),
681
- })
520
+ if (mergeResult._tag === 'reject') {
521
+ return yield* Effect.dieDebugger('The leader thread should never reject upstream advances')
682
522
  }
683
523
 
684
- yield* connectedClientSessionPullQueues.offer({
685
- payload: SyncState.payloadFromMergeResult(mergeResult),
686
- leaderHead: mergeResult.newSyncState.localHead,
687
- })
688
- } else {
689
- otelSpan?.addEvent(`pull:advance`, {
690
- newEventsCount: newEvents.length,
691
- mergeResult: TRACE_VERBOSE ? JSON.stringify(mergeResult) : undefined,
692
- })
524
+ const newBackendHead = newEvents.at(-1)!.seqNum
693
525
 
694
- // Ensure push fiber is active after advance by restarting with current pending (non-client) events
695
- const globalPendingEvents = mergeResult.newSyncState.pending.filter((event) => {
696
- const { eventDef } = getEventDef(schema, event.name)
697
- return eventDef.options.clientOnly === false
698
- })
699
- yield* restartBackendPushing(globalPendingEvents)
526
+ Eventlog.updateBackendHead(dbEventlog, newBackendHead)
700
527
 
701
- yield* connectedClientSessionPullQueues.offer({
702
- payload: SyncState.payloadFromMergeResult(mergeResult),
703
- leaderHead: mergeResult.newSyncState.localHead,
704
- })
528
+ if (mergeResult._tag === 'rebase') {
529
+ yield* Effect.spanEvent(`pull:rebase[${mergeResult.newSyncState.localHead.rebaseGeneration}]`, {
530
+ newEventsCount: newEvents.length,
531
+ ...(TRACE_VERBOSE === true ? { newEvents: jsonStringify(newEvents) } : {}),
532
+ rollbackCount: mergeResult.rollbackEvents.length,
533
+ ...(TRACE_VERBOSE === true ? { mergeResult: jsonStringify(mergeResult) } : {}),
534
+ })
705
535
 
706
- if (mergeResult.confirmedEvents.length > 0) {
707
- // `mergeResult.confirmedEvents` don't contain the correct sync metadata, so we need to use
708
- // `newEvents` instead which we filter via `mergeResult.confirmedEvents`
709
- const confirmedNewEvents = newEvents.filter((event) =>
710
- mergeResult.confirmedEvents.some((confirmedEvent) =>
711
- EventSequenceNumber.isEqual(event.seqNum, confirmedEvent.seqNum),
712
- ),
536
+ const globalOrUnknownRebasedPendingEvents = mergeResult.newSyncState.pending.filter(
537
+ (e) => !isClientOnlyEvent(e),
713
538
  )
714
- yield* Eventlog.updateSyncMetadata(confirmedNewEvents).pipe(UnexpectedError.mapToUnexpectedError)
539
+ yield* restartBackendPushing(globalOrUnknownRebasedPendingEvents)
540
+
541
+ if (mergeResult.rollbackEvents.length > 0) {
542
+ yield* rollback({
543
+ dbState: db,
544
+ dbEventlog,
545
+ eventNumsToRollback: mergeResult.rollbackEvents.map((_) => _.seqNum),
546
+ })
547
+ yield* stateHead
548
+ .set(mergeResult.rollbackEvents[0]!.parentSeqNum)
549
+ .pipe(Effect.mapError((cause) => MaterializeError.make({ cause })))
550
+ }
551
+
552
+ yield* connectedClientSessionPullQueues.offer({
553
+ payload: SyncState.payloadFromMergeResult(mergeResult),
554
+ leaderHead: mergeResult.newSyncState.localHead,
555
+ })
556
+ } else {
557
+ yield* Effect.spanEvent(`pull:advance`, {
558
+ newEventsCount: newEvents.length,
559
+ ...(TRACE_VERBOSE === true ? { mergeResult: jsonStringify(mergeResult) } : {}),
560
+ })
561
+
562
+ // Ensure push fiber is active after advance by restarting with current pending (non-client-only) events
563
+ const globalOrUnknownPendingEvents = mergeResult.newSyncState.pending.filter((e) => !isClientOnlyEvent(e))
564
+ yield* restartBackendPushing(globalOrUnknownPendingEvents)
565
+
566
+ yield* connectedClientSessionPullQueues.offer({
567
+ payload: SyncState.payloadFromMergeResult(mergeResult),
568
+ leaderHead: mergeResult.newSyncState.localHead,
569
+ })
570
+
571
+ if (mergeResult.confirmedEvents.length > 0) {
572
+ // `mergeResult.confirmedEvents` don't contain the correct sync metadata, so we need to use
573
+ // `newEvents` instead which we filter via `mergeResult.confirmedEvents`
574
+ const confirmedNewEvents = newEvents.filter((event) =>
575
+ mergeResult.confirmedEvents.some((confirmedEvent) =>
576
+ EventSequenceNumber.Client.isEqual(event.seqNum, confirmedEvent.seqNum),
577
+ ),
578
+ )
579
+ yield* Eventlog.updateSyncMetadata(confirmedNewEvents).pipe(Effect.orDieDebugger)
580
+ }
715
581
  }
716
- }
717
582
 
718
- // Removes the changeset rows which are no longer needed as we'll never have to rollback beyond this point
719
- trimChangesetRows(db, newBackendHead)
583
+ // Removes the changeset rows which are no longer needed as we'll never have to rollback beyond this point
584
+ trimChangesetRows(db, newBackendHead)
720
585
 
721
- advancePushHead(mergeResult.newSyncState.localHead)
586
+ // The backend merge may advance or rebase the authoritative head. Realign the admission
587
+ // fence now so newly arriving pushes are validated against that history, not the pre-pull head.
588
+ yield* reconcilePushHead(mergeResult.newSyncState.localHead)
722
589
 
723
- yield* materializeEventsBatch({ batchItems: mergeResult.newEvents, deferreds: undefined })
590
+ // Apply the merged events to storage before publishing the new sync state below, so readers
591
+ // cannot observe a leader head whose events have not yet been materialized.
592
+ yield* materializeEventsBatch({ batchItems: mergeResult.newEvents })
724
593
 
725
- yield* SubscriptionRef.set(syncStateSref, mergeResult.newSyncState)
594
+ yield* SubscriptionRef.set(syncStateSref, mergeResult.newSyncState)
595
+ }).pipe(Effect.exit)
596
+
597
+ if (Exit.isFailure(chunkExit) === true) {
598
+ yield* releasePullMutexIfHeld
599
+ return yield* Effect.failCause(chunkExit.cause)
600
+ }
726
601
 
727
- // Allow local pushes to be processed again
728
- if (pageInfo._tag === 'NoMore') {
729
- yield* localPushesLatch.open
602
+ if (isPullPaginationComplete(pageInfo) === true) {
603
+ yield* releasePullMutexIfHeld
730
604
  }
731
605
  })
732
606
 
733
- const syncState = yield* syncStateSref
734
- if (syncState === undefined) return shouldNeverHappen('Not initialized')
607
+ const syncState = yield* Effect.fromNullishOr(yield* SubscriptionRef.get(syncStateSref)).pipe(Effect.orDieDebugger)
735
608
  const cursorInfo = yield* Eventlog.getSyncBackendCursorInfo({ remoteHead: syncState.upstreamHead.global })
736
609
 
737
610
  const hashMaterializerResult = makeMaterializerHash({ schema, dbState })
@@ -740,23 +613,17 @@ const backgroundBackendPulling = ({
740
613
  // TODO only take from queue while connected
741
614
  Stream.tap(({ batch, pageInfo }) =>
742
615
  Effect.gen(function* () {
743
- // yield* Effect.spanEvent('batch', {
744
- // attributes: {
745
- // batchSize: batch.length,
746
- // batch: TRACE_VERBOSE ? batch : undefined,
747
- // },
748
- // })
749
616
  // NOTE we only want to take process events when the sync backend is connected
750
617
  // (e.g. needed for simulating being offline)
751
618
  // TODO remove when there's a better way to handle this in stream above
752
619
  yield* SubscriptionRef.waitUntil(syncBackend.isConnected, (isConnected) => isConnected === true)
753
620
  yield* onNewPullChunk(
754
621
  batch.map((_) =>
755
- LiveStoreEvent.EncodedWithMeta.fromGlobal(_.eventEncoded, {
622
+ LiveStoreEvent.Client.EncodedWithMeta.fromGlobal(_.eventEncoded, {
756
623
  syncMetadata: _.metadata,
757
624
  // 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
758
625
  // This is a bug and needs to be fixed https://github.com/livestorejs/livestore/issues/503#issuecomment-3114533165
759
- materializerHashLeader: hashMaterializerResult(LiveStoreEvent.encodedFromGlobal(_.eventEncoded)),
626
+ materializerHashLeader: hashMaterializerResult(LiveStoreEvent.Global.toClientEncoded(_.eventEncoded)),
760
627
  materializerHashSession: Option.none(),
761
628
  }),
762
629
  ),
@@ -767,41 +634,31 @@ const backgroundBackendPulling = ({
767
634
  ),
768
635
  Stream.runDrain,
769
636
  Effect.interruptible,
637
+ Effect.ensuring(releasePullMutexIfHeld),
770
638
  )
771
639
 
772
640
  // Should only ever happen when livePull is false
773
641
  yield* Effect.logDebug('backend-pulling finished', { livePull })
774
- }).pipe(Effect.withSpan('@livestore/common:LeaderSyncProcessor:backend-pulling'))
642
+ })
775
643
 
776
- const backgroundBackendPushing = ({
777
- syncBackendPushQueue,
778
- otelSpan,
779
- devtoolsLatch,
780
- backendPushBatchSize,
781
- }: {
782
- syncBackendPushQueue: BucketQueue.BucketQueue<LiveStoreEvent.EncodedWithMeta>
783
- otelSpan: otel.Span | undefined
784
- devtoolsLatch: Effect.Latch | undefined
785
- backendPushBatchSize: number
786
- }) =>
787
- Effect.gen(function* () {
644
+ const backgroundBackendPushing = Effect.gen(function* () {
788
645
  const { syncBackend } = yield* LeaderThreadCtx
789
646
  if (syncBackend === undefined) return
790
647
 
791
648
  while (true) {
792
649
  yield* SubscriptionRef.waitUntil(syncBackend.isConnected, (isConnected) => isConnected === true)
793
650
 
794
- const queueItems = yield* BucketQueue.takeBetween(syncBackendPushQueue, 1, backendPushBatchSize)
651
+ const queueItems = yield* TxQueue.takeBetween(syncBackendPushQueue, 1, backendPushBatchSize)
795
652
 
796
653
  yield* SubscriptionRef.waitUntil(syncBackend.isConnected, (isConnected) => isConnected === true)
797
654
 
798
- if (devtoolsLatch !== undefined) {
799
- yield* devtoolsLatch.await
655
+ if (ctxRef.current?.devtoolsLatch !== undefined) {
656
+ yield* ctxRef.current.devtoolsLatch.await
800
657
  }
801
658
 
802
- otelSpan?.addEvent('backend-push', {
659
+ yield* Effect.spanEvent('backend-push', {
803
660
  batchSize: queueItems.length,
804
- batch: TRACE_VERBOSE ? JSON.stringify(queueItems) : undefined,
661
+ ...(TRACE_VERBOSE === true ? { batch: jsonStringify(queueItems) } : {}),
805
662
  })
806
663
 
807
664
  // Push with declarative retry/backoff using Effect schedules
@@ -809,40 +666,24 @@ const backgroundBackendPushing = ({
809
666
  // - Delay clamped at 30s (continues retrying at 30s)
810
667
  // - Resets automatically after successful push
811
668
  // TODO(metrics): expose counters/gauges for retry attempts and queue health via devtools/metrics
812
-
813
- // Only retry for transient UnexpectedError cases
814
- const isRetryable = (err: InvalidPushError | IsOfflineError) =>
815
- err._tag === 'InvalidPushError' && err.cause._tag === 'LiveStore.UnexpectedError'
816
-
817
- // Input: InvalidPushError | IsOfflineError, Output: Duration
818
- const retrySchedule: Schedule.Schedule<Duration.DurationInput, InvalidPushError | IsOfflineError> =
819
- Schedule.exponential(Duration.seconds(1)).pipe(
820
- Schedule.andThenEither(Schedule.spaced(Duration.seconds(30))), // clamp at 30 second intervals
821
- Schedule.compose(Schedule.elapsed),
822
- Schedule.whileInput(isRetryable),
823
- )
824
-
825
669
  yield* Effect.gen(function* () {
826
- const iteration = yield* Schedule.CurrentIterationMetadata
670
+ const iteration = yield* Schedule.CurrentMetadata
827
671
 
828
- const pushResult = yield* syncBackend.push(queueItems.map((_) => _.toGlobal())).pipe(Effect.either)
672
+ const pushResult = yield* syncBackend.push(queueItems.map((_) => _.toGlobal())).pipe(Effect.result)
829
673
 
830
- const retries = iteration.recurrence
831
- if (retries > 0 && pushResult._tag === 'Right') {
832
- otelSpan?.addEvent('backend-push-retry-success', { retries, batchSize: queueItems.length })
674
+ const retries = iteration.attempt
675
+ if (retries > 0 && Result.isSuccess(pushResult) === true) {
676
+ yield* Effect.spanEvent('backend-push-retry-success', { retries, batchSize: queueItems.length })
833
677
  }
834
678
 
835
- if (pushResult._tag === 'Left') {
836
- otelSpan?.addEvent('backend-push-error', {
837
- error: pushResult.left.toString(),
679
+ if (Result.isFailure(pushResult) === true) {
680
+ yield* Effect.spanEvent('backend-push-error', {
681
+ error: pushResult.failure.toString(),
838
682
  retries,
839
683
  batchSize: queueItems.length,
840
684
  })
841
- const error = pushResult.left
842
- if (
843
- error._tag === 'IsOfflineError' ||
844
- (error._tag === 'InvalidPushError' && error.cause._tag === 'ServerAheadError')
845
- ) {
685
+ const error = pushResult.failure
686
+ if (error._tag === 'ServerAheadError') {
846
687
  // It's a core part of the sync protocol that the sync backend will emit a new pull chunk alongside the ServerAheadError
847
688
  yield* Effect.logDebug('handled backend-push-error (waiting for interupt caused by pull)', { error })
848
689
  return yield* Effect.never
@@ -850,11 +691,267 @@ const backgroundBackendPushing = ({
850
691
 
851
692
  return yield* error
852
693
  }
853
- }).pipe(Effect.retry(retrySchedule))
694
+ }).pipe(
695
+ // Retry transient errors
696
+ Effect.retry({
697
+ schedule: Schedule.exponential(Duration.seconds(1)).pipe(
698
+ Schedule.modifyDelay(({ duration }) => Effect.succeed(Duration.min(duration, Duration.seconds(30)))), // Cap delay at 30s intervals.
699
+ ),
700
+ while: (error) => error._tag === 'IsOfflineError' || error._tag === 'UnknownError',
701
+ }),
702
+ // This is needed to narrow the Error type. Our retry policy runs indefinitely, but Effect.retry does not narrow the Error type.
703
+ Effect.catchIf((error) => error._tag === 'IsOfflineError' || error._tag === 'UnknownError', Effect.die),
704
+ )
854
705
  }
855
- }).pipe(Effect.interruptible, Effect.withSpan('@livestore/common:LeaderSyncProcessor:backend-pushing'))
706
+ }).pipe(Effect.interruptible)
707
+
708
+ // NOTE: New events are only pushed to sync backend after successful local push processing
709
+ const push: Service['push'] = (newEvents) =>
710
+ Effect.gen(function* () {
711
+ if (newEvents.length === 0) return
712
+
713
+ const deferreds = yield* Effect.forEach(newEvents, () =>
714
+ Deferred.make<void, LeaderAheadError | StaleRebaseGenerationError>(),
715
+ )
856
716
 
857
- const trimChangesetRows = (db: SqliteDb, newHead: EventSequenceNumber.EventSequenceNumber) => {
717
+ const items = newEvents.map((eventEncoded, i) => [eventEncoded, deferreds[i]] as LocalPushQueueItem)
718
+
719
+ // Validation, reservation, enqueueing, and fence advancement form one admission transaction.
720
+ // Serializing them prevents concurrent sessions from both validating against the same head and
721
+ // prevents backend reconciliation from changing the fence midway through admission.
722
+ yield* pushAdmissionSemaphore.withPermits(1)(
723
+ // Cancellation must not leave reservations, queue contents, and the admission fence disagreeing.
724
+ Effect.gen(function* () {
725
+ yield* validatePushBatch(newEvents, pushHeadRef.current, isClientOnlyEvent)
726
+ for (const item of items) reservedLocalPushItems.add(item)
727
+ yield* TxQueue.offerAll(localPushesQueue, items)
728
+ pushHeadRef.current = newEvents.at(-1)!.seqNum
729
+ if (testing.hooks?.localPushAdmitted !== undefined) {
730
+ yield* testing.hooks.localPushAdmitted(newEvents)
731
+ }
732
+ }).pipe(Effect.uninterruptible),
733
+ )
734
+
735
+ yield* Effect.all(deferreds.map(Deferred.await))
736
+ }).pipe(
737
+ Effect.withSpan('@livestore/common:LeaderSyncProcessor:push', {
738
+ attributes: {
739
+ batchSize: newEvents.length,
740
+ batch: TRACE_VERBOSE === true ? newEvents : undefined,
741
+ },
742
+ links: ctxRef.current?.span !== undefined ? [{ span: ctxRef.current.span, attributes: {} }] : undefined,
743
+ }),
744
+ )
745
+
746
+ return LeaderSyncProcessor.of({
747
+ [TypeId]: TypeId,
748
+ // Starts various background loops
749
+ boot: Effect.gen(function* () {
750
+ const span = yield* Effect.currentSpan.pipe(Effect.orDie)
751
+ const { devtools, shutdownChannel } = yield* LeaderThreadCtx
752
+ const services = yield* Effect.context<LeaderThreadCtx>()
753
+
754
+ ctxRef.current = {
755
+ span,
756
+ devtoolsLatch: devtools.enabled === true ? devtools.syncBackendLatch : undefined,
757
+ services,
758
+ }
759
+
760
+ /** State transitions need to happen atomically, so we use a Ref to track the state */
761
+ yield* SubscriptionRef.set(syncStateSref, initialSyncState)
762
+
763
+ // Rehydrate sync queue
764
+ if (initialSyncState.pending.length > 0) {
765
+ const globalOrUnknownPendingEvents = initialSyncState.pending
766
+ // Don't sync client-only events
767
+ .filter((eventEncoded) => !isClientOnlyEvent(eventEncoded))
768
+
769
+ if (globalOrUnknownPendingEvents.length > 0) {
770
+ yield* TxQueue.offerAll(syncBackendPushQueue, globalOrUnknownPendingEvents)
771
+ }
772
+ }
773
+
774
+ const handleBackendIdMismatchError = (error: BackendIdMismatchError) =>
775
+ handleBackendIdMismatch({ error, onBackendIdMismatch, shutdownChannel })
776
+
777
+ const maybeShutdownOnError = (cause: Cause.Cause<UnknownError | MaterializeError>) =>
778
+ Effect.gen(function* () {
779
+ if (onError === 'ignore') {
780
+ if (LS_DEV === true) {
781
+ yield* Effect.logDebug(
782
+ `Ignoring sync error (${Option.getOrUndefined(Cause.findErrorOption(cause))?._tag ?? cause.toString()})`,
783
+ Cause.pretty(cause),
784
+ )
785
+ }
786
+ return
787
+ }
788
+
789
+ const error = Option.getOrUndefined(Cause.findErrorOption(cause))
790
+ const errorToSend = error === undefined ? UnknownError.make({ cause }) : error
791
+ yield* shutdownChannel.send(errorToSend).pipe(Effect.orDie)
792
+
793
+ return yield* Effect.failCause(cause).pipe(Effect.orDie)
794
+ })
795
+
796
+ yield* backgroundApplyLocalPushes.pipe(Effect.catchCause(maybeShutdownOnError), Effect.forkScoped)
797
+
798
+ const backendPushingFiberHandle = yield* FiberHandle.make<void, never>()
799
+ const backendPushingEffect = backgroundBackendPushing.pipe(
800
+ Effect.catchTag('BackendIdMismatchError', handleBackendIdMismatchError),
801
+ Effect.catchCause(maybeShutdownOnError),
802
+ )
803
+
804
+ yield* FiberHandle.run(backendPushingFiberHandle, backendPushingEffect)
805
+
806
+ yield* backgroundBackendPulling({
807
+ restartBackendPushing: (filteredRebasedPending) =>
808
+ Effect.gen(function* () {
809
+ // Stop current pushing fiber
810
+ yield* FiberHandle.clear(backendPushingFiberHandle)
811
+
812
+ // Reset the sync backend push queue
813
+ yield* TxQueue.clear(syncBackendPushQueue)
814
+ yield* TxQueue.offerAll(syncBackendPushQueue, filteredRebasedPending)
815
+
816
+ // Restart pushing fiber
817
+ yield* FiberHandle.run(backendPushingFiberHandle, backendPushingEffect)
818
+ }),
819
+ }).pipe(
820
+ Effect.retry({
821
+ // Retry pulling when we've lost connection to the sync backend
822
+ // We're using `until` with a refinement instead of `while` to narrow `IsOfflineError` out of the error type.
823
+ // See https://github.com/Effect-TS/effect/issues/6122
824
+ until: (error): error is Exclude<typeof error, IsOfflineError> => error._tag !== 'IsOfflineError',
825
+ }),
826
+ Effect.catchTag('BackendIdMismatchError', handleBackendIdMismatchError),
827
+ Effect.catchCause(maybeShutdownOnError),
828
+ // Needed to avoid `Fiber terminated with an unhandled error` logs which seem to happen because of the `Effect.retry` above.
829
+ // This might be a bug in Effect. Only seems to happen in the browser.
830
+ Effect.provideService(References.UnhandledLogLevel, undefined),
831
+ Effect.forkScoped,
832
+ )
833
+
834
+ return { initialLeaderHead: initialSyncState.localHead }
835
+ }).pipe(Effect.withSpanScoped('@livestore/common:LeaderSyncProcessor:boot')),
836
+ push,
837
+ pushPartial: ({ event: { name, args }, clientId, sessionId }) =>
838
+ Effect.gen(function* () {
839
+ const syncState = yield* Effect.fromNullishOr(yield* SubscriptionRef.get(syncStateSref)).pipe(
840
+ Effect.orDieDebugger,
841
+ )
842
+
843
+ const resolution = yield* resolveEventDef(schema, {
844
+ operation: '@livestore/common:LeaderSyncProcessor:pushPartial',
845
+ event: {
846
+ name,
847
+ args,
848
+ clientId,
849
+ sessionId,
850
+ seqNum: syncState.localHead,
851
+ },
852
+ })
853
+
854
+ if (resolution._tag === 'unknown') {
855
+ // Ignore partial pushes for unrecognised events – they are still
856
+ // persisted server-side once a schema update ships.
857
+ return
858
+ }
859
+
860
+ const eventEncoded = new LiveStoreEvent.Client.EncodedWithMeta({
861
+ name,
862
+ args,
863
+ clientId,
864
+ sessionId,
865
+ ...EventSequenceNumber.Client.nextPair({
866
+ seqNum: syncState.localHead,
867
+ isClientOnly: resolution.eventDef.options.clientOnly,
868
+ }),
869
+ })
870
+
871
+ yield* push([eventEncoded])
872
+ }).pipe(
873
+ // pushPartial constructs the event sequence number internally, so these errors should never happen.
874
+ Effect.catchIf(isRejectedPushError, Effect.die),
875
+ ),
876
+ pull: ({ cursor }) =>
877
+ Effect.gen(function* () {
878
+ const queue = yield* Effect.fromNullishOr(ctxRef.current?.services).pipe(
879
+ Effect.orDieDebugger,
880
+ Effect.flatMap((services) =>
881
+ connectedClientSessionPullQueues.makeQueue(cursor).pipe(Effect.provide(services)),
882
+ ),
883
+ )
884
+ return Stream.fromQueue(queue)
885
+ }).pipe(Stream.unwrap),
886
+ /*
887
+ Notes for a potential new `LeaderSyncProcessor.pull` implementation:
888
+
889
+ - Doesn't take cursor but is "atomically called" in the leader during the snapshot phase
890
+ - TODO: how is this done "atomically" in the web adapter where the snapshot is read optimistically?
891
+ - Would require a new kind of "boot-phase" API which is stream based:
892
+ - initial message: state snapshot + seq num head
893
+ - subsequent messages: sync state payloads
894
+
895
+ - alternative: instead of session pulling sync state payloads from leader, we could send
896
+ - events in the "advance" case
897
+ - full new state db snapshot in the "rebase" case
898
+ - downside: importing the snapshot is expensive
899
+ */
900
+ pullQueue: ({ cursor }) =>
901
+ Effect.fromNullishOr(ctxRef.current?.services).pipe(
902
+ Effect.orDieDebugger,
903
+ Effect.flatMap((services) => connectedClientSessionPullQueues.makeQueue(cursor).pipe(Effect.provide(services))),
904
+ ),
905
+ syncState: Subscribable.make({
906
+ get: SubscriptionRef.get(syncStateSref).pipe(Effect.flatMap(Effect.fromNullishOr), Effect.orDieDebugger),
907
+ changes: SubscriptionRef.changes(syncStateSref).pipe(Stream.filter(Predicate.isNotUndefined)),
908
+ }),
909
+ })
910
+ })
911
+
912
+ export const layer = (options: Options) => Layer.effect(LeaderSyncProcessor, make(options))
913
+
914
+ type MaterializeEventsBatch = (_: {
915
+ batchItems: ReadonlyArray<LiveStoreEvent.Client.EncodedWithMeta>
916
+ }) => Effect.Effect<void, MaterializeError, LeaderThreadCtx>
917
+
918
+ // TODO how to handle errors gracefully
919
+ const materializeEventsBatch: MaterializeEventsBatch = ({ batchItems }) =>
920
+ Effect.gen(function* () {
921
+ const { dbState: db, dbEventlog, materializeEvent } = yield* LeaderThreadCtx
922
+
923
+ // NOTE We always start a transaction to ensure consistency between db and eventlog (even for single-item batches)
924
+ db.execute('BEGIN TRANSACTION', undefined) // Start the transaction
925
+ dbEventlog.execute('BEGIN TRANSACTION', undefined) // Start the transaction
926
+
927
+ yield* Effect.addFinalizer((exit) =>
928
+ Effect.gen(function* () {
929
+ if (Exit.isSuccess(exit) === true) return
930
+
931
+ // Rollback in case of an error
932
+ db.execute('ROLLBACK', undefined)
933
+ dbEventlog.execute('ROLLBACK', undefined)
934
+ }),
935
+ )
936
+
937
+ for (let i = 0; i < batchItems.length; i++) {
938
+ const { sessionChangeset, hash } = yield* materializeEvent(batchItems[i]!)
939
+ batchItems[i]!.meta.sessionChangeset = sessionChangeset
940
+ batchItems[i]!.meta.materializerHashLeader = hash
941
+ }
942
+
943
+ db.execute('COMMIT', undefined) // Commit the transaction
944
+ dbEventlog.execute('COMMIT', undefined) // Commit the transaction
945
+ }).pipe(
946
+ Effect.uninterruptible,
947
+ Effect.scoped,
948
+ Effect.withSpan('@livestore/common:LeaderSyncProcessor:materializeEventItems', {
949
+ attributes: { batchSize: batchItems.length },
950
+ }),
951
+ Effect.tapCauseLogPretty,
952
+ )
953
+
954
+ const trimChangesetRows = (db: SqliteDb, newHead: EventSequenceNumber.Client.Composite) => {
858
955
  // Since we're using the session changeset rows to query for the current head,
859
956
  // we're keeping at least one row for the current head, and thus are using `<` instead of `<=`
860
957
  db.execute(sql`DELETE FROM ${SystemTables.SESSION_CHANGESET_META_TABLE} WHERE seqNumGlobal < ${newHead.global}`)
@@ -862,16 +959,16 @@ const trimChangesetRows = (db: SqliteDb, newHead: EventSequenceNumber.EventSeque
862
959
 
863
960
  interface PullQueueSet {
864
961
  makeQueue: (
865
- cursor: EventSequenceNumber.EventSequenceNumber,
962
+ cursor: EventSequenceNumber.Client.Composite,
866
963
  ) => Effect.Effect<
867
964
  Queue.Queue<{ payload: typeof SyncState.PayloadUpstream.Type }>,
868
- UnexpectedError,
965
+ never,
869
966
  Scope.Scope | LeaderThreadCtx
870
967
  >
871
968
  offer: (item: {
872
969
  payload: typeof SyncState.PayloadUpstream.Type
873
- leaderHead: EventSequenceNumber.EventSequenceNumber
874
- }) => Effect.Effect<void, UnexpectedError>
970
+ leaderHead: EventSequenceNumber.Client.Composite
971
+ }) => Effect.Effect<void, never>
875
972
  }
876
973
 
877
974
  const makePullQueueSet = Effect.gen(function* () {
@@ -893,25 +990,28 @@ const makePullQueueSet = Effect.gen(function* () {
893
990
 
894
991
  const makeQueue: PullQueueSet['makeQueue'] = (cursor) =>
895
992
  Effect.gen(function* () {
896
- const queue = yield* Queue.unbounded<{
897
- payload: typeof SyncState.PayloadUpstream.Type
898
- }>().pipe(Effect.acquireRelease(Queue.shutdown))
993
+ const queue = yield* Effect.acquireRelease(
994
+ Queue.unbounded<{
995
+ payload: typeof SyncState.PayloadUpstream.Type
996
+ }>(),
997
+ Queue.shutdown,
998
+ )
899
999
 
900
1000
  yield* Effect.addFinalizer(() => Effect.sync(() => set.delete(queue)))
901
1001
 
902
1002
  const payloadsSinceCursor = Array.from(cachedPayloads.entries())
903
1003
  .flatMap(([seqNumStr, payloads]) =>
904
- payloads.map((payload) => ({ payload, seqNum: EventSequenceNumber.fromString(seqNumStr) })),
1004
+ payloads.map((payload) => ({ payload, seqNum: EventSequenceNumber.Client.fromString(seqNumStr) })),
905
1005
  )
906
- .filter(({ seqNum }) => EventSequenceNumber.isGreaterThan(seqNum, cursor))
907
- .toSorted((a, b) => EventSequenceNumber.compare(a.seqNum, b.seqNum))
1006
+ .filter(({ seqNum }) => EventSequenceNumber.Client.isGreaterThan(seqNum, cursor))
1007
+ .toSorted((a, b) => EventSequenceNumber.Client.compare(a.seqNum, b.seqNum))
908
1008
  .map(({ payload }) => {
909
1009
  if (payload._tag === 'upstream-advance') {
910
1010
  return {
911
1011
  payload: {
912
1012
  _tag: 'upstream-advance' as const,
913
1013
  newEvents: ReadonlyArray.dropWhile(payload.newEvents, (eventEncoded) =>
914
- EventSequenceNumber.isGreaterThanOrEqual(cursor, eventEncoded.seqNum),
1014
+ EventSequenceNumber.Client.isGreaterThanOrEqual(cursor, eventEncoded.seqNum),
915
1015
  ),
916
1016
  },
917
1017
  }
@@ -948,7 +1048,7 @@ const makePullQueueSet = Effect.gen(function* () {
948
1048
  // ]),
949
1049
  // )
950
1050
 
951
- yield* queue.offerAll(payloadsSinceCursor)
1051
+ yield* Queue.offerAll(queue, payloadsSinceCursor)
952
1052
 
953
1053
  set.add(queue)
954
1054
 
@@ -957,8 +1057,8 @@ const makePullQueueSet = Effect.gen(function* () {
957
1057
 
958
1058
  const offer: PullQueueSet['offer'] = (item) =>
959
1059
  Effect.gen(function* () {
960
- const seqNumStr = EventSequenceNumber.toString(item.leaderHead)
961
- if (cachedPayloads.has(seqNumStr)) {
1060
+ const seqNumStr = EventSequenceNumber.Client.toString(item.leaderHead)
1061
+ if (cachedPayloads.has(seqNumStr) === true) {
962
1062
  cachedPayloads.get(seqNumStr)!.push(item.payload)
963
1063
  } else {
964
1064
  cachedPayloads.set(seqNumStr, [item.payload])
@@ -982,29 +1082,182 @@ const makePullQueueSet = Effect.gen(function* () {
982
1082
  }
983
1083
  })
984
1084
 
1085
+ /**
1086
+ * Validate a client-provided batch before it is admitted to the leader queue.
1087
+ * Ensures the numbers form a strictly increasing chain and that the first
1088
+ * event sits ahead of the current push head.
1089
+ */
985
1090
  const validatePushBatch = (
986
- batch: ReadonlyArray<LiveStoreEvent.EncodedWithMeta>,
987
- pushHead: EventSequenceNumber.EventSequenceNumber,
1091
+ batch: ReadonlyArray<LiveStoreEvent.Client.EncodedWithMeta>,
1092
+ pushHead: EventSequenceNumber.Client.Composite,
1093
+ isClientOnlyEvent: (event: LiveStoreEvent.Client.EncodedWithMeta) => boolean,
988
1094
  ) =>
989
1095
  Effect.gen(function* () {
990
1096
  if (batch.length === 0) {
991
1097
  return
992
1098
  }
993
1099
 
994
- // Make sure batch is monotonically increasing
1100
+ // Defensive check: callers should already provide a strictly increasing sequence
1101
+ // of event numbers.
995
1102
  for (let i = 1; i < batch.length; i++) {
996
- if (EventSequenceNumber.isGreaterThanOrEqual(batch[i - 1]!.seqNum, batch[i]!.seqNum)) {
997
- shouldNeverHappen(
998
- `Events must be ordered in monotonically ascending order by eventNum. Received: [${batch.map((e) => EventSequenceNumber.toString(e.seqNum)).join(', ')}]`,
999
- )
1103
+ if (EventSequenceNumber.Client.isGreaterThanOrEqual(batch[i - 1]!.seqNum, batch[i]!.seqNum) === true) {
1104
+ return yield* NonMonotonicBatchError.make({
1105
+ precedingSeqNum: batch[i - 1]!.seqNum,
1106
+ violatingSeqNum: batch[i]!.seqNum,
1107
+ violationIndex: i,
1108
+ sessionId: batch[i]!.sessionId,
1109
+ })
1000
1110
  }
1001
1111
  }
1002
1112
 
1003
- // Make sure smallest sequence number is > pushHead
1004
- if (EventSequenceNumber.isGreaterThanOrEqual(pushHead, batch[0]!.seqNum)) {
1113
+ // Reject stale batches whose first event is at or behind the leader's push head.
1114
+ if (EventSequenceNumber.Client.isGreaterThanOrEqual(pushHead, batch[0]!.seqNum) === true) {
1005
1115
  return yield* LeaderAheadError.make({
1006
1116
  minimumExpectedNum: pushHead,
1007
1117
  providedNum: batch[0]!.seqNum,
1118
+ sessionId: batch[0]!.sessionId,
1119
+ })
1120
+ }
1121
+
1122
+ // A rebase replaces the optimistic history that the client built on. Events from an older
1123
+ // generation may now have the wrong parent and must be recreated from the leader's current head.
1124
+ if (batch[0]!.seqNum.rebaseGeneration < pushHead.rebaseGeneration) {
1125
+ return yield* StaleRebaseGenerationError.make({
1126
+ currentRebaseGeneration: pushHead.rebaseGeneration,
1127
+ providedRebaseGeneration: batch[0]!.seqNum.rebaseGeneration,
1128
+ sessionId: batch[0]!.sessionId,
1129
+ })
1130
+ }
1131
+
1132
+ // Validate the batch as one unbroken chain starting at the admission fence. Checking only that
1133
+ // numbers increase would still allow gaps or events that point at an unrelated parent.
1134
+ let precedingSeqNum = pushHead
1135
+ for (let i = 0; i < batch.length; i++) {
1136
+ const event = batch[i]!
1137
+ // Global events advance the global position; client-only events advance its client-local suffix.
1138
+ const expectedPair = EventSequenceNumber.Client.nextPair({
1139
+ seqNum: precedingSeqNum,
1140
+ isClientOnly: isClientOnlyEvent(event),
1141
+ rebaseGeneration: event.seqNum.rebaseGeneration,
1008
1142
  })
1143
+
1144
+ if (
1145
+ EventSequenceNumber.Client.isEqual(event.seqNum, expectedPair.seqNum) === false ||
1146
+ isSameSequencePosition(event.parentSeqNum, expectedPair.parentSeqNum) === false
1147
+ ) {
1148
+ return yield* NonContiguousBatchError.make({
1149
+ expectedSeqNum: expectedPair.seqNum,
1150
+ providedSeqNum: event.seqNum,
1151
+ expectedParentSeqNum: expectedPair.parentSeqNum,
1152
+ providedParentSeqNum: event.parentSeqNum,
1153
+ violationIndex: i,
1154
+ sessionId: event.sessionId,
1155
+ })
1156
+ }
1157
+
1158
+ precedingSeqNum = event.seqNum
1159
+ }
1160
+ })
1161
+
1162
+ /**
1163
+ * Parent linkage identifies a position in the event chain. Rebase generation describes the version
1164
+ * of optimistic history, so it is validated on the event itself rather than as part of parent identity.
1165
+ */
1166
+ const isSameSequencePosition = (
1167
+ left: EventSequenceNumber.Client.Composite,
1168
+ right: EventSequenceNumber.Client.Composite,
1169
+ ) => left.global === right.global && left.client === right.client
1170
+
1171
+ /**
1172
+ * Handles a BackendIdMismatchError based on the configured behavior.
1173
+ * This occurs when the sync backend has been reset and has a new identity.
1174
+ */
1175
+ const handleBackendIdMismatch = Effect.fn('@livestore/common:LeaderSyncProcessor:handleBackendIdMismatch')(function* ({
1176
+ error,
1177
+ onBackendIdMismatch,
1178
+ shutdownChannel,
1179
+ }: {
1180
+ error: BackendIdMismatchError
1181
+ onBackendIdMismatch: 'reset' | 'shutdown' | 'ignore'
1182
+ shutdownChannel: ShutdownChannel
1183
+ }) {
1184
+ const { dbEventlog, dbState } = yield* LeaderThreadCtx
1185
+
1186
+ if (onBackendIdMismatch === 'reset') {
1187
+ yield* Effect.logWarning(
1188
+ 'Sync backend identity changed (backend was reset). Clearing local storage and shutting down.',
1189
+ error,
1190
+ )
1191
+
1192
+ // Clear local databases so the client can start fresh on next boot
1193
+ yield* clearLocalDatabases({ dbEventlog, dbState })
1194
+
1195
+ // Send shutdown signal with special reason
1196
+ yield* shutdownChannel.send(IntentionalShutdownCause.make({ reason: 'backend-id-mismatch' })).pipe(Effect.orDie)
1197
+
1198
+ return yield* Effect.die(error)
1199
+ }
1200
+
1201
+ if (onBackendIdMismatch === 'shutdown') {
1202
+ yield* Effect.logWarning(
1203
+ 'Sync backend identity changed (backend was reset). Shutting down without clearing local storage.',
1204
+ error,
1205
+ )
1206
+
1207
+ yield* shutdownChannel.send(error).pipe(Effect.orDie)
1208
+
1209
+ return yield* Effect.die(error)
1210
+ }
1211
+
1212
+ // ignore mode
1213
+ if (LS_DEV === true) {
1214
+ yield* Effect.logDebug(
1215
+ 'Ignoring BackendIdMismatchError (sync backend was reset but client continues with stale data)',
1216
+ error,
1217
+ )
1218
+ }
1219
+ })
1220
+
1221
+ /**
1222
+ * Clears local databases (eventlog and state) so the client can start fresh on next boot.
1223
+ * This is used when the sync backend identity has changed (i.e. backend was reset).
1224
+ */
1225
+ const clearLocalDatabases = ({ dbEventlog, dbState }: { dbEventlog: SqliteDb; dbState: SqliteDb }) =>
1226
+ Effect.sync(() => {
1227
+ // Clear eventlog tables
1228
+ dbEventlog.execute(sql`DELETE FROM ${EVENTLOG_META_TABLE}`)
1229
+ dbEventlog.execute(sql`DELETE FROM ${SYNC_STATUS_TABLE}`)
1230
+
1231
+ // Drop all state tables - they'll be recreated on next boot
1232
+ const tables = dbState.select<{ name: string }>(
1233
+ sql`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`,
1234
+ )
1235
+ for (const { name } of tables) {
1236
+ dbState.execute(`DROP TABLE IF EXISTS "${name}"`)
1009
1237
  }
1010
1238
  })
1239
+
1240
+ const snapshotTxQueue = <A>(queue: TxQueue.TxQueue<A>): Effect.Effect<ReadonlyArray<A>> =>
1241
+ Effect.tx(
1242
+ Effect.gen(function* () {
1243
+ const items = yield* TxQueue.clear(queue)
1244
+ yield* TxQueue.offerAll(queue, items)
1245
+ return items
1246
+ }),
1247
+ )
1248
+
1249
+ const takePrefixUntil = <A>(
1250
+ queue: TxQueue.TxQueue<A>,
1251
+ predicate: (value: A) => boolean,
1252
+ ): Effect.Effect<ReadonlyArray<A>> =>
1253
+ Effect.tx(
1254
+ Effect.gen(function* () {
1255
+ const items = yield* TxQueue.clear(queue)
1256
+ const [prefix, rest] = ReadonlyArray.splitWhere(items, predicate)
1257
+ yield* TxQueue.offerAll(queue, rest)
1258
+ return prefix
1259
+ }),
1260
+ )
1261
+
1262
+ /** Serialize value to JSON string for trace attributes */
1263
+ const jsonStringify = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown))