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