@livestore/common 0.4.0 → 0.5.0-dev.1

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