@abloatai/ablo 0.26.0 → 0.28.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/CHANGELOG.md +42 -2
- package/README.md +102 -86
- package/dist/BaseSyncedStore.d.ts +85 -88
- package/dist/BaseSyncedStore.js +134 -151
- package/dist/Database.d.ts +68 -69
- package/dist/Database.js +316 -135
- package/dist/{ObjectPool.d.ts → InstanceCache.d.ts} +10 -13
- package/dist/{ObjectPool.js → InstanceCache.js} +85 -83
- package/dist/LazyReferenceCollection.d.ts +11 -15
- package/dist/LazyReferenceCollection.js +12 -16
- package/dist/Model.d.ts +54 -52
- package/dist/Model.js +78 -62
- package/dist/ModelRegistry.d.ts +21 -19
- package/dist/ModelRegistry.js +23 -27
- package/dist/NetworkMonitor.d.ts +5 -6
- package/dist/NetworkMonitor.js +5 -6
- package/dist/SyncClient.d.ts +122 -118
- package/dist/SyncClient.js +541 -245
- package/dist/adapters/alwaysOnline.d.ts +6 -8
- package/dist/adapters/alwaysOnline.js +6 -8
- package/dist/adapters/inMemoryStorage.d.ts +10 -9
- package/dist/adapters/inMemoryStorage.js +21 -9
- package/dist/agent/Agent.d.ts +27 -32
- package/dist/agent/Agent.js +18 -19
- package/dist/agent/index.d.ts +4 -4
- package/dist/agent/index.js +5 -5
- package/dist/agent/session.d.ts +47 -44
- package/dist/agent/session.js +37 -48
- package/dist/agent/types.d.ts +26 -31
- package/dist/agent/types.js +6 -7
- package/dist/ai-sdk/coordinatedTool.d.ts +108 -0
- package/dist/ai-sdk/{coordinated-tool.js → coordinatedTool.js} +44 -38
- package/dist/ai-sdk/coordinationContext.d.ts +46 -0
- package/dist/ai-sdk/{coordination-context.js → coordinationContext.js} +26 -33
- package/dist/ai-sdk/index.d.ts +25 -22
- package/dist/ai-sdk/index.js +25 -22
- package/dist/ai-sdk/wrap.d.ts +6 -7
- package/dist/ai-sdk/wrap.js +1 -1
- package/dist/auth/credentialPolicy.d.ts +69 -74
- package/dist/auth/credentialPolicy.js +51 -56
- package/dist/auth/credentialSource.d.ts +6 -5
- package/dist/auth/credentialSource.js +9 -10
- package/dist/auth/index.d.ts +59 -58
- package/dist/auth/index.js +31 -37
- package/dist/auth/schemas.d.ts +5 -4
- package/dist/auth/schemas.js +5 -4
- package/dist/batching/index.d.ts +19 -21
- package/dist/batching/index.js +14 -17
- package/dist/cli.cjs +173 -121
- package/dist/client/Ablo.d.ts +97 -74
- package/dist/client/Ablo.js +129 -163
- package/dist/client/ApiClient.d.ts +30 -19
- package/dist/client/ApiClient.js +442 -81
- package/dist/client/auth.d.ts +47 -47
- package/dist/client/auth.js +108 -117
- package/dist/client/claimHeartbeatLoop.d.ts +50 -0
- package/dist/client/claimHeartbeatLoop.js +88 -0
- package/dist/client/consoleLogger.d.ts +5 -6
- package/dist/client/consoleLogger.js +5 -6
- package/dist/client/createInternalComponents.d.ts +16 -17
- package/dist/client/createInternalComponents.js +26 -31
- package/dist/client/createModelProxy.d.ts +130 -120
- package/dist/client/createModelProxy.js +152 -122
- package/dist/client/credentialEndpoint.d.ts +40 -42
- package/dist/client/credentialEndpoint.js +35 -36
- package/dist/client/functionalUpdate.d.ts +29 -27
- package/dist/client/functionalUpdate.js +21 -21
- package/dist/client/hostedEndpoints.d.ts +9 -12
- package/dist/client/hostedEndpoints.js +9 -12
- package/dist/client/httpClient.d.ts +59 -53
- package/dist/client/httpClient.js +29 -31
- package/dist/client/identity.d.ts +15 -20
- package/dist/client/identity.js +47 -58
- package/dist/client/modelRegistration.d.ts +5 -9
- package/dist/client/modelRegistration.js +78 -87
- package/dist/client/options.d.ts +157 -157
- package/dist/client/options.js +3 -7
- package/dist/client/registerDataSource.d.ts +9 -9
- package/dist/client/registerDataSource.js +15 -16
- package/dist/client/resourceTypes.d.ts +64 -75
- package/dist/client/resourceTypes.js +4 -10
- package/dist/client/schemaConfig.d.ts +31 -43
- package/dist/client/schemaConfig.js +38 -50
- package/dist/client/sessionMint.d.ts +16 -12
- package/dist/client/sessionMint.js +26 -31
- package/dist/client/validateAbloOptions.d.ts +12 -14
- package/dist/client/validateAbloOptions.js +8 -9
- package/dist/client/writeOptionsSchema.d.ts +18 -16
- package/dist/client/writeOptionsSchema.js +23 -20
- package/dist/client/wsMutationExecutor.d.ts +16 -20
- package/dist/client/wsMutationExecutor.js +18 -23
- package/dist/commit/contract.d.ts +493 -0
- package/dist/commit/contract.js +187 -0
- package/dist/commit/index.d.ts +6 -0
- package/dist/commit/index.js +5 -0
- package/dist/context.d.ts +6 -4
- package/dist/context.js +6 -4
- package/dist/coordination/index.d.ts +10 -8
- package/dist/coordination/index.js +14 -12
- package/dist/coordination/schema.d.ts +176 -128
- package/dist/coordination/schema.js +197 -133
- package/dist/coordination/trace.d.ts +9 -10
- package/dist/coordination/trace.js +13 -14
- package/dist/core/DatabaseManager.d.ts +5 -7
- package/dist/core/DatabaseManager.js +15 -19
- package/dist/core/QueryProcessor.d.ts +7 -9
- package/dist/core/QueryProcessor.js +22 -28
- package/dist/core/QueryView.d.ts +8 -8
- package/dist/core/QueryView.js +2 -2
- package/dist/core/StoreManager.d.ts +14 -14
- package/dist/core/StoreManager.js +33 -24
- package/dist/core/ViewRegistry.d.ts +5 -5
- package/dist/core/ViewRegistry.js +4 -4
- package/dist/core/index.d.ts +17 -12
- package/dist/core/index.js +32 -26
- package/dist/core/openIDBWithTimeout.d.ts +38 -36
- package/dist/core/openIDBWithTimeout.js +42 -43
- package/dist/core/queryUtils.d.ts +45 -0
- package/dist/core/queryUtils.js +69 -0
- package/dist/core/storeContract.d.ts +63 -61
- package/dist/core/storeContract.js +8 -12
- package/dist/environment.d.ts +28 -0
- package/dist/environment.js +21 -0
- package/dist/errorCodes.d.ts +107 -99
- package/dist/errorCodes.js +137 -134
- package/dist/errors.d.ts +160 -166
- package/dist/errors.js +155 -158
- package/dist/index.d.ts +36 -27
- package/dist/index.js +91 -86
- package/dist/interfaces/index.d.ts +102 -113
- package/dist/interfaces/index.js +5 -4
- package/dist/keys/index.d.ts +27 -29
- package/dist/keys/index.js +41 -40
- package/dist/mutators/RecordingTransaction.d.ts +16 -16
- package/dist/mutators/RecordingTransaction.js +31 -37
- package/dist/mutators/Transaction.d.ts +18 -26
- package/dist/mutators/Transaction.js +14 -20
- package/dist/mutators/UndoManager.d.ts +124 -131
- package/dist/mutators/UndoManager.js +177 -156
- package/dist/mutators/defineMutators.d.ts +23 -34
- package/dist/mutators/defineMutators.js +14 -20
- package/dist/mutators/inverseOp.d.ts +12 -15
- package/dist/mutators/inverseOp.js +12 -15
- package/dist/mutators/mutateActions.d.ts +10 -9
- package/dist/mutators/mutateActions.js +1 -1
- package/dist/mutators/readerActions.d.ts +9 -8
- package/dist/mutators/readerActions.js +2 -2
- package/dist/mutators/undoApply.d.ts +31 -27
- package/dist/mutators/undoApply.js +26 -24
- package/dist/policy/index.d.ts +5 -3
- package/dist/policy/index.js +5 -3
- package/dist/policy/types.d.ts +104 -100
- package/dist/policy/types.js +67 -66
- package/dist/query/client.d.ts +28 -23
- package/dist/query/client.js +45 -43
- package/dist/query/types.d.ts +37 -60
- package/dist/query/types.js +13 -33
- package/dist/react/AbloProvider.d.ts +1 -1
- package/dist/react/AbloProvider.js +2 -2
- package/dist/react/context.d.ts +25 -28
- package/dist/react/context.js +9 -10
- package/dist/react/index.d.ts +41 -42
- package/dist/react/index.js +37 -38
- package/dist/react/internalContext.d.ts +17 -19
- package/dist/react/useAblo.d.ts +28 -25
- package/dist/react/useAblo.js +41 -17
- package/dist/react/useCurrentUserId.d.ts +8 -7
- package/dist/react/useCurrentUserId.js +8 -7
- package/dist/react/useErrorListener.d.ts +7 -7
- package/dist/react/useErrorListener.js +10 -11
- package/dist/react/useMutationFailureListener.d.ts +8 -8
- package/dist/react/useMutationFailureListener.js +8 -8
- package/dist/react/useMutators.d.ts +11 -11
- package/dist/react/useMutators.js +3 -3
- package/dist/react/useReactive.js +2 -2
- package/dist/react/useSyncStatus.d.ts +4 -6
- package/dist/react/useUndoScope.d.ts +7 -9
- package/dist/react/useUndoScope.js +1 -1
- package/dist/schema/coordination.d.ts +21 -25
- package/dist/schema/coordination.js +21 -25
- package/dist/schema/ddl.d.ts +43 -39
- package/dist/schema/ddl.js +75 -68
- package/dist/schema/ddlLock.d.ts +20 -24
- package/dist/schema/ddlLock.js +18 -23
- package/dist/schema/diff.d.ts +99 -61
- package/dist/schema/diff.js +43 -34
- package/dist/schema/field.d.ts +37 -42
- package/dist/schema/field.js +35 -48
- package/dist/schema/generate.d.ts +12 -12
- package/dist/schema/generate.js +12 -12
- package/dist/schema/index.d.ts +3 -3
- package/dist/schema/index.js +21 -23
- package/dist/schema/model.d.ts +118 -143
- package/dist/schema/model.js +22 -33
- package/dist/schema/openapi.d.ts +10 -9
- package/dist/schema/openapi.js +5 -3
- package/dist/schema/queries.d.ts +29 -31
- package/dist/schema/queries.js +23 -25
- package/dist/schema/relation.d.ts +89 -99
- package/dist/schema/relation.js +13 -13
- package/dist/schema/residency.d.ts +16 -13
- package/dist/schema/residency.js +16 -13
- package/dist/schema/roles.d.ts +36 -43
- package/dist/schema/roles.js +31 -37
- package/dist/schema/schema.d.ts +64 -43
- package/dist/schema/schema.js +31 -32
- package/dist/schema/select.d.ts +13 -13
- package/dist/schema/select.js +13 -13
- package/dist/schema/serialize.d.ts +28 -31
- package/dist/schema/serialize.js +27 -31
- package/dist/schema/sugar.d.ts +17 -32
- package/dist/schema/sugar.js +14 -29
- package/dist/schema/{sync-delta-row.d.ts → syncDeltaRow.d.ts} +26 -49
- package/dist/schema/syncDeltaRow.js +89 -0
- package/dist/schema/tenancy.d.ts +44 -46
- package/dist/schema/tenancy.js +46 -48
- package/dist/server/adapter.d.ts +58 -58
- package/dist/server/adapter.js +13 -14
- package/dist/server/commit.d.ts +60 -64
- package/dist/server/index.d.ts +9 -10
- package/dist/server/index.js +1 -1
- package/dist/server/readConfig.d.ts +70 -0
- package/dist/server/readConfig.js +8 -0
- package/dist/server/storageMode.d.ts +23 -0
- package/dist/server/storageMode.js +17 -0
- package/dist/source/adapter.d.ts +30 -25
- package/dist/source/adapter.js +10 -10
- package/dist/source/adapters/drizzle.d.ts +28 -23
- package/dist/source/adapters/drizzle.js +30 -25
- package/dist/source/adapters/kysely.d.ts +27 -25
- package/dist/source/adapters/kysely.js +24 -23
- package/dist/source/adapters/memory.d.ts +8 -7
- package/dist/source/adapters/memory.js +9 -8
- package/dist/source/adapters/prisma.d.ts +13 -12
- package/dist/source/adapters/prisma.js +22 -25
- package/dist/source/conformance.d.ts +18 -11
- package/dist/source/conformance.js +17 -11
- package/dist/source/connector.d.ts +31 -32
- package/dist/source/connector.js +28 -28
- package/dist/source/connectorProtocol.d.ts +160 -0
- package/dist/source/connectorProtocol.js +162 -0
- package/dist/source/contract.d.ts +26 -27
- package/dist/source/contract.js +28 -29
- package/dist/source/factory.d.ts +46 -58
- package/dist/source/factory.js +22 -27
- package/dist/source/index.d.ts +7 -9
- package/dist/source/index.js +12 -14
- package/dist/source/migrations.d.ts +9 -9
- package/dist/source/migrations.js +9 -9
- package/dist/source/next.d.ts +9 -10
- package/dist/source/next.js +6 -7
- package/dist/source/pushQueue.d.ts +69 -47
- package/dist/source/pushQueue.js +32 -28
- package/dist/source/signing.d.ts +46 -17
- package/dist/source/signing.js +28 -11
- package/dist/source/types.d.ts +121 -104
- package/dist/source/types.js +13 -14
- package/dist/stores/ObjectStore.d.ts +24 -12
- package/dist/stores/ObjectStore.js +38 -16
- package/dist/stores/ObjectStoreContract.d.ts +14 -15
- package/dist/stores/SyncActionStore.d.ts +7 -11
- package/dist/stores/SyncActionStore.js +13 -17
- package/dist/surface.d.ts +28 -21
- package/dist/surface.js +29 -20
- package/dist/sync/{BootstrapHelper.d.ts → BootstrapFetcher.d.ts} +36 -42
- package/dist/sync/{BootstrapHelper.js → BootstrapFetcher.js} +76 -76
- package/dist/sync/ConnectionManager.d.ts +39 -50
- package/dist/sync/ConnectionManager.js +55 -66
- package/dist/sync/NetworkProbe.d.ts +24 -29
- package/dist/sync/NetworkProbe.js +63 -69
- package/dist/sync/{HydrationCoordinator.d.ts → OnDemandLoader.d.ts} +42 -41
- package/dist/sync/{HydrationCoordinator.js → OnDemandLoader.js} +59 -54
- package/dist/sync/{AreaOfInterestManager.d.ts → SubscriptionManager.d.ts} +43 -57
- package/dist/sync/{AreaOfInterestManager.js → SubscriptionManager.js} +43 -51
- package/dist/sync/SyncWebSocket.d.ts +141 -166
- package/dist/sync/SyncWebSocket.js +191 -223
- package/dist/sync/awaitClaimGrant.d.ts +18 -18
- package/dist/sync/awaitClaimGrant.js +11 -11
- package/dist/sync/bootstrapApply.d.ts +34 -24
- package/dist/sync/bootstrapApply.js +27 -19
- package/dist/sync/commitFrames.d.ts +21 -20
- package/dist/sync/commitFrames.js +18 -18
- package/dist/sync/createClaimStream.d.ts +23 -22
- package/dist/sync/createClaimStream.js +105 -23
- package/dist/sync/createPresenceStream.d.ts +19 -18
- package/dist/sync/createPresenceStream.js +25 -26
- package/dist/sync/createSnapshot.d.ts +12 -14
- package/dist/sync/createSnapshot.js +20 -26
- package/dist/sync/credentialLifecycle.d.ts +104 -104
- package/dist/sync/credentialLifecycle.js +140 -147
- package/dist/sync/deltaPipeline.d.ts +36 -34
- package/dist/sync/deltaPipeline.js +64 -65
- package/dist/sync/groupChange.d.ts +63 -61
- package/dist/sync/groupChange.js +74 -78
- package/dist/sync/heartbeat.d.ts +34 -33
- package/dist/sync/heartbeat.js +31 -31
- package/dist/sync/participants.d.ts +19 -19
- package/dist/sync/persistedPrefix.d.ts +12 -0
- package/dist/sync/persistedPrefix.js +22 -0
- package/dist/sync/schemas.d.ts +3 -2
- package/dist/sync/schemas.js +14 -10
- package/dist/sync/syncCursor.d.ts +17 -21
- package/dist/sync/syncCursor.js +17 -21
- package/dist/sync/syncPlan.d.ts +28 -36
- package/dist/sync/syncPlan.js +18 -19
- package/dist/sync/syncPosition.d.ts +54 -49
- package/dist/sync/syncPosition.js +57 -52
- package/dist/sync/wsFrameHandlers.d.ts +35 -36
- package/dist/sync/wsFrameHandlers.js +63 -67
- package/dist/testing/fixtures/bootstrap.d.ts +12 -6
- package/dist/testing/fixtures/bootstrap.js +12 -6
- package/dist/testing/fixtures/deltas.d.ts +30 -33
- package/dist/testing/fixtures/deltas.js +30 -33
- package/dist/testing/fixtures/models.d.ts +11 -10
- package/dist/testing/fixtures/models.js +11 -10
- package/dist/testing/helpers/{react-wrapper.d.ts → reactWrapper.d.ts} +13 -10
- package/dist/testing/helpers/{react-wrapper.js → reactWrapper.js} +15 -12
- package/dist/testing/helpers/{sync-engine-harness.d.ts → syncEngineHarness.d.ts} +17 -15
- package/dist/testing/helpers/{sync-engine-harness.js → syncEngineHarness.js} +12 -10
- package/dist/testing/helpers/wait.d.ts +13 -8
- package/dist/testing/helpers/wait.js +13 -8
- package/dist/testing/index.d.ts +5 -3
- package/dist/testing/index.js +3 -2
- package/dist/testing/mocks/FakeDatabase.d.ts +18 -0
- package/dist/testing/mocks/FakeDatabase.js +10 -0
- package/dist/testing/mocks/MockMutationExecutor.d.ts +18 -17
- package/dist/testing/mocks/MockMutationExecutor.js +15 -14
- package/dist/testing/mocks/MockNetworkMonitor.d.ts +8 -8
- package/dist/testing/mocks/MockNetworkMonitor.js +8 -8
- package/dist/testing/mocks/MockSyncContext.d.ts +20 -17
- package/dist/testing/mocks/MockSyncContext.js +15 -13
- package/dist/testing/mocks/MockSyncStore.d.ts +10 -10
- package/dist/testing/mocks/MockSyncStore.js +11 -11
- package/dist/testing/mocks/MockWebSocket.d.ts +28 -23
- package/dist/testing/mocks/MockWebSocket.js +22 -21
- package/dist/transactions/TransactionQueue.d.ts +244 -181
- package/dist/transactions/TransactionQueue.js +929 -423
- package/dist/transactions/TransactionStore.d.ts +6 -4
- package/dist/transactions/TransactionStore.js +6 -4
- package/dist/transactions/UnconfirmedWrites.d.ts +82 -0
- package/dist/transactions/UnconfirmedWrites.js +104 -0
- package/dist/transactions/coalesceRules.d.ts +41 -17
- package/dist/transactions/coalesceRules.js +40 -17
- package/dist/transactions/commitEnvelope.d.ts +132 -0
- package/dist/transactions/commitEnvelope.js +139 -0
- package/dist/transactions/commitOutboxStore.d.ts +32 -0
- package/dist/transactions/commitOutboxStore.js +26 -0
- package/dist/transactions/commitPayload.d.ts +63 -52
- package/dist/transactions/commitPayload.js +54 -57
- package/dist/transactions/deltaConfirmation.d.ts +20 -22
- package/dist/transactions/deltaConfirmation.js +37 -45
- package/dist/transactions/httpCommitEnvelope.d.ts +43 -0
- package/dist/transactions/httpCommitEnvelope.js +179 -0
- package/dist/transactions/optimisticApply.d.ts +49 -0
- package/dist/transactions/optimisticApply.js +65 -0
- package/dist/transactions/replayValidation.d.ts +182 -0
- package/dist/transactions/replayValidation.js +156 -0
- package/dist/types/global.d.ts +46 -41
- package/dist/types/global.js +20 -19
- package/dist/types/index.d.ts +71 -77
- package/dist/types/index.js +22 -22
- package/dist/types/modelData.d.ts +6 -8
- package/dist/types/modelData.js +5 -7
- package/dist/types/participant.d.ts +10 -11
- package/dist/types/participant.js +6 -8
- package/dist/types/streams.d.ts +208 -195
- package/dist/types/streams.js +7 -7
- package/dist/utils/asyncIterator.d.ts +25 -32
- package/dist/utils/asyncIterator.js +25 -32
- package/dist/utils/duration.d.ts +12 -15
- package/dist/utils/duration.js +12 -15
- package/dist/utils/mobxSetup.d.ts +53 -0
- package/dist/utils/{mobx-setup.js → mobxSetup.js} +42 -98
- package/dist/webhooks/events.d.ts +21 -16
- package/dist/webhooks/events.js +10 -8
- package/dist/webhooks/index.d.ts +5 -7
- package/dist/webhooks/index.js +5 -7
- package/dist/wire/bootstrapReason.d.ts +9 -0
- package/dist/wire/bootstrapReason.js +8 -0
- package/dist/{schema/sync-delta-wire.d.ts → wire/delta.d.ts} +58 -41
- package/dist/wire/delta.js +114 -0
- package/dist/wire/errorEnvelope.d.ts +30 -31
- package/dist/wire/errorEnvelope.js +34 -40
- package/dist/wire/frames.d.ts +315 -86
- package/dist/wire/frames.js +47 -33
- package/dist/wire/index.d.ts +18 -14
- package/dist/wire/index.js +32 -27
- package/dist/wire/listEnvelope.d.ts +16 -23
- package/dist/wire/listEnvelope.js +7 -6
- package/dist/wire/protocol.d.ts +25 -32
- package/dist/wire/protocol.js +25 -32
- package/dist/wire/protocolVersion.d.ts +44 -40
- package/dist/wire/protocolVersion.js +44 -40
- package/docs/api.md +10 -10
- package/docs/coordination.md +59 -0
- package/docs/mcp.md +1 -1
- package/package.json +17 -11
- package/dist/ai-sdk/coordinated-tool.d.ts +0 -101
- package/dist/ai-sdk/coordination-context.d.ts +0 -52
- package/dist/core/query-utils.d.ts +0 -34
- package/dist/core/query-utils.js +0 -59
- package/dist/schema/sync-delta-row.js +0 -103
- package/dist/schema/sync-delta-wire.js +0 -102
- package/dist/server/read-config.d.ts +0 -67
- package/dist/server/read-config.js +0 -8
- package/dist/server/storage-mode.d.ts +0 -8
- package/dist/server/storage-mode.js +0 -28
- package/dist/source/connector-protocol.d.ts +0 -159
- package/dist/source/connector-protocol.js +0 -161
- package/dist/transactions/OptimisticEchoTracker.d.ts +0 -82
- package/dist/transactions/OptimisticEchoTracker.js +0 -104
- package/dist/transactions/mutation-error-handler.d.ts +0 -5
- package/dist/transactions/mutation-error-handler.js +0 -39
- package/dist/transactions/optimistic.d.ts +0 -24
- package/dist/transactions/optimistic.js +0 -45
- package/dist/transactions/persistedReplay.d.ts +0 -93
- package/dist/transactions/persistedReplay.js +0 -105
- package/dist/utils/mobx-setup.d.ts +0 -42
package/dist/SyncClient.js
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
2
|
+
* Applies model mutations and manages the offline write queue. The
|
|
3
|
+
* SyncClient turns local create, update, delete, and archive calls into
|
|
4
|
+
* optimistic changes, holds them while the client is offline, sends them to
|
|
5
|
+
* the server when connectivity returns, and resolves conflicts when the
|
|
6
|
+
* server's version of a row disagrees with the local one. It sits between the
|
|
7
|
+
* reactive object pool and the {@link TransactionQueue} that delivers writes
|
|
8
|
+
* over the network.
|
|
9
9
|
*/
|
|
10
10
|
import { runInAction } from 'mobx';
|
|
11
|
-
import {
|
|
11
|
+
import { v4 as uuid } from 'uuid';
|
|
12
|
+
import { InstanceCache, ModelScope } from './InstanceCache.js';
|
|
12
13
|
import { Model } from './Model.js';
|
|
13
14
|
// ModelRegistry instance accessed via this.objectPool.registry
|
|
14
15
|
import { LoadStrategy } from './types/index.js';
|
|
@@ -17,17 +18,19 @@ import { AbloAuthenticationError, AbloError, AbloValidationError } from './error
|
|
|
17
18
|
import { EventEmitter } from 'events';
|
|
18
19
|
import { NetworkMonitor } from './NetworkMonitor.js';
|
|
19
20
|
import { TransactionQueue } from './transactions/TransactionQueue.js';
|
|
20
|
-
import { persistedMutationSchema } from './transactions/
|
|
21
|
-
import {
|
|
21
|
+
import { legacyPendingMutationRecordSchema, PENDING_MUTATION_REPLAY_WINDOW_MS, pendingMutationRecordId, pendingMutationRecordSchema, persistedMutationSchema, } from './transactions/replayValidation.js';
|
|
22
|
+
import { UnconfirmedWrites, } from './transactions/UnconfirmedWrites.js';
|
|
22
23
|
import { SyncPosition } from './sync/syncPosition.js';
|
|
24
|
+
import { DatabaseCommitOutboxStore, } from './transactions/commitOutboxStore.js';
|
|
23
25
|
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
26
|
+
* Reports whether an incoming snapshot record is strictly newer than the
|
|
27
|
+
* model already in the pool. The comparison uses the server-stamped
|
|
28
|
+
* `updatedAt` timestamp, since rows carry no numeric version and the delta
|
|
29
|
+
* pipeline resolves order by arrival (last write wins). An undefined incoming
|
|
30
|
+
* timestamp counts as not newer, so a known row is never clobbered; an
|
|
31
|
+
* undefined existing timestamp means the pooled row is unversioned, so the
|
|
32
|
+
* incoming record wins. The scoped hydrate-on-enter path uses this to drop
|
|
33
|
+
* snapshot rows that a live delta has already advanced past.
|
|
31
34
|
*/
|
|
32
35
|
function rawRecordIsNewer(data, existing) {
|
|
33
36
|
const raw = data.updatedAt;
|
|
@@ -46,10 +49,10 @@ function rawRecordIsNewer(data, existing) {
|
|
|
46
49
|
return inMs > exMs;
|
|
47
50
|
}
|
|
48
51
|
/**
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
* non-date values
|
|
52
|
-
*
|
|
52
|
+
* Converts an untyped server `updatedAt` value — an ISO string, epoch number,
|
|
53
|
+
* or Date read off an untyped row — into epoch milliseconds for
|
|
54
|
+
* last-write-wins comparison. Falsy or non-date values become 0, matching the
|
|
55
|
+
* conflict resolver's rule that a missing timestamp sorts as the epoch.
|
|
53
56
|
*/
|
|
54
57
|
function toEpochMs(value) {
|
|
55
58
|
if (!value)
|
|
@@ -66,6 +69,10 @@ export class SyncClient extends EventEmitter {
|
|
|
66
69
|
database;
|
|
67
70
|
get mutationExecutor() { return getContext().mutationExecutor; }
|
|
68
71
|
networkMonitor;
|
|
72
|
+
/**
|
|
73
|
+
* @internal — test seam, stripped from the published declarations by
|
|
74
|
+
* `stripInternal`. Unit suites deliver queue lifecycle events directly.
|
|
75
|
+
*/
|
|
69
76
|
transactionQueue;
|
|
70
77
|
observers = new Set();
|
|
71
78
|
// Authentication context
|
|
@@ -73,45 +80,46 @@ export class SyncClient extends EventEmitter {
|
|
|
73
80
|
organizationId = null;
|
|
74
81
|
// Pending mutations queue
|
|
75
82
|
pendingMutations = [];
|
|
83
|
+
stagedMutationIds = new Set();
|
|
84
|
+
pendingJournalBatch = [];
|
|
85
|
+
journalFlushScheduled = false;
|
|
86
|
+
commitOutboxNamespace;
|
|
76
87
|
/**
|
|
77
|
-
* Tracks
|
|
78
|
-
* the server has not yet confirmed.
|
|
79
|
-
* to recognize
|
|
80
|
-
*
|
|
81
|
-
* because the delta is the authoritative version of the row.
|
|
88
|
+
* Tracks the ids of transactions the client has applied optimistically but
|
|
89
|
+
* the server has not yet confirmed. When a delta arrives, the receive path
|
|
90
|
+
* consults this set to recognize the echo of the client's own mutation and
|
|
91
|
+
* skip the now-redundant pool update; the IndexedDB write still runs,
|
|
92
|
+
* because the delta is the authoritative version of the row. Without this
|
|
93
|
+
* discriminator, an optimistically applied delete followed by a
|
|
94
|
+
* server-confirmed create echo would resurrect the row for the window
|
|
95
|
+
* between the two confirmations.
|
|
82
96
|
*
|
|
83
|
-
* The
|
|
84
|
-
*
|
|
85
|
-
* it, an optimistically-applied DELETE followed by a
|
|
86
|
-
* server-confirming CREATE echo resurrects the row for the window
|
|
87
|
-
* between the two confirmations (the chart-delete flicker).
|
|
88
|
-
*
|
|
89
|
-
* Bounded with FIFO eviction; observability via `getEchoMetrics()`.
|
|
97
|
+
* The set is bounded with first-in-first-out eviction, and
|
|
98
|
+
* {@link SyncClient.getEchoMetrics} exposes its counters.
|
|
90
99
|
*/
|
|
91
|
-
echoTracker = new
|
|
100
|
+
echoTracker = new UnconfirmedWrites();
|
|
92
101
|
// Connection state
|
|
93
102
|
connectionState = 'disconnected';
|
|
94
|
-
offlineSince;
|
|
95
103
|
// Configuration
|
|
96
|
-
maxRetries = 3;
|
|
97
104
|
isDisposed = false;
|
|
98
105
|
/**
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
* `
|
|
102
|
-
*
|
|
106
|
+
* The client's position in the global delta order, held as the single
|
|
107
|
+
* canonical {@link SyncPosition} instance. The store advances `applied` and
|
|
108
|
+
* `persisted` as deltas land, the queue advances `acked` on commit
|
|
109
|
+
* responses, and snapshots and claims read `readFloor`.
|
|
103
110
|
*/
|
|
104
111
|
position = new SyncPosition();
|
|
105
|
-
constructor(objectPool, database) {
|
|
112
|
+
constructor(objectPool, database, commitOutbox = new DatabaseCommitOutboxStore(database), commitOutboxNamespace = 'default') {
|
|
106
113
|
super();
|
|
107
114
|
this.objectPool = objectPool;
|
|
108
115
|
this.database = database;
|
|
116
|
+
this.commitOutboxNamespace = commitOutboxNamespace;
|
|
109
117
|
this.networkMonitor = new NetworkMonitor();
|
|
110
118
|
// Initialize TransactionQueue with proper configuration
|
|
111
119
|
this.transactionQueue = new TransactionQueue({
|
|
112
120
|
position: this.position,
|
|
113
|
-
maxBatchSize: 50, //
|
|
114
|
-
//
|
|
121
|
+
maxBatchSize: 50, // Larger batches keep the batch count low for bulk operations
|
|
122
|
+
// A short delay keeps writes responsive; coalescing still groups them
|
|
115
123
|
batchDelay: 150,
|
|
116
124
|
maxRetries: 3,
|
|
117
125
|
enableOptimistic: true,
|
|
@@ -120,18 +128,66 @@ export class SyncClient extends EventEmitter {
|
|
|
120
128
|
strategy: 'last-write-wins',
|
|
121
129
|
},
|
|
122
130
|
});
|
|
131
|
+
this.transactionQueue.setCommitOutbox(commitOutbox);
|
|
132
|
+
this.transactionQueue.on('commit:envelope_persisted', (event) => {
|
|
133
|
+
if (event.sourceMutationIds.length === 0)
|
|
134
|
+
return;
|
|
135
|
+
const consumed = new Set(event.sourceMutationIds);
|
|
136
|
+
this.pendingMutations = this.pendingMutations.filter((mutation) => !consumed.has(mutation.mutationId));
|
|
137
|
+
for (const mutationId of consumed)
|
|
138
|
+
this.stagedMutationIds.delete(mutationId);
|
|
139
|
+
if (this.stagedMutationIds.size === 0 && this.pendingMutations.length > 0) {
|
|
140
|
+
this.scheduleSync();
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
this.transactionQueue.on('transaction:completed', (transaction) => {
|
|
144
|
+
const completed = new Set(transaction.sourceMutationIds ?? []);
|
|
145
|
+
if (completed.size > 0) {
|
|
146
|
+
this.pendingMutations = this.pendingMutations.filter((mutation) => !completed.has(mutation.mutationId));
|
|
147
|
+
}
|
|
148
|
+
for (const mutationId of transaction.sourceMutationIds ?? []) {
|
|
149
|
+
this.stagedMutationIds.delete(mutationId);
|
|
150
|
+
void this.database
|
|
151
|
+
.removeTransaction(pendingMutationRecordId(mutationId))
|
|
152
|
+
.catch(() => undefined);
|
|
153
|
+
}
|
|
154
|
+
if (this.stagedMutationIds.size === 0 && this.pendingMutations.length > 0) {
|
|
155
|
+
this.scheduleSync();
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
this.transactionQueue.on('transaction:failed', ({ transaction }) => {
|
|
159
|
+
const failed = transaction.sourceMutationIds ?? [];
|
|
160
|
+
if (failed.length === 0)
|
|
161
|
+
return;
|
|
162
|
+
const failedSet = new Set(failed);
|
|
163
|
+
this.pendingMutations = this.pendingMutations.filter((mutation) => !failedSet.has(mutation.mutationId));
|
|
164
|
+
for (const mutationId of failed) {
|
|
165
|
+
this.stagedMutationIds.delete(mutationId);
|
|
166
|
+
// The queue has already rolled the model back, so replaying the
|
|
167
|
+
// journal row on the next boot would resurrect a rejected write.
|
|
168
|
+
void this.database
|
|
169
|
+
.removeTransaction(pendingMutationRecordId(mutationId))
|
|
170
|
+
.catch(() => undefined);
|
|
171
|
+
}
|
|
172
|
+
// Without this drain, terminally failed ids stayed claimed forever and
|
|
173
|
+
// the size guard in processPendingMutations stalled every later write.
|
|
174
|
+
if (this.stagedMutationIds.size === 0 && this.pendingMutations.length > 0) {
|
|
175
|
+
this.scheduleSync();
|
|
176
|
+
}
|
|
177
|
+
});
|
|
123
178
|
// Provide connection state to TransactionQueue - prevents rollbacks during disconnection
|
|
124
179
|
this.transactionQueue.setConnectionChecker(() => this.connectionState === 'connected');
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
180
|
+
// Restore object-pool state when a transaction is rolled back. If the
|
|
181
|
+
// server rejects a write or it times out, the model's previous state is
|
|
182
|
+
// put back. Because writes are no longer applied to IndexedDB
|
|
183
|
+
// optimistically, that store already holds the correct state.
|
|
128
184
|
this.setupTransactionRollbackHandling();
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
//
|
|
185
|
+
// Forward reconciliation requests from the transaction queue. When delta
|
|
186
|
+
// confirmation times out, the client cycles the WebSocket connection to
|
|
187
|
+
// trigger a catch-up from the server rather than rolling the write back.
|
|
132
188
|
this.setupReconciliationForwarding();
|
|
133
|
-
//
|
|
134
|
-
//
|
|
189
|
+
// Persist unconfirmed transactions to IndexedDB. When delta retries are
|
|
190
|
+
// exhausted, the write is cached so it survives a tab close.
|
|
135
191
|
this.setupAwaitingTransactionPersistence();
|
|
136
192
|
// Setup network monitoring
|
|
137
193
|
this.setupNetworkMonitoring();
|
|
@@ -289,10 +345,10 @@ export class SyncClient extends EventEmitter {
|
|
|
289
345
|
});
|
|
290
346
|
}
|
|
291
347
|
/**
|
|
292
|
-
* Forward reconciliation requests from TransactionQueue to the
|
|
293
|
-
* When delta confirmation times out,
|
|
294
|
-
* instead of rolling back
|
|
295
|
-
*
|
|
348
|
+
* Forward reconciliation requests from the {@link TransactionQueue} to the
|
|
349
|
+
* sync layer. When delta confirmation times out, the queue emits
|
|
350
|
+
* `reconciliation:needed` instead of rolling back, so optimistic state the
|
|
351
|
+
* server may already have committed is never destroyed.
|
|
296
352
|
*/
|
|
297
353
|
setupReconciliationForwarding() {
|
|
298
354
|
this.transactionQueue.on('reconciliation:needed', (event) => {
|
|
@@ -310,10 +366,10 @@ export class SyncClient extends EventEmitter {
|
|
|
310
366
|
});
|
|
311
367
|
}
|
|
312
368
|
/**
|
|
313
|
-
*
|
|
314
|
-
*
|
|
315
|
-
*
|
|
316
|
-
*
|
|
369
|
+
* Persist unconfirmed transactions to IndexedDB. When delta-confirmation
|
|
370
|
+
* retries are exhausted, the transaction is cached so it survives a tab
|
|
371
|
+
* close. On the next session, a WebSocket reconnect and delta catch-up
|
|
372
|
+
* deliver the missing deltas and confirm the transaction.
|
|
317
373
|
*/
|
|
318
374
|
setupAwaitingTransactionPersistence() {
|
|
319
375
|
this.transactionQueue.on('transaction:persist_awaiting', (event) => {
|
|
@@ -341,7 +397,7 @@ export class SyncClient extends EventEmitter {
|
|
|
341
397
|
this.echoTracker.drainOnRollback(event.transaction.id);
|
|
342
398
|
});
|
|
343
399
|
}
|
|
344
|
-
/** Persist an unconfirmed transaction to
|
|
400
|
+
/** Persist an unconfirmed transaction to IndexedDB (never rejects — failures are captured). */
|
|
345
401
|
async persistAwaitingTransaction(event) {
|
|
346
402
|
if (!this.database)
|
|
347
403
|
return;
|
|
@@ -390,23 +446,38 @@ export class SyncClient extends EventEmitter {
|
|
|
390
446
|
this.userId = userId;
|
|
391
447
|
this.organizationId = organizationId;
|
|
392
448
|
getContext().observability.setContext(userId, organizationId);
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
//
|
|
399
|
-
//
|
|
400
|
-
//
|
|
449
|
+
this.transactionQueue.setCommitOutboxScope({
|
|
450
|
+
organizationId,
|
|
451
|
+
participantId: userId,
|
|
452
|
+
namespace: this.commitOutboxNamespace,
|
|
453
|
+
});
|
|
454
|
+
// Calls made during startup are allowed to queue before identity arrives,
|
|
455
|
+
// but they cannot be serialized with a trustworthy scope until now. Flush
|
|
456
|
+
// those already-created journal promises before any write can be staged.
|
|
457
|
+
if (this.pendingJournalBatch.length > 0) {
|
|
458
|
+
this.scheduleJournalFlush();
|
|
459
|
+
await Promise.all(this.pendingMutations.map((mutation) => mutation.journaled));
|
|
460
|
+
}
|
|
461
|
+
// Restore exact, already-sealed requests first. The returned source ids
|
|
462
|
+
// suppress any legacy queue entry left behind by an older non-atomic
|
|
463
|
+
// handoff.
|
|
464
|
+
const sealedMutationIds = await this.transactionQueue.restoreDurableCommits();
|
|
465
|
+
await this.restoreMutationQueue(sealedMutationIds);
|
|
466
|
+
// Read the initial network status from the injected OnlineStatusProvider.
|
|
467
|
+
// In the browser this reflects the host's connectivity signal; in Node it
|
|
468
|
+
// reports online by default. NetworkMonitor drives the ongoing
|
|
469
|
+
// online/offline transitions below — this read is only the initial
|
|
470
|
+
// snapshot taken when identity is set.
|
|
401
471
|
if (getContext().onlineStatus.isOnline()) {
|
|
402
472
|
this.setConnectionState('connected');
|
|
403
473
|
}
|
|
404
474
|
else {
|
|
405
475
|
// Offline - start in offline mode
|
|
406
476
|
this.setConnectionState('disconnected');
|
|
407
|
-
this.offlineSince = new Date();
|
|
408
477
|
this.emit('sync:offline');
|
|
409
478
|
}
|
|
479
|
+
if (this.pendingMutations.length > 0)
|
|
480
|
+
this.scheduleSync();
|
|
410
481
|
}
|
|
411
482
|
/**
|
|
412
483
|
* The organization this client writes under (set by `initialize`).
|
|
@@ -420,7 +491,7 @@ export class SyncClient extends EventEmitter {
|
|
|
420
491
|
* Self-healing helper for individual model records.
|
|
421
492
|
*
|
|
422
493
|
* Two registry-driven repair passes run on every row hydrated from
|
|
423
|
-
*
|
|
494
|
+
* IndexedDB or merged from a delta:
|
|
424
495
|
*
|
|
425
496
|
* 1. **Auto-fill** — for each `autoFill` rule the consumer's schema
|
|
426
497
|
* declares on this model, copy the corresponding identity value
|
|
@@ -476,7 +547,7 @@ export class SyncClient extends EventEmitter {
|
|
|
476
547
|
return { data: result, healed };
|
|
477
548
|
}
|
|
478
549
|
/**
|
|
479
|
-
* Hydrate
|
|
550
|
+
* Hydrate InstanceCache with data from Database
|
|
480
551
|
* Called after bootstrap is complete
|
|
481
552
|
*/
|
|
482
553
|
async hydrateFromDatabase() {
|
|
@@ -591,7 +662,7 @@ export class SyncClient extends EventEmitter {
|
|
|
591
662
|
catch { }
|
|
592
663
|
}
|
|
593
664
|
/**
|
|
594
|
-
* Re-hydrate
|
|
665
|
+
* Re-hydrate InstanceCache from IndexedDB when the pool already has data.
|
|
595
666
|
*
|
|
596
667
|
* Unlike hydrateFromDatabase() (which uses addBatch and skips existing IDs),
|
|
597
668
|
* this method properly:
|
|
@@ -726,13 +797,14 @@ export class SyncClient extends EventEmitter {
|
|
|
726
797
|
return stats;
|
|
727
798
|
}
|
|
728
799
|
/**
|
|
729
|
-
*
|
|
730
|
-
* IndexedDB is only
|
|
800
|
+
* Apply a mutation to a model optimistically and queue it for server sync.
|
|
801
|
+
* IndexedDB is updated only once the server confirms the change with a delta
|
|
802
|
+
* packet.
|
|
731
803
|
*
|
|
732
|
-
*
|
|
733
|
-
*
|
|
734
|
-
*
|
|
735
|
-
*
|
|
804
|
+
* A model's changes are captured before the pool action runs, because a pool
|
|
805
|
+
* operation such as an upsert can clear the model's local change set;
|
|
806
|
+
* capturing first ensures those changes are never lost. The captured set is
|
|
807
|
+
* frozen and handed to {@link queueMutation}.
|
|
736
808
|
*/
|
|
737
809
|
mutate(type, model, poolAction, writeOptions) {
|
|
738
810
|
// No-op UPDATE guard (O(1)). An update with no dirty fields would travel
|
|
@@ -749,9 +821,9 @@ export class SyncClient extends EventEmitter {
|
|
|
749
821
|
// real write. Only a genuine Model with an empty dirty-set is skipped.
|
|
750
822
|
if (type === 'update' && model.hasChanges === false)
|
|
751
823
|
return;
|
|
752
|
-
//
|
|
753
|
-
//
|
|
754
|
-
//
|
|
824
|
+
// Capture changes before the pool action runs. Pool operations —
|
|
825
|
+
// upsert in particular — can clear the model's local changes, so
|
|
826
|
+
// capturing first ensures they are never lost.
|
|
755
827
|
const capturedChanges = type === 'update' || type === 'create' ? this.captureModelChanges(model) : undefined;
|
|
756
828
|
poolAction();
|
|
757
829
|
this.queueMutation({ type, model, timestamp: new Date(), capturedChanges, writeOptions });
|
|
@@ -849,6 +921,7 @@ export class SyncClient extends EventEmitter {
|
|
|
849
921
|
*/
|
|
850
922
|
clearPendingMutationsForModel(modelId) {
|
|
851
923
|
const beforeCount = this.pendingMutations.length;
|
|
924
|
+
const removed = this.pendingMutations.filter((mutation) => mutation.model.id === modelId);
|
|
852
925
|
this.pendingMutations = this.pendingMutations.filter((m) => m.model.id !== modelId);
|
|
853
926
|
const afterCount = this.pendingMutations.length;
|
|
854
927
|
if (beforeCount !== afterCount) {
|
|
@@ -857,13 +930,24 @@ export class SyncClient extends EventEmitter {
|
|
|
857
930
|
clearedCount: beforeCount - afterCount,
|
|
858
931
|
remainingCount: afterCount,
|
|
859
932
|
});
|
|
860
|
-
|
|
861
|
-
|
|
933
|
+
for (const mutation of removed) {
|
|
934
|
+
// Once staged, TransactionQueue owns cancellation and transfers this
|
|
935
|
+
// source id into the superseding delete envelope. Deleting the journal
|
|
936
|
+
// row here would make that valid atomic promotion look like a
|
|
937
|
+
// multi-tab loser. Truly unstaged work can be canceled locally.
|
|
938
|
+
if (this.stagedMutationIds.has(mutation.mutationId))
|
|
939
|
+
continue;
|
|
940
|
+
this.stagedMutationIds.delete(mutation.mutationId);
|
|
941
|
+
void mutation.journaled
|
|
942
|
+
.then(() => this.database.removeTransaction(pendingMutationRecordId(mutation.mutationId)))
|
|
943
|
+
.catch(() => undefined);
|
|
944
|
+
}
|
|
862
945
|
}
|
|
863
946
|
}
|
|
864
947
|
/**
|
|
865
|
-
* Upload file and create attachment
|
|
866
|
-
*
|
|
948
|
+
* Upload a file and create its attachment record. The upload runs through
|
|
949
|
+
* the {@link TransactionQueue}, and a model is built from the server's
|
|
950
|
+
* response and added to the pool.
|
|
867
951
|
*/
|
|
868
952
|
async uploadFile(file, options) {
|
|
869
953
|
if (!this.userId || !this.organizationId) {
|
|
@@ -955,21 +1039,102 @@ export class SyncClient extends EventEmitter {
|
|
|
955
1039
|
this.mutate('archive', model, () => { this.objectPool.updateScope(model.id, ModelScope.archived); });
|
|
956
1040
|
}
|
|
957
1041
|
/**
|
|
958
|
-
* Append a mutation and schedule its sync work.
|
|
1042
|
+
* Append a mutation to the pending queue and schedule its sync work.
|
|
959
1043
|
*
|
|
960
|
-
*
|
|
961
|
-
* pushes
|
|
962
|
-
* process call. Without the deferral, queueing
|
|
963
|
-
*
|
|
964
|
-
* growing queue
|
|
1044
|
+
* IndexedDB persistence and the server push are deferred to a microtask, so
|
|
1045
|
+
* many pushes within the same tick collapse into a single serialization and
|
|
1046
|
+
* a single process call. Without the deferral, queueing a hundred mutations
|
|
1047
|
+
* at once — a large paste, a document import, bulk layer creation — would
|
|
1048
|
+
* reserialize the whole growing queue a hundred times, an O(N²) cost in
|
|
1049
|
+
* `model.toJSON()`.
|
|
965
1050
|
*
|
|
966
|
-
* @param mutation.capturedChanges - Pre-captured changes
|
|
967
|
-
*
|
|
1051
|
+
* @param mutation.capturedChanges - Pre-captured, frozen changes, used to
|
|
1052
|
+
* avoid re-reading a model after pool operations that might clear them.
|
|
968
1053
|
*/
|
|
969
1054
|
queueMutation(mutation) {
|
|
970
|
-
|
|
1055
|
+
const mutationId = `mutation_${uuid()}`;
|
|
1056
|
+
const modelData = mutation.model.toJSON
|
|
1057
|
+
? mutation.model.toJSON()
|
|
1058
|
+
: { ...mutation.model };
|
|
1059
|
+
let resolveJournal;
|
|
1060
|
+
let rejectJournal;
|
|
1061
|
+
const journaled = new Promise((resolve, reject) => {
|
|
1062
|
+
resolveJournal = resolve;
|
|
1063
|
+
rejectJournal = reject;
|
|
1064
|
+
});
|
|
1065
|
+
let resolveStaged;
|
|
1066
|
+
let rejectStaged;
|
|
1067
|
+
const staged = new Promise((resolve, reject) => {
|
|
1068
|
+
resolveStaged = resolve;
|
|
1069
|
+
rejectStaged = reject;
|
|
1070
|
+
});
|
|
1071
|
+
const pending = {
|
|
1072
|
+
...mutation,
|
|
1073
|
+
mutationId,
|
|
1074
|
+
modelData,
|
|
1075
|
+
journaled,
|
|
1076
|
+
resolveJournal,
|
|
1077
|
+
rejectJournal,
|
|
1078
|
+
staged,
|
|
1079
|
+
resolveStaged,
|
|
1080
|
+
rejectStaged,
|
|
1081
|
+
};
|
|
1082
|
+
this.pendingJournalBatch.push(pending);
|
|
1083
|
+
this.scheduleJournalFlush();
|
|
1084
|
+
// Offline drains may not await this until much later. Observe rejection
|
|
1085
|
+
// immediately to avoid an unhandled-promise report while retaining the
|
|
1086
|
+
// original rejecting promise for fail-closed dispatch.
|
|
1087
|
+
void pending.journaled.catch(() => undefined);
|
|
1088
|
+
void pending.staged.catch(() => undefined);
|
|
1089
|
+
this.pendingMutations.push(pending);
|
|
971
1090
|
this.scheduleSync();
|
|
972
1091
|
}
|
|
1092
|
+
scheduleJournalFlush() {
|
|
1093
|
+
if (this.journalFlushScheduled)
|
|
1094
|
+
return;
|
|
1095
|
+
this.journalFlushScheduled = true;
|
|
1096
|
+
const schedule = typeof queueMicrotask === 'function'
|
|
1097
|
+
? queueMicrotask
|
|
1098
|
+
: (callback) => { void Promise.resolve().then(callback); };
|
|
1099
|
+
schedule(() => {
|
|
1100
|
+
this.journalFlushScheduled = false;
|
|
1101
|
+
const batch = this.pendingJournalBatch;
|
|
1102
|
+
this.pendingJournalBatch = [];
|
|
1103
|
+
void this.flushPendingMutationJournal(batch);
|
|
1104
|
+
});
|
|
1105
|
+
}
|
|
1106
|
+
async flushPendingMutationJournal(batch) {
|
|
1107
|
+
if (batch.length === 0)
|
|
1108
|
+
return;
|
|
1109
|
+
if (!this.userId || !this.organizationId) {
|
|
1110
|
+
// Startup writes remain behind their unresolved journal promise. Identity
|
|
1111
|
+
// initialization re-kicks this batch once its durable scope is known.
|
|
1112
|
+
this.pendingJournalBatch = [...batch, ...this.pendingJournalBatch];
|
|
1113
|
+
return;
|
|
1114
|
+
}
|
|
1115
|
+
try {
|
|
1116
|
+
const records = batch.map((mutation) => this.pendingMutationRecord(mutation));
|
|
1117
|
+
const database = this.database;
|
|
1118
|
+
if (database.saveTransactions) {
|
|
1119
|
+
await database.saveTransactions(records);
|
|
1120
|
+
}
|
|
1121
|
+
else {
|
|
1122
|
+
await Promise.all(records.map((record) => database.saveTransaction(record)));
|
|
1123
|
+
}
|
|
1124
|
+
for (const mutation of batch)
|
|
1125
|
+
mutation.resolveJournal?.();
|
|
1126
|
+
}
|
|
1127
|
+
catch (error) {
|
|
1128
|
+
for (const mutation of batch)
|
|
1129
|
+
mutation.rejectJournal?.(error);
|
|
1130
|
+
}
|
|
1131
|
+
finally {
|
|
1132
|
+
for (const mutation of batch) {
|
|
1133
|
+
mutation.resolveJournal = undefined;
|
|
1134
|
+
mutation.rejectJournal = undefined;
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
973
1138
|
syncScheduled = false;
|
|
974
1139
|
scheduleSync() {
|
|
975
1140
|
if (this.syncScheduled)
|
|
@@ -980,7 +1145,6 @@ export class SyncClient extends EventEmitter {
|
|
|
980
1145
|
: (cb) => Promise.resolve().then(cb);
|
|
981
1146
|
schedule(() => {
|
|
982
1147
|
this.syncScheduled = false;
|
|
983
|
-
void this.persistMutationQueue();
|
|
984
1148
|
if (getContext().onlineStatus.isOnline()) {
|
|
985
1149
|
this.processPendingMutations().catch((err) => {
|
|
986
1150
|
getContext().observability.breadcrumb('Background sync failed', 'sync.transaction', 'warning', { error: err instanceof Error ? err.message : String(err) });
|
|
@@ -988,69 +1152,136 @@ export class SyncClient extends EventEmitter {
|
|
|
988
1152
|
}
|
|
989
1153
|
});
|
|
990
1154
|
}
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
async persistMutationQueue() {
|
|
995
|
-
if (!this.database || !this.userId)
|
|
996
|
-
return;
|
|
997
|
-
try {
|
|
998
|
-
const serializedMutations = this.pendingMutations.map((m) => ({
|
|
999
|
-
type: m.type,
|
|
1000
|
-
modelData: m.model.toJSON ? m.model.toJSON() : { ...m.model },
|
|
1001
|
-
modelName: m.model.getModelName(),
|
|
1002
|
-
timestamp: m.timestamp.toISOString(),
|
|
1003
|
-
writeOptions: m.writeOptions,
|
|
1004
|
-
}));
|
|
1005
|
-
await this.database.saveTransaction({
|
|
1006
|
-
id: 'mutation-queue',
|
|
1007
|
-
type: 'queue',
|
|
1008
|
-
mutations: serializedMutations,
|
|
1009
|
-
timestamp: Date.now(),
|
|
1010
|
-
});
|
|
1011
|
-
}
|
|
1012
|
-
catch (error) {
|
|
1013
|
-
// Best-effort persistence — the in-memory queue still processes; only
|
|
1014
|
-
// a tab close before reconnect loses these. Forensic → debug.
|
|
1015
|
-
getContext().logger.debug('[SyncClient] Failed to persist offline mutation queue', {
|
|
1016
|
-
error: error instanceof Error ? error.message : String(error),
|
|
1017
|
-
});
|
|
1155
|
+
pendingMutationRecord(mutation) {
|
|
1156
|
+
if (!this.userId || !this.organizationId) {
|
|
1157
|
+
throw new AbloValidationError('Cannot persist a mutation before participant scope is initialized', { code: 'write_options_invalid' });
|
|
1018
1158
|
}
|
|
1159
|
+
return pendingMutationRecordSchema.parse({
|
|
1160
|
+
id: pendingMutationRecordId(mutation.mutationId),
|
|
1161
|
+
type: 'pending_mutation',
|
|
1162
|
+
storageVersion: 2,
|
|
1163
|
+
mutation: {
|
|
1164
|
+
mutationId: mutation.mutationId,
|
|
1165
|
+
type: mutation.type,
|
|
1166
|
+
modelData: mutation.modelData,
|
|
1167
|
+
modelName: mutation.model.getModelName(),
|
|
1168
|
+
timestamp: mutation.timestamp.toISOString(),
|
|
1169
|
+
...(mutation.capturedChanges !== undefined
|
|
1170
|
+
? { capturedChanges: mutation.capturedChanges }
|
|
1171
|
+
: {}),
|
|
1172
|
+
...(mutation.writeOptions !== undefined
|
|
1173
|
+
? { writeOptions: mutation.writeOptions }
|
|
1174
|
+
: {}),
|
|
1175
|
+
},
|
|
1176
|
+
scope: {
|
|
1177
|
+
organizationId: this.organizationId,
|
|
1178
|
+
participantId: this.userId,
|
|
1179
|
+
namespace: this.commitOutboxNamespace,
|
|
1180
|
+
},
|
|
1181
|
+
timestamp: mutation.timestamp.getTime(),
|
|
1182
|
+
});
|
|
1183
|
+
}
|
|
1184
|
+
async persistPendingMutation(mutation) {
|
|
1185
|
+
await this.database.saveTransaction(this.pendingMutationRecord(mutation));
|
|
1019
1186
|
}
|
|
1020
1187
|
/**
|
|
1021
|
-
* Restore mutation queue from IndexedDB.
|
|
1188
|
+
* Restore the mutation queue from IndexedDB.
|
|
1022
1189
|
*
|
|
1023
|
-
* The persisted record was written by
|
|
1024
|
-
*
|
|
1025
|
-
* corrupt entries are dropped
|
|
1026
|
-
*
|
|
1190
|
+
* The persisted record was written by an earlier session, possibly by an
|
|
1191
|
+
* older build of the SDK, so each entry is validated as it is replayed:
|
|
1192
|
+
* corrupt entries are dropped and logged at debug level, and a failure is
|
|
1193
|
+
* never swallowed silently, because the survival of offline writes must be
|
|
1194
|
+
* observable.
|
|
1027
1195
|
*/
|
|
1028
|
-
async restoreMutationQueue() {
|
|
1196
|
+
async restoreMutationQueue(sealedMutationIds = new Set()) {
|
|
1029
1197
|
if (!this.database || !this.userId)
|
|
1030
1198
|
return;
|
|
1031
1199
|
try {
|
|
1032
1200
|
const stored = await this.database.getPersistedTransactions();
|
|
1033
|
-
const
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1201
|
+
const restoredMutationIds = new Set();
|
|
1202
|
+
let heldForReview = 0;
|
|
1203
|
+
const restore = async (mutation, migrateLegacy, legacyMutationId) => {
|
|
1204
|
+
const parsed = persistedMutationSchema.safeParse(mutation);
|
|
1205
|
+
if (!parsed.success) {
|
|
1206
|
+
getContext().logger.debug('[SyncClient] Dropping malformed persisted mutation', {
|
|
1207
|
+
issues: parsed.error.issues.map((i) => i.path.join('.')).join(', '),
|
|
1208
|
+
});
|
|
1209
|
+
return;
|
|
1210
|
+
}
|
|
1211
|
+
// The window is anchored to when the write was made, because a
|
|
1212
|
+
// record re-sealed on restore would otherwise reset its own expiry
|
|
1213
|
+
// clock. An unparseable timestamp is held rather than replayed.
|
|
1214
|
+
const writtenAt = Date.parse(parsed.data.timestamp);
|
|
1215
|
+
const age = Date.now() - writtenAt;
|
|
1216
|
+
if (!(age < PENDING_MUTATION_REPLAY_WINDOW_MS)) {
|
|
1217
|
+
heldForReview += 1;
|
|
1218
|
+
getContext().logger.warn('A saved local write is older than the server idempotency window and was held for review.');
|
|
1219
|
+
return;
|
|
1220
|
+
}
|
|
1221
|
+
const mutationId = parsed.data.mutationId ?? legacyMutationId ?? `mutation_${uuid()}`;
|
|
1222
|
+
if (sealedMutationIds.has(mutationId) ||
|
|
1223
|
+
restoredMutationIds.has(mutationId))
|
|
1224
|
+
return;
|
|
1225
|
+
const model = this.objectPool.createFromData(parsed.data.modelData);
|
|
1226
|
+
if (model) {
|
|
1227
|
+
const pending = {
|
|
1228
|
+
mutationId,
|
|
1229
|
+
type: parsed.data.type,
|
|
1230
|
+
model,
|
|
1231
|
+
modelData: parsed.data.modelData,
|
|
1232
|
+
timestamp: new Date(parsed.data.timestamp),
|
|
1233
|
+
...(parsed.data.capturedChanges !== undefined
|
|
1234
|
+
? { capturedChanges: parsed.data.capturedChanges }
|
|
1235
|
+
: {}),
|
|
1236
|
+
...(parsed.data.writeOptions !== undefined
|
|
1237
|
+
? { writeOptions: parsed.data.writeOptions }
|
|
1238
|
+
: {}),
|
|
1239
|
+
journaled: Promise.resolve(),
|
|
1240
|
+
// Restored mutations have no live `wait: 'confirmed'` caller, so
|
|
1241
|
+
// their staging needs no waiter handshake.
|
|
1242
|
+
staged: Promise.resolve(),
|
|
1243
|
+
};
|
|
1244
|
+
if (migrateLegacy) {
|
|
1245
|
+
pending.journaled = this.persistPendingMutation(pending);
|
|
1246
|
+
await pending.journaled;
|
|
1042
1247
|
}
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1248
|
+
this.pendingMutations.push(pending);
|
|
1249
|
+
restoredMutationIds.add(mutationId);
|
|
1250
|
+
}
|
|
1251
|
+
};
|
|
1252
|
+
for (const row of stored) {
|
|
1253
|
+
if (row.type !== 'pending_mutation')
|
|
1254
|
+
continue;
|
|
1255
|
+
const parsed = pendingMutationRecordSchema.safeParse(row);
|
|
1256
|
+
if (!parsed.success) {
|
|
1257
|
+
const legacy = legacyPendingMutationRecordSchema.safeParse(row);
|
|
1258
|
+
if (legacy.success) {
|
|
1259
|
+
await restore(legacy.data.mutation, true);
|
|
1260
|
+
continue;
|
|
1053
1261
|
}
|
|
1262
|
+
getContext().logger.debug('[SyncClient] Dropping malformed pending mutation record', {
|
|
1263
|
+
rowId: row.id,
|
|
1264
|
+
});
|
|
1265
|
+
continue;
|
|
1266
|
+
}
|
|
1267
|
+
if (parsed.data.scope.organizationId !== this.organizationId ||
|
|
1268
|
+
parsed.data.scope.participantId !== this.userId ||
|
|
1269
|
+
parsed.data.scope.namespace !== this.commitOutboxNamespace) {
|
|
1270
|
+
getContext().logger.warn('A saved local write belongs to a different account or server and was held for review.');
|
|
1271
|
+
continue;
|
|
1272
|
+
}
|
|
1273
|
+
await restore(parsed.data.mutation, false);
|
|
1274
|
+
}
|
|
1275
|
+
const legacyQueue = stored.find((row) => row.id === 'mutation-queue');
|
|
1276
|
+
if (legacyQueue?.mutations) {
|
|
1277
|
+
const heldBefore = heldForReview;
|
|
1278
|
+
for (const [index, mutation] of legacyQueue.mutations.entries()) {
|
|
1279
|
+
await restore(mutation, true, `legacy_mutation_${index}`);
|
|
1280
|
+
}
|
|
1281
|
+
// Deleting the legacy row would discard any entry held for review, so
|
|
1282
|
+
// it is only removed once every entry has migrated.
|
|
1283
|
+
if (heldForReview === heldBefore) {
|
|
1284
|
+
await this.database.removeTransaction('mutation-queue');
|
|
1054
1285
|
}
|
|
1055
1286
|
}
|
|
1056
1287
|
}
|
|
@@ -1102,17 +1333,45 @@ export class SyncClient extends EventEmitter {
|
|
|
1102
1333
|
return; // Skip if offline
|
|
1103
1334
|
if (this.isDisposed)
|
|
1104
1335
|
return; // Skip if disposed
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
//
|
|
1336
|
+
if (this.stagedMutationIds.size > 0)
|
|
1337
|
+
return;
|
|
1338
|
+
const mutations = this.pendingMutations.filter((mutation) => !this.stagedMutationIds.has(mutation.mutationId)).slice(0, 500);
|
|
1339
|
+
if (mutations.length === 0)
|
|
1340
|
+
return;
|
|
1341
|
+
// Claim the batch BEFORE awaiting the journal. This method runs
|
|
1342
|
+
// concurrently — the scheduleSync microtask and a direct syncNow() caller
|
|
1343
|
+
// land in the same tick — and both would otherwise capture this same
|
|
1344
|
+
// batch, suspend on the identical `journaled` promises, and stage every
|
|
1345
|
+
// mutation twice (two transactions on the wire for one write). Claiming
|
|
1346
|
+
// synchronously makes the second caller hit the guard above and return.
|
|
1111
1347
|
for (const mutation of mutations) {
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1348
|
+
this.stagedMutationIds.add(mutation.mutationId);
|
|
1349
|
+
}
|
|
1350
|
+
// A journal rejection is permanent for that mutation (fail-closed: it can
|
|
1351
|
+
// never dispatch without its durable record), so drop it rather than
|
|
1352
|
+
// leaving it queued to poison every later pass. Healthy batch members
|
|
1353
|
+
// still stage.
|
|
1354
|
+
const journalResults = await Promise.allSettled(mutations.map((mutation) => mutation.journaled));
|
|
1355
|
+
const journaledMutations = [];
|
|
1356
|
+
journalResults.forEach((result, index) => {
|
|
1357
|
+
const mutation = mutations[index];
|
|
1358
|
+
if (result.status === 'fulfilled') {
|
|
1359
|
+
journaledMutations.push(mutation);
|
|
1360
|
+
return;
|
|
1115
1361
|
}
|
|
1362
|
+
this.stagedMutationIds.delete(mutation.mutationId);
|
|
1363
|
+
this.pendingMutations = this.pendingMutations.filter((pending) => pending.mutationId !== mutation.mutationId);
|
|
1364
|
+
mutation.rejectStaged?.(result.reason);
|
|
1365
|
+
getContext().observability.captureTransactionFailure({
|
|
1366
|
+
context: 'persist-pending-mutation',
|
|
1367
|
+
error: result.reason instanceof Error
|
|
1368
|
+
? result.reason
|
|
1369
|
+
: new Error(String(result.reason)),
|
|
1370
|
+
});
|
|
1371
|
+
});
|
|
1372
|
+
// Stage every mutation synchronously within the same event-loop tick;
|
|
1373
|
+
// the transaction queue's microtask batches and sends them together.
|
|
1374
|
+
for (const mutation of journaledMutations) {
|
|
1116
1375
|
// Stage synchronously - TransactionQueue handles batching, retry, and errors
|
|
1117
1376
|
this.stageMutation(mutation);
|
|
1118
1377
|
}
|
|
@@ -1123,8 +1382,12 @@ export class SyncClient extends EventEmitter {
|
|
|
1123
1382
|
* @param mutation.capturedChanges - Pre-captured changes to use instead of re-reading from model
|
|
1124
1383
|
*/
|
|
1125
1384
|
stageMutation(mutation) {
|
|
1126
|
-
if (!this.userId || !this.organizationId)
|
|
1385
|
+
if (!this.userId || !this.organizationId) {
|
|
1386
|
+
// Nothing will stage this call; settle the waiter with the legacy
|
|
1387
|
+
// "silently dropped" semantics rather than hanging a `wait: 'confirmed'`.
|
|
1388
|
+
mutation.resolveStaged?.();
|
|
1127
1389
|
return;
|
|
1390
|
+
}
|
|
1128
1391
|
const ctx = { userId: this.userId, organizationId: this.organizationId };
|
|
1129
1392
|
// Settlement is delivered via transaction.confirmation, not this promise —
|
|
1130
1393
|
// it only rejects when staging itself throws (change extraction, optimistic
|
|
@@ -1137,23 +1400,23 @@ export class SyncClient extends EventEmitter {
|
|
|
1137
1400
|
modelId: mutation.model.id,
|
|
1138
1401
|
error: error instanceof Error ? error : new Error(String(error)),
|
|
1139
1402
|
});
|
|
1403
|
+
this.stagedMutationIds.delete(mutation.mutationId);
|
|
1140
1404
|
};
|
|
1141
|
-
|
|
1142
|
-
this.transactionQueue
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
}
|
|
1405
|
+
const staging = mutation.type === 'update'
|
|
1406
|
+
? this.transactionQueue.update(mutation.model, ctx, mutation.capturedChanges, mutation.writeOptions, mutation.mutationId)
|
|
1407
|
+
: this.transactionQueue[mutation.type].bind(this.transactionQueue)(mutation.model, ctx, mutation.writeOptions, mutation.mutationId);
|
|
1408
|
+
staging
|
|
1409
|
+
.then(() => mutation.resolveStaged?.())
|
|
1410
|
+
.catch((error) => {
|
|
1411
|
+
captureStagingFailure(error);
|
|
1412
|
+
mutation.rejectStaged?.(error);
|
|
1413
|
+
});
|
|
1150
1414
|
}
|
|
1151
1415
|
/**
|
|
1152
|
-
* Resolve
|
|
1153
|
-
*
|
|
1154
|
-
*
|
|
1155
|
-
*
|
|
1156
|
-
* even when there are local changes, to maintain data consistency.
|
|
1416
|
+
* Resolve a conflict between the local model and incoming server data,
|
|
1417
|
+
* called while processing deltas from the WebSocket. Certain server states,
|
|
1418
|
+
* such as deletions and deactivations, always take precedence even when the
|
|
1419
|
+
* local model has unsynced changes, so the two sides stay consistent.
|
|
1157
1420
|
*/
|
|
1158
1421
|
resolveConflicts(localModel, serverData) {
|
|
1159
1422
|
const hasLocalChanges = localModel.hasChanges;
|
|
@@ -1218,9 +1481,9 @@ export class SyncClient extends EventEmitter {
|
|
|
1218
1481
|
return localModel;
|
|
1219
1482
|
}
|
|
1220
1483
|
/**
|
|
1221
|
-
* Extract critical state fields from server data
|
|
1222
|
-
*
|
|
1223
|
-
*
|
|
1484
|
+
* Extract the critical state fields from server data. These are the states
|
|
1485
|
+
* that must be honored even when the local model has unsynced changes. The
|
|
1486
|
+
* conflict resolver reads exactly these fields and no others.
|
|
1224
1487
|
*/
|
|
1225
1488
|
extractCriticalState(serverData) {
|
|
1226
1489
|
const critical = {};
|
|
@@ -1267,8 +1530,6 @@ export class SyncClient extends EventEmitter {
|
|
|
1267
1530
|
await this.processPendingMutations();
|
|
1268
1531
|
this.setConnectionState('connected');
|
|
1269
1532
|
this.emit('sync:reconnected');
|
|
1270
|
-
// Clear offline timestamp
|
|
1271
|
-
this.offlineSince = undefined;
|
|
1272
1533
|
}
|
|
1273
1534
|
catch (error) {
|
|
1274
1535
|
getContext().observability.captureTransactionFailure({
|
|
@@ -1284,7 +1545,6 @@ export class SyncClient extends EventEmitter {
|
|
|
1284
1545
|
async handleDisconnection() {
|
|
1285
1546
|
getContext().observability.breadcrumb('Network disconnected', 'sync.offline');
|
|
1286
1547
|
this.setConnectionState('disconnected');
|
|
1287
|
-
this.offlineSince = new Date();
|
|
1288
1548
|
this.emit('sync:offline');
|
|
1289
1549
|
}
|
|
1290
1550
|
/**
|
|
@@ -1368,6 +1628,16 @@ export class SyncClient extends EventEmitter {
|
|
|
1368
1628
|
*/
|
|
1369
1629
|
markConnected() {
|
|
1370
1630
|
this.setConnectionState('connected');
|
|
1631
|
+
// Browser online state may have marked the client connected before the
|
|
1632
|
+
// WebSocket itself was ready. Always kick both durable lanes on the real
|
|
1633
|
+
// socket event, even when the high-level state did not change.
|
|
1634
|
+
void this.transactionQueue.flushOfflineQueue().catch((error) => {
|
|
1635
|
+
getContext().observability.captureTransactionFailure({
|
|
1636
|
+
context: 'restore-commit-outbox',
|
|
1637
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
1638
|
+
});
|
|
1639
|
+
});
|
|
1640
|
+
void this.processPendingMutations();
|
|
1371
1641
|
}
|
|
1372
1642
|
/**
|
|
1373
1643
|
* Dispose and cleanup
|
|
@@ -1381,9 +1651,10 @@ export class SyncClient extends EventEmitter {
|
|
|
1381
1651
|
this.removeAllListeners();
|
|
1382
1652
|
}
|
|
1383
1653
|
/**
|
|
1384
|
-
*
|
|
1385
|
-
*
|
|
1386
|
-
*
|
|
1654
|
+
* Notify the {@link TransactionQueue} of an incoming delta so it can confirm
|
|
1655
|
+
* transactions by sync-id threshold. A transaction is confirmed once any
|
|
1656
|
+
* delta with an id at or beyond its `lastSyncId` threshold arrives.
|
|
1657
|
+
* @param syncId - The sync id of the received delta.
|
|
1387
1658
|
*/
|
|
1388
1659
|
onDeltaReceived(syncId) {
|
|
1389
1660
|
try {
|
|
@@ -1396,22 +1667,21 @@ export class SyncClient extends EventEmitter {
|
|
|
1396
1667
|
}
|
|
1397
1668
|
}
|
|
1398
1669
|
/**
|
|
1399
|
-
*
|
|
1400
|
-
*
|
|
1401
|
-
*
|
|
1402
|
-
* Cancels pending transactions for children that reference the deleted parent.
|
|
1670
|
+
* Cancel pending transactions for child entities orphaned by a parent's
|
|
1671
|
+
* deletion. The store calls this when a delete delta arrives for a parent,
|
|
1672
|
+
* cancelling any queued writes on children that reference it.
|
|
1403
1673
|
*
|
|
1404
|
-
* @param childModelName - The child model type (
|
|
1405
|
-
* @param foreignKey - The
|
|
1406
|
-
* @param parentId - The deleted parent
|
|
1407
|
-
* @returns
|
|
1674
|
+
* @param childModelName - The child model type (for example, `SlideLayer`).
|
|
1675
|
+
* @param foreignKey - The foreign-key property name (for example, `slideId`).
|
|
1676
|
+
* @param parentId - The id of the deleted parent.
|
|
1677
|
+
* @returns The number of transactions cancelled.
|
|
1408
1678
|
*/
|
|
1409
1679
|
cancelTransactionsByForeignKey(childModelName, foreignKey, parentId) {
|
|
1410
1680
|
return this.transactionQueue.cancelTransactionsByForeignKey(childModelName, foreignKey, parentId);
|
|
1411
1681
|
}
|
|
1412
1682
|
/**
|
|
1413
|
-
* Wait for a transaction to be confirmed
|
|
1414
|
-
*
|
|
1683
|
+
* Wait for a transaction to be confirmed by its delta echo. Delegates to the
|
|
1684
|
+
* {@link TransactionQueue}, which handles the confirmation timeout.
|
|
1415
1685
|
*/
|
|
1416
1686
|
waitForDeltaConfirmation(transactionId) {
|
|
1417
1687
|
return this.transactionQueue.waitForConfirmation(transactionId);
|
|
@@ -1420,12 +1690,19 @@ export class SyncClient extends EventEmitter {
|
|
|
1420
1690
|
* Force sync now - process pending mutations
|
|
1421
1691
|
*/
|
|
1422
1692
|
async syncNow() {
|
|
1693
|
+
// Snapshot before draining: a concurrent drain may already have claimed
|
|
1694
|
+
// this caller's write, in which case processPendingMutations returns
|
|
1695
|
+
// without staging anything. `wait: 'confirmed'` resolves on finding no
|
|
1696
|
+
// in-flight work, so it must not run until every write queued before this
|
|
1697
|
+
// call has a real transaction in the queue or was definitively dropped.
|
|
1698
|
+
const queuedBeforeCall = this.pendingMutations.map((mutation) => mutation.staged);
|
|
1423
1699
|
await this.processPendingMutations();
|
|
1700
|
+
await Promise.allSettled(queuedBeforeCall);
|
|
1424
1701
|
}
|
|
1425
1702
|
/**
|
|
1426
1703
|
* Get sync statistics. Return type is inferred from the literal so
|
|
1427
1704
|
* the call site sees the actual shape — `connectionState` narrowed
|
|
1428
|
-
* to its three states, `objectPoolStats` typed by `
|
|
1705
|
+
* to its three states, `objectPoolStats` typed by `InstanceCache.getStats`.
|
|
1429
1706
|
*/
|
|
1430
1707
|
getSyncStats() {
|
|
1431
1708
|
return {
|
|
@@ -1460,28 +1737,41 @@ export class SyncClient extends EventEmitter {
|
|
|
1460
1737
|
* can render typed UI (toast keyed by `AbloError.type`, route-level
|
|
1461
1738
|
* "this entity reverted" boundaries, telemetry).
|
|
1462
1739
|
*
|
|
1463
|
-
* Distinct from `onTransactionEvent('failed', cb)`, which
|
|
1464
|
-
*
|
|
1465
|
-
*
|
|
1466
|
-
*
|
|
1740
|
+
* Distinct from `onTransactionEvent('failed', cb)`, which serves the
|
|
1741
|
+
* parameterless `pendingChanges` counter and intentionally drops the
|
|
1742
|
+
* payload. The two coexist: the counter callback stays lightweight, while
|
|
1743
|
+
* this typed listener drives user-visible surfaces.
|
|
1467
1744
|
*/
|
|
1468
1745
|
onMutationFailure(listener) {
|
|
1469
1746
|
this.transactionQueue.on('transaction:failed', listener);
|
|
1470
1747
|
return () => this.transactionQueue.off('transaction:failed', listener);
|
|
1471
1748
|
}
|
|
1472
1749
|
/**
|
|
1473
|
-
* Subscribe to
|
|
1750
|
+
* Subscribe to local transaction creation with the full {@link Transaction}
|
|
1474
1751
|
* payload (`type`, `modelName`, `modelId`, `data`, `previousData`). This is
|
|
1475
|
-
* the feed
|
|
1752
|
+
* the feed the store's local-mutation subscription taps for undo recording.
|
|
1476
1753
|
*
|
|
1477
|
-
*
|
|
1478
|
-
*
|
|
1479
|
-
* (reached
|
|
1480
|
-
* `subscribe('transaction:created')`
|
|
1481
|
-
*
|
|
1754
|
+
* It subscribes to the {@link TransactionQueue}'s emitter directly, since
|
|
1755
|
+
* that is the only emitter that fires `transaction:created`. The SyncClient's
|
|
1756
|
+
* own emitter (reached through {@link subscribe}) never rebroadcasts that
|
|
1757
|
+
* event, so routing undo through `subscribe('transaction:created')` would
|
|
1758
|
+
* record nothing. {@link onMutationFailure} taps the queue for the same
|
|
1759
|
+
* reason.
|
|
1482
1760
|
*/
|
|
1483
1761
|
onLocalTransaction(listener) {
|
|
1484
1762
|
this.transactionQueue.on('transaction:created', listener);
|
|
1763
|
+
const snapshotsByCommit = new Map();
|
|
1764
|
+
const onCommitStaging = (payload) => {
|
|
1765
|
+
snapshotsByCommit.set(payload.clientTxId, payload.operations.map((operation) => {
|
|
1766
|
+
if (operation.type === 'CREATE')
|
|
1767
|
+
return undefined;
|
|
1768
|
+
const resident = this.objectPool.get(operation.id);
|
|
1769
|
+
return resident?.toJSON();
|
|
1770
|
+
}));
|
|
1771
|
+
};
|
|
1772
|
+
const onCommitSealFailed = (payload) => {
|
|
1773
|
+
snapshotsByCommit.delete(payload.clientTxId);
|
|
1774
|
+
};
|
|
1485
1775
|
// Commit-lane writes (`ablo.commits.create` — the agent/atomic door) ride
|
|
1486
1776
|
// their own `commit:created` event: they have no optimistic pool apply,
|
|
1487
1777
|
// so they must not feed the echo tracker's `transaction:created` path.
|
|
@@ -1489,6 +1779,8 @@ export class SyncClient extends EventEmitter {
|
|
|
1489
1779
|
// (the queue is pool-free) and hand the synthesized transaction to the
|
|
1490
1780
|
// same listener, so undo observes every write door — one stream.
|
|
1491
1781
|
const onCommitCreated = (payload) => {
|
|
1782
|
+
const stagedSnapshots = snapshotsByCommit.get(payload.clientTxId);
|
|
1783
|
+
snapshotsByCommit.delete(payload.clientTxId);
|
|
1492
1784
|
const TYPE_BY_WIRE = {
|
|
1493
1785
|
CREATE: 'create',
|
|
1494
1786
|
UPDATE: 'update',
|
|
@@ -1500,8 +1792,11 @@ export class SyncClient extends EventEmitter {
|
|
|
1500
1792
|
const type = TYPE_BY_WIRE[op.type];
|
|
1501
1793
|
if (!type || !op.id)
|
|
1502
1794
|
return;
|
|
1503
|
-
const
|
|
1504
|
-
|
|
1795
|
+
const snapshot = type === 'create'
|
|
1796
|
+
? undefined
|
|
1797
|
+
: stagedSnapshots
|
|
1798
|
+
? stagedSnapshots[index]
|
|
1799
|
+
: this.objectPool.get(op.id)?.toJSON();
|
|
1505
1800
|
// A DELETE of a row the local graph never saw is not invertible —
|
|
1506
1801
|
// recording it would make undo "restore" an empty husk. Skip it.
|
|
1507
1802
|
if (type === 'delete' && !snapshot)
|
|
@@ -1532,10 +1827,15 @@ export class SyncClient extends EventEmitter {
|
|
|
1532
1827
|
});
|
|
1533
1828
|
});
|
|
1534
1829
|
};
|
|
1830
|
+
this.transactionQueue.on('commit:staging', onCommitStaging);
|
|
1831
|
+
this.transactionQueue.on('commit:seal_failed', onCommitSealFailed);
|
|
1535
1832
|
this.transactionQueue.on('commit:created', onCommitCreated);
|
|
1536
1833
|
return () => {
|
|
1537
1834
|
this.transactionQueue.off('transaction:created', listener);
|
|
1835
|
+
this.transactionQueue.off('commit:staging', onCommitStaging);
|
|
1836
|
+
this.transactionQueue.off('commit:seal_failed', onCommitSealFailed);
|
|
1538
1837
|
this.transactionQueue.off('commit:created', onCommitCreated);
|
|
1838
|
+
snapshotsByCommit.clear();
|
|
1539
1839
|
};
|
|
1540
1840
|
}
|
|
1541
1841
|
/**
|
|
@@ -1574,11 +1874,11 @@ export class SyncClient extends EventEmitter {
|
|
|
1574
1874
|
assigneeId,
|
|
1575
1875
|
});
|
|
1576
1876
|
}
|
|
1577
|
-
// ── Delta + Bootstrap application (owns
|
|
1877
|
+
// ── Delta + Bootstrap application (owns InstanceCache writes) ──────────────
|
|
1578
1878
|
/**
|
|
1579
|
-
* Apply a batch of delta results from Database to the
|
|
1879
|
+
* Apply a batch of delta results from Database to the InstanceCache.
|
|
1580
1880
|
* Owns: model creation, upsert, remove, archive, conflict resolution.
|
|
1581
|
-
* Returns: nothing —
|
|
1881
|
+
* Returns: nothing — InstanceCache is updated in place.
|
|
1582
1882
|
*/
|
|
1583
1883
|
/**
|
|
1584
1884
|
* Mark a local transaction as optimistically applied. The matching
|
|
@@ -1600,12 +1900,12 @@ export class SyncClient extends EventEmitter {
|
|
|
1600
1900
|
return this.echoTracker.getMetrics();
|
|
1601
1901
|
}
|
|
1602
1902
|
/**
|
|
1603
|
-
* Package-internal accessor for the TransactionQueue. Used by
|
|
1604
|
-
* `Ablo.commits.create()` to route raw multi-
|
|
1605
|
-
* same retry-on-reconnect lane as the
|
|
1606
|
-
*
|
|
1607
|
-
* instance the SyncClient subscribes to.
|
|
1608
|
-
* consumers
|
|
1903
|
+
* Package-internal accessor for the {@link TransactionQueue}. Used by
|
|
1904
|
+
* `Ablo.commits.create()` to route raw multi-operation envelopes through the
|
|
1905
|
+
* same retry-on-reconnect lane as the model proxy path, and by tests to
|
|
1906
|
+
* exercise the queue's interaction with {@link markTransactionPending} on the
|
|
1907
|
+
* real instance the SyncClient subscribes to. It is not re-exported to SDK
|
|
1908
|
+
* consumers; `Ablo` is the public surface.
|
|
1609
1909
|
*/
|
|
1610
1910
|
getTransactionQueue() {
|
|
1611
1911
|
return this.transactionQueue;
|
|
@@ -1633,16 +1933,14 @@ export class SyncClient extends EventEmitter {
|
|
|
1633
1933
|
}
|
|
1634
1934
|
for (const result of dbResults) {
|
|
1635
1935
|
const { modelName, modelId, action, transactionId } = result;
|
|
1636
|
-
//
|
|
1637
|
-
//
|
|
1638
|
-
//
|
|
1639
|
-
//
|
|
1640
|
-
//
|
|
1641
|
-
// the
|
|
1642
|
-
//
|
|
1643
|
-
//
|
|
1644
|
-
// for the ~2s window before the matching DELETE confirmation
|
|
1645
|
-
// lands. See `OPTIMISTIC_RECONCILIATION.md` for the framing.
|
|
1936
|
+
// Echo detection: if this delta carries a transaction id that matches
|
|
1937
|
+
// one already applied optimistically, the pool already reflects the
|
|
1938
|
+
// mutation, so the pool operation is skipped. The IndexedDB write in
|
|
1939
|
+
// Database.processDeltaBatch still runs; only the in-memory pool update
|
|
1940
|
+
// is suppressed. This prevents a resurrection flicker: a server-confirmed
|
|
1941
|
+
// create arriving after the user has optimistically deleted the row would
|
|
1942
|
+
// otherwise re-add it for the brief window before the matching delete
|
|
1943
|
+
// confirmation lands.
|
|
1646
1944
|
if (this.echoTracker.consumeEcho(transactionId)) {
|
|
1647
1945
|
continue;
|
|
1648
1946
|
}
|
|
@@ -1708,17 +2006,15 @@ export class SyncClient extends EventEmitter {
|
|
|
1708
2006
|
break;
|
|
1709
2007
|
}
|
|
1710
2008
|
}
|
|
1711
|
-
// Reveal the whole frame in
|
|
1712
|
-
// `removeBatch
|
|
1713
|
-
// so calling them
|
|
1714
|
-
// boundary — a catch-up frame that adds
|
|
1715
|
-
// every dependent reaction
|
|
1716
|
-
//
|
|
1717
|
-
//
|
|
1718
|
-
//
|
|
1719
|
-
//
|
|
1720
|
-
// equivalent of Replicache's "atomically reveal the new state" — the
|
|
1721
|
-
// app never observes a partially-applied frame.
|
|
2009
|
+
// Reveal the whole frame in a single MobX action. `addBatch`,
|
|
2010
|
+
// `upsertBatch`, `removeBatch`, and `updateScope` are each individually
|
|
2011
|
+
// wrapped in an action, so calling them in sequence flushes reactions at
|
|
2012
|
+
// every action boundary — a catch-up frame that adds, updates, and removes
|
|
2013
|
+
// would fire every dependent reaction several times in a row, re-rendering
|
|
2014
|
+
// and re-sorting on each. Wrapping them in one outer `runInAction` defers
|
|
2015
|
+
// all reaction flushes to a single boundary, so dependents recompute
|
|
2016
|
+
// exactly once regardless of how many models or operation kinds the frame
|
|
2017
|
+
// touched. The app therefore never observes a partially applied frame.
|
|
1722
2018
|
runInAction(() => {
|
|
1723
2019
|
if (modelsToAdd.length > 0)
|
|
1724
2020
|
this.objectPool.addBatch(modelsToAdd, ModelScope.live);
|
|
@@ -1737,7 +2033,7 @@ export class SyncClient extends EventEmitter {
|
|
|
1737
2033
|
});
|
|
1738
2034
|
}
|
|
1739
2035
|
/**
|
|
1740
|
-
* Apply bootstrap data to the
|
|
2036
|
+
* Apply bootstrap data to the InstanceCache with ghost removal.
|
|
1741
2037
|
* Owns: model creation, batch upsert, ghost detection + removal.
|
|
1742
2038
|
*/
|
|
1743
2039
|
applyBootstrapDataToPool(bootstrapData, protectedIds, options) {
|
|
@@ -1775,12 +2071,12 @@ export class SyncClient extends EventEmitter {
|
|
|
1775
2071
|
const recordId = data.id;
|
|
1776
2072
|
if (recordId)
|
|
1777
2073
|
idsForType.add(recordId);
|
|
1778
|
-
// Scoped backfill
|
|
1779
|
-
// a server watermark. If a concurrent live delta already
|
|
1780
|
-
// row past the snapshot, skip it
|
|
1781
|
-
// model
|
|
1782
|
-
// version guard
|
|
1783
|
-
// late, the row would already be clobbered.
|
|
2074
|
+
// Scoped backfill for the hydrate-on-enter path: a subset snapshot is
|
|
2075
|
+
// taken at a server watermark. If a concurrent live delta already
|
|
2076
|
+
// advanced this row past the snapshot, skip it. `createFromData`
|
|
2077
|
+
// mutates the pooled model in place to keep instances alive, so this
|
|
2078
|
+
// version guard has to run before it; a guard at the upsert layer would
|
|
2079
|
+
// be too late, because the row would already be clobbered.
|
|
1784
2080
|
if (options?.scoped && recordId) {
|
|
1785
2081
|
const existing = this.objectPool.get(recordId);
|
|
1786
2082
|
if (existing && !rawRecordIsNewer(data, existing)) {
|
|
@@ -1804,10 +2100,10 @@ export class SyncClient extends EventEmitter {
|
|
|
1804
2100
|
this.objectPool.upsertBatch(allModels, ModelScope.live);
|
|
1805
2101
|
const addedCount = this.objectPool.size - beforeSize;
|
|
1806
2102
|
const updatedCount = allModels.length - addedCount;
|
|
1807
|
-
// Ghost removal
|
|
1808
|
-
// valid for a
|
|
1809
|
-
// returned type. A
|
|
1810
|
-
// type that belong to other
|
|
2103
|
+
// Ghost removal: drop pool entities absent from the server snapshot. This
|
|
2104
|
+
// is valid only for a full bootstrap, where the snapshot is authoritative
|
|
2105
|
+
// for each returned type. A scoped subset snapshot must not remove rows of
|
|
2106
|
+
// the same type that belong to other, unhydrated groups.
|
|
1811
2107
|
let removedCount = 0;
|
|
1812
2108
|
if (!options?.scoped) {
|
|
1813
2109
|
const ghostIds = [];
|