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