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