@livestore/common 0.4.0 → 0.5.0-dev.0

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