@livestore/common 0.4.0 → 0.5.0-dev.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (501) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/ClientSessionLeaderThreadProxy.d.ts +1 -1
  3. package/dist/ClientSessionLeaderThreadProxy.d.ts.map +1 -1
  4. package/dist/MaterializationJournal.d.ts +44 -0
  5. package/dist/MaterializationJournal.d.ts.map +1 -0
  6. package/dist/MaterializationJournal.js +112 -0
  7. package/dist/MaterializationJournal.js.map +1 -0
  8. package/dist/StateHead.d.ts +26 -0
  9. package/dist/StateHead.d.ts.map +1 -0
  10. package/dist/StateHead.js +50 -0
  11. package/dist/StateHead.js.map +1 -0
  12. package/dist/WorkerTransportError.d.ts +3 -3
  13. package/dist/WorkerTransportError.d.ts.map +1 -1
  14. package/dist/WorkerTransportError.js +3 -3
  15. package/dist/WorkerTransportError.js.map +1 -1
  16. package/dist/__tests__/fixture.d.ts +8 -8
  17. package/dist/__tests__/fixture.d.ts.map +1 -1
  18. package/dist/__tests__/fixture.js +4 -4
  19. package/dist/__tests__/fixture.js.map +1 -1
  20. package/dist/adapter-types.d.ts +33 -57
  21. package/dist/adapter-types.d.ts.map +1 -1
  22. package/dist/adapter-types.js +24 -41
  23. package/dist/adapter-types.js.map +1 -1
  24. package/dist/bounded-collections.d.ts.map +1 -1
  25. package/dist/bounded-collections.js.map +1 -1
  26. package/dist/debug-info.d.ts +56 -56
  27. package/dist/debug-info.d.ts.map +1 -1
  28. package/dist/debug-info.js +32 -44
  29. package/dist/debug-info.js.map +1 -1
  30. package/dist/defs.d.ts +9 -9
  31. package/dist/defs.js +2 -2
  32. package/dist/devtools/devtools-compatibility.test.js +2 -1
  33. package/dist/devtools/devtools-compatibility.test.js.map +1 -1
  34. package/dist/devtools/devtools-messages-client-session.d.ts +522 -334
  35. package/dist/devtools/devtools-messages-client-session.d.ts.map +1 -1
  36. package/dist/devtools/devtools-messages-client-session.js +80 -78
  37. package/dist/devtools/devtools-messages-client-session.js.map +1 -1
  38. package/dist/devtools/devtools-messages-common.d.ts +27 -27
  39. package/dist/devtools/devtools-messages-common.d.ts.map +1 -1
  40. package/dist/devtools/devtools-messages-common.js +6 -6
  41. package/dist/devtools/devtools-messages-common.js.map +1 -1
  42. package/dist/devtools/devtools-messages-leader.d.ts +410 -348
  43. package/dist/devtools/devtools-messages-leader.d.ts.map +1 -1
  44. package/dist/devtools/devtools-messages-leader.js +105 -93
  45. package/dist/devtools/devtools-messages-leader.js.map +1 -1
  46. package/dist/devtools/devtools-messages.js +3 -3
  47. package/dist/devtools/devtools-sessioninfo.d.ts +18 -19
  48. package/dist/devtools/devtools-sessioninfo.d.ts.map +1 -1
  49. package/dist/devtools/devtools-sessioninfo.js +8 -5
  50. package/dist/devtools/devtools-sessioninfo.js.map +1 -1
  51. package/dist/devtools/mod.d.ts +4 -5
  52. package/dist/devtools/mod.d.ts.map +1 -1
  53. package/dist/devtools/mod.js +12 -8
  54. package/dist/devtools/mod.js.map +1 -1
  55. package/dist/errors.d.ts +45 -69
  56. package/dist/errors.d.ts.map +1 -1
  57. package/dist/errors.js +19 -17
  58. package/dist/errors.js.map +1 -1
  59. package/dist/index.d.ts +4 -1
  60. package/dist/index.d.ts.map +1 -1
  61. package/dist/index.js +22 -19
  62. package/dist/index.js.map +1 -1
  63. package/dist/leader-thread/LeaderSyncProcessor.d.ts +87 -18
  64. package/dist/leader-thread/LeaderSyncProcessor.d.ts.map +1 -1
  65. package/dist/leader-thread/LeaderSyncProcessor.js +548 -480
  66. package/dist/leader-thread/LeaderSyncProcessor.js.map +1 -1
  67. package/dist/leader-thread/RejectedPushError.d.ts +72 -41
  68. package/dist/leader-thread/RejectedPushError.d.ts.map +1 -1
  69. package/dist/leader-thread/RejectedPushError.js +25 -6
  70. package/dist/leader-thread/RejectedPushError.js.map +1 -1
  71. package/dist/leader-thread/connection.d.ts +1 -0
  72. package/dist/leader-thread/connection.d.ts.map +1 -1
  73. package/dist/leader-thread/connection.js +11 -10
  74. package/dist/leader-thread/connection.js.map +1 -1
  75. package/dist/leader-thread/eventlog.d.ts +15 -5
  76. package/dist/leader-thread/eventlog.d.ts.map +1 -1
  77. package/dist/leader-thread/eventlog.js +19 -19
  78. package/dist/leader-thread/eventlog.js.map +1 -1
  79. package/dist/leader-thread/leader-worker-devtools.d.ts +1 -1
  80. package/dist/leader-thread/leader-worker-devtools.d.ts.map +1 -1
  81. package/dist/leader-thread/leader-worker-devtools.js +36 -20
  82. package/dist/leader-thread/leader-worker-devtools.js.map +1 -1
  83. package/dist/leader-thread/make-leader-thread-layer.d.ts +15 -5
  84. package/dist/leader-thread/make-leader-thread-layer.d.ts.map +1 -1
  85. package/dist/leader-thread/make-leader-thread-layer.js +62 -58
  86. package/dist/leader-thread/make-leader-thread-layer.js.map +1 -1
  87. package/dist/leader-thread/make-leader-thread-layer.test.js +9 -9
  88. package/dist/leader-thread/make-leader-thread-layer.test.js.map +1 -1
  89. package/dist/leader-thread/materialize-event.d.ts +2 -1
  90. package/dist/leader-thread/materialize-event.d.ts.map +1 -1
  91. package/dist/leader-thread/materialize-event.js +12 -8
  92. package/dist/leader-thread/materialize-event.js.map +1 -1
  93. package/dist/leader-thread/mod.js +9 -9
  94. package/dist/leader-thread/recreate-db.d.ts +5 -1
  95. package/dist/leader-thread/recreate-db.d.ts.map +1 -1
  96. package/dist/leader-thread/recreate-db.js +34 -59
  97. package/dist/leader-thread/recreate-db.js.map +1 -1
  98. package/dist/leader-thread/shutdown-channel.d.ts +2 -6
  99. package/dist/leader-thread/shutdown-channel.d.ts.map +1 -1
  100. package/dist/leader-thread/shutdown-channel.js +2 -3
  101. package/dist/leader-thread/shutdown-channel.js.map +1 -1
  102. package/dist/leader-thread/stream-events.d.ts +4 -7
  103. package/dist/leader-thread/stream-events.d.ts.map +1 -1
  104. package/dist/leader-thread/stream-events.js +16 -21
  105. package/dist/leader-thread/stream-events.js.map +1 -1
  106. package/dist/leader-thread/types.d.ts +38 -73
  107. package/dist/leader-thread/types.d.ts.map +1 -1
  108. package/dist/leader-thread/types.js +10 -6
  109. package/dist/leader-thread/types.js.map +1 -1
  110. package/dist/logging.d.ts +5 -30
  111. package/dist/logging.d.ts.map +1 -1
  112. package/dist/logging.js +1 -32
  113. package/dist/logging.js.map +1 -1
  114. package/dist/make-client-session.d.ts +2 -3
  115. package/dist/make-client-session.d.ts.map +1 -1
  116. package/dist/make-client-session.js +5 -5
  117. package/dist/make-client-session.js.map +1 -1
  118. package/dist/materializer-helper.d.ts +0 -1
  119. package/dist/materializer-helper.d.ts.map +1 -1
  120. package/dist/materializer-helper.js +9 -37
  121. package/dist/materializer-helper.js.map +1 -1
  122. package/dist/otel.d.ts +2 -3
  123. package/dist/otel.d.ts.map +1 -1
  124. package/dist/otel.js +2 -7
  125. package/dist/otel.js.map +1 -1
  126. package/dist/rematerialize-from-eventlog.d.ts +3 -1
  127. package/dist/rematerialize-from-eventlog.d.ts.map +1 -1
  128. package/dist/rematerialize-from-eventlog.js +42 -33
  129. package/dist/rematerialize-from-eventlog.js.map +1 -1
  130. package/dist/schema/EventDef/define.d.ts +6 -6
  131. package/dist/schema/EventDef/define.d.ts.map +1 -1
  132. package/dist/schema/EventDef/define.js +5 -5
  133. package/dist/schema/EventDef/define.js.map +1 -1
  134. package/dist/schema/EventDef/define.test.d.ts +2 -0
  135. package/dist/schema/EventDef/define.test.d.ts.map +1 -0
  136. package/dist/schema/EventDef/define.test.js +23 -0
  137. package/dist/schema/EventDef/define.test.js.map +1 -0
  138. package/dist/schema/EventDef/deprecated.d.ts +8 -13
  139. package/dist/schema/EventDef/deprecated.d.ts.map +1 -1
  140. package/dist/schema/EventDef/deprecated.js +12 -13
  141. package/dist/schema/EventDef/deprecated.js.map +1 -1
  142. package/dist/schema/EventDef/deprecated.test.js +3 -3
  143. package/dist/schema/EventDef/deprecated.test.js.map +1 -1
  144. package/dist/schema/EventDef/event-def.d.ts +1 -1
  145. package/dist/schema/EventDef/event-def.d.ts.map +1 -1
  146. package/dist/schema/EventDef/facts.d.ts.map +1 -1
  147. package/dist/schema/EventDef/materializer.d.ts +2 -2
  148. package/dist/schema/EventDef/materializer.d.ts.map +1 -1
  149. package/dist/schema/EventDef/mod.js +5 -5
  150. package/dist/schema/EventSequenceNumber/client.d.ts +12 -17
  151. package/dist/schema/EventSequenceNumber/client.d.ts.map +1 -1
  152. package/dist/schema/EventSequenceNumber/client.js +20 -24
  153. package/dist/schema/EventSequenceNumber/client.js.map +1 -1
  154. package/dist/schema/EventSequenceNumber/global.d.ts +2 -2
  155. package/dist/schema/EventSequenceNumber/global.d.ts.map +1 -1
  156. package/dist/schema/EventSequenceNumber/global.js +1 -1
  157. package/dist/schema/EventSequenceNumber/global.js.map +1 -1
  158. package/dist/schema/EventSequenceNumber/mod.js +2 -2
  159. package/dist/schema/EventSequenceNumber.test.js +6 -6
  160. package/dist/schema/EventSequenceNumber.test.js.map +1 -1
  161. package/dist/schema/LiveStoreEvent/client.d.ts +58 -187
  162. package/dist/schema/LiveStoreEvent/client.d.ts.map +1 -1
  163. package/dist/schema/LiveStoreEvent/client.js +33 -30
  164. package/dist/schema/LiveStoreEvent/client.js.map +1 -1
  165. package/dist/schema/LiveStoreEvent/client.test.js +13 -4
  166. package/dist/schema/LiveStoreEvent/client.test.js.map +1 -1
  167. package/dist/schema/LiveStoreEvent/for-event-def.d.ts +7 -7
  168. package/dist/schema/LiveStoreEvent/for-event-def.d.ts.map +1 -1
  169. package/dist/schema/LiveStoreEvent/global.d.ts +6 -6
  170. package/dist/schema/LiveStoreEvent/global.d.ts.map +1 -1
  171. package/dist/schema/LiveStoreEvent/global.js +2 -2
  172. package/dist/schema/LiveStoreEvent/global.js.map +1 -1
  173. package/dist/schema/LiveStoreEvent/input.d.ts +6 -6
  174. package/dist/schema/LiveStoreEvent/input.d.ts.map +1 -1
  175. package/dist/schema/LiveStoreEvent/input.js +3 -3
  176. package/dist/schema/LiveStoreEvent/input.js.map +1 -1
  177. package/dist/schema/LiveStoreEvent/mod.js +4 -4
  178. package/dist/schema/events.js +1 -1
  179. package/dist/schema/mod.d.ts +1 -0
  180. package/dist/schema/mod.d.ts.map +1 -1
  181. package/dist/schema/mod.js +11 -10
  182. package/dist/schema/mod.js.map +1 -1
  183. package/dist/schema/schema.d.ts +1 -1
  184. package/dist/schema/schema.d.ts.map +1 -1
  185. package/dist/schema/schema.js +6 -5
  186. package/dist/schema/schema.js.map +1 -1
  187. package/dist/schema/state/mod.js +2 -2
  188. package/dist/schema/state/sqlite/client-document-def.d.ts +13 -18
  189. package/dist/schema/state/sqlite/client-document-def.d.ts.map +1 -1
  190. package/dist/schema/state/sqlite/client-document-def.js +30 -27
  191. package/dist/schema/state/sqlite/client-document-def.js.map +1 -1
  192. package/dist/schema/state/sqlite/client-document-def.test.js +53 -18
  193. package/dist/schema/state/sqlite/client-document-def.test.js.map +1 -1
  194. package/dist/schema/state/sqlite/column-annotations.d.ts +13 -13
  195. package/dist/schema/state/sqlite/column-annotations.d.ts.map +1 -1
  196. package/dist/schema/state/sqlite/column-annotations.js +13 -15
  197. package/dist/schema/state/sqlite/column-annotations.js.map +1 -1
  198. package/dist/schema/state/sqlite/column-annotations.test.js +19 -43
  199. package/dist/schema/state/sqlite/column-annotations.test.js.map +1 -1
  200. package/dist/schema/state/sqlite/column-def.d.ts +1 -1
  201. package/dist/schema/state/sqlite/column-def.d.ts.map +1 -1
  202. package/dist/schema/state/sqlite/column-def.js +73 -33
  203. package/dist/schema/state/sqlite/column-def.js.map +1 -1
  204. package/dist/schema/state/sqlite/column-def.test.js +176 -75
  205. package/dist/schema/state/sqlite/column-def.test.js.map +1 -1
  206. package/dist/schema/state/sqlite/column-spec.d.ts.map +1 -1
  207. package/dist/schema/state/sqlite/column-spec.js +1 -1
  208. package/dist/schema/state/sqlite/column-spec.js.map +1 -1
  209. package/dist/schema/state/sqlite/column-spec.test.js +4 -4
  210. package/dist/schema/state/sqlite/column-spec.test.js.map +1 -1
  211. package/dist/schema/state/sqlite/db-schema/ast/fingerprint-digest.d.ts +3 -0
  212. package/dist/schema/state/sqlite/db-schema/ast/fingerprint-digest.d.ts.map +1 -0
  213. package/dist/schema/state/sqlite/db-schema/ast/fingerprint-digest.js +5 -0
  214. package/dist/schema/state/sqlite/db-schema/ast/fingerprint-digest.js.map +1 -0
  215. package/dist/schema/state/sqlite/db-schema/ast/fingerprint.d.ts +5 -0
  216. package/dist/schema/state/sqlite/db-schema/ast/fingerprint.d.ts.map +1 -0
  217. package/dist/schema/state/sqlite/db-schema/ast/fingerprint.js +183 -0
  218. package/dist/schema/state/sqlite/db-schema/ast/fingerprint.js.map +1 -0
  219. package/dist/schema/state/sqlite/db-schema/ast/fingerprint.test.d.ts +2 -0
  220. package/dist/schema/state/sqlite/db-schema/ast/fingerprint.test.d.ts.map +1 -0
  221. package/dist/schema/state/sqlite/db-schema/ast/fingerprint.test.js +142 -0
  222. package/dist/schema/state/sqlite/db-schema/ast/fingerprint.test.js.map +1 -0
  223. package/dist/schema/state/sqlite/db-schema/ast/sqlite.d.ts +3 -8
  224. package/dist/schema/state/sqlite/db-schema/ast/sqlite.d.ts.map +1 -1
  225. package/dist/schema/state/sqlite/db-schema/ast/sqlite.js +1 -73
  226. package/dist/schema/state/sqlite/db-schema/ast/sqlite.js.map +1 -1
  227. package/dist/schema/state/sqlite/db-schema/ast/validate.d.ts.map +1 -1
  228. package/dist/schema/state/sqlite/db-schema/dsl/field-defs.d.ts +9 -9
  229. package/dist/schema/state/sqlite/db-schema/dsl/field-defs.d.ts.map +1 -1
  230. package/dist/schema/state/sqlite/db-schema/dsl/field-defs.js +9 -13
  231. package/dist/schema/state/sqlite/db-schema/dsl/field-defs.js.map +1 -1
  232. package/dist/schema/state/sqlite/db-schema/dsl/field-defs.test.js +28 -17
  233. package/dist/schema/state/sqlite/db-schema/dsl/field-defs.test.js.map +1 -1
  234. package/dist/schema/state/sqlite/db-schema/dsl/mod.d.ts +13 -11
  235. package/dist/schema/state/sqlite/db-schema/dsl/mod.d.ts.map +1 -1
  236. package/dist/schema/state/sqlite/db-schema/dsl/mod.js +6 -4
  237. package/dist/schema/state/sqlite/db-schema/dsl/mod.js.map +1 -1
  238. package/dist/schema/state/sqlite/db-schema/has-json-string-encoding.d.ts +3 -0
  239. package/dist/schema/state/sqlite/db-schema/has-json-string-encoding.d.ts.map +1 -0
  240. package/dist/schema/state/sqlite/db-schema/has-json-string-encoding.js +6 -0
  241. package/dist/schema/state/sqlite/db-schema/has-json-string-encoding.js.map +1 -0
  242. package/dist/schema/state/sqlite/db-schema/mod.js +2 -2
  243. package/dist/schema/state/sqlite/mod.d.ts +1 -3
  244. package/dist/schema/state/sqlite/mod.d.ts.map +1 -1
  245. package/dist/schema/state/sqlite/mod.js +11 -10
  246. package/dist/schema/state/sqlite/mod.js.map +1 -1
  247. package/dist/schema/state/sqlite/persistence.d.ts +11 -0
  248. package/dist/schema/state/sqlite/persistence.d.ts.map +1 -0
  249. package/dist/schema/state/sqlite/persistence.js +10 -0
  250. package/dist/schema/state/sqlite/persistence.js.map +1 -0
  251. package/dist/schema/state/sqlite/query-builder/api.d.ts +7 -7
  252. package/dist/schema/state/sqlite/query-builder/api.d.ts.map +1 -1
  253. package/dist/schema/state/sqlite/query-builder/astToSql.d.ts.map +1 -1
  254. package/dist/schema/state/sqlite/query-builder/astToSql.js +22 -23
  255. package/dist/schema/state/sqlite/query-builder/astToSql.js.map +1 -1
  256. package/dist/schema/state/sqlite/query-builder/impl.d.ts +1 -1
  257. package/dist/schema/state/sqlite/query-builder/impl.d.ts.map +1 -1
  258. package/dist/schema/state/sqlite/query-builder/impl.js +18 -16
  259. package/dist/schema/state/sqlite/query-builder/impl.js.map +1 -1
  260. package/dist/schema/state/sqlite/query-builder/impl.test.js +364 -598
  261. package/dist/schema/state/sqlite/query-builder/impl.test.js.map +1 -1
  262. package/dist/schema/state/sqlite/query-builder/mod.js +2 -2
  263. package/dist/schema/state/sqlite/schema-helpers.d.ts.map +1 -1
  264. package/dist/schema/state/sqlite/schema-helpers.js +2 -2
  265. package/dist/schema/state/sqlite/schema-helpers.js.map +1 -1
  266. package/dist/schema/state/sqlite/schema-helpers.test.js +2 -2
  267. package/dist/schema/state/sqlite/system-tables/eventlog-tables.d.ts +102 -134
  268. package/dist/schema/state/sqlite/system-tables/eventlog-tables.d.ts.map +1 -1
  269. package/dist/schema/state/sqlite/system-tables/eventlog-tables.js +5 -5
  270. package/dist/schema/state/sqlite/system-tables/eventlog-tables.js.map +1 -1
  271. package/dist/schema/state/sqlite/system-tables/mod.js +2 -2
  272. package/dist/schema/state/sqlite/system-tables/state-tables.d.ts +380 -115
  273. package/dist/schema/state/sqlite/system-tables/state-tables.d.ts.map +1 -1
  274. package/dist/schema/state/sqlite/system-tables/state-tables.js +47 -6
  275. package/dist/schema/state/sqlite/system-tables/state-tables.js.map +1 -1
  276. package/dist/schema/state/sqlite/table-def.d.ts +17 -20
  277. package/dist/schema/state/sqlite/table-def.d.ts.map +1 -1
  278. package/dist/schema/state/sqlite/table-def.js +72 -6
  279. package/dist/schema/state/sqlite/table-def.js.map +1 -1
  280. package/dist/schema/state/sqlite/table-def.test.js +207 -37
  281. package/dist/schema/state/sqlite/table-def.test.js.map +1 -1
  282. package/dist/schema/unknown-events.d.ts.map +1 -1
  283. package/dist/schema/unknown-events.js +3 -2
  284. package/dist/schema/unknown-events.js.map +1 -1
  285. package/dist/schema-management/__tests__/migrations-autoincrement-quoting.test.js +28 -6
  286. package/dist/schema-management/__tests__/migrations-autoincrement-quoting.test.js.map +1 -1
  287. package/dist/schema-management/common.d.ts.map +1 -1
  288. package/dist/schema-management/common.js +2 -2
  289. package/dist/schema-management/migrations.d.ts +4 -4
  290. package/dist/schema-management/migrations.d.ts.map +1 -1
  291. package/dist/schema-management/migrations.js +9 -9
  292. package/dist/schema-management/migrations.js.map +1 -1
  293. package/dist/schema-management/validate-schema.d.ts.map +1 -1
  294. package/dist/schema-management/validate-schema.js +1 -1
  295. package/dist/session-id-symbol.d.ts +29 -0
  296. package/dist/session-id-symbol.d.ts.map +1 -0
  297. package/dist/session-id-symbol.js +44 -0
  298. package/dist/session-id-symbol.js.map +1 -0
  299. package/dist/sql-queries/index.js +3 -3
  300. package/dist/sql-queries/sql-queries.d.ts.map +1 -1
  301. package/dist/sql-queries/sql-queries.js +11 -13
  302. package/dist/sql-queries/sql-queries.js.map +1 -1
  303. package/dist/sql-queries/sql-query-builder.d.ts.map +1 -1
  304. package/dist/sql-queries/sql-query-builder.js +1 -1
  305. package/dist/sql-queries/types.d.ts +6 -7
  306. package/dist/sql-queries/types.d.ts.map +1 -1
  307. package/dist/sql-queries/types.js.map +1 -1
  308. package/dist/sqlite-db-helper.d.ts +23 -3
  309. package/dist/sqlite-db-helper.d.ts.map +1 -1
  310. package/dist/sqlite-db-helper.js +39 -4
  311. package/dist/sqlite-db-helper.js.map +1 -1
  312. package/dist/sqlite-types.d.ts +3 -6
  313. package/dist/sqlite-types.d.ts.map +1 -1
  314. package/dist/sqlite-types.js +3 -3
  315. package/dist/sqlite-types.js.map +1 -1
  316. package/dist/sync/ClientSessionSyncProcessor.d.ts +41 -19
  317. package/dist/sync/ClientSessionSyncProcessor.d.ts.map +1 -1
  318. package/dist/sync/ClientSessionSyncProcessor.js +200 -70
  319. package/dist/sync/ClientSessionSyncProcessor.js.map +1 -1
  320. package/dist/sync/errors.d.ts +12 -18
  321. package/dist/sync/errors.d.ts.map +1 -1
  322. package/dist/sync/errors.js +3 -3
  323. package/dist/sync/errors.js.map +1 -1
  324. package/dist/sync/index.js +6 -6
  325. package/dist/sync/mock-sync-backend.d.ts +2 -3
  326. package/dist/sync/mock-sync-backend.d.ts.map +1 -1
  327. package/dist/sync/mock-sync-backend.js +58 -37
  328. package/dist/sync/mock-sync-backend.js.map +1 -1
  329. package/dist/sync/mock-sync-backend.test.d.ts +2 -0
  330. package/dist/sync/mock-sync-backend.test.d.ts.map +1 -0
  331. package/dist/sync/mock-sync-backend.test.js +49 -0
  332. package/dist/sync/mock-sync-backend.test.js.map +1 -0
  333. package/dist/sync/next/compact-events.d.ts.map +1 -1
  334. package/dist/sync/next/compact-events.js +3 -3
  335. package/dist/sync/next/facts.d.ts.map +1 -1
  336. package/dist/sync/next/facts.js +1 -1
  337. package/dist/sync/next/history-dag-common.d.ts +5 -1
  338. package/dist/sync/next/history-dag-common.d.ts.map +1 -1
  339. package/dist/sync/next/history-dag-common.js +53 -32
  340. package/dist/sync/next/history-dag-common.js.map +1 -1
  341. package/dist/sync/next/history-dag.d.ts.map +1 -1
  342. package/dist/sync/next/history-dag.js +5 -4
  343. package/dist/sync/next/history-dag.js.map +1 -1
  344. package/dist/sync/next/mod.js +5 -5
  345. package/dist/sync/next/rebase-events.d.ts.map +1 -1
  346. package/dist/sync/next/rebase-events.js +2 -2
  347. package/dist/sync/next/test/compact-events.calculator.test.js +6 -6
  348. package/dist/sync/next/test/compact-events.test.js +4 -4
  349. package/dist/sync/next/test/event-fixtures.d.ts +8 -8
  350. package/dist/sync/next/test/event-fixtures.d.ts.map +1 -1
  351. package/dist/sync/next/test/event-fixtures.js +6 -6
  352. package/dist/sync/next/test/event-fixtures.js.map +1 -1
  353. package/dist/sync/next/test/history-dag-common.test.d.ts +2 -0
  354. package/dist/sync/next/test/history-dag-common.test.d.ts.map +1 -0
  355. package/dist/sync/next/test/history-dag-common.test.js +73 -0
  356. package/dist/sync/next/test/history-dag-common.test.js.map +1 -0
  357. package/dist/sync/next/test/mod.js +1 -1
  358. package/dist/sync/sync-backend-kv.d.ts.map +1 -1
  359. package/dist/sync/sync-backend-kv.js +5 -3
  360. package/dist/sync/sync-backend-kv.js.map +1 -1
  361. package/dist/sync/sync-backend.d.ts +15 -15
  362. package/dist/sync/sync-backend.d.ts.map +1 -1
  363. package/dist/sync/sync-backend.js +10 -6
  364. package/dist/sync/sync-backend.js.map +1 -1
  365. package/dist/sync/sync.d.ts +1 -1
  366. package/dist/sync/sync.d.ts.map +1 -1
  367. package/dist/sync/sync.js +2 -2
  368. package/dist/sync/syncstate.d.ts +100 -176
  369. package/dist/sync/syncstate.d.ts.map +1 -1
  370. package/dist/sync/syncstate.js +36 -39
  371. package/dist/sync/syncstate.js.map +1 -1
  372. package/dist/sync/syncstate.test.js +103 -64
  373. package/dist/sync/syncstate.test.js.map +1 -1
  374. package/dist/sync/transport-chunking.d.ts +15 -16
  375. package/dist/sync/transport-chunking.d.ts.map +1 -1
  376. package/dist/sync/transport-chunking.js +23 -32
  377. package/dist/sync/transport-chunking.js.map +1 -1
  378. package/dist/sync/validate-push-payload.d.ts.map +1 -1
  379. package/dist/sync/validate-push-payload.js +2 -2
  380. package/dist/testing/event-factory.d.ts.map +1 -1
  381. package/dist/testing/event-factory.js +2 -2
  382. package/dist/testing/event-factory.js.map +1 -1
  383. package/dist/testing/mod.js +1 -1
  384. package/dist/util.d.ts +11 -4
  385. package/dist/util.d.ts.map +1 -1
  386. package/dist/util.js +20 -2
  387. package/dist/util.js.map +1 -1
  388. package/dist/version.d.ts.map +1 -1
  389. package/dist/version.js.map +1 -1
  390. package/package.json +40 -57
  391. package/src/ClientSessionLeaderThreadProxy.ts +1 -1
  392. package/src/MaterializationJournal.ts +184 -0
  393. package/src/StateHead.ts +80 -0
  394. package/src/WorkerTransportError.ts +4 -4
  395. package/src/__tests__/fixture.ts +3 -3
  396. package/src/adapter-types.ts +28 -61
  397. package/src/debug-info.ts +53 -70
  398. package/src/defs.ts +2 -2
  399. package/src/devtools/devtools-compatibility.test.ts +1 -0
  400. package/src/devtools/devtools-messages-client-session.ts +56 -65
  401. package/src/devtools/devtools-messages-common.ts +12 -11
  402. package/src/devtools/devtools-messages-leader.ts +71 -69
  403. package/src/devtools/devtools-sessioninfo.ts +13 -11
  404. package/src/devtools/mod.ts +4 -5
  405. package/src/errors.ts +46 -34
  406. package/src/index.ts +8 -1
  407. package/src/leader-thread/LeaderSyncProcessor.ts +777 -650
  408. package/src/leader-thread/RejectedPushError.ts +40 -27
  409. package/src/leader-thread/connection.ts +12 -8
  410. package/src/leader-thread/eventlog.ts +20 -12
  411. package/src/leader-thread/leader-worker-devtools.ts +38 -20
  412. package/src/leader-thread/make-leader-thread-layer.test.ts +7 -7
  413. package/src/leader-thread/make-leader-thread-layer.ts +100 -71
  414. package/src/leader-thread/materialize-event.ts +13 -8
  415. package/src/leader-thread/recreate-db.ts +41 -75
  416. package/src/leader-thread/shutdown-channel.ts +3 -14
  417. package/src/leader-thread/stream-events.ts +102 -101
  418. package/src/leader-thread/types.ts +29 -62
  419. package/src/logging.ts +5 -52
  420. package/src/make-client-session.ts +6 -6
  421. package/src/materializer-helper.ts +7 -36
  422. package/src/otel.ts +5 -14
  423. package/src/rematerialize-from-eventlog.ts +116 -98
  424. package/src/schema/EventDef/define.test.ts +28 -0
  425. package/src/schema/EventDef/define.ts +20 -19
  426. package/src/schema/EventDef/deprecated.test.ts +1 -8
  427. package/src/schema/EventDef/deprecated.ts +17 -20
  428. package/src/schema/EventDef/event-def.ts +2 -4
  429. package/src/schema/EventSequenceNumber/client.ts +20 -24
  430. package/src/schema/EventSequenceNumber/global.ts +1 -1
  431. package/src/schema/EventSequenceNumber.test.ts +5 -5
  432. package/src/schema/LiveStoreEvent/client.test.ts +17 -4
  433. package/src/schema/LiveStoreEvent/client.ts +36 -35
  434. package/src/schema/LiveStoreEvent/for-event-def.ts +7 -7
  435. package/src/schema/LiveStoreEvent/global.ts +1 -1
  436. package/src/schema/LiveStoreEvent/input.ts +6 -6
  437. package/src/schema/mod.ts +1 -0
  438. package/src/schema/schema.ts +4 -3
  439. package/src/schema/state/sqlite/client-document-def.test.ts +55 -20
  440. package/src/schema/state/sqlite/client-document-def.ts +95 -80
  441. package/src/schema/state/sqlite/column-annotations.test.ts +26 -45
  442. package/src/schema/state/sqlite/column-annotations.ts +26 -30
  443. package/src/schema/state/sqlite/column-def.test.ts +213 -77
  444. package/src/schema/state/sqlite/column-def.ts +87 -37
  445. package/src/schema/state/sqlite/column-spec.test.ts +2 -2
  446. package/src/schema/state/sqlite/column-spec.ts +3 -2
  447. package/src/schema/state/sqlite/db-schema/ast/fingerprint-digest.ts +6 -0
  448. package/src/schema/state/sqlite/db-schema/ast/fingerprint.test.ts +209 -0
  449. package/src/schema/state/sqlite/db-schema/ast/fingerprint.ts +225 -0
  450. package/src/schema/state/sqlite/db-schema/ast/sqlite.ts +2 -81
  451. package/src/schema/state/sqlite/db-schema/dsl/__snapshots__/field-defs.test.ts.snap +123 -63
  452. package/src/schema/state/sqlite/db-schema/dsl/field-defs.test.ts +35 -16
  453. package/src/schema/state/sqlite/db-schema/dsl/field-defs.ts +26 -31
  454. package/src/schema/state/sqlite/db-schema/dsl/mod.ts +35 -21
  455. package/src/schema/state/sqlite/db-schema/has-json-string-encoding.ts +9 -0
  456. package/src/schema/state/sqlite/mod.ts +4 -5
  457. package/src/schema/state/sqlite/persistence.ts +11 -0
  458. package/src/schema/state/sqlite/query-builder/api.ts +9 -9
  459. package/src/schema/state/sqlite/query-builder/astToSql.ts +24 -26
  460. package/src/schema/state/sqlite/query-builder/impl.test.ts +433 -669
  461. package/src/schema/state/sqlite/query-builder/impl.ts +19 -15
  462. package/src/schema/state/sqlite/schema-helpers.ts +1 -1
  463. package/src/schema/state/sqlite/system-tables/eventlog-tables.ts +2 -2
  464. package/src/schema/state/sqlite/system-tables/state-tables.ts +54 -3
  465. package/src/schema/state/sqlite/table-def.test.ts +270 -66
  466. package/src/schema/state/sqlite/table-def.ts +112 -45
  467. package/src/schema/unknown-events.ts +3 -2
  468. package/src/schema-management/__tests__/migrations-autoincrement-quoting.test.ts +35 -4
  469. package/src/schema-management/migrations.ts +7 -6
  470. package/src/session-id-symbol.ts +59 -0
  471. package/src/sql-queries/sql-queries.ts +9 -13
  472. package/src/sql-queries/types.ts +6 -7
  473. package/src/sqlite-db-helper.ts +64 -2
  474. package/src/sqlite-types.ts +5 -8
  475. package/src/sync/ClientSessionSyncProcessor.ts +303 -119
  476. package/src/sync/errors.ts +14 -12
  477. package/src/sync/mock-sync-backend.test.ts +78 -0
  478. package/src/sync/mock-sync-backend.ts +93 -47
  479. package/src/sync/next/history-dag-common.ts +60 -34
  480. package/src/sync/next/history-dag.ts +4 -1
  481. package/src/sync/next/test/compact-events.calculator.test.ts +2 -2
  482. package/src/sync/next/test/event-fixtures.ts +1 -1
  483. package/src/sync/next/test/history-dag-common.test.ts +87 -0
  484. package/src/sync/sync-backend-kv.ts +4 -2
  485. package/src/sync/sync-backend.ts +15 -15
  486. package/src/sync/sync.ts +1 -1
  487. package/src/sync/syncstate.test.ts +191 -140
  488. package/src/sync/syncstate.ts +63 -53
  489. package/src/sync/transport-chunking.ts +34 -39
  490. package/src/util.ts +19 -9
  491. package/src/version.ts +1 -2
  492. package/dist/schema/state/sqlite/db-schema/hash.d.ts +0 -2
  493. package/dist/schema/state/sqlite/db-schema/hash.d.ts.map +0 -1
  494. package/dist/schema/state/sqlite/db-schema/hash.js +0 -15
  495. package/dist/schema/state/sqlite/db-schema/hash.js.map +0 -1
  496. package/dist/sql-queries/misc.d.ts +0 -2
  497. package/dist/sql-queries/misc.d.ts.map +0 -1
  498. package/dist/sql-queries/misc.js +0 -2
  499. package/dist/sql-queries/misc.js.map +0 -1
  500. package/src/schema/state/sqlite/db-schema/hash.ts +0 -13
  501. package/src/sql-queries/misc.ts +0 -2
@@ -1,18 +1,17 @@
1
- import { casesHandled, isNotUndefined, LS_DEV, TRACE_VERBOSE } from '@livestore/utils';
2
- import { BucketQueue, Cause, Deferred, Duration, Effect, Exit, FiberHandle, Layer, Option, Queue, ReadonlyArray, Schedule, Schema, Stream, Subscribable, SubscriptionRef, } from '@livestore/utils/effect';
3
- import { UnknownError } from "../adapter-types.js";
4
- import { IntentionalShutdownCause } from "../errors.js";
5
- import { makeMaterializerHash } from "../materializer-helper.js";
6
- import { EventSequenceNumber, LiveStoreEvent, resolveEventDef, SystemTables } from "../schema/mod.js";
7
- import { EVENTLOG_META_TABLE, SYNC_STATUS_TABLE } from "../schema/state/sqlite/system-tables/eventlog-tables.js";
8
- import { isRejectedPushError, LeaderAheadError, NonMonotonicBatchError, StaleRebaseGenerationError } from "./RejectedPushError.js";
9
- import * as SyncState from "../sync/syncstate.js";
10
- import { sql } from "../util.js";
11
- import * as Eventlog from "./eventlog.js";
12
- import { rollback } from "./materialize-event.js";
13
- import { LeaderThreadCtx } from "./types.js";
14
- /** Serialize value to JSON string for trace attributes */
15
- const jsonStringify = Schema.encodeSync(Schema.parseJson());
1
+ import { casesHandled, LS_DEV, TRACE_VERBOSE } from '@livestore/utils';
2
+ import { Cause, Context, Deferred, Duration, Effect, Exit, FiberHandle, Layer, Option, Predicate, Queue, Random, ReadonlyArray, References, Result, Schedule, Schema, Semaphore, Stream, Subscribable, SubscriptionRef, TxQueue, } from '@livestore/utils/effect';
3
+ import { MaterializeError, UnknownError } from '../adapter-types.js';
4
+ import { IntentionalShutdownCause } from '../errors.js';
5
+ import { makeMaterializerHash } from '../materializer-helper.js';
6
+ import { EventSequenceNumber, LiveStoreEvent, resolveEventDef, SystemTables } from '../schema/mod.js';
7
+ import { EVENTLOG_META_TABLE, SYNC_STATUS_TABLE } from '../schema/state/sqlite/system-tables/eventlog-tables.js';
8
+ import * as StateHead from '../StateHead.js';
9
+ import * as SyncState from '../sync/syncstate.js';
10
+ import { sql } from '../util.js';
11
+ import * as Eventlog from './eventlog.js';
12
+ import { rollback } from './materialize-event.js';
13
+ import { isRejectedPushError, LeaderAheadError, NonContiguousBatchError, NonMonotonicBatchError, StaleRebaseGenerationError, } from './RejectedPushError.js';
14
+ export const TypeId = '~@livestore/common/LeaderSyncProcessor';
16
15
  /**
17
16
  * The LeaderSyncProcessor manages synchronization of events between
18
17
  * the local state and the sync backend, ensuring efficient and orderly processing.
@@ -43,513 +42,532 @@ const jsonStringify = Schema.encodeSync(Schema.parseJson());
43
42
  *
44
43
  * See ClientSessionSyncProcessor for how the leader and session sync processors are similar/different.
45
44
  */
46
- export const makeLeaderSyncProcessor = ({ schema, dbState, initialBlockingSyncContext, initialSyncState, onError, onBackendIdMismatch, livePull, params, testing, }) => Effect.gen(function* () {
47
- const syncBackendPushQueue = yield* BucketQueue.make();
45
+ export class LeaderSyncProcessor extends Context.Service()('@livestore/common/LeaderSyncProcessor') {
46
+ }
47
+ export const make = Effect.fnUntraced(function* ({ schema, runtime, initialBlockingSyncContext, initialSyncState, onError, onBackendIdMismatch, livePull, params, testing, }) {
48
+ const stateHead = yield* StateHead.StateHead;
49
+ const { dbState, dbEventlog, devtoolsLatch, materializeEvent, shutdownChannel, span, syncBackend } = runtime;
50
+ const syncBackendPushQueue = yield* TxQueue.unbounded();
48
51
  const localPushBatchSize = params.localPushBatchSize ?? 10;
49
52
  const backendPushBatchSize = params.backendPushBatchSize ?? 50;
50
53
  const syncStateSref = yield* SubscriptionRef.make(undefined);
51
- const isClientEvent = (eventEncoded) => schema.eventsDefsMap.get(eventEncoded.name)?.options.clientOnly ?? false;
54
+ const isClientOnlyEvent = (eventEncoded) => schema.eventsDefsMap.get(eventEncoded.name)?.options.clientOnly ?? false;
52
55
  const connectedClientSessionPullQueues = yield* makePullQueueSet;
53
- // This context depends on data from `boot`, we should find a better implementation to avoid this ref indirection.
54
- const ctxRef = {
55
- current: undefined,
56
- };
57
- const localPushesQueue = yield* BucketQueue.make();
56
+ const materializeEventsBatch = makeMaterializeEventsBatch({ dbState, dbEventlog, materializeEvent });
57
+ const localPushesQueue = yield* TxQueue.unbounded();
58
+ // Reservations cover admitted pushes from validation until they are applied or rejected. The Set's
59
+ // insertion order mirrors admission order, so its last relevant item is the optimistic sequence head.
60
+ const reservedLocalPushItems = new Set();
58
61
  // Ensures mutual exclusion between local push and backend pull processing.
59
- const localPushBackendPullMutex = yield* Effect.makeSemaphore(1);
62
+ const localPushBackendPullMutex = yield* Semaphore.make(1);
63
+ // Serializes validation, queue admission, and prefix-fence reconciliation.
64
+ const pushAdmissionSemaphore = yield* Semaphore.make(1);
60
65
  /**
61
- * Additionally to the `syncStateSref` we also need the `pushHeadRef` in order to prevent old/duplicate
62
- * events from being pushed in a scenario like this:
63
- * - client session A pushes e1
64
- * - leader sync processor takes a bit and hasn't yet taken e1 from the localPushesQueue
65
- * - client session B also pushes e1 (which should be rejected)
66
+ * Admission fence for local pushes. Unlike `syncState.localHead`, this advances as soon as an event
67
+ * is queued, so another session cannot claim the same sequence number while that event is waiting
68
+ * to be applied. With authoritative head e0 and reserved pushes e1/e2, this points to e2.
66
69
  *
67
- * Thus the purpose of the pushHeadRef is the guard the integrity of the local push queue
70
+ * All reads and writes are protected by `pushAdmissionSemaphore` so validation and reservation are
71
+ * one atomic operation from the perspective of concurrent sessions and backend pulls.
68
72
  */
69
- const pushHeadRef = { current: EventSequenceNumber.Client.ROOT };
70
- const advancePushHead = (eventNum) => {
71
- pushHeadRef.current = EventSequenceNumber.Client.max(pushHeadRef.current, eventNum);
72
- };
73
- // NOTE: New events are only pushed to sync backend after successful local push processing
74
- const push = (newEvents, options) => Effect.gen(function* () {
75
- if (newEvents.length === 0)
76
- return;
77
- // console.debug('push', newEvents)
78
- yield* validatePushBatch(newEvents, pushHeadRef.current);
79
- advancePushHead(newEvents.at(-1).seqNum);
80
- const waitForProcessing = options?.waitForProcessing ?? false;
81
- if (waitForProcessing === true) {
82
- const deferreds = yield* Effect.forEach(newEvents, () => Deferred.make());
83
- const items = newEvents.map((eventEncoded, i) => [eventEncoded, deferreds[i]]);
84
- yield* BucketQueue.offerAll(localPushesQueue, items);
85
- yield* Effect.all(deferreds);
86
- }
87
- else {
88
- const items = newEvents.map((eventEncoded) => [eventEncoded, undefined]);
89
- yield* BucketQueue.offerAll(localPushesQueue, items);
73
+ const pushHeadRef = { current: initialSyncState.localHead };
74
+ /**
75
+ * A backend pull may advance or rebase authoritative history while local pushes remain reserved.
76
+ * Keep the fence at the newest reservation that is still valid for that history; if none remains,
77
+ * fall back to the authoritative head. Stale reservations are released later by the queue worker.
78
+ */
79
+ const reconcilePushHead = (authoritativeHead) => pushAdmissionSemaphore.withPermits(1)(Effect.gen(function* () {
80
+ const latestCurrentGenerationItem = [...reservedLocalPushItems].findLast(([event]) => event.seqNum.rebaseGeneration >= authoritativeHead.rebaseGeneration);
81
+ pushHeadRef.current = latestCurrentGenerationItem?.[0].seqNum ?? authoritativeHead;
82
+ }).pipe(Effect.uninterruptible));
83
+ /**
84
+ * Stop completed, rejected, or stale queue items from extending the admission fence, then rebuild
85
+ * the fence from any pushes that are still reserved.
86
+ */
87
+ const releasePushReservations = (items, authoritativeHead) => pushAdmissionSemaphore.withPermits(1)(Effect.gen(function* () {
88
+ for (const item of items)
89
+ reservedLocalPushItems.delete(item);
90
+ const latestCurrentGenerationItem = [...reservedLocalPushItems].findLast(([event]) => event.seqNum.rebaseGeneration >= authoritativeHead.rebaseGeneration);
91
+ pushHeadRef.current = latestCurrentGenerationItem?.[0].seqNum ?? authoritativeHead;
92
+ }).pipe(Effect.uninterruptible));
93
+ const backgroundApplyLocalPushes = Effect.gen(function* () {
94
+ while (true) {
95
+ if (testing.delays?.localPushProcessing !== undefined) {
96
+ yield* testing.delays.localPushProcessing.pipe(Effect.withSpan('localPushProcessingDelay'));
97
+ }
98
+ const batchItems = yield* TxQueue.takeBetween(localPushesQueue, 1, localPushBatchSize);
99
+ // Applies a batch of local pushes, guarded by the localPushBackendPullMutex to ensure mutual exclusion with backend pulling
100
+ yield* Effect.gen(function* () {
101
+ const syncState = yield* Effect.fromNullishOr(yield* SubscriptionRef.get(syncStateSref)).pipe(Effect.orDieDebugger);
102
+ const currentRebaseGeneration = syncState.localHead.rebaseGeneration;
103
+ // Since the rebase generation might have changed since enqueuing, we need to filter out items with older generation
104
+ // It's important that we filter after acquiring the localPushBackendPullMutex, otherwise we might filter with the old generation
105
+ const [filteredItems, droppedItems] = ReadonlyArray.partition(batchItems, (batchItem) => batchItem[0].seqNum.rebaseGeneration >= currentRebaseGeneration
106
+ ? Result.succeed(batchItem)
107
+ : Result.fail(batchItem));
108
+ if (droppedItems.length > 0) {
109
+ yield* Effect.spanEvent(`push:drop-old-generation`, {
110
+ droppedCount: droppedItems.length,
111
+ currentRebaseGeneration,
112
+ });
113
+ yield* Effect.forEach(droppedItems, ([eventEncoded, deferred]) => Deferred.fail(deferred, StaleRebaseGenerationError.make({
114
+ currentRebaseGeneration,
115
+ providedRebaseGeneration: eventEncoded.seqNum.rebaseGeneration,
116
+ sessionId: eventEncoded.sessionId,
117
+ })));
118
+ yield* releasePushReservations(droppedItems, syncState.localHead);
119
+ }
120
+ if (filteredItems.length === 0) {
121
+ return;
122
+ }
123
+ const [newEvents, deferreds] = ReadonlyArray.unzip(filteredItems);
124
+ yield* Effect.annotateCurrentSpan({
125
+ batchSize: newEvents.length,
126
+ ...(TRACE_VERBOSE === true ? { newEvents: jsonStringify(newEvents) } : {}),
127
+ });
128
+ const mergeResult = yield* SyncState.merge({
129
+ syncState,
130
+ payload: { _tag: 'local-push', newEvents },
131
+ isClientOnlyEvent,
132
+ isEqualEvent: LiveStoreEvent.Client.isEqualEncoded,
133
+ });
134
+ switch (mergeResult._tag) {
135
+ case 'rebase': {
136
+ return yield* Effect.dieDebugger('The leader thread should never have to rebase due to a local push');
137
+ }
138
+ case 'reject': {
139
+ yield* Effect.spanEvent(`push:reject`, {
140
+ batchSize: newEvents.length,
141
+ ...(TRACE_VERBOSE === true ? { mergeResult: jsonStringify(mergeResult) } : {}),
142
+ });
143
+ // TODO: how to test this?
144
+ const nextRebaseGeneration = currentRebaseGeneration + 1;
145
+ const providedNum = newEvents.at(0).seqNum;
146
+ // All subsequent pushes with same generation should be rejected as well
147
+ // We're also handling the case where the localPushQueue already contains events
148
+ // from the next generation which we preserve in the queue
149
+ const remainingEventsMatchingGeneration = yield* takePrefixUntil(localPushesQueue, ([eventEncoded]) => eventEncoded.seqNum.rebaseGeneration >= nextRebaseGeneration);
150
+ // TODO we still need to better understand and handle this scenario
151
+ const remainingLocalPushes = yield* snapshotTxQueue(localPushesQueue);
152
+ if (LS_DEV === true && remainingLocalPushes.length > 0) {
153
+ console.log('localPushesQueue is not empty', remainingLocalPushes.length);
154
+ // oxlint-disable-next-line no-debugger -- intentional breakpoint for unexpected queue state
155
+ debugger;
156
+ }
157
+ const allDeferredsToReject = [
158
+ ...deferreds,
159
+ ...remainingEventsMatchingGeneration.map(([_, deferred]) => deferred),
160
+ ];
161
+ // The rejected batch and its drained same-generation suffix will never advance leader state,
162
+ // so release their sequence-number reservations before clients retry from the authoritative head.
163
+ yield* releasePushReservations([...filteredItems, ...remainingEventsMatchingGeneration], syncState.localHead);
164
+ yield* Effect.forEach(allDeferredsToReject, (deferred) => Deferred.fail(deferred, LeaderAheadError.make({
165
+ minimumExpectedNum: mergeResult.expectedMinimumId,
166
+ providedNum,
167
+ sessionId: newEvents.at(0).sessionId,
168
+ })));
169
+ // In this case we're skipping state update and down/upstream processing
170
+ // We've cleared the local push queue and are now waiting for new local pushes / backend pulls
171
+ return;
172
+ }
173
+ case 'advance': {
174
+ break;
175
+ }
176
+ default: {
177
+ casesHandled(mergeResult);
178
+ }
179
+ }
180
+ // For a local-push advance, `newEvents` and the appended pending suffix describe the same logical
181
+ // events but serve different roles and may be distinct instances. Materialize the retained pending
182
+ // instances so their rollback metadata remains available if a later backend event causes a rebase.
183
+ const acceptedPendingEvents = mergeResult.newSyncState.pending.slice(syncState.pending.length);
184
+ if (acceptedPendingEvents.length !== mergeResult.newEvents.length) {
185
+ return yield* Effect.dieDebugger('Local push events must be retained in pending state');
186
+ }
187
+ yield* materializeEventsBatch({ batchItems: acceptedPendingEvents });
188
+ yield* SubscriptionRef.set(syncStateSref, mergeResult.newSyncState);
189
+ yield* connectedClientSessionPullQueues.offer({
190
+ payload: SyncState.PayloadUpstreamAdvance.make({ newEvents: acceptedPendingEvents }),
191
+ leaderHead: mergeResult.newSyncState.localHead,
192
+ });
193
+ yield* Effect.spanEvent(`push:advance`, {
194
+ batchSize: newEvents.length,
195
+ ...(TRACE_VERBOSE === true ? { mergeResult: jsonStringify(mergeResult) } : {}),
196
+ });
197
+ // Don't sync client-only events
198
+ const globalOrUnknownEvents = acceptedPendingEvents.filter((e) => !isClientOnlyEvent(e));
199
+ yield* TxQueue.offerAll(syncBackendPushQueue, globalOrUnknownEvents);
200
+ yield* releasePushReservations(filteredItems, mergeResult.newSyncState.localHead);
201
+ // A push is acknowledged only after the complete batch is materialized, published in
202
+ // leader sync state, exposed to sessions, and queued for backend propagation.
203
+ yield* Effect.forEach(deferreds, (deferred) => Deferred.succeed(deferred, void 0));
204
+ }).pipe(localPushBackendPullMutex.withPermits(1));
90
205
  }
91
- }).pipe(Effect.withSpan('@livestore/common:LeaderSyncProcessor:push', {
92
- attributes: {
93
- batchSize: newEvents.length,
94
- batch: TRACE_VERBOSE === true ? newEvents : undefined,
95
- },
96
- links: ctxRef.current?.span !== undefined
97
- ? [{ _tag: 'SpanLink', span: ctxRef.current.span, attributes: {} }]
98
- : undefined,
99
- }));
100
- const pushPartial = ({ event: { name, args }, clientId, sessionId }) => Effect.gen(function* () {
101
- const syncState = yield* Effect.fromNullable(yield* syncStateSref).pipe(Effect.orDieDebugger);
102
- const resolution = yield* resolveEventDef(schema, {
103
- operation: '@livestore/common:LeaderSyncProcessor:pushPartial',
104
- event: {
105
- name,
106
- args,
107
- clientId,
108
- sessionId,
109
- seqNum: syncState.localHead,
110
- },
111
- });
112
- if (resolution._tag === 'unknown') {
113
- // Ignore partial pushes for unrecognised events – they are still
114
- // persisted server-side once a schema update ships.
206
+ });
207
+ const backgroundBackendPulling = Effect.fn('@livestore/common:LeaderSyncProcessor:backend-pulling')(function* ({ restartBackendPushing, onPullSuccess, }) {
208
+ if (syncBackend === undefined)
115
209
  return;
116
- }
117
- const eventEncoded = new LiveStoreEvent.Client.EncodedWithMeta({
118
- name,
119
- args,
120
- clientId,
121
- sessionId,
122
- ...EventSequenceNumber.Client.nextPair({
123
- seqNum: syncState.localHead,
124
- isClient: resolution.eventDef.options.clientOnly,
125
- }),
210
+ let pullMutexHeld = false;
211
+ const releasePullMutexIfHeld = Effect.gen(function* () {
212
+ if (pullMutexHeld === false)
213
+ return;
214
+ pullMutexHeld = false;
215
+ yield* localPushBackendPullMutex.release(1);
126
216
  });
127
- yield* push([eventEncoded]);
128
- }).pipe(
129
- // pushPartial constructs the event sequence number internally, so these errors should never happen.
130
- Effect.catchIf(isRejectedPushError, Effect.die));
131
- // Starts various background loops
132
- const boot = Effect.gen(function* () {
133
- const span = yield* Effect.currentSpan.pipe(Effect.orDie);
134
- const { devtools, shutdownChannel } = yield* LeaderThreadCtx;
135
- const runtime = yield* Effect.runtime();
136
- ctxRef.current = {
137
- span,
138
- devtoolsLatch: devtools.enabled === true ? devtools.syncBackendLatch : undefined,
139
- runtime,
140
- };
141
- /** State transitions need to happen atomically, so we use a Ref to track the state */
142
- yield* SubscriptionRef.set(syncStateSref, initialSyncState);
143
- // Rehydrate sync queue
144
- if (initialSyncState.pending.length > 0) {
145
- const globalPendingEvents = initialSyncState.pending
146
- // Don't sync client-local events
147
- .filter((eventEncoded) => {
148
- const eventDef = schema.eventsDefsMap.get(eventEncoded.name);
149
- return eventDef === undefined ? true : eventDef.options.clientOnly === false;
150
- });
151
- if (globalPendingEvents.length > 0) {
152
- yield* BucketQueue.offerAll(syncBackendPushQueue, globalPendingEvents);
217
+ const isPullPaginationComplete = (pageInfo) => pageInfo._tag === 'NoMore';
218
+ const onNewPullChunk = (newEvents, pageInfo) => Effect.gen(function* () {
219
+ if (devtoolsLatch !== undefined) {
220
+ yield* devtoolsLatch.await;
153
221
  }
154
- }
155
- const handleBackendIdMismatchError = (error) => handleBackendIdMismatch({ error, onBackendIdMismatch, shutdownChannel });
156
- const maybeShutdownOnError = (cause) => Effect.gen(function* () {
157
- if (onError === 'ignore') {
158
- if (LS_DEV === true) {
159
- yield* Effect.logDebug(`Ignoring sync error (${cause._tag === 'Fail' ? cause.error._tag : cause._tag})`, Cause.pretty(cause));
222
+ if (newEvents.length === 0) {
223
+ if (isPullPaginationComplete(pageInfo) === true) {
224
+ yield* releasePullMutexIfHeld;
160
225
  }
161
226
  return;
162
227
  }
163
- const errorToSend = Cause.isFailType(cause) === true ? cause.error : UnknownError.make({ cause });
164
- yield* shutdownChannel.send(errorToSend).pipe(Effect.orDie);
165
- return yield* Effect.failCause(cause).pipe(Effect.orDie);
166
- });
167
- yield* backgroundApplyLocalPushes({
168
- localPushBackendPullMutex,
169
- localPushesQueue,
170
- syncStateSref,
171
- syncBackendPushQueue,
172
- schema,
173
- isClientEvent,
174
- connectedClientSessionPullQueues,
175
- localPushBatchSize,
176
- testing: {
177
- delay: testing?.delays?.localPushProcessing,
178
- },
179
- }).pipe(Effect.catchAllCause(maybeShutdownOnError), Effect.forkScoped);
180
- const backendPushingFiberHandle = yield* FiberHandle.make();
181
- const backendPushingEffect = backgroundBackendPushing({
182
- syncBackendPushQueue,
183
- devtoolsLatch: ctxRef.current?.devtoolsLatch,
184
- backendPushBatchSize,
185
- }).pipe(Effect.catchTag('BackendIdMismatchError', handleBackendIdMismatchError), Effect.catchAllCause(maybeShutdownOnError));
186
- yield* FiberHandle.run(backendPushingFiberHandle, backendPushingEffect);
187
- yield* backgroundBackendPulling({
188
- isClientEvent,
189
- restartBackendPushing: (filteredRebasedPending) => Effect.gen(function* () {
190
- // Stop current pushing fiber
191
- yield* FiberHandle.clear(backendPushingFiberHandle);
192
- // Reset the sync backend push queue
193
- yield* BucketQueue.clear(syncBackendPushQueue);
194
- yield* BucketQueue.offerAll(syncBackendPushQueue, filteredRebasedPending);
195
- // Restart pushing fiber
196
- yield* FiberHandle.run(backendPushingFiberHandle, backendPushingEffect);
197
- }),
198
- syncStateSref,
199
- localPushBackendPullMutex,
200
- livePull,
201
- dbState,
202
- initialBlockingSyncContext,
203
- devtoolsLatch: ctxRef.current?.devtoolsLatch,
204
- connectedClientSessionPullQueues,
205
- advancePushHead,
206
- }).pipe(Effect.retry({
207
- // Retry pulling when we've lost connection to the sync backend
208
- // We're using `until` with a refinement instead of `while` to narrow `IsOfflineError` out of the error type.
209
- // See https://github.com/Effect-TS/effect/issues/6122
210
- until: (error) => error._tag !== 'IsOfflineError',
211
- }), Effect.catchTag('BackendIdMismatchError', handleBackendIdMismatchError), Effect.catchAllCause(maybeShutdownOnError),
212
- // Needed to avoid `Fiber terminated with an unhandled error` logs which seem to happen because of the `Effect.retry` above.
213
- // This might be a bug in Effect. Only seems to happen in the browser.
214
- Effect.provide(Layer.setUnhandledErrorLogLevel(Option.none())), Effect.forkScoped);
215
- return { initialLeaderHead: initialSyncState.localHead };
216
- }).pipe(Effect.withSpanScoped('@livestore/common:LeaderSyncProcessor:boot'));
217
- const pull = ({ cursor }) => Effect.gen(function* () {
218
- const queue = yield* pullQueue({ cursor });
219
- return Stream.fromQueue(queue);
220
- }).pipe(Stream.unwrapScoped);
221
- /*
222
- Notes for a potential new `LeaderSyncProcessor.pull` implementation:
223
-
224
- - Doesn't take cursor but is "atomically called" in the leader during the snapshot phase
225
- - TODO: how is this done "atomically" in the web adapter where the snapshot is read optimistically?
226
- - Would require a new kind of "boot-phase" API which is stream based:
227
- - initial message: state snapshot + seq num head
228
- - subsequent messages: sync state payloads
229
-
230
- - alternative: instead of session pulling sync state payloads from leader, we could send
231
- - events in the "advance" case
232
- - full new state db snapshot in the "rebase" case
233
- - downside: importing the snapshot is expensive
234
- */
235
- const pullQueue = ({ cursor }) => Effect.fromNullable(ctxRef.current?.runtime).pipe(Effect.orDieDebugger, Effect.flatMap((runtime) => connectedClientSessionPullQueues.makeQueue(cursor).pipe(Effect.provide(runtime))));
236
- const syncState = Subscribable.make({
237
- get: syncStateSref.pipe(Effect.flatMap(Effect.fromNullable), Effect.orDieDebugger),
238
- changes: syncStateSref.changes.pipe(Stream.filter(isNotUndefined)),
239
- });
240
- return {
241
- pull,
242
- pullQueue,
243
- push,
244
- pushPartial,
245
- boot,
246
- syncState,
247
- };
248
- });
249
- const backgroundApplyLocalPushes = ({ localPushBackendPullMutex, localPushesQueue, syncStateSref, syncBackendPushQueue, schema, isClientEvent, connectedClientSessionPullQueues, localPushBatchSize, testing, }) => Effect.gen(function* () {
250
- while (true) {
251
- if (testing.delay !== undefined) {
252
- yield* testing.delay.pipe(Effect.withSpan('localPushProcessingDelay'));
253
- }
254
- const batchItems = yield* BucketQueue.takeBetween(localPushesQueue, 1, localPushBatchSize);
255
- // Applies a batch of local pushes, guarded by the localPushBackendPullMutex to ensure mutual exclusion with backend pulling
256
- yield* Effect.gen(function* () {
257
- const syncState = yield* Effect.fromNullable(yield* syncStateSref).pipe(Effect.orDieDebugger);
258
- const currentRebaseGeneration = syncState.localHead.rebaseGeneration;
259
- // Since the rebase generation might have changed since enqueuing, we need to filter out items with older generation
260
- // It's important that we filter after acquiring the localPushBackendPullMutex, otherwise we might filter with the old generation
261
- const [droppedItems, filteredItems] = ReadonlyArray.partition(batchItems, ([eventEncoded]) => eventEncoded.seqNum.rebaseGeneration >= currentRebaseGeneration);
262
- if (droppedItems.length > 0) {
263
- yield* Effect.spanEvent(`push:drop-old-generation`, {
264
- droppedCount: droppedItems.length,
265
- currentRebaseGeneration,
228
+ // Prevent more local pushes from being processed until this pull pagination sequence is finished.
229
+ if (pullMutexHeld === false) {
230
+ yield* localPushBackendPullMutex.take(1);
231
+ pullMutexHeld = true;
232
+ }
233
+ const chunkExit = yield* Effect.gen(function* () {
234
+ const syncState = yield* Effect.fromNullishOr(yield* SubscriptionRef.get(syncStateSref)).pipe(Effect.orDieDebugger);
235
+ yield* Effect.annotateCurrentSpan({
236
+ 'merge.newEventsCount': newEvents.length,
237
+ ...(TRACE_VERBOSE === true ? { 'merge.newEvents': jsonStringify(newEvents) } : {}),
238
+ });
239
+ const mergeResult = yield* SyncState.merge({
240
+ syncState,
241
+ payload: SyncState.PayloadUpstreamAdvance.make({ newEvents }),
242
+ isClientOnlyEvent,
243
+ isEqualEvent: LiveStoreEvent.Client.isEqualEncoded,
244
+ ignoreClientOnlyEvents: true,
266
245
  });
267
- /**
268
- * Dropped pushes may still have a deferred awaiting completion.
269
- * Fail it so the caller learns the leader advanced and resubmits with the updated generation.
270
- */
271
- yield* Effect.forEach(droppedItems.filter((item) => item[1] !== undefined), ([eventEncoded, deferred]) => Deferred.fail(deferred, StaleRebaseGenerationError.make({
272
- currentRebaseGeneration,
273
- providedRebaseGeneration: eventEncoded.seqNum.rebaseGeneration,
274
- sessionId: eventEncoded.sessionId,
275
- })));
246
+ if (mergeResult._tag === 'reject') {
247
+ return yield* Effect.dieDebugger('The leader thread should never reject upstream advances');
248
+ }
249
+ const newBackendHead = newEvents.at(-1).seqNum;
250
+ Eventlog.updateBackendHead(dbEventlog, newBackendHead);
251
+ if (mergeResult._tag === 'rebase') {
252
+ yield* Effect.spanEvent(`pull:rebase[${mergeResult.newSyncState.localHead.rebaseGeneration}]`, {
253
+ newEventsCount: newEvents.length,
254
+ ...(TRACE_VERBOSE === true ? { newEvents: jsonStringify(newEvents) } : {}),
255
+ rollbackCount: mergeResult.rollbackEvents.length,
256
+ ...(TRACE_VERBOSE === true ? { mergeResult: jsonStringify(mergeResult) } : {}),
257
+ });
258
+ const globalOrUnknownRebasedPendingEvents = mergeResult.newSyncState.pending.filter((e) => !isClientOnlyEvent(e));
259
+ yield* restartBackendPushing(globalOrUnknownRebasedPendingEvents);
260
+ if (mergeResult.rollbackEvents.length > 0) {
261
+ yield* rollback({
262
+ dbState,
263
+ dbEventlog,
264
+ eventNumsToRollback: mergeResult.rollbackEvents.map((_) => _.seqNum),
265
+ });
266
+ yield* stateHead
267
+ .set(mergeResult.rollbackEvents[0].parentSeqNum)
268
+ .pipe(Effect.mapError((cause) => MaterializeError.make({ cause })));
269
+ }
270
+ yield* connectedClientSessionPullQueues.offer({
271
+ payload: SyncState.payloadFromMergeResult(mergeResult),
272
+ leaderHead: mergeResult.newSyncState.localHead,
273
+ });
274
+ }
275
+ else {
276
+ yield* Effect.spanEvent(`pull:advance`, {
277
+ newEventsCount: newEvents.length,
278
+ ...(TRACE_VERBOSE === true ? { mergeResult: jsonStringify(mergeResult) } : {}),
279
+ });
280
+ // Ensure push fiber is active after advance by restarting with current pending (non-client-only) events
281
+ const globalOrUnknownPendingEvents = mergeResult.newSyncState.pending.filter((e) => !isClientOnlyEvent(e));
282
+ yield* restartBackendPushing(globalOrUnknownPendingEvents);
283
+ yield* connectedClientSessionPullQueues.offer({
284
+ payload: SyncState.payloadFromMergeResult(mergeResult),
285
+ leaderHead: mergeResult.newSyncState.localHead,
286
+ });
287
+ if (mergeResult.confirmedEvents.length > 0) {
288
+ // `mergeResult.confirmedEvents` don't contain the correct sync metadata, so we need to use
289
+ // `newEvents` instead which we filter via `mergeResult.confirmedEvents`
290
+ const confirmedNewEvents = newEvents.filter((event) => mergeResult.confirmedEvents.some((confirmedEvent) => EventSequenceNumber.Client.isEqual(event.seqNum, confirmedEvent.seqNum)));
291
+ yield* Eventlog.updateSyncMetadataForDb(dbEventlog, confirmedNewEvents).pipe(Effect.orDieDebugger);
292
+ }
293
+ }
294
+ // Removes the changeset rows which are no longer needed as we'll never have to rollback beyond this point
295
+ trimChangesetRows(dbState, newBackendHead);
296
+ // The backend merge may advance or rebase the authoritative head. Realign the admission
297
+ // fence now so newly arriving pushes are validated against that history, not the pre-pull head.
298
+ yield* reconcilePushHead(mergeResult.newSyncState.localHead);
299
+ // Apply the merged events to storage before publishing the new sync state below, so readers
300
+ // cannot observe a leader head whose events have not yet been materialized.
301
+ yield* materializeEventsBatch({ batchItems: mergeResult.newEvents });
302
+ yield* SubscriptionRef.set(syncStateSref, mergeResult.newSyncState);
303
+ }).pipe(Effect.exit);
304
+ if (Exit.isFailure(chunkExit) === true) {
305
+ yield* releasePullMutexIfHeld;
306
+ return yield* Effect.failCause(chunkExit.cause);
276
307
  }
277
- if (filteredItems.length === 0) {
278
- return;
308
+ if (isPullPaginationComplete(pageInfo) === true) {
309
+ yield* releasePullMutexIfHeld;
279
310
  }
280
- const [newEvents, deferreds] = ReadonlyArray.unzip(filteredItems);
281
- yield* Effect.annotateCurrentSpan({
282
- 'batchSize': newEvents.length,
283
- ...(TRACE_VERBOSE === true ? { 'newEvents': jsonStringify(newEvents) } : {}),
284
- });
285
- const mergeResult = yield* SyncState.merge({
286
- syncState,
287
- payload: { _tag: 'local-push', newEvents },
288
- isClientEvent,
289
- isEqualEvent: LiveStoreEvent.Client.isEqualEncoded,
311
+ });
312
+ const syncState = yield* Effect.fromNullishOr(yield* SubscriptionRef.get(syncStateSref)).pipe(Effect.orDieDebugger);
313
+ const cursorInfo = yield* Eventlog.getSyncBackendCursorInfoForDb(dbEventlog, {
314
+ remoteHead: syncState.upstreamHead.global,
315
+ });
316
+ const hashMaterializerResult = makeMaterializerHash({ schema, dbState });
317
+ yield* syncBackend.pull(cursorInfo, { live: livePull }).pipe(
318
+ // TODO only take from queue while connected
319
+ Stream.tap(({ batch, pageInfo }) => Effect.gen(function* () {
320
+ // Any response (including an empty batch) proves backend contact, even with stale connectivity.
321
+ onPullSuccess();
322
+ // NOTE we only want to take process events when the sync backend is connected
323
+ // (e.g. needed for simulating being offline)
324
+ // TODO remove when there's a better way to handle this in stream above
325
+ yield* SubscriptionRef.waitUntil(syncBackend.isConnected, (isConnected) => isConnected === true);
326
+ yield* onNewPullChunk(batch.map((_) => LiveStoreEvent.Client.EncodedWithMeta.fromGlobal(_.eventEncoded, {
327
+ syncMetadata: _.metadata,
328
+ // 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
329
+ // This is a bug and needs to be fixed https://github.com/livestorejs/livestore/issues/503#issuecomment-3114533165
330
+ materializerHashLeader: hashMaterializerResult(LiveStoreEvent.Global.toClientEncoded(_.eventEncoded)),
331
+ materializerHashSession: Option.none(),
332
+ })), pageInfo);
333
+ yield* initialBlockingSyncContext.update({ processed: batch.length, pageInfo });
334
+ })), Stream.runDrain, Effect.interruptible, Effect.ensuring(releasePullMutexIfHeld));
335
+ // Should only ever happen when livePull is false
336
+ yield* Effect.logDebug('backend-pulling finished', { livePull });
337
+ });
338
+ const backgroundBackendPushing = Effect.gen(function* () {
339
+ if (syncBackend === undefined)
340
+ return;
341
+ while (true) {
342
+ yield* SubscriptionRef.waitUntil(syncBackend.isConnected, (isConnected) => isConnected === true);
343
+ const queueItems = yield* TxQueue.takeBetween(syncBackendPushQueue, 1, backendPushBatchSize);
344
+ yield* SubscriptionRef.waitUntil(syncBackend.isConnected, (isConnected) => isConnected === true);
345
+ if (devtoolsLatch !== undefined) {
346
+ yield* devtoolsLatch.await;
347
+ }
348
+ yield* Effect.spanEvent('backend-push', {
349
+ batchSize: queueItems.length,
350
+ ...(TRACE_VERBOSE === true ? { batch: jsonStringify(queueItems) } : {}),
290
351
  });
291
- switch (mergeResult._tag) {
292
- case 'rebase': {
293
- return yield* Effect.dieDebugger('The leader thread should never have to rebase due to a local push');
352
+ // Push with declarative retry/backoff using Effect schedules
353
+ // - Exponential backoff starting at 1s and doubling (1s, 2s, 4s, 8s, 16s, 30s ...)
354
+ // - Delay clamped at 30s (continues retrying at 30s)
355
+ // - Resets automatically after successful push
356
+ // TODO(metrics): expose counters/gauges for retry attempts and queue health via devtools/metrics
357
+ yield* Effect.gen(function* () {
358
+ const iteration = yield* Schedule.CurrentMetadata;
359
+ const pushResult = yield* syncBackend.push(queueItems.map((_) => _.toGlobal())).pipe(Effect.result);
360
+ const retries = iteration.attempt;
361
+ if (retries > 0 && Result.isSuccess(pushResult) === true) {
362
+ yield* Effect.spanEvent('backend-push-retry-success', { retries, batchSize: queueItems.length });
294
363
  }
295
- case 'reject': {
296
- yield* Effect.spanEvent(`push:reject`, {
297
- batchSize: newEvents.length,
298
- ...(TRACE_VERBOSE === true ? { mergeResult: jsonStringify(mergeResult) } : {}),
364
+ if (Result.isFailure(pushResult) === true) {
365
+ yield* Effect.spanEvent('backend-push-error', {
366
+ error: pushResult.failure.toString(),
367
+ retries,
368
+ batchSize: queueItems.length,
299
369
  });
300
- // TODO: how to test this?
301
- const nextRebaseGeneration = currentRebaseGeneration + 1;
302
- const providedNum = newEvents.at(0).seqNum;
303
- // All subsequent pushes with same generation should be rejected as well
304
- // We're also handling the case where the localPushQueue already contains events
305
- // from the next generation which we preserve in the queue
306
- const remainingEventsMatchingGeneration = yield* BucketQueue.takeSplitWhere(localPushesQueue, ([eventEncoded]) => eventEncoded.seqNum.rebaseGeneration >= nextRebaseGeneration);
307
- // TODO we still need to better understand and handle this scenario
308
- if (LS_DEV === true && (yield* BucketQueue.size(localPushesQueue)) > 0) {
309
- console.log('localPushesQueue is not empty', yield* BucketQueue.size(localPushesQueue));
310
- // oxlint-disable-next-line eslint(no-debugger) -- intentional breakpoint for unexpected queue state
311
- debugger;
370
+ const error = pushResult.failure;
371
+ if (error._tag === 'ServerAheadError') {
372
+ // It's a core part of the sync protocol that the sync backend will emit a new pull chunk alongside the ServerAheadError
373
+ yield* Effect.logDebug('handled backend-push-error (waiting for interupt caused by pull)', { error });
374
+ return yield* Effect.never;
312
375
  }
313
- const allDeferredsToReject = [
314
- ...deferreds,
315
- ...remainingEventsMatchingGeneration.map(([_, deferred]) => deferred),
316
- ].filter(isNotUndefined);
317
- yield* Effect.forEach(allDeferredsToReject, (deferred) => Deferred.fail(deferred, LeaderAheadError.make({ minimumExpectedNum: mergeResult.expectedMinimumId, providedNum, sessionId: newEvents.at(0).sessionId })));
318
- // In this case we're skipping state update and down/upstream processing
319
- // We've cleared the local push queue and are now waiting for new local pushes / backend pulls
320
- return;
376
+ return yield* error;
321
377
  }
322
- case 'advance': {
323
- break;
324
- }
325
- default: {
326
- casesHandled(mergeResult);
378
+ }).pipe(
379
+ // Retry transient errors
380
+ Effect.retry({
381
+ schedule: Schedule.exponential(Duration.seconds(1)).pipe(Schedule.modifyDelay(({ duration }) => Effect.succeed(Duration.min(duration, Duration.seconds(30))))),
382
+ while: (error) => error._tag === 'IsOfflineError' || error._tag === 'UnknownError',
383
+ }),
384
+ // This is needed to narrow the Error type. Our retry policy runs indefinitely, but Effect.retry does not narrow the Error type.
385
+ Effect.catchIf((error) => error._tag === 'IsOfflineError' || error._tag === 'UnknownError', Effect.die));
386
+ }
387
+ }).pipe(Effect.interruptible);
388
+ // NOTE: New events are only pushed to sync backend after successful local push processing
389
+ const push = (newEvents) => Effect.gen(function* () {
390
+ if (newEvents.length === 0)
391
+ return;
392
+ const deferreds = yield* Effect.forEach(newEvents, () => Deferred.make());
393
+ const items = newEvents.map((eventEncoded, i) => [eventEncoded, deferreds[i]]);
394
+ // Validation, reservation, enqueueing, and fence advancement form one admission transaction.
395
+ // Serializing them prevents concurrent sessions from both validating against the same head and
396
+ // prevents backend reconciliation from changing the fence midway through admission.
397
+ yield* pushAdmissionSemaphore.withPermits(1)(
398
+ // Cancellation must not leave reservations, queue contents, and the admission fence disagreeing.
399
+ Effect.gen(function* () {
400
+ yield* validatePushBatch(newEvents, pushHeadRef.current, isClientOnlyEvent);
401
+ for (const item of items)
402
+ reservedLocalPushItems.add(item);
403
+ yield* TxQueue.offerAll(localPushesQueue, items);
404
+ pushHeadRef.current = newEvents.at(-1).seqNum;
405
+ if (testing.hooks?.localPushAdmitted !== undefined) {
406
+ yield* testing.hooks.localPushAdmitted(newEvents);
407
+ }
408
+ }).pipe(Effect.uninterruptible));
409
+ yield* Effect.forEach(deferreds, Deferred.await);
410
+ }).pipe(Effect.withSpan('@livestore/common:LeaderSyncProcessor:push', {
411
+ attributes: {
412
+ batchSize: newEvents.length,
413
+ batch: TRACE_VERBOSE === true ? newEvents : undefined,
414
+ },
415
+ links: span !== undefined ? [{ span, attributes: {} }] : undefined,
416
+ }));
417
+ return LeaderSyncProcessor.of({
418
+ [TypeId]: TypeId,
419
+ // Starts various background loops
420
+ boot: Effect.gen(function* () {
421
+ /** State transitions need to happen atomically, so we use a Ref to track the state */
422
+ yield* SubscriptionRef.set(syncStateSref, initialSyncState);
423
+ // Rehydrate sync queue
424
+ if (initialSyncState.pending.length > 0) {
425
+ const globalOrUnknownPendingEvents = initialSyncState.pending
426
+ // Don't sync client-only events
427
+ .filter((eventEncoded) => !isClientOnlyEvent(eventEncoded));
428
+ if (globalOrUnknownPendingEvents.length > 0) {
429
+ yield* TxQueue.offerAll(syncBackendPushQueue, globalOrUnknownPendingEvents);
327
430
  }
328
431
  }
329
- yield* SubscriptionRef.set(syncStateSref, mergeResult.newSyncState);
330
- yield* connectedClientSessionPullQueues.offer({
331
- payload: SyncState.PayloadUpstreamAdvance.make({ newEvents: mergeResult.newEvents }),
332
- leaderHead: mergeResult.newSyncState.localHead,
432
+ const handleBackendIdMismatchError = (error) => handleBackendIdMismatch({ error, onBackendIdMismatch, shutdownChannel, dbEventlog, dbState });
433
+ const maybeShutdownOnError = (cause) => Effect.gen(function* () {
434
+ if (onError === 'ignore') {
435
+ if (LS_DEV === true) {
436
+ yield* Effect.logDebug(`Ignoring sync error (${Option.getOrUndefined(Cause.findErrorOption(cause))?._tag ?? cause.toString()})`, Cause.pretty(cause));
437
+ }
438
+ return;
439
+ }
440
+ const error = Option.getOrUndefined(Cause.findErrorOption(cause));
441
+ const errorToSend = error === undefined ? UnknownError.make({ cause }) : error;
442
+ yield* shutdownChannel.send(errorToSend).pipe(Effect.orDie);
443
+ return yield* Effect.failCause(cause).pipe(Effect.orDie);
333
444
  });
334
- yield* Effect.spanEvent(`push:advance`, {
335
- batchSize: newEvents.length,
336
- ...(TRACE_VERBOSE === true ? { mergeResult: jsonStringify(mergeResult) } : {}),
445
+ yield* backgroundApplyLocalPushes.pipe(Effect.catchCause(maybeShutdownOnError), Effect.forkScoped);
446
+ const backendPushingFiberHandle = yield* FiberHandle.make();
447
+ const backendPushingEffect = backgroundBackendPushing.pipe(Effect.catchTag('BackendIdMismatchError', handleBackendIdMismatchError), Effect.catchCause(maybeShutdownOnError));
448
+ yield* FiberHandle.run(backendPushingFiberHandle, backendPushingEffect);
449
+ let offlinePullFallbackMs = 1_000;
450
+ yield* backgroundBackendPulling({
451
+ onPullSuccess: () => {
452
+ offlinePullFallbackMs = 1_000;
453
+ },
454
+ restartBackendPushing: (filteredRebasedPending) => Effect.gen(function* () {
455
+ // Stop current pushing fiber
456
+ yield* FiberHandle.clear(backendPushingFiberHandle);
457
+ // Reset the sync backend push queue
458
+ yield* TxQueue.clear(syncBackendPushQueue);
459
+ yield* TxQueue.offerAll(syncBackendPushQueue, filteredRebasedPending);
460
+ // Restart pushing fiber
461
+ yield* FiberHandle.run(backendPushingFiberHandle, backendPushingEffect);
462
+ }),
463
+ }).pipe(Effect.tapError((error) => {
464
+ if (error._tag !== 'IsOfflineError' || syncBackend === undefined)
465
+ return Effect.void;
466
+ const isConnected = syncBackend.isConnected;
467
+ return Effect.gen(function* () {
468
+ // Wake immediately on reconnection. Probe on a capped fallback timer if connectivity is stale.
469
+ const jitter = yield* Random.nextBetween(0.8, 1.2);
470
+ const fallbackMs = testing.delays?.backendPullReconnect?.(offlinePullFallbackMs * jitter) ?? offlinePullFallbackMs * jitter;
471
+ offlinePullFallbackMs = Math.min(offlinePullFallbackMs * 2, 30_000);
472
+ // A stale initial true is not a reconnect. Watch false→true even while the fallback timer is pending.
473
+ const waitHook = testing.hooks?.backendPullReconnectWait;
474
+ const changes = SubscriptionRef.changes(isConnected);
475
+ const observedChanges = waitHook === undefined
476
+ ? changes
477
+ : changes.pipe(Stream.tap((value) => waitHook({ isConnected: value, fallbackMs })));
478
+ const reconnected = yield* observedChanges.pipe(Stream.dropWhile((value) => value === true), Stream.filter((value) => value === true), Stream.runHead, Effect.as(true), Effect.timeoutOrElse({ duration: fallbackMs, orElse: () => Effect.succeed(false) }));
479
+ if (reconnected === true)
480
+ offlinePullFallbackMs = 1_000;
481
+ });
482
+ }), Effect.retry({
483
+ // The unbounded offline-only retry keeps IsOfflineError out of the terminal error channel.
484
+ until: (error) => error._tag !== 'IsOfflineError',
485
+ }), Effect.catchTag('BackendIdMismatchError', handleBackendIdMismatchError), Effect.catchCause(maybeShutdownOnError),
486
+ // Needed to avoid `Fiber terminated with an unhandled error` logs which seem to happen because of the `Effect.retry` above.
487
+ // This might be a bug in Effect. Only seems to happen in the browser.
488
+ Effect.provideService(References.UnhandledLogLevel, undefined), Effect.forkScoped);
489
+ return { initialLeaderHead: initialSyncState.localHead };
490
+ }).pipe(Effect.withSpanScoped('@livestore/common:LeaderSyncProcessor:boot')),
491
+ push,
492
+ pushPartial: ({ event: { name, args }, clientId, sessionId }) => Effect.gen(function* () {
493
+ const syncState = yield* Effect.fromNullishOr(yield* SubscriptionRef.get(syncStateSref)).pipe(Effect.orDieDebugger);
494
+ const resolution = yield* resolveEventDef(schema, {
495
+ operation: '@livestore/common:LeaderSyncProcessor:pushPartial',
496
+ event: {
497
+ name,
498
+ args,
499
+ clientId,
500
+ sessionId,
501
+ seqNum: syncState.localHead,
502
+ },
337
503
  });
338
- // Don't sync client-local events
339
- const filteredBatch = mergeResult.newEvents.filter((eventEncoded) => {
340
- const eventDef = schema.eventsDefsMap.get(eventEncoded.name);
341
- return eventDef === undefined ? true : eventDef.options.clientOnly === false;
504
+ if (resolution._tag === 'unknown') {
505
+ // Ignore partial pushes for unrecognised events – they are still
506
+ // persisted server-side once a schema update ships.
507
+ return;
508
+ }
509
+ const eventEncoded = new LiveStoreEvent.Client.EncodedWithMeta({
510
+ name,
511
+ args,
512
+ clientId,
513
+ sessionId,
514
+ ...EventSequenceNumber.Client.nextPair({
515
+ seqNum: syncState.localHead,
516
+ isClientOnly: resolution.eventDef.options.clientOnly,
517
+ }),
342
518
  });
343
- yield* BucketQueue.offerAll(syncBackendPushQueue, filteredBatch);
344
- yield* materializeEventsBatch({ batchItems: mergeResult.newEvents, deferreds });
345
- }).pipe(localPushBackendPullMutex.withPermits(1));
346
- }
519
+ yield* push([eventEncoded]);
520
+ }).pipe(
521
+ // pushPartial constructs the event sequence number internally, so these errors should never happen.
522
+ Effect.catchIf(isRejectedPushError, Effect.die)),
523
+ pull: ({ cursor }) => Effect.gen(function* () {
524
+ const queue = yield* connectedClientSessionPullQueues.makeQueue(cursor);
525
+ return Stream.fromQueue(queue);
526
+ }).pipe(Stream.unwrap),
527
+ /*
528
+ Notes for a potential new `LeaderSyncProcessor.pull` implementation:
529
+
530
+ - Doesn't take cursor but is "atomically called" in the leader during the snapshot phase
531
+ - TODO: how is this done "atomically" in the web adapter where the snapshot is read optimistically?
532
+ - Would require a new kind of "boot-phase" API which is stream based:
533
+ - initial message: state snapshot + seq num head
534
+ - subsequent messages: sync state payloads
535
+
536
+ - alternative: instead of session pulling sync state payloads from leader, we could send
537
+ - events in the "advance" case
538
+ - full new state db snapshot in the "rebase" case
539
+ - downside: importing the snapshot is expensive
540
+ */
541
+ pullQueue: ({ cursor }) => connectedClientSessionPullQueues.makeQueue(cursor),
542
+ syncState: Subscribable.make({
543
+ get: SubscriptionRef.get(syncStateSref).pipe(Effect.flatMap(Effect.fromNullishOr), Effect.orDieDebugger),
544
+ changes: SubscriptionRef.changes(syncStateSref).pipe(Stream.filter(Predicate.isNotUndefined)),
545
+ }),
546
+ });
347
547
  });
548
+ export const layer = (options) => Layer.effect(LeaderSyncProcessor, make(options));
348
549
  // TODO how to handle errors gracefully
349
- const materializeEventsBatch = ({ batchItems, deferreds }) => Effect.gen(function* () {
350
- const { dbState: db, dbEventlog, materializeEvent } = yield* LeaderThreadCtx;
550
+ const makeMaterializeEventsBatch = ({ dbState, dbEventlog, materializeEvent, }) => ({ batchItems }) => Effect.gen(function* () {
351
551
  // NOTE We always start a transaction to ensure consistency between db and eventlog (even for single-item batches)
352
- db.execute('BEGIN TRANSACTION', undefined); // Start the transaction
552
+ dbState.execute('BEGIN TRANSACTION', undefined); // Start the transaction
353
553
  dbEventlog.execute('BEGIN TRANSACTION', undefined); // Start the transaction
354
554
  yield* Effect.addFinalizer((exit) => Effect.gen(function* () {
355
555
  if (Exit.isSuccess(exit) === true)
356
556
  return;
357
557
  // Rollback in case of an error
358
- db.execute('ROLLBACK', undefined);
558
+ dbState.execute('ROLLBACK', undefined);
359
559
  dbEventlog.execute('ROLLBACK', undefined);
360
560
  }));
361
561
  for (let i = 0; i < batchItems.length; i++) {
362
562
  const { sessionChangeset, hash } = yield* materializeEvent(batchItems[i]);
363
563
  batchItems[i].meta.sessionChangeset = sessionChangeset;
364
564
  batchItems[i].meta.materializerHashLeader = hash;
365
- if (deferreds?.[i] !== undefined) {
366
- yield* Deferred.succeed(deferreds[i], void 0);
367
- }
368
565
  }
369
- db.execute('COMMIT', undefined); // Commit the transaction
566
+ dbState.execute('COMMIT', undefined); // Commit the transaction
370
567
  dbEventlog.execute('COMMIT', undefined); // Commit the transaction
371
568
  }).pipe(Effect.uninterruptible, Effect.scoped, Effect.withSpan('@livestore/common:LeaderSyncProcessor:materializeEventItems', {
372
569
  attributes: { batchSize: batchItems.length },
373
570
  }), Effect.tapCauseLogPretty);
374
- const backgroundBackendPulling = Effect.fn('@livestore/common:LeaderSyncProcessor:backend-pulling')(function* ({ isClientEvent, restartBackendPushing, dbState, syncStateSref, localPushBackendPullMutex, livePull, devtoolsLatch, initialBlockingSyncContext, connectedClientSessionPullQueues, advancePushHead, }) {
375
- const { syncBackend, dbState: db, dbEventlog, schema } = yield* LeaderThreadCtx;
376
- if (syncBackend === undefined)
377
- return;
378
- let pullMutexHeld = false;
379
- const releasePullMutexIfHeld = Effect.gen(function* () {
380
- if (pullMutexHeld === false)
381
- return;
382
- pullMutexHeld = false;
383
- yield* localPushBackendPullMutex.release(1);
384
- });
385
- const isPullPaginationComplete = (pageInfo) => pageInfo._tag === 'NoMore';
386
- const onNewPullChunk = (newEvents, pageInfo) => Effect.gen(function* () {
387
- if (devtoolsLatch !== undefined) {
388
- yield* devtoolsLatch.await;
389
- }
390
- if (newEvents.length === 0) {
391
- if (isPullPaginationComplete(pageInfo) === true) {
392
- yield* releasePullMutexIfHeld;
393
- }
394
- return;
395
- }
396
- // Prevent more local pushes from being processed until this pull pagination sequence is finished.
397
- if (pullMutexHeld === false) {
398
- yield* localPushBackendPullMutex.take(1);
399
- pullMutexHeld = true;
400
- }
401
- const chunkExit = yield* Effect.gen(function* () {
402
- const syncState = yield* Effect.fromNullable(yield* syncStateSref).pipe(Effect.orDieDebugger);
403
- yield* Effect.annotateCurrentSpan({
404
- 'merge.newEventsCount': newEvents.length,
405
- ...(TRACE_VERBOSE === true ? { 'merge.newEvents': jsonStringify(newEvents) } : {}),
406
- });
407
- const mergeResult = yield* SyncState.merge({
408
- syncState,
409
- payload: SyncState.PayloadUpstreamAdvance.make({ newEvents }),
410
- isClientEvent,
411
- isEqualEvent: LiveStoreEvent.Client.isEqualEncoded,
412
- ignoreClientEvents: true,
413
- });
414
- if (mergeResult._tag === 'reject') {
415
- return yield* Effect.dieDebugger('The leader thread should never reject upstream advances');
416
- }
417
- const newBackendHead = newEvents.at(-1).seqNum;
418
- Eventlog.updateBackendHead(dbEventlog, newBackendHead);
419
- if (mergeResult._tag === 'rebase') {
420
- yield* Effect.spanEvent(`pull:rebase[${mergeResult.newSyncState.localHead.rebaseGeneration}]`, {
421
- newEventsCount: newEvents.length,
422
- ...(TRACE_VERBOSE === true ? { newEvents: jsonStringify(newEvents) } : {}),
423
- rollbackCount: mergeResult.rollbackEvents.length,
424
- ...(TRACE_VERBOSE === true ? { mergeResult: jsonStringify(mergeResult) } : {}),
425
- });
426
- const globalRebasedPendingEvents = mergeResult.newSyncState.pending.filter((event) => {
427
- const eventDef = schema.eventsDefsMap.get(event.name);
428
- return eventDef === undefined ? true : eventDef.options.clientOnly === false;
429
- });
430
- yield* restartBackendPushing(globalRebasedPendingEvents);
431
- if (mergeResult.rollbackEvents.length > 0) {
432
- yield* rollback({
433
- dbState: db,
434
- dbEventlog,
435
- eventNumsToRollback: mergeResult.rollbackEvents.map((_) => _.seqNum),
436
- });
437
- }
438
- yield* connectedClientSessionPullQueues.offer({
439
- payload: SyncState.payloadFromMergeResult(mergeResult),
440
- leaderHead: mergeResult.newSyncState.localHead,
441
- });
442
- }
443
- else {
444
- yield* Effect.spanEvent(`pull:advance`, {
445
- newEventsCount: newEvents.length,
446
- ...(TRACE_VERBOSE === true ? { mergeResult: jsonStringify(mergeResult) } : {}),
447
- });
448
- // Ensure push fiber is active after advance by restarting with current pending (non-client) events
449
- const globalPendingEvents = mergeResult.newSyncState.pending.filter((event) => {
450
- const eventDef = schema.eventsDefsMap.get(event.name);
451
- return eventDef === undefined ? true : eventDef.options.clientOnly === false;
452
- });
453
- yield* restartBackendPushing(globalPendingEvents);
454
- yield* connectedClientSessionPullQueues.offer({
455
- payload: SyncState.payloadFromMergeResult(mergeResult),
456
- leaderHead: mergeResult.newSyncState.localHead,
457
- });
458
- if (mergeResult.confirmedEvents.length > 0) {
459
- // `mergeResult.confirmedEvents` don't contain the correct sync metadata, so we need to use
460
- // `newEvents` instead which we filter via `mergeResult.confirmedEvents`
461
- const confirmedNewEvents = newEvents.filter((event) => mergeResult.confirmedEvents.some((confirmedEvent) => EventSequenceNumber.Client.isEqual(event.seqNum, confirmedEvent.seqNum)));
462
- yield* Eventlog.updateSyncMetadata(confirmedNewEvents).pipe(Effect.orDieDebugger);
463
- }
464
- }
465
- // Removes the changeset rows which are no longer needed as we'll never have to rollback beyond this point
466
- trimChangesetRows(db, newBackendHead);
467
- advancePushHead(mergeResult.newSyncState.localHead);
468
- yield* materializeEventsBatch({ batchItems: mergeResult.newEvents, deferreds: undefined });
469
- yield* SubscriptionRef.set(syncStateSref, mergeResult.newSyncState);
470
- }).pipe(Effect.exit);
471
- if (Exit.isFailure(chunkExit) === true) {
472
- yield* releasePullMutexIfHeld;
473
- return yield* Effect.failCause(chunkExit.cause);
474
- }
475
- if (isPullPaginationComplete(pageInfo) === true) {
476
- yield* releasePullMutexIfHeld;
477
- }
478
- });
479
- const syncState = yield* Effect.fromNullable(yield* syncStateSref).pipe(Effect.orDieDebugger);
480
- const cursorInfo = yield* Eventlog.getSyncBackendCursorInfo({ remoteHead: syncState.upstreamHead.global });
481
- const hashMaterializerResult = makeMaterializerHash({ schema, dbState });
482
- yield* syncBackend.pull(cursorInfo, { live: livePull }).pipe(
483
- // TODO only take from queue while connected
484
- Stream.tap(({ batch, pageInfo }) => Effect.gen(function* () {
485
- // NOTE we only want to take process events when the sync backend is connected
486
- // (e.g. needed for simulating being offline)
487
- // TODO remove when there's a better way to handle this in stream above
488
- yield* SubscriptionRef.waitUntil(syncBackend.isConnected, (isConnected) => isConnected === true);
489
- yield* onNewPullChunk(batch.map((_) => LiveStoreEvent.Client.EncodedWithMeta.fromGlobal(_.eventEncoded, {
490
- syncMetadata: _.metadata,
491
- // 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
492
- // This is a bug and needs to be fixed https://github.com/livestorejs/livestore/issues/503#issuecomment-3114533165
493
- materializerHashLeader: hashMaterializerResult(LiveStoreEvent.Global.toClientEncoded(_.eventEncoded)),
494
- materializerHashSession: Option.none(),
495
- })), pageInfo);
496
- yield* initialBlockingSyncContext.update({ processed: batch.length, pageInfo });
497
- })), Stream.runDrain, Effect.interruptible, Effect.ensuring(releasePullMutexIfHeld));
498
- // Should only ever happen when livePull is false
499
- yield* Effect.logDebug('backend-pulling finished', { livePull });
500
- });
501
- const backgroundBackendPushing = Effect.fn('@livestore/common:LeaderSyncProcessor:backend-pushing')(function* ({ syncBackendPushQueue, devtoolsLatch, backendPushBatchSize, }) {
502
- const { syncBackend } = yield* LeaderThreadCtx;
503
- if (syncBackend === undefined)
504
- return;
505
- while (true) {
506
- yield* SubscriptionRef.waitUntil(syncBackend.isConnected, (isConnected) => isConnected === true);
507
- const queueItems = yield* BucketQueue.takeBetween(syncBackendPushQueue, 1, backendPushBatchSize);
508
- yield* SubscriptionRef.waitUntil(syncBackend.isConnected, (isConnected) => isConnected === true);
509
- if (devtoolsLatch !== undefined) {
510
- yield* devtoolsLatch.await;
511
- }
512
- yield* Effect.spanEvent('backend-push', {
513
- batchSize: queueItems.length,
514
- ...(TRACE_VERBOSE === true ? { batch: jsonStringify(queueItems) } : {}),
515
- });
516
- // Push with declarative retry/backoff using Effect schedules
517
- // - Exponential backoff starting at 1s and doubling (1s, 2s, 4s, 8s, 16s, 30s ...)
518
- // - Delay clamped at 30s (continues retrying at 30s)
519
- // - Resets automatically after successful push
520
- // TODO(metrics): expose counters/gauges for retry attempts and queue health via devtools/metrics
521
- yield* Effect.gen(function* () {
522
- const iteration = yield* Schedule.CurrentIterationMetadata;
523
- const pushResult = yield* syncBackend.push(queueItems.map((_) => _.toGlobal())).pipe(Effect.either);
524
- const retries = iteration.recurrence;
525
- if (retries > 0 && pushResult._tag === 'Right') {
526
- yield* Effect.spanEvent('backend-push-retry-success', { retries, batchSize: queueItems.length });
527
- }
528
- if (pushResult._tag === 'Left') {
529
- yield* Effect.spanEvent('backend-push-error', {
530
- error: pushResult.left.toString(),
531
- retries,
532
- batchSize: queueItems.length,
533
- });
534
- const error = pushResult.left;
535
- if (error._tag === 'ServerAheadError') {
536
- // It's a core part of the sync protocol that the sync backend will emit a new pull chunk alongside the ServerAheadError
537
- yield* Effect.logDebug('handled backend-push-error (waiting for interupt caused by pull)', { error });
538
- return yield* Effect.never;
539
- }
540
- return yield* error;
541
- }
542
- }).pipe(
543
- // Retry transient errors
544
- Effect.retry({
545
- schedule: Schedule.exponential(Duration.seconds(1)).pipe(Schedule.modifyDelay((_, delay) => Duration.min(delay, Duration.seconds(30))) // Cap delay at 30s intervals.
546
- ),
547
- while: (error) => error._tag === 'IsOfflineError' || error._tag === 'UnknownError',
548
- }),
549
- // This is needed to narrow the Error type. Our retry policy runs indefinitely, but Effect.retry does not narrow the Error type.
550
- Effect.catchIf((error) => error._tag === 'IsOfflineError' || error._tag === 'UnknownError', Effect.die));
551
- }
552
- }, Effect.interruptible);
553
571
  const trimChangesetRows = (db, newHead) => {
554
572
  // Since we're using the session changeset rows to query for the current head,
555
573
  // we're keeping at least one row for the current head, and thus are using `<` instead of `<=`
@@ -566,7 +584,7 @@ const makePullQueueSet = Effect.gen(function* () {
566
584
  set.clear();
567
585
  }));
568
586
  const makeQueue = (cursor) => Effect.gen(function* () {
569
- const queue = yield* Queue.unbounded().pipe(Effect.acquireRelease(Queue.shutdown));
587
+ const queue = yield* Effect.acquireRelease(Queue.unbounded(), Queue.shutdown);
570
588
  yield* Effect.addFinalizer(() => Effect.sync(() => set.delete(queue)));
571
589
  const payloadsSinceCursor = Array.from(cachedPayloads.entries())
572
590
  .flatMap(([seqNumStr, payloads]) => payloads.map((payload) => ({ payload, seqNum: EventSequenceNumber.Client.fromString(seqNumStr) })))
@@ -612,7 +630,7 @@ const makePullQueueSet = Effect.gen(function* () {
612
630
  // ...(payload._tag === 'upstream-rebase' ? payload.rollbackEvents.map((_) => _.toJSON()) : []),
613
631
  // ]),
614
632
  // )
615
- yield* queue.offerAll(payloadsSinceCursor);
633
+ yield* Queue.offerAll(queue, payloadsSinceCursor);
616
634
  set.add(queue);
617
635
  return queue;
618
636
  });
@@ -643,7 +661,7 @@ const makePullQueueSet = Effect.gen(function* () {
643
661
  * Ensures the numbers form a strictly increasing chain and that the first
644
662
  * event sits ahead of the current push head.
645
663
  */
646
- const validatePushBatch = (batch, pushHead) => Effect.gen(function* () {
664
+ const validatePushBatch = (batch, pushHead, isClientOnlyEvent) => Effect.gen(function* () {
647
665
  if (batch.length === 0) {
648
666
  return;
649
667
  }
@@ -667,13 +685,50 @@ const validatePushBatch = (batch, pushHead) => Effect.gen(function* () {
667
685
  sessionId: batch[0].sessionId,
668
686
  });
669
687
  }
688
+ // A rebase replaces the optimistic history that the client built on. Events from an older
689
+ // generation may now have the wrong parent and must be recreated from the leader's current head.
690
+ if (batch[0].seqNum.rebaseGeneration < pushHead.rebaseGeneration) {
691
+ return yield* StaleRebaseGenerationError.make({
692
+ currentRebaseGeneration: pushHead.rebaseGeneration,
693
+ providedRebaseGeneration: batch[0].seqNum.rebaseGeneration,
694
+ sessionId: batch[0].sessionId,
695
+ });
696
+ }
697
+ // Validate the batch as one unbroken chain starting at the admission fence. Checking only that
698
+ // numbers increase would still allow gaps or events that point at an unrelated parent.
699
+ let precedingSeqNum = pushHead;
700
+ for (let i = 0; i < batch.length; i++) {
701
+ const event = batch[i];
702
+ // Global events advance the global position; client-only events advance its client-local suffix.
703
+ const expectedPair = EventSequenceNumber.Client.nextPair({
704
+ seqNum: precedingSeqNum,
705
+ isClientOnly: isClientOnlyEvent(event),
706
+ rebaseGeneration: event.seqNum.rebaseGeneration,
707
+ });
708
+ if (EventSequenceNumber.Client.isEqual(event.seqNum, expectedPair.seqNum) === false ||
709
+ isSameSequencePosition(event.parentSeqNum, expectedPair.parentSeqNum) === false) {
710
+ return yield* NonContiguousBatchError.make({
711
+ expectedSeqNum: expectedPair.seqNum,
712
+ providedSeqNum: event.seqNum,
713
+ expectedParentSeqNum: expectedPair.parentSeqNum,
714
+ providedParentSeqNum: event.parentSeqNum,
715
+ violationIndex: i,
716
+ sessionId: event.sessionId,
717
+ });
718
+ }
719
+ precedingSeqNum = event.seqNum;
720
+ }
670
721
  });
722
+ /**
723
+ * Parent linkage identifies a position in the event chain. Rebase generation describes the version
724
+ * of optimistic history, so it is validated on the event itself rather than as part of parent identity.
725
+ */
726
+ const isSameSequencePosition = (left, right) => left.global === right.global && left.client === right.client;
671
727
  /**
672
728
  * Handles a BackendIdMismatchError based on the configured behavior.
673
729
  * This occurs when the sync backend has been reset and has a new identity.
674
730
  */
675
- const handleBackendIdMismatch = Effect.fn('@livestore/common:LeaderSyncProcessor:handleBackendIdMismatch')(function* ({ error, onBackendIdMismatch, shutdownChannel, }) {
676
- const { dbEventlog, dbState } = yield* LeaderThreadCtx;
731
+ const handleBackendIdMismatch = Effect.fn('@livestore/common:LeaderSyncProcessor:handleBackendIdMismatch')(function* ({ error, onBackendIdMismatch, shutdownChannel, dbEventlog, dbState, }) {
677
732
  if (onBackendIdMismatch === 'reset') {
678
733
  yield* Effect.logWarning('Sync backend identity changed (backend was reset). Clearing local storage and shutting down.', error);
679
734
  // Clear local databases so the client can start fresh on next boot
@@ -706,4 +761,17 @@ const clearLocalDatabases = ({ dbEventlog, dbState }) => Effect.sync(() => {
706
761
  dbState.execute(`DROP TABLE IF EXISTS "${name}"`);
707
762
  }
708
763
  });
764
+ const snapshotTxQueue = (queue) => Effect.tx(Effect.gen(function* () {
765
+ const items = yield* TxQueue.clear(queue);
766
+ yield* TxQueue.offerAll(queue, items);
767
+ return items;
768
+ }));
769
+ const takePrefixUntil = (queue, predicate) => Effect.tx(Effect.gen(function* () {
770
+ const items = yield* TxQueue.clear(queue);
771
+ const [prefix, rest] = ReadonlyArray.splitWhere(items, predicate);
772
+ yield* TxQueue.offerAll(queue, rest);
773
+ return prefix;
774
+ }));
775
+ /** Serialize value to JSON string for trace attributes */
776
+ const jsonStringify = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
709
777
  //# sourceMappingURL=LeaderSyncProcessor.js.map