@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
|
@@ -1,25 +1,35 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* TransactionQueue
|
|
2
|
+
* TransactionQueue manages the lifecycle of local writes on their way to the
|
|
3
|
+
* server: it applies each change optimistically, batches the writes made in one
|
|
4
|
+
* event-loop tick into a single commit, retries transient failures, and rolls
|
|
5
|
+
* back on permanent rejection.
|
|
3
6
|
*
|
|
4
|
-
* Key
|
|
5
|
-
* - Optimistic updates with rollback
|
|
6
|
-
* -
|
|
7
|
-
* -
|
|
8
|
-
*
|
|
7
|
+
* Key behaviours:
|
|
8
|
+
* - Optimistic updates with rollback on failure.
|
|
9
|
+
* - Configurable conflict resolution.
|
|
10
|
+
* - Microtask batching: transactions created in the same event-loop tick share
|
|
11
|
+
* a batch id and commit together in one round trip.
|
|
12
|
+
* - A dependency-injected executor, so several queues can coexist.
|
|
9
13
|
*/
|
|
10
14
|
import { EventEmitter } from 'events';
|
|
15
|
+
import { v4 as uuid } from 'uuid';
|
|
11
16
|
import { Model } from '../Model.js';
|
|
12
17
|
import { getContext } from '../context.js';
|
|
13
|
-
import {
|
|
14
|
-
import { AbloError, AbloConnectionError, errorCodeSpec } from '../errors.js';
|
|
18
|
+
import { AbloError, AbloConnectionError, AbloIdempotencyError, AbloValidationError, errorCodeSpec, } from '../errors.js';
|
|
15
19
|
import { SyncPosition } from '../sync/syncPosition.js';
|
|
16
|
-
import { projectCommitPayload, computePriorityScore, normalizeModelKey,
|
|
20
|
+
import { projectCommitPayload, computePriorityScore, normalizeModelKey,
|
|
21
|
+
// Includes stale guards as well as request identity/audit barriers.
|
|
22
|
+
hasCommitCoalescingBarrier, applyWriteOptions, asTransportError, extractStatusCode, TX_TYPE_TO_MUTATION_OP, } from './commitPayload.js';
|
|
17
23
|
import { TransactionStore } from './TransactionStore.js';
|
|
18
24
|
import { entityKey, mergeUpdateData, takeUnsentCreateForModel, findCreateBarrierForDelete, deferDeleteUntilCreateSettles, releaseDeferredDeletesForCreate, } from './coalesceRules.js';
|
|
19
25
|
import { DeltaConfirmationTracker } from './deltaConfirmation.js';
|
|
20
|
-
import { deserializePersistedTransaction, isNonReplayablePersistedRow, } from './
|
|
21
|
-
import {
|
|
26
|
+
import { deserializePersistedTransaction, isNonReplayablePersistedRow, pendingMutationRecordId, } from './replayValidation.js';
|
|
27
|
+
import { createCommitEnvelopeMember, createDurableCommitEnvelope, commitEnvelopeRecordId, durableCommitEnvelopeSchema, } from './commitEnvelope.js';
|
|
28
|
+
import { stableStringify } from '../utils/json.js';
|
|
29
|
+
import { applyOptimisticCreate, applyOptimisticUpdate, applyOptimisticDelete, rollbackOptimistic, } from './optimisticApply.js';
|
|
22
30
|
export class TransactionQueue extends EventEmitter {
|
|
31
|
+
// Keep one hour of clock/network margin inside the server's 24-hour ledger.
|
|
32
|
+
static DURABLE_REPLAY_WINDOW_MS = 23 * 60 * 60 * 1000;
|
|
23
33
|
store = new TransactionStore();
|
|
24
34
|
// Signature of the last permanent-error we logged at `warn`. A `create`
|
|
25
35
|
// whose id already exists (`unique_violation`) is a permanent rejection
|
|
@@ -27,17 +37,17 @@ export class TransactionQueue extends EventEmitter {
|
|
|
27
37
|
// this, the identical cause prints on a loop. We log the first occurrence
|
|
28
38
|
// and demote exact repeats to `debug`.
|
|
29
39
|
lastPermanentErrorSig;
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
40
|
+
// The executor bound to this queue instance, set by `setMutationExecutor(...)`
|
|
41
|
+
// just after construction. When unset it falls back to the ambient executor
|
|
42
|
+
// from `getContext()`.
|
|
33
43
|
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
44
|
+
// The binding matters because the ambient executor is a module-level
|
|
45
|
+
// singleton: constructing a second client instance overwrites the first
|
|
46
|
+
// instance's executor. Without a per-instance binding, commits on one
|
|
47
|
+
// instance would dispatch through another instance's executor closure; once
|
|
48
|
+
// that other instance disposed its store, the closure would resolve no live
|
|
49
|
+
// connection and every commit here would fail with `ws_not_ready`, which the
|
|
50
|
+
// queue treats as transient and retries endlessly.
|
|
41
51
|
_mutationExecutor = null;
|
|
42
52
|
get mutationExecutor() {
|
|
43
53
|
return this._mutationExecutor ?? getContext().mutationExecutor;
|
|
@@ -46,8 +56,8 @@ export class TransactionQueue extends EventEmitter {
|
|
|
46
56
|
isProcessing = false;
|
|
47
57
|
processTimer;
|
|
48
58
|
processScheduled = false;
|
|
49
|
-
//
|
|
50
|
-
//
|
|
59
|
+
// Staging area for transactions created in the same event-loop tick. Each one
|
|
60
|
+
// lands here first, then a microtask commits them together.
|
|
51
61
|
createdTransactions = [];
|
|
52
62
|
commitScheduled = false;
|
|
53
63
|
// Per-model in-flight tracking and merge buffer
|
|
@@ -60,6 +70,39 @@ export class TransactionQueue extends EventEmitter {
|
|
|
60
70
|
commitLane = [];
|
|
61
71
|
commitStore = new Map();
|
|
62
72
|
commitProcessing = false;
|
|
73
|
+
lastCommitSequence = 0;
|
|
74
|
+
durableReplayBlock = null;
|
|
75
|
+
/** Browser-backed strict outbox; absent for standalone/in-memory consumers. */
|
|
76
|
+
commitOutbox = null;
|
|
77
|
+
commitOutboxScope = null;
|
|
78
|
+
nextCommitSequence() {
|
|
79
|
+
const wallSequence = Date.now() * 1_000;
|
|
80
|
+
this.lastCommitSequence = Math.max(wallSequence, this.lastCommitSequence + 1);
|
|
81
|
+
return this.lastCommitSequence;
|
|
82
|
+
}
|
|
83
|
+
emitCommitLifecycle(event, payload) {
|
|
84
|
+
try {
|
|
85
|
+
this.emit(event, payload);
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
getContext().observability.captureTransactionFailure({
|
|
89
|
+
context: `commit-lifecycle-listener:${event}`,
|
|
90
|
+
error: error instanceof Error ? error : String(error),
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
assertDurableReplayOpen() {
|
|
95
|
+
if (this.durableReplayBlock)
|
|
96
|
+
throw this.durableReplayBlock;
|
|
97
|
+
}
|
|
98
|
+
assertEnvelopeInsideReplayWindow(envelope) {
|
|
99
|
+
this.assertDurableReplayOpen();
|
|
100
|
+
if (Date.now() - envelope.sealedAt >=
|
|
101
|
+
TransactionQueue.DURABLE_REPLAY_WINDOW_MS) {
|
|
102
|
+
this.durableReplayBlock = new AbloIdempotencyError('A pending commit is older than the server idempotency window; newer writes are blocked until it is reviewed.', { code: 'idempotency_conflict' });
|
|
103
|
+
throw this.durableReplayBlock;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
63
106
|
computePriorityScore(type, modelName) {
|
|
64
107
|
return computePriorityScore(type, modelName);
|
|
65
108
|
}
|
|
@@ -94,7 +137,7 @@ export class TransactionQueue extends EventEmitter {
|
|
|
94
137
|
findCreateBarrierForDelete(modelName, modelId) {
|
|
95
138
|
return findCreateBarrierForDelete(this.store, modelName, modelId);
|
|
96
139
|
}
|
|
97
|
-
completeLocalDelete(model, context, writeOptions) {
|
|
140
|
+
completeLocalDelete(model, context, writeOptions, sourceMutationIds = []) {
|
|
98
141
|
const actualModelName = model.getModelName();
|
|
99
142
|
const modelKey = normalizeModelKey(actualModelName);
|
|
100
143
|
const transaction = {
|
|
@@ -111,6 +154,9 @@ export class TransactionQueue extends EventEmitter {
|
|
|
111
154
|
attempts: 0,
|
|
112
155
|
priority: 'high',
|
|
113
156
|
writeOptions,
|
|
157
|
+
...(sourceMutationIds.length > 0
|
|
158
|
+
? { sourceMutationIds: [...new Set(sourceMutationIds)] }
|
|
159
|
+
: {}),
|
|
114
160
|
localOnly: true,
|
|
115
161
|
};
|
|
116
162
|
this.attachConfirmation(transaction);
|
|
@@ -130,11 +176,12 @@ export class TransactionQueue extends EventEmitter {
|
|
|
130
176
|
releaseDeferredDeletesForCreate(createTransaction) {
|
|
131
177
|
releaseDeferredDeletesForCreate(this.deferredDeletesByCreate, this.store, (tx) => { this.enqueue(tx); }, createTransaction);
|
|
132
178
|
}
|
|
133
|
-
//
|
|
134
|
-
//
|
|
179
|
+
// Default configuration, tuned so more operations coalesce into a single
|
|
180
|
+
// commit: a larger batch size and delay give rapid operations more time to
|
|
181
|
+
// merge before the batch is sent.
|
|
135
182
|
config = {
|
|
136
|
-
maxBatchSize: 50, //
|
|
137
|
-
batchDelay: 150, //
|
|
183
|
+
maxBatchSize: 50, // send up to this many operations per commit
|
|
184
|
+
batchDelay: 150, // milliseconds to wait for more operations before sending
|
|
138
185
|
maxRetries: 3,
|
|
139
186
|
conflictResolution: {
|
|
140
187
|
strategy: 'last-write-wins',
|
|
@@ -151,19 +198,21 @@ export class TransactionQueue extends EventEmitter {
|
|
|
151
198
|
};
|
|
152
199
|
// Track executing transactions for backpressure
|
|
153
200
|
executingCount = 0;
|
|
154
|
-
// Optimistic update tracking. The entry shape
|
|
155
|
-
// in `./
|
|
201
|
+
// Optimistic update tracking. The entry shape and apply/rollback rules live
|
|
202
|
+
// in `./optimisticApply.js`; the map itself stays on the queue because the
|
|
156
203
|
// completion paths, `getStats`, and `dispose` all read it.
|
|
157
204
|
optimisticUpdates = new Map();
|
|
158
|
-
// Stale-context notifications
|
|
159
|
-
//
|
|
160
|
-
//
|
|
205
|
+
// Stale-context notifications, keyed by transaction id. When the server
|
|
206
|
+
// accepts a commit but reports that an operation's read premise had moved,
|
|
207
|
+
// the notification lands here from the commit acknowledgement and is drained
|
|
208
|
+
// by `waitForCommitReceipt`, so the receipt can carry it back to the caller.
|
|
161
209
|
commitNotifications = new Map();
|
|
162
210
|
// Delta-confirmation tracking (ack watermark advance + the awaiting_delta
|
|
163
211
|
// timeout/retry maps) lives in `./deltaConfirmation.js`. Constructed in the
|
|
164
212
|
// constructor once `position` is bound.
|
|
165
213
|
deltaConfirmation;
|
|
166
|
-
// Connection
|
|
214
|
+
// Connection-state check, supplied by the client, used to hold off rollbacks
|
|
215
|
+
// while disconnected.
|
|
167
216
|
isConnectedFn = () => true;
|
|
168
217
|
// Grace timer that, when fired, fails any commit-lane transaction
|
|
169
218
|
// still awaiting an ack. Started on `setConnectionState('disconnected')`,
|
|
@@ -171,11 +220,11 @@ export class TransactionQueue extends EventEmitter {
|
|
|
171
220
|
// is preserved for brief blips; this only catches persistent disconnects.
|
|
172
221
|
commitOfflineGraceTimer = null;
|
|
173
222
|
/**
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
* queue advances `acked`
|
|
177
|
-
* `applied
|
|
178
|
-
*
|
|
223
|
+
* This client's place in the global order of sync deltas. The instance is
|
|
224
|
+
* shared: the client injects one, and a standalone queue creates its own. The
|
|
225
|
+
* queue advances the `acked` cursor as commit responses arrive, the store
|
|
226
|
+
* advances `applied` and `persisted`, and snapshots and claims read
|
|
227
|
+
* `readFloor`. See `../sync/syncPosition.js` for the full contract.
|
|
179
228
|
*/
|
|
180
229
|
position;
|
|
181
230
|
/** Applied-cursor alias, kept so the many internal read sites stay legible. */
|
|
@@ -187,6 +236,207 @@ export class TransactionQueue extends EventEmitter {
|
|
|
187
236
|
}
|
|
188
237
|
// Batch management
|
|
189
238
|
batchIndex = 0;
|
|
239
|
+
/** Mints the request identity once; retry paths only read the stored value. */
|
|
240
|
+
generateCommitIdempotencyKey() {
|
|
241
|
+
return `commit_${uuid()}`;
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Binds an ordered transaction batch to one wire-level idempotency key.
|
|
245
|
+
* Existing envelopes are validated and restored to their original order;
|
|
246
|
+
* they are never extended with newly queued work.
|
|
247
|
+
*/
|
|
248
|
+
ensureCommitEnvelope(batch) {
|
|
249
|
+
const firstTransaction = batch[0];
|
|
250
|
+
if (!firstTransaction) {
|
|
251
|
+
throw new Error('Cannot create an idempotency envelope for an empty commit');
|
|
252
|
+
}
|
|
253
|
+
const existingKeys = new Set(batch
|
|
254
|
+
.map((tx) => tx.commitEnvelope?.idempotencyKey)
|
|
255
|
+
.filter((key) => key !== undefined));
|
|
256
|
+
if (existingKeys.size > 1) {
|
|
257
|
+
throw new Error('Cannot combine transactions from different commit envelopes');
|
|
258
|
+
}
|
|
259
|
+
const existingKey = existingKeys.values().next().value;
|
|
260
|
+
if (existingKey) {
|
|
261
|
+
const expectedCount = firstTransaction.commitEnvelope?.operationCount;
|
|
262
|
+
const indexes = new Set(batch.map((tx) => tx.commitEnvelope?.operationIndex));
|
|
263
|
+
const isCompleteEnvelope = expectedCount === batch.length &&
|
|
264
|
+
indexes.size === batch.length &&
|
|
265
|
+
batch.every((tx) => tx.commitEnvelope?.idempotencyKey === existingKey &&
|
|
266
|
+
tx.commitEnvelope.operationCount === expectedCount &&
|
|
267
|
+
tx.commitEnvelope.operationIndex < expectedCount);
|
|
268
|
+
if (!isCompleteEnvelope) {
|
|
269
|
+
throw new Error('Cannot replay a partial or malformed commit envelope');
|
|
270
|
+
}
|
|
271
|
+
const persistedSealTimes = new Set(batch
|
|
272
|
+
.map((transaction) => transaction.commitEnvelope?.sealedAt)
|
|
273
|
+
.filter((sealedAt) => sealedAt !== undefined));
|
|
274
|
+
if (persistedSealTimes.size > 1) {
|
|
275
|
+
throw new Error('Cannot replay a commit envelope with inconsistent seal times');
|
|
276
|
+
}
|
|
277
|
+
const sealedAt = persistedSealTimes.values().next().value ?? Date.now();
|
|
278
|
+
for (const transaction of batch) {
|
|
279
|
+
if (transaction.commitEnvelope)
|
|
280
|
+
transaction.commitEnvelope.sealedAt = sealedAt;
|
|
281
|
+
}
|
|
282
|
+
batch.sort((a, b) => (a.commitEnvelope?.operationIndex ?? 0) -
|
|
283
|
+
(b.commitEnvelope?.operationIndex ?? 0));
|
|
284
|
+
return existingKey;
|
|
285
|
+
}
|
|
286
|
+
// An explicit public key owns its request. Transactions carrying one are
|
|
287
|
+
// selected as solo batches by takeNextExecutionBatch().
|
|
288
|
+
const explicitKey = batch.length === 1
|
|
289
|
+
? firstTransaction.writeOptions?.idempotencyKey
|
|
290
|
+
: undefined;
|
|
291
|
+
const idempotencyKey = typeof explicitKey === 'string' && explicitKey.length > 0
|
|
292
|
+
? explicitKey
|
|
293
|
+
: this.generateCommitIdempotencyKey();
|
|
294
|
+
const sealedAt = Date.now();
|
|
295
|
+
const sequence = this.nextCommitSequence();
|
|
296
|
+
batch.forEach((tx, operationIndex) => {
|
|
297
|
+
tx.commitEnvelope = createCommitEnvelopeMember({
|
|
298
|
+
idempotencyKey,
|
|
299
|
+
operationIndex,
|
|
300
|
+
operationCount: batch.length,
|
|
301
|
+
sealedAt,
|
|
302
|
+
sequence,
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
return idempotencyKey;
|
|
306
|
+
}
|
|
307
|
+
/** Bind the strict local outbox used before any mutation reaches the wire. */
|
|
308
|
+
setCommitOutbox(outbox) {
|
|
309
|
+
this.commitOutbox = outbox;
|
|
310
|
+
}
|
|
311
|
+
setCommitOutboxScope(scope) {
|
|
312
|
+
this.commitOutboxScope = scope;
|
|
313
|
+
}
|
|
314
|
+
sourceMutationIdsFor(batch) {
|
|
315
|
+
return [...new Set(batch.flatMap((transaction) => transaction.sourceMutationIds ?? []))];
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Atomically replaces staged mutation journal rows with one exact request.
|
|
319
|
+
* The returned operations are the JSON-normalized values that must be sent;
|
|
320
|
+
* callers never send a separately reconstructed payload after sealing.
|
|
321
|
+
*/
|
|
322
|
+
async sealDurableCommit(input) {
|
|
323
|
+
const sourceMutationIds = [...new Set(input.sourceMutationIds ?? [])];
|
|
324
|
+
const envelope = createDurableCommitEnvelope({
|
|
325
|
+
idempotencyKey: input.idempotencyKey,
|
|
326
|
+
origin: input.origin,
|
|
327
|
+
operations: [...input.operations],
|
|
328
|
+
sourceMutationIds,
|
|
329
|
+
commitOptions: {
|
|
330
|
+
...(input.commitOptions?.causedByTaskId !== undefined
|
|
331
|
+
? { causedByTaskId: input.commitOptions.causedByTaskId }
|
|
332
|
+
: {}),
|
|
333
|
+
...(input.commitOptions?.reads !== undefined
|
|
334
|
+
? {
|
|
335
|
+
reads: input.commitOptions.reads === null
|
|
336
|
+
? null
|
|
337
|
+
: [...input.commitOptions.reads],
|
|
338
|
+
}
|
|
339
|
+
: {}),
|
|
340
|
+
},
|
|
341
|
+
...(this.commitOutboxScope ? { scope: this.commitOutboxScope } : {}),
|
|
342
|
+
createdAt: input.createdAt,
|
|
343
|
+
sealedAt: input.sealedAt,
|
|
344
|
+
sequence: input.sequence ?? input.sealedAt * 1_000,
|
|
345
|
+
});
|
|
346
|
+
if (this.config.enablePersistence && this.commitOutbox) {
|
|
347
|
+
try {
|
|
348
|
+
await this.commitOutbox.seal(envelope, sourceMutationIds.map(pendingMutationRecordId));
|
|
349
|
+
}
|
|
350
|
+
catch (cause) {
|
|
351
|
+
if (cause instanceof AbloError)
|
|
352
|
+
throw cause;
|
|
353
|
+
throw new AbloConnectionError('Could not seal the commit outbox before dispatch', {
|
|
354
|
+
code: 'db_not_opened',
|
|
355
|
+
cause,
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
this.emitCommitLifecycle('commit:envelope_persisted', {
|
|
359
|
+
idempotencyKey: envelope.idempotencyKey,
|
|
360
|
+
sourceMutationIds: envelope.sourceMutationIds,
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
return envelope;
|
|
364
|
+
}
|
|
365
|
+
/** Best-effort cleanup after a definitive result; replay is safe if it fails. */
|
|
366
|
+
async removeDurableCommit(idempotencyKey) {
|
|
367
|
+
if (!this.config.enablePersistence || !this.commitOutbox)
|
|
368
|
+
return;
|
|
369
|
+
try {
|
|
370
|
+
await this.commitOutbox.remove(commitEnvelopeRecordId(idempotencyKey));
|
|
371
|
+
}
|
|
372
|
+
catch (error) {
|
|
373
|
+
getContext().logger.debug('[TransactionQueue] Commit outbox cleanup deferred', {
|
|
374
|
+
idempotencyKey,
|
|
375
|
+
error: error instanceof Error ? error.message : String(error),
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* Takes either one complete retry envelope or a fresh batch. A retry waits
|
|
381
|
+
* until every original member has re-entered the queue, preventing an
|
|
382
|
+
* ambiguous A+B commit from being replayed later as A and B separately.
|
|
383
|
+
*/
|
|
384
|
+
takeNextExecutionBatch() {
|
|
385
|
+
const retryGroups = new Map();
|
|
386
|
+
for (const tx of this.executionQueue) {
|
|
387
|
+
const envelope = tx.commitEnvelope;
|
|
388
|
+
if (!envelope)
|
|
389
|
+
continue;
|
|
390
|
+
const group = retryGroups.get(envelope.idempotencyKey) ??
|
|
391
|
+
new Map();
|
|
392
|
+
group.set(tx.id, tx);
|
|
393
|
+
retryGroups.set(envelope.idempotencyKey, group);
|
|
394
|
+
}
|
|
395
|
+
for (const [idempotencyKey, byId] of retryGroups) {
|
|
396
|
+
const members = [...byId.values()];
|
|
397
|
+
const expectedCount = members[0]?.commitEnvelope?.operationCount;
|
|
398
|
+
if (expectedCount === undefined || members.length !== expectedCount)
|
|
399
|
+
continue;
|
|
400
|
+
this.executionQueue = this.executionQueue.filter((tx) => tx.commitEnvelope?.idempotencyKey !== idempotencyKey);
|
|
401
|
+
members.sort((a, b) => (a.commitEnvelope?.operationIndex ?? 0) -
|
|
402
|
+
(b.commitEnvelope?.operationIndex ?? 0));
|
|
403
|
+
return members;
|
|
404
|
+
}
|
|
405
|
+
const fresh = this.executionQueue.filter((tx) => !tx.commitEnvelope);
|
|
406
|
+
const firstFresh = fresh[0];
|
|
407
|
+
if (!firstFresh)
|
|
408
|
+
return [];
|
|
409
|
+
// A caller-supplied key describes exactly one public mutation call. Keep
|
|
410
|
+
// that transaction out of an SDK-created aggregate batch so the key maps
|
|
411
|
+
// to the request its caller intended.
|
|
412
|
+
const explicitIndex = fresh.findIndex((tx) => typeof tx.writeOptions?.idempotencyKey === 'string');
|
|
413
|
+
const selected = explicitIndex === 0
|
|
414
|
+
? [firstFresh]
|
|
415
|
+
: fresh.slice(0, Math.min(this.config.maxBatchSize, explicitIndex > 0 ? explicitIndex : fresh.length));
|
|
416
|
+
const selectedIds = new Set(selected.map((tx) => tx.id));
|
|
417
|
+
this.executionQueue = this.executionQueue.filter((tx) => !selectedIds.has(tx.id));
|
|
418
|
+
return selected;
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* Selects one reconnect batch without changing the request identity of work
|
|
422
|
+
* that was already attempted. Explicit caller keys remain one-call batches;
|
|
423
|
+
* an existing envelope is replayed only with all of its original members.
|
|
424
|
+
*/
|
|
425
|
+
takeOfflineFlushBatch(pending) {
|
|
426
|
+
const first = pending[0];
|
|
427
|
+
if (!first)
|
|
428
|
+
return [];
|
|
429
|
+
const envelope = first.commitEnvelope;
|
|
430
|
+
if (envelope) {
|
|
431
|
+
return pending.filter((tx) => tx.commitEnvelope?.idempotencyKey === envelope.idempotencyKey);
|
|
432
|
+
}
|
|
433
|
+
if (typeof first.writeOptions?.idempotencyKey === 'string') {
|
|
434
|
+
return [first];
|
|
435
|
+
}
|
|
436
|
+
const boundary = pending.findIndex((tx) => tx.commitEnvelope !== undefined ||
|
|
437
|
+
typeof tx.writeOptions?.idempotencyKey === 'string');
|
|
438
|
+
return pending.slice(0, Math.min(this.config.maxBatchSize, boundary > 0 ? boundary : pending.length));
|
|
439
|
+
}
|
|
190
440
|
/**
|
|
191
441
|
* Resolvers for per-transaction `confirmation` promises. Populated in
|
|
192
442
|
* `attachConfirmation` at staging time, consumed by the constructor-time
|
|
@@ -244,22 +494,18 @@ export class TransactionQueue extends EventEmitter {
|
|
|
244
494
|
});
|
|
245
495
|
}
|
|
246
496
|
/**
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
* outcomes for the routing-helper grace-window use case).
|
|
252
|
-
*
|
|
253
|
-
* Looks across `pending`, `executing`, and `awaiting_delta` — these
|
|
254
|
-
* are the three non-terminal statuses where rollback is still
|
|
255
|
-
* possible. Skips `completed` (already settled) and `failed` /
|
|
256
|
-
* `rolled_back` (already rejected; the call site missed the
|
|
257
|
-
* `confirmation` window and should rely on `onMutationFailure` toast
|
|
258
|
-
* instead).
|
|
497
|
+
* Returns the in-flight confirmation promise for a given model and id. When
|
|
498
|
+
* several transactions match, it returns the most recent one's promise; when
|
|
499
|
+
* none is open it resolves immediately, which covers both "already confirmed"
|
|
500
|
+
* and "never staged".
|
|
259
501
|
*
|
|
260
|
-
*
|
|
261
|
-
*
|
|
262
|
-
* `
|
|
502
|
+
* It considers the three non-terminal statuses in which the write can still
|
|
503
|
+
* be rolled back — `pending`, `executing`, and `awaiting_delta` — and ignores
|
|
504
|
+
* `completed` (already settled) and `failed`/`rolled_back` (already
|
|
505
|
+
* rejected). This complements the `confirmation` promise carried on a known
|
|
506
|
+
* {@link Transaction}: use this method at call sites that hold a model
|
|
507
|
+
* returned by `ablo.<model>.create()` but never see the underlying
|
|
508
|
+
* transaction.
|
|
263
509
|
*/
|
|
264
510
|
confirmationFor(modelName, modelId) {
|
|
265
511
|
const candidates = [
|
|
@@ -275,15 +521,15 @@ export class TransactionQueue extends EventEmitter {
|
|
|
275
521
|
return latest.confirmation ?? Promise.resolve();
|
|
276
522
|
}
|
|
277
523
|
/**
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
*
|
|
281
|
-
*
|
|
524
|
+
* Attaches a `confirmation` promise to a newly created transaction. Call this
|
|
525
|
+
* before the transaction is staged so a caller can `await tx.confirmation`
|
|
526
|
+
* immediately after a create, update, or delete returns. It is idempotent and
|
|
527
|
+
* returns early if one is already attached.
|
|
282
528
|
*
|
|
283
|
-
*
|
|
284
|
-
*
|
|
285
|
-
* rejection.
|
|
286
|
-
* `.then`/`.catch
|
|
529
|
+
* It also attaches a no-op rejection handler. Most callers never await the
|
|
530
|
+
* confirmation, and without this the runtime would report an unhandled
|
|
531
|
+
* rejection when a write fails. Callers that do want to observe failure simply
|
|
532
|
+
* attach their own `.then`/`.catch`.
|
|
287
533
|
*/
|
|
288
534
|
attachConfirmation(tx) {
|
|
289
535
|
if (tx.confirmation)
|
|
@@ -292,34 +538,30 @@ export class TransactionQueue extends EventEmitter {
|
|
|
292
538
|
this.confirmationResolvers.set(tx.id, { resolve, reject });
|
|
293
539
|
});
|
|
294
540
|
tx.confirmation.catch(() => {
|
|
295
|
-
// Swallow unhandled rejections
|
|
296
|
-
// handler; silent failure is the leak we're already fixing elsewhere.
|
|
541
|
+
// Swallow unhandled rejections; callers that care attach their own handler.
|
|
297
542
|
});
|
|
298
543
|
}
|
|
299
544
|
/**
|
|
300
|
-
*
|
|
301
|
-
*
|
|
545
|
+
* Registers a predicate the queue uses to check whether it is connected.
|
|
546
|
+
* While disconnected, confirmation timeouts re-schedule themselves instead of
|
|
547
|
+
* escalating, so a transaction is never rolled back merely because the client
|
|
548
|
+
* was briefly offline.
|
|
302
549
|
*/
|
|
303
550
|
setConnectionChecker(fn) {
|
|
304
551
|
this.isConnectedFn = fn;
|
|
305
552
|
}
|
|
306
553
|
/**
|
|
307
|
-
*
|
|
554
|
+
* Drives the offline-grace timer for in-flight commit-lane transactions.
|
|
308
555
|
*
|
|
309
|
-
* On `'disconnected'
|
|
310
|
-
* `config.commitOfflineGraceMs`. If
|
|
311
|
-
*
|
|
312
|
-
* `
|
|
313
|
-
*
|
|
314
|
-
* lets `waitForCommitReceipt` reject in seconds instead of hanging
|
|
315
|
-
* forever — which is what wedged the 2026-05-15 subagent run.
|
|
556
|
+
* On `'disconnected'` it starts a one-shot timer of
|
|
557
|
+
* `config.commitOfflineGraceMs`. If that timer fires — meaning the disconnect
|
|
558
|
+
* outlasted the grace window — every commit-lane transaction still `pending`
|
|
559
|
+
* or `executing` is failed with an {@link AbloConnectionError}, so
|
|
560
|
+
* {@link waitForCommitReceipt} rejects within seconds instead of hanging.
|
|
316
561
|
*
|
|
317
|
-
* On `'connected'
|
|
318
|
-
* absorbed transparently;
|
|
319
|
-
*
|
|
320
|
-
*
|
|
321
|
-
* Called from SyncClient's `setConnectionState` after the
|
|
322
|
-
* `'connection:disconnected'` / `'connection:established'` events.
|
|
562
|
+
* On `'connected'` it clears any pending grace timer. Brief disconnects are
|
|
563
|
+
* absorbed transparently; {@link processCommitLane} and
|
|
564
|
+
* {@link flushOfflineQueue} resume the work on reconnect.
|
|
323
565
|
*/
|
|
324
566
|
setConnectionState(state) {
|
|
325
567
|
if (state === 'connected') {
|
|
@@ -347,8 +589,8 @@ export class TransactionQueue extends EventEmitter {
|
|
|
347
589
|
}
|
|
348
590
|
if (inFlight.length === 0)
|
|
349
591
|
return;
|
|
350
|
-
//
|
|
351
|
-
//
|
|
592
|
+
// Each failed commit reaches the consumer through its own rejection path,
|
|
593
|
+
// so this aggregate line is forensic and logged at debug rather than warn.
|
|
352
594
|
getContext().logger.debug(`[TransactionQueue] WS disconnected > ${graceMs}ms; failing ${inFlight.length} in-flight commit(s) with AbloConnectionError`, { inFlightIds: inFlight.map((id) => id.slice(0, 8)) });
|
|
353
595
|
for (const id of inFlight) {
|
|
354
596
|
const tx = this.commitStore.get(id);
|
|
@@ -361,39 +603,41 @@ export class TransactionQueue extends EventEmitter {
|
|
|
361
603
|
}
|
|
362
604
|
}
|
|
363
605
|
/**
|
|
364
|
-
*
|
|
365
|
-
* right after
|
|
366
|
-
*
|
|
367
|
-
*
|
|
606
|
+
* Binds the mutation executor for this queue instance. The owning client
|
|
607
|
+
* calls this right after construction, so commits made here always dispatch
|
|
608
|
+
* through this instance's connection even when several client instances exist
|
|
609
|
+
* in the same process.
|
|
368
610
|
*/
|
|
369
611
|
setMutationExecutor(executor) {
|
|
370
612
|
this._mutationExecutor = executor;
|
|
371
613
|
}
|
|
372
614
|
// ============================================================================
|
|
373
|
-
//
|
|
615
|
+
// Microtask-based transaction staging
|
|
374
616
|
// ============================================================================
|
|
375
617
|
//
|
|
376
|
-
//
|
|
377
|
-
// A microtask commits them
|
|
378
|
-
//
|
|
618
|
+
// Every transaction lands in the `createdTransactions` staging area first.
|
|
619
|
+
// A microtask then commits them together under one batch index, so a bulk
|
|
620
|
+
// operation such as importing a hundred rows is sent efficiently.
|
|
379
621
|
//
|
|
380
622
|
// Flow:
|
|
381
|
-
// 1. create()/update()/delete() calls stageTransaction()
|
|
382
|
-
// 2. stageTransaction() adds to createdTransactions and schedules microtask
|
|
383
|
-
// 3.
|
|
384
|
-
//
|
|
623
|
+
// 1. create()/update()/delete() calls stageTransaction().
|
|
624
|
+
// 2. stageTransaction() adds to createdTransactions and schedules a microtask.
|
|
625
|
+
// 3. The microtask runs commitCreatedTransactions() once the current
|
|
626
|
+
// synchronous code finishes.
|
|
627
|
+
// 4. All staged transactions share one batch index and move to the execution
|
|
628
|
+
// queue.
|
|
385
629
|
// ============================================================================
|
|
386
630
|
/**
|
|
387
|
-
*
|
|
388
|
-
*
|
|
631
|
+
* Stages a transaction for commit. Transactions staged within the same
|
|
632
|
+
* event-loop tick are committed together.
|
|
389
633
|
*/
|
|
390
634
|
stageTransaction(transaction) {
|
|
391
635
|
this.createdTransactions.push(transaction);
|
|
392
636
|
this.scheduleCommit();
|
|
393
637
|
}
|
|
394
638
|
/**
|
|
395
|
-
*
|
|
396
|
-
*
|
|
639
|
+
* Schedules the staged transactions to commit on a microtask, so all
|
|
640
|
+
* transactions created synchronously within one tick are batched together.
|
|
397
641
|
*/
|
|
398
642
|
scheduleCommit() {
|
|
399
643
|
if (this.commitScheduled)
|
|
@@ -409,8 +653,8 @@ export class TransactionQueue extends EventEmitter {
|
|
|
409
653
|
});
|
|
410
654
|
}
|
|
411
655
|
/**
|
|
412
|
-
*
|
|
413
|
-
*
|
|
656
|
+
* Moves all staged transactions onto the execution queue, assigning them a
|
|
657
|
+
* single shared batch index so they commit together.
|
|
414
658
|
*/
|
|
415
659
|
commitCreatedTransactions() {
|
|
416
660
|
this.commitScheduled = false;
|
|
@@ -434,64 +678,99 @@ export class TransactionQueue extends EventEmitter {
|
|
|
434
678
|
this.enqueue(transaction);
|
|
435
679
|
}
|
|
436
680
|
}
|
|
437
|
-
|
|
681
|
+
/**
|
|
682
|
+
* Flushes every pending transaction in one commit, the fast path taken on
|
|
683
|
+
* reconnect. If transport fails, the transactions retain this exact commit
|
|
684
|
+
* envelope when they fall back to normal queue processing.
|
|
685
|
+
*/
|
|
438
686
|
async flushOfflineQueue() {
|
|
439
|
-
|
|
440
|
-
//
|
|
441
|
-
//
|
|
687
|
+
this.assertDurableReplayOpen();
|
|
688
|
+
// Kick the commit lane too: atomic envelopes from `commits.create()` may
|
|
689
|
+
// have been left at the head of the lane while the connection was down.
|
|
690
|
+
// Fire-and-forget; processCommitLane serializes itself.
|
|
442
691
|
void this.processCommitLane();
|
|
443
692
|
// Collect pending transactions in created order
|
|
444
693
|
const pending = this.store.getByStatus('pending').sort((a, b) => a.createdAt - b.createdAt);
|
|
445
694
|
if (pending.length === 0)
|
|
446
695
|
return;
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
const
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
696
|
+
const pendingIds = new Set(pending.map((tx) => tx.id));
|
|
697
|
+
// These rows may already be waiting behind the normal batch timer. The
|
|
698
|
+
// reconnect fast path takes ownership of them for this attempt so the same
|
|
699
|
+
// transaction cannot dispatch concurrently through both paths.
|
|
700
|
+
this.executionQueue = this.executionQueue.filter((tx) => !pendingIds.has(tx.id));
|
|
701
|
+
const remaining = [...pending];
|
|
702
|
+
while (remaining.length > 0) {
|
|
703
|
+
const batch = this.takeOfflineFlushBatch(remaining);
|
|
704
|
+
if (batch.length === 0)
|
|
705
|
+
break;
|
|
706
|
+
const batchIds = new Set(batch.map((tx) => tx.id));
|
|
707
|
+
const nextRemaining = remaining.filter((tx) => !batchIds.has(tx.id));
|
|
708
|
+
try {
|
|
709
|
+
const idempotencyKey = this.ensureCommitEnvelope(batch);
|
|
710
|
+
const projectedOperations = batch.map((tx) => {
|
|
711
|
+
this.ensureDerivedFields(tx);
|
|
712
|
+
return applyWriteOptions({
|
|
713
|
+
type: TX_TYPE_TO_MUTATION_OP[tx.type],
|
|
714
|
+
model: tx.modelKey,
|
|
715
|
+
id: tx.modelId,
|
|
716
|
+
input: tx.type === 'create' || tx.type === 'update' ? tx.data || {} : undefined,
|
|
717
|
+
transactionId: tx.id,
|
|
718
|
+
}, tx);
|
|
719
|
+
});
|
|
720
|
+
const durableEnvelope = await this.sealDurableCommit({
|
|
721
|
+
idempotencyKey,
|
|
722
|
+
origin: 'model_batch',
|
|
723
|
+
operations: projectedOperations,
|
|
724
|
+
sourceMutationIds: this.sourceMutationIdsFor(batch),
|
|
725
|
+
createdAt: Math.min(...batch.map((transaction) => transaction.createdAt)),
|
|
726
|
+
sealedAt: batch[0]?.commitEnvelope?.sealedAt ?? Date.now(),
|
|
727
|
+
sequence: batch[0]?.commitEnvelope?.sequence,
|
|
728
|
+
});
|
|
729
|
+
this.assertEnvelopeInsideReplayWindow(durableEnvelope);
|
|
730
|
+
const result = await this.mutationExecutor.commit(durableEnvelope.operations, {
|
|
731
|
+
idempotencyKey,
|
|
732
|
+
});
|
|
733
|
+
await this.removeDurableCommit(idempotencyKey);
|
|
734
|
+
// Mark this request envelope as completed before moving to the next.
|
|
735
|
+
for (const tx of batch) {
|
|
736
|
+
this.store.updateStatus(tx.id, 'completed');
|
|
737
|
+
this.emit('transaction:completed', tx);
|
|
738
|
+
this.emit(`transaction:completed:${tx.id}`, tx);
|
|
739
|
+
this.optimisticUpdates.delete(tx.id);
|
|
740
|
+
}
|
|
741
|
+
getContext().logger.debug('txn:commit', 0, {
|
|
742
|
+
count: batch.length,
|
|
743
|
+
lastSyncId: result.lastSyncId,
|
|
483
744
|
});
|
|
745
|
+
remaining.splice(0, remaining.length, ...nextRemaining);
|
|
484
746
|
}
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
747
|
+
catch (err) {
|
|
748
|
+
// If one request fails, hand it and every later request back to the
|
|
749
|
+
// normal lane. Their envelopes stay attached for safe retry.
|
|
750
|
+
const isOffline = !getContext().onlineStatus.isOnline();
|
|
751
|
+
const isNetworkError = err instanceof Error &&
|
|
752
|
+
(err.message.includes('Failed to fetch') ||
|
|
753
|
+
err.message.includes('Network request failed') ||
|
|
754
|
+
err.message.includes('NetworkError'));
|
|
755
|
+
if (!isOffline || !isNetworkError) {
|
|
756
|
+
getContext().observability.breadcrumb('Batch flush fallback failed', 'sync.transaction', 'warning', {
|
|
757
|
+
error: err instanceof Error ? err.message : String(err),
|
|
758
|
+
});
|
|
759
|
+
}
|
|
760
|
+
for (const tx of [...batch, ...nextRemaining]) {
|
|
761
|
+
this.enqueue(tx);
|
|
762
|
+
}
|
|
763
|
+
return;
|
|
488
764
|
}
|
|
489
765
|
}
|
|
490
766
|
}
|
|
491
767
|
/**
|
|
492
|
-
*
|
|
768
|
+
* Records a create and applies it optimistically, then stages it for the next
|
|
769
|
+
* batched commit. Returns the {@link Transaction}, whose `confirmation`
|
|
770
|
+
* promise settles once the server confirms the write.
|
|
493
771
|
*/
|
|
494
|
-
async create(model, context, writeOptions) {
|
|
772
|
+
async create(model, context, writeOptions, sourceMutationId) {
|
|
773
|
+
this.assertDurableReplayOpen();
|
|
495
774
|
const actualModelName = model.getModelName();
|
|
496
775
|
const transaction = {
|
|
497
776
|
id: this.generateId(),
|
|
@@ -501,8 +780,8 @@ export class TransactionQueue extends EventEmitter {
|
|
|
501
780
|
modelKey: normalizeModelKey(actualModelName),
|
|
502
781
|
priorityScore: this.computePriorityScore('create', actualModelName),
|
|
503
782
|
data: this.extractCreateData(model),
|
|
504
|
-
//
|
|
505
|
-
// restore
|
|
783
|
+
// Rolling back a create removes the row, so there is no prior state to
|
|
784
|
+
// restore and no snapshot is captured here.
|
|
506
785
|
previousData: null,
|
|
507
786
|
context,
|
|
508
787
|
status: 'pending',
|
|
@@ -510,36 +789,39 @@ export class TransactionQueue extends EventEmitter {
|
|
|
510
789
|
attempts: 0,
|
|
511
790
|
priority: 'normal',
|
|
512
791
|
writeOptions,
|
|
792
|
+
...(sourceMutationId ? { sourceMutationIds: [sourceMutationId] } : {}),
|
|
513
793
|
};
|
|
514
794
|
this.attachConfirmation(transaction);
|
|
515
795
|
this.store.add(transaction);
|
|
516
796
|
if (this.config.enableOptimistic) {
|
|
517
797
|
this.applyOptimisticCreate(model, transaction);
|
|
518
798
|
}
|
|
519
|
-
//
|
|
520
|
-
//
|
|
521
|
-
// `commitCreatedTransactions`. No batch
|
|
799
|
+
// The microtask coalescer (`scheduleCommit`) collapses all creates in this
|
|
800
|
+
// tick into one commit under a single `batchIndex` — see
|
|
801
|
+
// `commitCreatedTransactions`. No batch call is needed at the call site.
|
|
522
802
|
this.stageTransaction(transaction);
|
|
523
803
|
this.emit('transaction:created', transaction);
|
|
524
804
|
return transaction;
|
|
525
805
|
}
|
|
526
806
|
/**
|
|
527
|
-
*
|
|
528
|
-
*
|
|
807
|
+
* Records an update and applies it optimistically, then stages it for the next
|
|
808
|
+
* batched commit. Rapid updates to the same entity coalesce into a single wire
|
|
809
|
+
* operation.
|
|
810
|
+
* @param precomputedChanges - Optional pre-captured changes, used instead of re-reading them from the model.
|
|
529
811
|
*/
|
|
530
|
-
async update(model, context, precomputedChanges, writeOptions) {
|
|
812
|
+
async update(model, context, precomputedChanges, writeOptions, sourceMutationId) {
|
|
813
|
+
this.assertDurableReplayOpen();
|
|
531
814
|
const actualModelName = model.getModelName();
|
|
532
815
|
// Use pre-computed changes if provided, otherwise extract from model
|
|
533
816
|
const updateInput = precomputedChanges
|
|
534
817
|
? this.mapChangesToInput(actualModelName, precomputedChanges)
|
|
535
818
|
: this.extractUpdateData(model);
|
|
536
819
|
const previousData = this.extractPreviousData(model, updateInput);
|
|
537
|
-
// Advance the per-field baseline for the keys
|
|
538
|
-
// transaction.
|
|
539
|
-
// sync
|
|
540
|
-
// first
|
|
541
|
-
// instead of
|
|
542
|
-
// inverse (the second move's "before" would point all the way back). The
|
|
820
|
+
// Advance the per-field baseline for the keys just frozen into this
|
|
821
|
+
// transaction. The model records the first old value per field and clears it
|
|
822
|
+
// only on sync acknowledgement, so a second update to the same field before
|
|
823
|
+
// the first is acknowledged would otherwise re-capture the original value
|
|
824
|
+
// instead of this update's result, corrupting the recorded undo inverse. The
|
|
543
825
|
// wire payload is already frozen in `transaction.data`, so dropping the
|
|
544
826
|
// consumed entries is safe.
|
|
545
827
|
model.consumeModifiedFields(Object.keys(updateInput));
|
|
@@ -560,6 +842,7 @@ export class TransactionQueue extends EventEmitter {
|
|
|
560
842
|
attempts: 0,
|
|
561
843
|
priority: this.isReorderPayload(updateInput) ? 'high' : 'normal',
|
|
562
844
|
writeOptions,
|
|
845
|
+
...(sourceMutationId ? { sourceMutationIds: [sourceMutationId] } : {}),
|
|
563
846
|
};
|
|
564
847
|
this.attachConfirmation(transaction);
|
|
565
848
|
this.store.add(transaction);
|
|
@@ -567,18 +850,23 @@ export class TransactionQueue extends EventEmitter {
|
|
|
567
850
|
if (this.config.enableOptimistic) {
|
|
568
851
|
this.applyOptimisticUpdate(model, transaction);
|
|
569
852
|
}
|
|
570
|
-
//
|
|
571
|
-
//
|
|
572
|
-
//
|
|
853
|
+
// Stage the transaction for the microtask commit; updates made in the same
|
|
854
|
+
// tick are batched together, and enqueue() still coalesces same-entity
|
|
855
|
+
// updates.
|
|
573
856
|
this.stageTransaction(transaction);
|
|
574
857
|
this.emit('transaction:created', transaction);
|
|
575
858
|
return transaction;
|
|
576
859
|
}
|
|
577
860
|
/**
|
|
578
|
-
*
|
|
861
|
+
* Records a delete and applies it optimistically. If the row's own create has
|
|
862
|
+
* not yet been sent, both are cancelled locally rather than sending a create
|
|
863
|
+
* followed by a delete; if the create is already in flight, the delete waits
|
|
864
|
+
* until it settles so the server never sees a delete before the create.
|
|
579
865
|
*/
|
|
580
|
-
async delete(model, context, writeOptions) {
|
|
581
|
-
|
|
866
|
+
async delete(model, context, writeOptions, sourceMutationId) {
|
|
867
|
+
this.assertDurableReplayOpen();
|
|
868
|
+
// Use getModelName() rather than constructor.name, which is unreliable once
|
|
869
|
+
// class names are minified.
|
|
582
870
|
const actualModelName = model.getModelName();
|
|
583
871
|
// Skip Activity delete transactions - activities are permanent audit records
|
|
584
872
|
if (actualModelName === 'Activity') {
|
|
@@ -599,6 +887,7 @@ export class TransactionQueue extends EventEmitter {
|
|
|
599
887
|
attempts: 0,
|
|
600
888
|
priority: 'high',
|
|
601
889
|
writeOptions,
|
|
890
|
+
...(sourceMutationId ? { sourceMutationIds: [sourceMutationId] } : {}),
|
|
602
891
|
localOnly: true,
|
|
603
892
|
// Activity deletes complete synchronously (audit-record skip path).
|
|
604
893
|
// Pre-resolved so consumers can still `await tx.confirmation` uniformly.
|
|
@@ -617,7 +906,10 @@ export class TransactionQueue extends EventEmitter {
|
|
|
617
906
|
const unsentCreate = this.takeUnsentCreateForModel(actualModelName, model.id);
|
|
618
907
|
if (unsentCreate) {
|
|
619
908
|
await this.cancelUnsentCreateForDelete(unsentCreate);
|
|
620
|
-
return this.completeLocalDelete(model, context, writeOptions
|
|
909
|
+
return this.completeLocalDelete(model, context, writeOptions, [
|
|
910
|
+
...(unsentCreate.sourceMutationIds ?? []),
|
|
911
|
+
...(sourceMutationId ? [sourceMutationId] : []),
|
|
912
|
+
]);
|
|
621
913
|
}
|
|
622
914
|
const transaction = {
|
|
623
915
|
id: this.generateId(),
|
|
@@ -633,13 +925,22 @@ export class TransactionQueue extends EventEmitter {
|
|
|
633
925
|
attempts: 0,
|
|
634
926
|
priority: 'high', // Deletes are high priority
|
|
635
927
|
writeOptions,
|
|
928
|
+
...(sourceMutationId ? { sourceMutationIds: [sourceMutationId] } : {}),
|
|
636
929
|
};
|
|
637
930
|
this.attachConfirmation(transaction);
|
|
638
931
|
this.store.add(transaction);
|
|
639
932
|
// Cancel any pending/in-flight updates for this model to prevent "no rows" errors
|
|
640
933
|
// when the delete executes before the update (race condition fix)
|
|
641
|
-
this.cancelTransactionsForModel(model.id, 'update');
|
|
934
|
+
const canceledUpdates = this.cancelTransactionsForModel(model.id, 'update');
|
|
642
935
|
const entityKey = this.entityKey(actualModelName, model.id);
|
|
936
|
+
const pendingMerge = this.pendingMergeByModel.get(entityKey);
|
|
937
|
+
transaction.sourceMutationIds = [
|
|
938
|
+
...new Set([
|
|
939
|
+
...(transaction.sourceMutationIds ?? []),
|
|
940
|
+
...canceledUpdates.flatMap((candidate) => candidate.sourceMutationIds ?? []),
|
|
941
|
+
...(pendingMerge?.sourceMutationIds ?? []),
|
|
942
|
+
]),
|
|
943
|
+
];
|
|
643
944
|
this.pendingMergeByModel.delete(entityKey);
|
|
644
945
|
this.inFlightByModel.delete(entityKey);
|
|
645
946
|
// Apply optimistic delete
|
|
@@ -651,30 +952,35 @@ export class TransactionQueue extends EventEmitter {
|
|
|
651
952
|
this.deferDeleteUntilCreateSettles(createBarrier, transaction);
|
|
652
953
|
}
|
|
653
954
|
else {
|
|
654
|
-
//
|
|
655
|
-
//
|
|
955
|
+
// Stage the transaction for the microtask commit; deletes in the same
|
|
956
|
+
// tick are batched together.
|
|
656
957
|
this.stageTransaction(transaction);
|
|
657
958
|
}
|
|
658
959
|
this.emit('transaction:created', transaction);
|
|
659
960
|
return transaction;
|
|
660
961
|
}
|
|
661
962
|
/**
|
|
662
|
-
*
|
|
963
|
+
* Uploads a single attachment, delegating to the mutation executor.
|
|
663
964
|
*/
|
|
664
|
-
async uploadAttachment(_file, options, _context
|
|
965
|
+
async uploadAttachment(_file, options, _context // eslint-disable-line @typescript-eslint/no-unused-vars -- reserved executor context
|
|
966
|
+
) {
|
|
665
967
|
return this.mutationExecutor.uploadAttachment?.(options.id, options) ?? null;
|
|
666
968
|
}
|
|
667
969
|
/**
|
|
668
|
-
*
|
|
970
|
+
* Uploads several attachments in one call, delegating to the mutation executor.
|
|
669
971
|
*/
|
|
670
|
-
async batchUploadAttachments(_files, items, _context
|
|
972
|
+
async batchUploadAttachments(_files, items, _context // eslint-disable-line @typescript-eslint/no-unused-vars -- reserved executor context
|
|
973
|
+
) {
|
|
671
974
|
return this.mutationExecutor.batchUploadAttachments?.(items.map(i => ({ id: i.id, input: i }))) ?? [];
|
|
672
975
|
}
|
|
673
976
|
/**
|
|
674
|
-
*
|
|
977
|
+
* Records an archive and applies it optimistically, then stages it for the
|
|
978
|
+
* next batched commit.
|
|
675
979
|
*/
|
|
676
|
-
async archive(model, context, writeOptions) {
|
|
677
|
-
|
|
980
|
+
async archive(model, context, writeOptions, sourceMutationId) {
|
|
981
|
+
this.assertDurableReplayOpen();
|
|
982
|
+
// Use getModelName() rather than constructor.name, which is unreliable once
|
|
983
|
+
// class names are minified.
|
|
678
984
|
const actualModelName = model.getModelName();
|
|
679
985
|
const modelKey = normalizeModelKey(actualModelName);
|
|
680
986
|
const priorityScore = this.computePriorityScore('archive', actualModelName);
|
|
@@ -692,19 +998,23 @@ export class TransactionQueue extends EventEmitter {
|
|
|
692
998
|
attempts: 0,
|
|
693
999
|
priority: 'normal',
|
|
694
1000
|
writeOptions,
|
|
1001
|
+
...(sourceMutationId ? { sourceMutationIds: [sourceMutationId] } : {}),
|
|
695
1002
|
};
|
|
696
1003
|
this.attachConfirmation(transaction);
|
|
697
1004
|
this.store.add(transaction);
|
|
698
|
-
//
|
|
1005
|
+
// Stage the transaction for the microtask commit.
|
|
699
1006
|
this.stageTransaction(transaction);
|
|
700
1007
|
this.emit('transaction:created', transaction);
|
|
701
1008
|
return transaction;
|
|
702
1009
|
}
|
|
703
1010
|
/**
|
|
704
|
-
*
|
|
1011
|
+
* Records an unarchive and applies it optimistically, then stages it for the
|
|
1012
|
+
* next batched commit.
|
|
705
1013
|
*/
|
|
706
1014
|
async unarchive(model, context) {
|
|
707
|
-
|
|
1015
|
+
this.assertDurableReplayOpen();
|
|
1016
|
+
// Use getModelName() rather than constructor.name, which is unreliable once
|
|
1017
|
+
// class names are minified.
|
|
708
1018
|
const actualModelName = model.getModelName();
|
|
709
1019
|
const modelKey = normalizeModelKey(actualModelName);
|
|
710
1020
|
const priorityScore = this.computePriorityScore('unarchive', actualModelName);
|
|
@@ -724,37 +1034,44 @@ export class TransactionQueue extends EventEmitter {
|
|
|
724
1034
|
};
|
|
725
1035
|
this.attachConfirmation(transaction);
|
|
726
1036
|
this.store.add(transaction);
|
|
727
|
-
//
|
|
1037
|
+
// Stage the transaction for the microtask commit.
|
|
728
1038
|
this.stageTransaction(transaction);
|
|
729
1039
|
this.emit('transaction:created', transaction);
|
|
730
1040
|
return transaction;
|
|
731
1041
|
}
|
|
732
1042
|
/**
|
|
733
|
-
*
|
|
1043
|
+
* Places a transaction on the execution queue, coalescing it into an existing
|
|
1044
|
+
* same-entity update where possible so redundant writes collapse.
|
|
734
1045
|
*/
|
|
735
1046
|
enqueue(transaction) {
|
|
736
1047
|
this.ensureDerivedFields(transaction);
|
|
737
1048
|
const modelKey = `${transaction.modelName}:${transaction.modelId}`;
|
|
738
|
-
//
|
|
739
|
-
//
|
|
740
|
-
//
|
|
1049
|
+
// Coalescing for updates. Staging already batches everything created in one
|
|
1050
|
+
// event-loop tick, so only two cases remain here: merging into an in-flight
|
|
1051
|
+
// update, and merging into a pending same-entity update.
|
|
741
1052
|
//
|
|
742
|
-
//
|
|
743
|
-
// re-enqueued
|
|
744
|
-
//
|
|
745
|
-
//
|
|
746
|
-
//
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
if (transaction.type === 'update' && transaction.attempts === 0) {
|
|
752
|
-
const preserveWatermark = hasStaleWriteOptions(transaction.writeOptions);
|
|
1053
|
+
// Retries (attempts > 0 or an already assigned commit envelope) must not
|
|
1054
|
+
// take either merge path. A retry is re-enqueued while its own model key may
|
|
1055
|
+
// still be marked in `inFlightByModel`; treating it as a concurrent edit
|
|
1056
|
+
// would change the already-attempted request, discard its stable envelope,
|
|
1057
|
+
// and either defeat maxRetries or duplicate an ambiguously committed write.
|
|
1058
|
+
if (transaction.type === 'update' &&
|
|
1059
|
+
transaction.attempts === 0 &&
|
|
1060
|
+
!transaction.commitEnvelope) {
|
|
1061
|
+
const preserveWatermark = hasCommitCoalescingBarrier(transaction.writeOptions);
|
|
753
1062
|
// If there is an in-flight update for this model, merge into post-flight buffer
|
|
754
1063
|
if (!preserveWatermark && this.inFlightByModel.has(modelKey)) {
|
|
755
|
-
const
|
|
756
|
-
const merged = mergeUpdateData(
|
|
757
|
-
this.pendingMergeByModel.set(modelKey,
|
|
1064
|
+
const previous = this.pendingMergeByModel.get(modelKey);
|
|
1065
|
+
const merged = mergeUpdateData(previous?.data ?? {}, transaction.data || {}, transaction.modelName);
|
|
1066
|
+
this.pendingMergeByModel.set(modelKey, {
|
|
1067
|
+
data: merged,
|
|
1068
|
+
sourceMutationIds: [
|
|
1069
|
+
...new Set([
|
|
1070
|
+
...(previous?.sourceMutationIds ?? []),
|
|
1071
|
+
...(transaction.sourceMutationIds ?? []),
|
|
1072
|
+
]),
|
|
1073
|
+
],
|
|
1074
|
+
});
|
|
758
1075
|
this.store.remove(transaction.id);
|
|
759
1076
|
return;
|
|
760
1077
|
}
|
|
@@ -763,9 +1080,15 @@ export class TransactionQueue extends EventEmitter {
|
|
|
763
1080
|
t.type === 'update' &&
|
|
764
1081
|
t.modelId === transaction.modelId &&
|
|
765
1082
|
t.modelName === transaction.modelName &&
|
|
766
|
-
!
|
|
1083
|
+
!hasCommitCoalescingBarrier(t.writeOptions));
|
|
767
1084
|
if (!preserveWatermark && pendingInQueue) {
|
|
768
1085
|
pendingInQueue.data = mergeUpdateData(pendingInQueue.data || {}, transaction.data || {}, transaction.modelName);
|
|
1086
|
+
pendingInQueue.sourceMutationIds = [
|
|
1087
|
+
...new Set([
|
|
1088
|
+
...(pendingInQueue.sourceMutationIds ?? []),
|
|
1089
|
+
...(transaction.sourceMutationIds ?? []),
|
|
1090
|
+
]),
|
|
1091
|
+
];
|
|
769
1092
|
this.store.remove(transaction.id);
|
|
770
1093
|
return;
|
|
771
1094
|
}
|
|
@@ -782,8 +1105,9 @@ export class TransactionQueue extends EventEmitter {
|
|
|
782
1105
|
scheduleProcessing(immediate = false) {
|
|
783
1106
|
if (this.processScheduled)
|
|
784
1107
|
return;
|
|
785
|
-
//
|
|
786
|
-
//
|
|
1108
|
+
// Backpressure: don't schedule another batch while too many transactions
|
|
1109
|
+
// are already executing, so the server is not flooded with concurrent
|
|
1110
|
+
// requests.
|
|
787
1111
|
if (this.executingCount >= this.config.maxExecutingTransactions) {
|
|
788
1112
|
getContext().logger.debug('[TransactionQueue] Backpressure: delaying batch, too many executing', {
|
|
789
1113
|
executingCount: this.executingCount,
|
|
@@ -810,15 +1134,15 @@ export class TransactionQueue extends EventEmitter {
|
|
|
810
1134
|
}, delay);
|
|
811
1135
|
}
|
|
812
1136
|
/**
|
|
813
|
-
*
|
|
814
|
-
*
|
|
815
|
-
*
|
|
816
|
-
*
|
|
817
|
-
*
|
|
818
|
-
*
|
|
819
|
-
* This reduces N round-trips to 1, dramatically improving batch latency.
|
|
1137
|
+
* Processes one batch of transactions in a single commit. Rather than calling
|
|
1138
|
+
* the server once per operation type or model, it collects every batchable
|
|
1139
|
+
* operation and sends them together; the server applies the mixed operations
|
|
1140
|
+
* atomically within one transaction. This turns many round trips into one and
|
|
1141
|
+
* greatly reduces batch latency.
|
|
820
1142
|
*/
|
|
821
1143
|
async processBatch() {
|
|
1144
|
+
if (this.durableReplayBlock)
|
|
1145
|
+
return;
|
|
822
1146
|
const batchStart = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
823
1147
|
if (this.isProcessing || this.executionQueue.length === 0) {
|
|
824
1148
|
return;
|
|
@@ -828,9 +1152,9 @@ export class TransactionQueue extends EventEmitter {
|
|
|
828
1152
|
let batch = [];
|
|
829
1153
|
await getContext().observability.startSpanAsync('sync.batch', 'sync.transaction.batch', async () => {
|
|
830
1154
|
try {
|
|
831
|
-
// Sort
|
|
832
|
-
//
|
|
833
|
-
//
|
|
1155
|
+
// Sort the execution queue by foreign-key priority before selecting a
|
|
1156
|
+
// batch, so a parent row is always committed before its children, even
|
|
1157
|
+
// across batch boundaries.
|
|
834
1158
|
this.executionQueue.sort((a, b) => {
|
|
835
1159
|
// Ensure derived fields exist (covers restored/persisted transactions)
|
|
836
1160
|
this.ensureDerivedFields(a);
|
|
@@ -843,8 +1167,12 @@ export class TransactionQueue extends EventEmitter {
|
|
|
843
1167
|
}
|
|
844
1168
|
return a.priorityScore - b.priorityScore;
|
|
845
1169
|
});
|
|
846
|
-
//
|
|
847
|
-
|
|
1170
|
+
// Take a fresh batch or one complete retry envelope. Retry envelopes
|
|
1171
|
+
// retain both their original membership and operation order.
|
|
1172
|
+
batch = this.takeNextExecutionBatch();
|
|
1173
|
+
if (batch.length === 0)
|
|
1174
|
+
return;
|
|
1175
|
+
const commitIdempotencyKey = this.ensureCommitEnvelope(batch);
|
|
848
1176
|
// Track executing count for backpressure
|
|
849
1177
|
this.executingCount += batch.length;
|
|
850
1178
|
// Mark all as executing
|
|
@@ -854,15 +1182,15 @@ export class TransactionQueue extends EventEmitter {
|
|
|
854
1182
|
this.inFlightByModel.add(key);
|
|
855
1183
|
this.store.updateStatus(tx.id, 'executing');
|
|
856
1184
|
}
|
|
857
|
-
// Build
|
|
1185
|
+
// Build every operation for one unified commit (a single round trip).
|
|
858
1186
|
const batchOps = [];
|
|
859
1187
|
for (const tx of batch) {
|
|
860
|
-
//
|
|
861
|
-
// the wire so the server can stamp it on the resulting
|
|
862
|
-
//
|
|
863
|
-
//
|
|
864
|
-
//
|
|
865
|
-
// batch-level idempotency key in mutation_log
|
|
1188
|
+
// The per-operation `transactionId` carries the local transaction id
|
|
1189
|
+
// over the wire so the server can stamp it on the resulting sync
|
|
1190
|
+
// delta. The receive path matches it through
|
|
1191
|
+
// {@link UnconfirmedWrites.consumeEcho} to avoid applying the
|
|
1192
|
+
// client's own optimistic change twice. This is separate from the
|
|
1193
|
+
// batch-level idempotency key recorded in `mutation_log`.
|
|
866
1194
|
const op = applyWriteOptions({
|
|
867
1195
|
type: TX_TYPE_TO_MUTATION_OP[tx.type],
|
|
868
1196
|
model: tx.modelKey,
|
|
@@ -872,28 +1200,40 @@ export class TransactionQueue extends EventEmitter {
|
|
|
872
1200
|
}, tx);
|
|
873
1201
|
batchOps.push({ tx, op });
|
|
874
1202
|
}
|
|
875
|
-
// Execute unified commit for
|
|
1203
|
+
// Execute the unified commit for every operation in one round trip.
|
|
876
1204
|
if (batchOps.length > 0) {
|
|
877
|
-
|
|
1205
|
+
let dispatchStarted = false;
|
|
878
1206
|
try {
|
|
879
|
-
|
|
1207
|
+
const durableEnvelope = await this.sealDurableCommit({
|
|
1208
|
+
idempotencyKey: commitIdempotencyKey,
|
|
1209
|
+
origin: 'model_batch',
|
|
1210
|
+
operations: batchOps.map(({ op }) => op),
|
|
1211
|
+
sourceMutationIds: this.sourceMutationIdsFor(batch),
|
|
1212
|
+
createdAt: Math.min(...batch.map((transaction) => transaction.createdAt)),
|
|
1213
|
+
sealedAt: batch[0]?.commitEnvelope?.sealedAt ?? Date.now(),
|
|
1214
|
+
sequence: batch[0]?.commitEnvelope?.sequence,
|
|
1215
|
+
});
|
|
1216
|
+
const operations = durableEnvelope.operations;
|
|
1217
|
+
// Capture lastSyncId from the server response for threshold-based
|
|
1218
|
+
// confirmation.
|
|
880
1219
|
//
|
|
881
|
-
//
|
|
882
|
-
//
|
|
883
|
-
//
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
1220
|
+
// The queue owns request identity. A lost acknowledgement may be
|
|
1221
|
+
// retried after a backoff or reconnect, so every retry must send
|
|
1222
|
+
// the exact key assigned before the first transport attempt.
|
|
1223
|
+
this.assertEnvelopeInsideReplayWindow(durableEnvelope);
|
|
1224
|
+
dispatchStarted = true;
|
|
1225
|
+
const result = await this.mutationExecutor.commit(operations, {
|
|
1226
|
+
idempotencyKey: commitIdempotencyKey,
|
|
1227
|
+
});
|
|
1228
|
+
await this.removeDurableCommit(commitIdempotencyKey);
|
|
889
1229
|
const lastSyncId = result?.lastSyncId ?? 0;
|
|
890
1230
|
this.noteAck(lastSyncId);
|
|
891
|
-
//
|
|
892
|
-
//
|
|
893
|
-
//
|
|
894
|
-
// must be rolled back here
|
|
895
|
-
//
|
|
896
|
-
// receipt.
|
|
1231
|
+
// The server returned stale-context notifications for operations
|
|
1232
|
+
// marked `onStale: 'notify'` whose read premise had moved. Each
|
|
1233
|
+
// notified operation was held rather than written, so its
|
|
1234
|
+
// optimistic state must be rolled back here — no delta will ever
|
|
1235
|
+
// confirm it — and the signal is stamped so
|
|
1236
|
+
// {@link waitForCommitReceipt} can carry it onto the receipt.
|
|
897
1237
|
const notifications = result?.notifications;
|
|
898
1238
|
const heldIds = new Set((notifications ?? []).map((n) => n.id));
|
|
899
1239
|
for (const { tx } of batchOps) {
|
|
@@ -902,15 +1242,16 @@ export class TransactionQueue extends EventEmitter {
|
|
|
902
1242
|
this.commitNotifications.set(tx.id, txNotifs);
|
|
903
1243
|
}
|
|
904
1244
|
}
|
|
905
|
-
//
|
|
1245
|
+
// A lastSyncId of 0 means the mutation succeeded but the server
|
|
1246
|
+
// emitted no sync delta; record that anomaly for observability.
|
|
906
1247
|
if (lastSyncId === 0) {
|
|
907
1248
|
getContext().observability.captureCommitZeroSyncId({
|
|
908
1249
|
operationCount: operations.length,
|
|
909
1250
|
operations: operations.map((op) => `${op.type}:${op.model}:${op.id?.slice(0, 8) ?? '?'}`),
|
|
910
1251
|
});
|
|
911
1252
|
}
|
|
912
|
-
//
|
|
913
|
-
//
|
|
1253
|
+
// Mark each transaction with the sync-id threshold that will
|
|
1254
|
+
// confirm it: any delta whose id is at least lastSyncId.
|
|
914
1255
|
for (const { tx } of batchOps) {
|
|
915
1256
|
// Held op ('notify'): the server withheld the write, so no delta
|
|
916
1257
|
// will confirm it. Roll back the optimistic update (server
|
|
@@ -942,17 +1283,17 @@ export class TransactionQueue extends EventEmitter {
|
|
|
942
1283
|
});
|
|
943
1284
|
continue;
|
|
944
1285
|
}
|
|
945
|
-
//
|
|
946
|
-
// real watermark means the server durably
|
|
947
|
-
// that
|
|
948
|
-
// contract
|
|
949
|
-
//
|
|
950
|
-
//
|
|
951
|
-
//
|
|
952
|
-
//
|
|
953
|
-
//
|
|
954
|
-
//
|
|
955
|
-
//
|
|
1286
|
+
// Acknowledgement-based confirmation. A successful commit
|
|
1287
|
+
// response carrying a real watermark means the server durably
|
|
1288
|
+
// applied the write, and that is the confirmation the
|
|
1289
|
+
// `wait: 'confirmed'` contract promises. The delta echo is not
|
|
1290
|
+
// an acknowledgement channel: it exists to replicate the change
|
|
1291
|
+
// to other clients, and this client's own echo is suppressed by
|
|
1292
|
+
// the {@link UnconfirmedWrites} regardless. Waiting on the
|
|
1293
|
+
// echo would tie "did my write land?" to the health of the
|
|
1294
|
+
// subscription stream, so a client with no live delta stream
|
|
1295
|
+
// would wait forever in `awaiting_delta` for a write the server
|
|
1296
|
+
// had already applied.
|
|
956
1297
|
if (lastSyncId > 0) {
|
|
957
1298
|
this.store.updateStatus(tx.id, 'completed');
|
|
958
1299
|
this.emit('transaction:completed', tx);
|
|
@@ -966,12 +1307,11 @@ export class TransactionQueue extends EventEmitter {
|
|
|
966
1307
|
});
|
|
967
1308
|
}
|
|
968
1309
|
else {
|
|
969
|
-
// lastSyncId
|
|
970
|
-
// commit but emitted no delta
|
|
971
|
-
//
|
|
972
|
-
//
|
|
973
|
-
//
|
|
974
|
-
// confirming a write with no watermark.
|
|
1310
|
+
// A lastSyncId of 0 on a non-delete: the server accepted the
|
|
1311
|
+
// commit but emitted no delta, which is an anomaly (already
|
|
1312
|
+
// captured above). Keep the delta wait and reconciliation
|
|
1313
|
+
// timeout only for this case, so the anomaly surfaces instead
|
|
1314
|
+
// of silently confirming a write that carries no watermark.
|
|
975
1315
|
this.store.updateStatus(tx.id, 'awaiting_delta');
|
|
976
1316
|
getContext().logger.debug('tx:awaiting_delta', {
|
|
977
1317
|
txId: tx.id.slice(0, 8),
|
|
@@ -986,11 +1326,14 @@ export class TransactionQueue extends EventEmitter {
|
|
|
986
1326
|
}
|
|
987
1327
|
catch (error) {
|
|
988
1328
|
const errorMessage = error.message || '';
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
//
|
|
993
|
-
//
|
|
1329
|
+
if (dispatchStarted && this.isDefinitiveRejection(error)) {
|
|
1330
|
+
await this.removeDurableCommit(commitIdempotencyKey);
|
|
1331
|
+
}
|
|
1332
|
+
// Surface the raw server rejection for the whole batch so a
|
|
1333
|
+
// cascaded failure — for example a foreign-key violation that
|
|
1334
|
+
// rolls back a multi-operation transaction — is attributable to a
|
|
1335
|
+
// specific cause rather than showing as a generic permanent error
|
|
1336
|
+
// on each operation.
|
|
994
1337
|
const abloErr = error instanceof AbloError ? error : undefined;
|
|
995
1338
|
// SyncWebSocket attaches a `diagnostics` snapshot to its
|
|
996
1339
|
// "not connected" / "closed while in flight" rejections.
|
|
@@ -1026,16 +1369,20 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1026
1369
|
message: errorMessage,
|
|
1027
1370
|
diagnostics,
|
|
1028
1371
|
});
|
|
1029
|
-
//
|
|
1030
|
-
//
|
|
1031
|
-
//
|
|
1372
|
+
// Handle "no rows in result set" gracefully: the row was already
|
|
1373
|
+
// deleted, so for update and delete operations this is effectively
|
|
1374
|
+
// success — the intended end state already holds — and the
|
|
1375
|
+
// transaction is treated as completed.
|
|
1032
1376
|
if (errorMessage.includes('no rows in result set')) {
|
|
1377
|
+
if (dispatchStarted) {
|
|
1378
|
+
await this.removeDurableCommit(commitIdempotencyKey);
|
|
1379
|
+
}
|
|
1033
1380
|
getContext().logger.info('[TransactionQueue] Graceful handling: entity already deleted', {
|
|
1034
1381
|
batchSize: batchOps.length,
|
|
1035
1382
|
});
|
|
1036
1383
|
for (const { tx, op } of batchOps) {
|
|
1037
1384
|
if (op.type === 'UPDATE' || op.type === 'DELETE') {
|
|
1038
|
-
//
|
|
1385
|
+
// Row already gone: the intended state holds, mark completed.
|
|
1039
1386
|
this.store.updateStatus(tx.id, 'completed');
|
|
1040
1387
|
this.emit('transaction:completed', tx);
|
|
1041
1388
|
getContext().logger.debug('[TransactionQueue] Orphaned transaction treated as success', {
|
|
@@ -1064,7 +1411,7 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1064
1411
|
if (tx.type === 'update') {
|
|
1065
1412
|
this.inFlightByModel.delete(key);
|
|
1066
1413
|
const pending = this.pendingMergeByModel.get(key);
|
|
1067
|
-
if (pending && Object.keys(pending).length > 0) {
|
|
1414
|
+
if (pending && Object.keys(pending.data).length > 0) {
|
|
1068
1415
|
// Create a single merged follow-up transaction
|
|
1069
1416
|
const followUp = {
|
|
1070
1417
|
id: this.generateId(),
|
|
@@ -1072,7 +1419,7 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1072
1419
|
modelName: tx.modelName,
|
|
1073
1420
|
modelId: tx.modelId,
|
|
1074
1421
|
modelKey: tx.modelKey ?? normalizeModelKey(tx.modelName),
|
|
1075
|
-
data: pending,
|
|
1422
|
+
data: pending.data,
|
|
1076
1423
|
previousData: undefined,
|
|
1077
1424
|
context: tx.context,
|
|
1078
1425
|
status: 'pending',
|
|
@@ -1080,6 +1427,7 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1080
1427
|
attempts: 0,
|
|
1081
1428
|
priority: 'normal',
|
|
1082
1429
|
priorityScore: this.computePriorityScore('update', tx.modelName),
|
|
1430
|
+
sourceMutationIds: pending.sourceMutationIds,
|
|
1083
1431
|
};
|
|
1084
1432
|
this.pendingMergeByModel.delete(key);
|
|
1085
1433
|
this.store.add(followUp);
|
|
@@ -1093,7 +1441,7 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1093
1441
|
// Decrement executing count for backpressure tracking
|
|
1094
1442
|
this.executingCount -= batch.length;
|
|
1095
1443
|
// Process next batch if needed
|
|
1096
|
-
if (this.executionQueue.length > 0) {
|
|
1444
|
+
if (this.executionQueue.length > 0 && batch.length > 0) {
|
|
1097
1445
|
this.scheduleProcessing(true);
|
|
1098
1446
|
}
|
|
1099
1447
|
const batchEnd = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
@@ -1106,22 +1454,23 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1106
1454
|
}, { batchSize: this.executionQueue.length + (batch?.length ?? 0) });
|
|
1107
1455
|
}
|
|
1108
1456
|
/**
|
|
1109
|
-
*
|
|
1110
|
-
*
|
|
1111
|
-
*
|
|
1112
|
-
* @param syncId - The sync
|
|
1457
|
+
* Confirms every awaiting transaction whose sync-id threshold this delta meets
|
|
1458
|
+
* or exceeds. The confirmation policy and timeout tracking live in
|
|
1459
|
+
* {@link DeltaConfirmationTracker} (`./deltaConfirmation.js`).
|
|
1460
|
+
* @param syncId - The sync id of the received delta.
|
|
1113
1461
|
*/
|
|
1114
1462
|
onDeltaReceived(syncId) {
|
|
1115
1463
|
this.deltaConfirmation.onDeltaReceived(syncId);
|
|
1116
1464
|
}
|
|
1117
|
-
//
|
|
1118
|
-
//
|
|
1465
|
+
// Schedule the retry-and-reconciliation wait for a transaction's confirming
|
|
1466
|
+
// delta; see {@link DeltaConfirmationTracker} in `./deltaConfirmation.js`.
|
|
1119
1467
|
scheduleDeltaConfirmationTimeout(tx, timeoutMs) {
|
|
1120
1468
|
this.deltaConfirmation.scheduleDeltaConfirmationTimeout(tx, timeoutMs);
|
|
1121
1469
|
}
|
|
1122
1470
|
/**
|
|
1123
|
-
*
|
|
1124
|
-
*
|
|
1471
|
+
* Resolves once the given transaction is confirmed and rejects if it fails.
|
|
1472
|
+
* The confirming delta's timeout is handled by
|
|
1473
|
+
* {@link scheduleDeltaConfirmationTimeout}.
|
|
1125
1474
|
*/
|
|
1126
1475
|
waitForConfirmation(transactionId) {
|
|
1127
1476
|
return new Promise((resolve, reject) => {
|
|
@@ -1148,21 +1497,45 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1148
1497
|
this.on(`transaction:failed:${transactionId}`, onFailed);
|
|
1149
1498
|
});
|
|
1150
1499
|
}
|
|
1151
|
-
//
|
|
1500
|
+
// Reports whether a client mutation id is known to this queue, which helps
|
|
1501
|
+
// identify a delta as this client's own echo.
|
|
1152
1502
|
hasClientMutationId(id) {
|
|
1153
1503
|
return !!this.store.get(id) || this.commitStore.has(id);
|
|
1154
1504
|
}
|
|
1155
1505
|
/**
|
|
1156
|
-
*
|
|
1157
|
-
* path
|
|
1158
|
-
*
|
|
1159
|
-
*
|
|
1160
|
-
*
|
|
1161
|
-
*
|
|
1506
|
+
* Enqueues a pre-built, multi-operation atomic commit — the
|
|
1507
|
+
* `ablo.commits.create()` path. The caller supplies the operations; the queue
|
|
1508
|
+
* only retries on reconnect and de-duplicates, and does not apply the change
|
|
1509
|
+
* optimistically or reorder for foreign keys. A duplicate `clientTxId`
|
|
1510
|
+
* already in flight is ignored: the server's `mutation_log` de-duplicates
|
|
1511
|
+
* across sessions, and this guard covers a double-enqueue within one session.
|
|
1162
1512
|
*/
|
|
1163
|
-
enqueueCommit(clientTxId, operations, options = {}) {
|
|
1164
|
-
|
|
1513
|
+
async enqueueCommit(clientTxId, operations, options = {}) {
|
|
1514
|
+
this.assertDurableReplayOpen();
|
|
1515
|
+
const existing = this.commitStore.get(clientTxId);
|
|
1516
|
+
if (existing) {
|
|
1517
|
+
await existing.sealPromise;
|
|
1518
|
+
const existingIntent = stableStringify({
|
|
1519
|
+
operations: existing.operations,
|
|
1520
|
+
causedByTaskId: existing.causedByTaskId ?? null,
|
|
1521
|
+
reads: existing.reads ?? null,
|
|
1522
|
+
});
|
|
1523
|
+
const incomingIntent = stableStringify({
|
|
1524
|
+
operations,
|
|
1525
|
+
causedByTaskId: options.causedByTaskId ?? null,
|
|
1526
|
+
reads: options.reads ?? null,
|
|
1527
|
+
});
|
|
1528
|
+
if (existingIntent !== incomingIntent) {
|
|
1529
|
+
throw new AbloIdempotencyError('Idempotency key reused with a different atomic commit request', { code: 'idempotency_conflict' });
|
|
1530
|
+
}
|
|
1531
|
+
if (existing.status === 'pending')
|
|
1532
|
+
void this.processCommitLane();
|
|
1165
1533
|
return;
|
|
1534
|
+
}
|
|
1535
|
+
this.emitCommitLifecycle('commit:staging', {
|
|
1536
|
+
clientTxId,
|
|
1537
|
+
operations,
|
|
1538
|
+
});
|
|
1166
1539
|
const tx = {
|
|
1167
1540
|
id: clientTxId,
|
|
1168
1541
|
kind: 'commit',
|
|
@@ -1172,25 +1545,56 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1172
1545
|
status: 'pending',
|
|
1173
1546
|
createdAt: Date.now(),
|
|
1174
1547
|
attempts: 0,
|
|
1548
|
+
sealedAt: Date.now(),
|
|
1549
|
+
sequence: this.nextCommitSequence(),
|
|
1175
1550
|
};
|
|
1176
1551
|
this.commitStore.set(clientTxId, tx);
|
|
1552
|
+
tx.sealPromise = this.sealDurableCommit({
|
|
1553
|
+
idempotencyKey: tx.id,
|
|
1554
|
+
origin: 'atomic_commit',
|
|
1555
|
+
operations: tx.operations,
|
|
1556
|
+
commitOptions: {
|
|
1557
|
+
causedByTaskId: tx.causedByTaskId ?? null,
|
|
1558
|
+
...(tx.reads ? { reads: tx.reads } : {}),
|
|
1559
|
+
},
|
|
1560
|
+
createdAt: tx.createdAt,
|
|
1561
|
+
sealedAt: tx.sealedAt,
|
|
1562
|
+
sequence: tx.sequence,
|
|
1563
|
+
}).then((envelope) => {
|
|
1564
|
+
tx.durableEnvelope = envelope;
|
|
1565
|
+
tx.operations = envelope.operations.map((operation) => ({ ...operation }));
|
|
1566
|
+
});
|
|
1567
|
+
try {
|
|
1568
|
+
await tx.sealPromise;
|
|
1569
|
+
}
|
|
1570
|
+
catch (error) {
|
|
1571
|
+
this.commitStore.delete(clientTxId);
|
|
1572
|
+
this.emitCommitLifecycle('commit:seal_failed', { clientTxId });
|
|
1573
|
+
throw error;
|
|
1574
|
+
}
|
|
1575
|
+
finally {
|
|
1576
|
+
tx.sealPromise = undefined;
|
|
1577
|
+
}
|
|
1177
1578
|
this.commitLane.push(tx);
|
|
1178
|
-
//
|
|
1179
|
-
// commit-lane writes
|
|
1180
|
-
//
|
|
1181
|
-
//
|
|
1182
|
-
|
|
1183
|
-
|
|
1579
|
+
// Emit the envelope on its own event so the undo stream can record
|
|
1580
|
+
// commit-lane writes as well. This deliberately avoids `transaction:created`,
|
|
1581
|
+
// which also feeds the optimistic-echo tracker: commit-lane operations apply
|
|
1582
|
+
// nothing optimistically and so have no echo to suppress.
|
|
1583
|
+
this.emitCommitLifecycle('commit:created', {
|
|
1584
|
+
clientTxId,
|
|
1585
|
+
operations: tx.operations,
|
|
1586
|
+
});
|
|
1184
1587
|
void this.processCommitLane();
|
|
1185
1588
|
}
|
|
1186
1589
|
/**
|
|
1187
|
-
*
|
|
1188
|
-
*
|
|
1189
|
-
*
|
|
1190
|
-
*
|
|
1590
|
+
* Drains the pending commit-lane envelopes one at a time. A transient
|
|
1591
|
+
* failure, such as a network error, leaves the envelope at the head of the
|
|
1592
|
+
* lane in `pending` and stops; reconnect re-kicks it through
|
|
1593
|
+
* {@link flushOfflineQueue}. A permanent failure emits
|
|
1594
|
+
* `transaction:failed:<id>` and drops the envelope.
|
|
1191
1595
|
*/
|
|
1192
1596
|
async processCommitLane() {
|
|
1193
|
-
if (this.commitProcessing)
|
|
1597
|
+
if (this.commitProcessing || this.durableReplayBlock)
|
|
1194
1598
|
return;
|
|
1195
1599
|
this.commitProcessing = true;
|
|
1196
1600
|
try {
|
|
@@ -1204,24 +1608,52 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1204
1608
|
}
|
|
1205
1609
|
tx.status = 'executing';
|
|
1206
1610
|
tx.attempts += 1;
|
|
1611
|
+
let dispatchStarted = false;
|
|
1207
1612
|
try {
|
|
1208
|
-
const
|
|
1613
|
+
const durableEnvelope = tx.durableEnvelope ??
|
|
1614
|
+
(await this.sealDurableCommit({
|
|
1615
|
+
idempotencyKey: tx.id,
|
|
1616
|
+
origin: 'atomic_commit',
|
|
1617
|
+
operations: tx.operations,
|
|
1618
|
+
sourceMutationIds: tx.sourceMutationIds,
|
|
1619
|
+
commitOptions: {
|
|
1620
|
+
causedByTaskId: tx.causedByTaskId ?? null,
|
|
1621
|
+
...(tx.reads ? { reads: tx.reads } : {}),
|
|
1622
|
+
},
|
|
1623
|
+
createdAt: tx.createdAt,
|
|
1624
|
+
sealedAt: tx.sealedAt,
|
|
1625
|
+
sequence: tx.sequence,
|
|
1626
|
+
}));
|
|
1627
|
+
tx.durableEnvelope = durableEnvelope;
|
|
1628
|
+
this.assertEnvelopeInsideReplayWindow(durableEnvelope);
|
|
1629
|
+
dispatchStarted = true;
|
|
1630
|
+
const result = await this.mutationExecutor.commit(durableEnvelope.operations, {
|
|
1209
1631
|
idempotencyKey: tx.id,
|
|
1210
|
-
causedByTaskId:
|
|
1211
|
-
...(
|
|
1632
|
+
causedByTaskId: durableEnvelope.commitOptions.causedByTaskId ?? undefined,
|
|
1633
|
+
...(durableEnvelope.commitOptions.reads
|
|
1634
|
+
? { reads: durableEnvelope.commitOptions.reads }
|
|
1635
|
+
: {}),
|
|
1212
1636
|
});
|
|
1637
|
+
await this.removeDurableCommit(tx.id);
|
|
1213
1638
|
tx.lastSyncId = result?.lastSyncId ?? 0;
|
|
1214
1639
|
this.noteAck(tx.lastSyncId);
|
|
1215
1640
|
tx.status = 'completed';
|
|
1216
1641
|
this.commitLane.shift();
|
|
1217
|
-
|
|
1218
|
-
|
|
1642
|
+
// Guarded: a throwing observer here would land in the catch below,
|
|
1643
|
+
// whose permanent branch shifts the lane a second time and rejects a
|
|
1644
|
+
// commit the server has already durably applied.
|
|
1645
|
+
this.emitCommitLifecycle('transaction:completed', tx);
|
|
1646
|
+
this.emitCommitLifecycle(`transaction:completed:${tx.id}`, tx);
|
|
1219
1647
|
}
|
|
1220
1648
|
catch (err) {
|
|
1221
1649
|
const error = err instanceof Error ? err : new Error(String(err));
|
|
1650
|
+
if (dispatchStarted && this.isDefinitiveRejection(error)) {
|
|
1651
|
+
await this.removeDurableCommit(tx.id);
|
|
1652
|
+
}
|
|
1222
1653
|
if (!this.isPermanentError(error)) {
|
|
1223
|
-
// Transient
|
|
1224
|
-
// next enqueueCommit)
|
|
1654
|
+
// Transient: leave it at the head and retry on the next kick
|
|
1655
|
+
// (reconnect or the next enqueueCommit) rather than tight-looping
|
|
1656
|
+
// while the connection is down.
|
|
1225
1657
|
tx.status = 'pending';
|
|
1226
1658
|
getContext().logger.debug('[TransactionQueue] commit lane transient', {
|
|
1227
1659
|
txId: tx.id.slice(0, 12),
|
|
@@ -1233,15 +1665,16 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1233
1665
|
tx.status = 'failed';
|
|
1234
1666
|
tx.error = error;
|
|
1235
1667
|
this.commitLane.shift();
|
|
1236
|
-
// Internal bookkeeping
|
|
1237
|
-
// 'transaction:failed' and surfaced by the permanent-error headline
|
|
1668
|
+
// Internal bookkeeping; the consumer-facing rejection is emitted on
|
|
1669
|
+
// 'transaction:failed' and surfaced by the permanent-error headline,
|
|
1670
|
+
// so this line stays at debug.
|
|
1238
1671
|
getContext().logger.debug('[TransactionQueue] commit lane permanent error', {
|
|
1239
1672
|
txId: tx.id.slice(0, 12),
|
|
1240
1673
|
attempts: tx.attempts,
|
|
1241
1674
|
message: error.message,
|
|
1242
1675
|
});
|
|
1243
|
-
this.
|
|
1244
|
-
this.
|
|
1676
|
+
this.emitCommitLifecycle('transaction:failed', { transaction: tx, error, permanent: true });
|
|
1677
|
+
this.emitCommitLifecycle(`transaction:failed:${tx.id}`, { error });
|
|
1245
1678
|
}
|
|
1246
1679
|
}
|
|
1247
1680
|
}
|
|
@@ -1250,10 +1683,10 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1250
1683
|
}
|
|
1251
1684
|
}
|
|
1252
1685
|
/**
|
|
1253
|
-
*
|
|
1254
|
-
*
|
|
1255
|
-
*
|
|
1256
|
-
*
|
|
1686
|
+
* Resolves once a commit-lane transaction is confirmed, returning the server's
|
|
1687
|
+
* `lastSyncId` and any stale-context notifications; rejects on permanent
|
|
1688
|
+
* failure. This backs the `wait: 'confirmed'` semantics of
|
|
1689
|
+
* `ablo.commits.create()`.
|
|
1257
1690
|
*/
|
|
1258
1691
|
waitForCommitReceipt(clientTxId) {
|
|
1259
1692
|
// Drain any stale-context notifications stamped for this tx on the ack.
|
|
@@ -1296,20 +1729,20 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1296
1729
|
return 'order' in data || 'orderKey' in data || 'position' in data;
|
|
1297
1730
|
}
|
|
1298
1731
|
/**
|
|
1299
|
-
*
|
|
1300
|
-
*
|
|
1301
|
-
*
|
|
1302
|
-
*
|
|
1732
|
+
* Classifies an error as transient (worth retrying) or permanent. The
|
|
1733
|
+
* approach is deliberately conservative: only known-transient errors are
|
|
1734
|
+
* retried, and anything unrecognized is treated as permanent so a failing
|
|
1735
|
+
* write cannot loop forever.
|
|
1303
1736
|
*
|
|
1304
|
-
* Transient
|
|
1305
|
-
* - Network failures, connection errors, timeouts
|
|
1306
|
-
* - Server errors (5xx
|
|
1307
|
-
* - Rate limiting (429)
|
|
1737
|
+
* Transient (retried):
|
|
1738
|
+
* - Network failures, connection errors, and timeouts.
|
|
1739
|
+
* - Server errors (HTTP 5xx).
|
|
1740
|
+
* - Rate limiting (HTTP 429).
|
|
1308
1741
|
*
|
|
1309
|
-
* Permanent
|
|
1310
|
-
* - Validation errors
|
|
1311
|
-
* - Not found, unauthorized, forbidden
|
|
1312
|
-
* - Any other business
|
|
1742
|
+
* Permanent (not retried), among others:
|
|
1743
|
+
* - Validation errors and constraint violations.
|
|
1744
|
+
* - Not found, unauthorized, and forbidden.
|
|
1745
|
+
* - Any other business-logic error from the server.
|
|
1313
1746
|
*/
|
|
1314
1747
|
isPermanentError(error) {
|
|
1315
1748
|
// Typed connection error (e.g. ws_not_ready, transport timeout) is
|
|
@@ -1366,19 +1799,31 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1366
1799
|
// This is the safe default - better to fail fast than retry forever
|
|
1367
1800
|
return true;
|
|
1368
1801
|
}
|
|
1802
|
+
/** True only when the server definitively rejected before applying. */
|
|
1803
|
+
isDefinitiveRejection(error) {
|
|
1804
|
+
if (error instanceof AbloError && error.code) {
|
|
1805
|
+
const spec = errorCodeSpec(error.code);
|
|
1806
|
+
if (spec)
|
|
1807
|
+
return !spec.retryable;
|
|
1808
|
+
}
|
|
1809
|
+
const status = extractStatusCode(error);
|
|
1810
|
+
return status !== undefined && status >= 400 && status < 500 && status !== 429;
|
|
1811
|
+
}
|
|
1369
1812
|
/**
|
|
1370
|
-
*
|
|
1813
|
+
* Handles a failed transaction: retries transient failures with backoff and
|
|
1814
|
+
* rolls back permanent ones, settling the transaction's confirmation promise
|
|
1815
|
+
* either way.
|
|
1371
1816
|
*/
|
|
1372
1817
|
async handleFailure(transaction, error) {
|
|
1373
1818
|
transaction.attempts++;
|
|
1374
|
-
// Check
|
|
1819
|
+
// Check whether this is a permanent error that should not be retried.
|
|
1375
1820
|
if (this.isPermanentError(error)) {
|
|
1376
|
-
//
|
|
1377
|
-
//
|
|
1378
|
-
//
|
|
1379
|
-
//
|
|
1380
|
-
//
|
|
1381
|
-
//
|
|
1821
|
+
// Logged at warn: a permanent error means the server rejected the write,
|
|
1822
|
+
// so the developer should see the reason in the console. The typed
|
|
1823
|
+
// AbloError fields (`type`, `code`, `httpStatus`) are included so the
|
|
1824
|
+
// cause is visible — for example a foreign-key violation
|
|
1825
|
+
// (AbloValidationError) versus expired authentication
|
|
1826
|
+
// (AbloAuthenticationError).
|
|
1382
1827
|
try {
|
|
1383
1828
|
const abloErr = error instanceof AbloError ? error : undefined;
|
|
1384
1829
|
const details = {
|
|
@@ -1407,17 +1852,16 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1407
1852
|
const isRepeat = sig === this.lastPermanentErrorSig;
|
|
1408
1853
|
this.lastPermanentErrorSig = sig;
|
|
1409
1854
|
const logger = getContext().logger;
|
|
1410
|
-
// Two registers
|
|
1411
|
-
//
|
|
1412
|
-
//
|
|
1413
|
-
//
|
|
1414
|
-
// their verb (`update`), their model, the typed
|
|
1415
|
-
// message, and the wire `code` for
|
|
1416
|
-
//
|
|
1417
|
-
//
|
|
1418
|
-
//
|
|
1419
|
-
//
|
|
1420
|
-
// is debugging the engine internals.
|
|
1855
|
+
// Two registers from one call site, split by log level (the default
|
|
1856
|
+
// logger is gated at `warn`, so `debug` stays hidden unless
|
|
1857
|
+
// ABLO_LOG_LEVEL=debug is set to inspect the engine):
|
|
1858
|
+
// - the default-visible line speaks the application developer's
|
|
1859
|
+
// language: their verb (such as `update`), their model, the typed
|
|
1860
|
+
// error's own message, and the wire `code` for searching. It uses
|
|
1861
|
+
// no engine jargon and prints no JSON dump, which would alarm
|
|
1862
|
+
// without helping.
|
|
1863
|
+
// - the forensic `details` ride a companion `debug` line for anyone
|
|
1864
|
+
// debugging the engine internals.
|
|
1421
1865
|
const revertNote = this.config.enableOptimistic
|
|
1422
1866
|
? ' The local change was reverted.'
|
|
1423
1867
|
: '';
|
|
@@ -1453,15 +1897,11 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1453
1897
|
return;
|
|
1454
1898
|
}
|
|
1455
1899
|
if (transaction.attempts < this.config.maxRetries) {
|
|
1456
|
-
// Exponential backoff with
|
|
1457
|
-
//
|
|
1458
|
-
//
|
|
1459
|
-
//
|
|
1460
|
-
//
|
|
1461
|
-
// AND the sleep blocked the whole batch loop — every other failure
|
|
1462
|
-
// retried at raw `batchDelay` cadence. The re-enqueue is scheduled
|
|
1463
|
-
// (never awaited) so one backing-off transaction can't stall
|
|
1464
|
-
// unrelated commits.
|
|
1900
|
+
// Exponential backoff with full jitter on every transient retry:
|
|
1901
|
+
// `sleep = random(0, min(cap, base * 2^attempt))`. Throttling responses
|
|
1902
|
+
// (429/503) use a longer base than other transient errors. The re-enqueue
|
|
1903
|
+
// is scheduled rather than awaited, so one backing-off transaction cannot
|
|
1904
|
+
// stall unrelated commits.
|
|
1465
1905
|
const { baseMs, capMs } = this.config.retryBackoff;
|
|
1466
1906
|
let base = baseMs;
|
|
1467
1907
|
try {
|
|
@@ -1493,7 +1933,9 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1493
1933
|
}
|
|
1494
1934
|
}
|
|
1495
1935
|
/**
|
|
1496
|
-
*
|
|
1936
|
+
* Resolves a conflict against server data using the configured strategy:
|
|
1937
|
+
* last-write-wins rolls the local change back, merge and reject re-enqueue it,
|
|
1938
|
+
* and custom applies the caller's resolver.
|
|
1497
1939
|
*/
|
|
1498
1940
|
async handleConflict(transaction, serverData) {
|
|
1499
1941
|
const { strategy, resolver } = this.config.conflictResolution;
|
|
@@ -1523,8 +1965,8 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1523
1965
|
}
|
|
1524
1966
|
}
|
|
1525
1967
|
/**
|
|
1526
|
-
* Optimistic updates
|
|
1527
|
-
* these
|
|
1968
|
+
* Optimistic updates. The apply and rollback rules live in `./optimisticApply.js`;
|
|
1969
|
+
* these methods bind them to the queue's own tracking map and event emitter.
|
|
1528
1970
|
*/
|
|
1529
1971
|
applyOptimisticCreate(model, transaction) {
|
|
1530
1972
|
applyOptimisticCreate(this.optimisticUpdates, this, model, transaction);
|
|
@@ -1539,25 +1981,9 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1539
1981
|
await rollbackOptimistic(this.optimisticUpdates, this, transaction, reason, error);
|
|
1540
1982
|
}
|
|
1541
1983
|
/**
|
|
1542
|
-
*
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
const { type, modelName, modelId, data } = transaction;
|
|
1546
|
-
const schemaName = stripModelSuffix(modelName);
|
|
1547
|
-
const mutationType = TX_TYPE_TO_MUTATION_OP[type];
|
|
1548
|
-
const model = normalizeModelKey(modelName);
|
|
1549
|
-
const input = (type === 'create' || type === 'update') ? data : undefined;
|
|
1550
|
-
try {
|
|
1551
|
-
await this.mutationExecutor.commit([
|
|
1552
|
-
applyWriteOptions({ type: mutationType, model, id: modelId, input }, transaction),
|
|
1553
|
-
]);
|
|
1554
|
-
}
|
|
1555
|
-
catch (error) {
|
|
1556
|
-
handleMutationError(error, `${type}-mutation`, schemaName, modelId);
|
|
1557
|
-
}
|
|
1558
|
-
}
|
|
1559
|
-
/**
|
|
1560
|
-
* Persistence
|
|
1984
|
+
* Loads transactions persisted from a previous session and re-enqueues them,
|
|
1985
|
+
* so writes made while offline survive a restart. Does nothing when
|
|
1986
|
+
* persistence is disabled.
|
|
1561
1987
|
*/
|
|
1562
1988
|
async loadPersistedTransactions(database) {
|
|
1563
1989
|
if (!this.config.enablePersistence)
|
|
@@ -1580,11 +2006,102 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1580
2006
|
}
|
|
1581
2007
|
}
|
|
1582
2008
|
/**
|
|
1583
|
-
*
|
|
1584
|
-
*
|
|
1585
|
-
*
|
|
1586
|
-
|
|
1587
|
-
|
|
2009
|
+
* Restore exact sealed requests after the local database is open. Sealed
|
|
2010
|
+
* envelopes replay through the atomic commit lane and are never re-projected
|
|
2011
|
+
* through model/schema state from the new process.
|
|
2012
|
+
*/
|
|
2013
|
+
async restoreDurableCommits() {
|
|
2014
|
+
if (!this.config.enablePersistence)
|
|
2015
|
+
return new Set();
|
|
2016
|
+
const sourceMutationIds = new Set();
|
|
2017
|
+
try {
|
|
2018
|
+
if (!this.commitOutbox)
|
|
2019
|
+
return sourceMutationIds;
|
|
2020
|
+
const rows = await this.commitOutbox.list();
|
|
2021
|
+
const envelopes = [];
|
|
2022
|
+
for (const row of rows) {
|
|
2023
|
+
if (typeof row !== 'object' ||
|
|
2024
|
+
row === null ||
|
|
2025
|
+
row.type !== 'commit_envelope')
|
|
2026
|
+
continue;
|
|
2027
|
+
const parsed = durableCommitEnvelopeSchema.safeParse(row);
|
|
2028
|
+
if (parsed.success) {
|
|
2029
|
+
envelopes.push(parsed.data);
|
|
2030
|
+
}
|
|
2031
|
+
else {
|
|
2032
|
+
getContext().logger.warn('A saved local write is unreadable and was held for review.');
|
|
2033
|
+
getContext().observability.captureTransactionFailure({
|
|
2034
|
+
context: 'restore-commit-envelope',
|
|
2035
|
+
error: parsed.error,
|
|
2036
|
+
});
|
|
2037
|
+
throw new AbloValidationError('A saved commit envelope is unreadable; replay stopped before newer writes were sent.', { code: 'write_options_invalid', cause: parsed.error });
|
|
2038
|
+
}
|
|
2039
|
+
}
|
|
2040
|
+
envelopes.sort((a, b) => (a.sequence ?? a.sealedAt * 1_000) -
|
|
2041
|
+
(b.sequence ?? b.sealedAt * 1_000) ||
|
|
2042
|
+
a.id.localeCompare(b.id));
|
|
2043
|
+
for (const envelope of envelopes) {
|
|
2044
|
+
for (const mutationId of envelope.sourceMutationIds) {
|
|
2045
|
+
sourceMutationIds.add(mutationId);
|
|
2046
|
+
}
|
|
2047
|
+
if (Date.now() - envelope.sealedAt >=
|
|
2048
|
+
TransactionQueue.DURABLE_REPLAY_WINDOW_MS) {
|
|
2049
|
+
getContext().logger.warn('A saved local write is too old to retry safely and was held for review.');
|
|
2050
|
+
getContext().observability.captureTransactionFailure({
|
|
2051
|
+
context: 'quarantine-expired-commit-envelope',
|
|
2052
|
+
error: `Envelope ${envelope.idempotencyKey} is too old to replay safely`,
|
|
2053
|
+
});
|
|
2054
|
+
throw new AbloIdempotencyError('A saved commit is older than the server idempotency window and cannot be replayed safely.', { code: 'idempotency_conflict' });
|
|
2055
|
+
}
|
|
2056
|
+
if (this.commitOutboxScope &&
|
|
2057
|
+
(!envelope.scope || // eslint-disable-line @typescript-eslint/prefer-optional-chain -- missing scope must quarantine
|
|
2058
|
+
envelope.scope.organizationId !== this.commitOutboxScope.organizationId ||
|
|
2059
|
+
envelope.scope.participantId !== this.commitOutboxScope.participantId ||
|
|
2060
|
+
envelope.scope.namespace !== this.commitOutboxScope.namespace)) {
|
|
2061
|
+
getContext().logger.warn('A saved local write belongs to a different account or server and was held for review.');
|
|
2062
|
+
continue;
|
|
2063
|
+
}
|
|
2064
|
+
if (this.commitStore.has(envelope.idempotencyKey))
|
|
2065
|
+
continue;
|
|
2066
|
+
const transaction = {
|
|
2067
|
+
id: envelope.idempotencyKey,
|
|
2068
|
+
kind: 'commit',
|
|
2069
|
+
operations: envelope.operations.map((operation) => ({ ...operation })),
|
|
2070
|
+
causedByTaskId: envelope.commitOptions.causedByTaskId ?? null,
|
|
2071
|
+
...(envelope.commitOptions.reads
|
|
2072
|
+
? { reads: [...envelope.commitOptions.reads] }
|
|
2073
|
+
: {}),
|
|
2074
|
+
status: 'pending',
|
|
2075
|
+
createdAt: envelope.createdAt,
|
|
2076
|
+
sealedAt: envelope.sealedAt,
|
|
2077
|
+
sequence: envelope.sequence ?? envelope.sealedAt * 1_000,
|
|
2078
|
+
attempts: 0,
|
|
2079
|
+
sourceMutationIds: [...envelope.sourceMutationIds],
|
|
2080
|
+
durableEnvelope: envelope,
|
|
2081
|
+
};
|
|
2082
|
+
this.commitStore.set(transaction.id, transaction);
|
|
2083
|
+
this.commitLane.push(transaction);
|
|
2084
|
+
}
|
|
2085
|
+
if (this.commitLane.length > 0)
|
|
2086
|
+
void this.processCommitLane();
|
|
2087
|
+
}
|
|
2088
|
+
catch (error) {
|
|
2089
|
+
getContext().logger.debug('[TransactionQueue] Failed to restore commit outbox', {
|
|
2090
|
+
error: error instanceof Error ? error.message : String(error),
|
|
2091
|
+
});
|
|
2092
|
+
getContext().observability.captureTransactionFailure({
|
|
2093
|
+
context: 'restore-commit-envelopes',
|
|
2094
|
+
error: error instanceof Error ? error : String(error),
|
|
2095
|
+
});
|
|
2096
|
+
throw error;
|
|
2097
|
+
}
|
|
2098
|
+
return sourceMutationIds;
|
|
2099
|
+
}
|
|
2100
|
+
/**
|
|
2101
|
+
* Validates and rehydrates one persisted row. Rows written to the same store
|
|
2102
|
+
* by other subsystems are skipped, and rows that fail the persisted
|
|
2103
|
+
* transaction schema — from an older version or corruption — are dropped and
|
|
2104
|
+
* reported rather than replayed as commits.
|
|
1588
2105
|
*/
|
|
1589
2106
|
deserializeTransaction(data) {
|
|
1590
2107
|
if (isNonReplayablePersistedRow(data))
|
|
@@ -1606,7 +2123,9 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1606
2123
|
return transaction;
|
|
1607
2124
|
}
|
|
1608
2125
|
/**
|
|
1609
|
-
*
|
|
2126
|
+
* Cancels every pending or executing transaction for a given model id,
|
|
2127
|
+
* optionally limited to one operation type, rolling back their optimistic
|
|
2128
|
+
* state. Returns the cancelled transactions.
|
|
1610
2129
|
*/
|
|
1611
2130
|
cancelTransactionsForModel(modelId, transactionType) {
|
|
1612
2131
|
const cancelledTransactions = [];
|
|
@@ -1634,16 +2153,14 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1634
2153
|
return cancelledTransactions;
|
|
1635
2154
|
}
|
|
1636
2155
|
/**
|
|
1637
|
-
*
|
|
2156
|
+
* Cancels pending transactions for child rows that reference a deleted parent,
|
|
2157
|
+
* used to cascade a parent deletion. The caller supplies the foreign-key
|
|
2158
|
+
* relationship; this method performs the cancellation.
|
|
1638
2159
|
*
|
|
1639
|
-
*
|
|
1640
|
-
*
|
|
1641
|
-
*
|
|
1642
|
-
*
|
|
1643
|
-
* @param childModelName - The child model type (e.g., 'SlideLayer')
|
|
1644
|
-
* @param foreignKey - The FK property name (e.g., 'slideId')
|
|
1645
|
-
* @param parentId - The deleted parent's ID
|
|
1646
|
-
* @returns Number of transactions cancelled
|
|
2160
|
+
* @param childModelName - The child model type (for example 'SlideLayer').
|
|
2161
|
+
* @param foreignKey - The foreign-key property name (for example 'slideId').
|
|
2162
|
+
* @param parentId - The deleted parent's id.
|
|
2163
|
+
* @returns The number of transactions cancelled.
|
|
1647
2164
|
*/
|
|
1648
2165
|
cancelTransactionsByForeignKey(childModelName, foreignKey, parentId) {
|
|
1649
2166
|
let cancelled = 0;
|
|
@@ -1677,14 +2194,12 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1677
2194
|
return cancelled;
|
|
1678
2195
|
}
|
|
1679
2196
|
/**
|
|
1680
|
-
*
|
|
2197
|
+
* Returns the number of transactions still pending or executing.
|
|
1681
2198
|
*/
|
|
1682
2199
|
getOutstandingTransactionCount() {
|
|
1683
2200
|
return this.store.getByStatus('pending').length + this.store.getByStatus('executing').length;
|
|
1684
2201
|
}
|
|
1685
|
-
/**
|
|
1686
|
-
* Utilities
|
|
1687
|
-
*/
|
|
2202
|
+
/** Generates a unique local transaction id. */
|
|
1688
2203
|
generateId() {
|
|
1689
2204
|
return `tx_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
|
|
1690
2205
|
}
|
|
@@ -1700,35 +2215,26 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1700
2215
|
extractUpdateData(model) {
|
|
1701
2216
|
return projectCommitPayload(model.getModelName(), model.getChanges(), { dropUndefined: true });
|
|
1702
2217
|
}
|
|
1703
|
-
buildUpdateInput(modelName, changes) {
|
|
1704
|
-
return projectCommitPayload(modelName, changes, { dropUndefined: true });
|
|
1705
|
-
}
|
|
1706
2218
|
// Derive previous values for changed fields to support accurate rollback.
|
|
1707
|
-
//
|
|
1708
|
-
//
|
|
1709
|
-
//
|
|
1710
|
-
// code (`_data`), no `Slide` class still defines either field, and
|
|
1711
|
-
// hardcoded model-name checks don't belong in a generic queue. If a
|
|
1712
|
-
// model ever needs to surface previous-state outside `modifiedProperties`,
|
|
1713
|
-
// expose a typed `getPreviousData()` accessor on Model and call that.
|
|
2219
|
+
// Model-specific special cases do not belong here; a model that needs to
|
|
2220
|
+
// surface previous state beyond `modifiedProperties` should expose a typed
|
|
2221
|
+
// `getPreviousData()` accessor for this method to call.
|
|
1714
2222
|
extractPreviousData(model, updateInput) {
|
|
1715
2223
|
// When the update's written keys are known, capture a before-image for
|
|
1716
|
-
//
|
|
1717
|
-
//
|
|
1718
|
-
// fields
|
|
1719
|
-
// any key it
|
|
1720
|
-
//
|
|
1721
|
-
//
|
|
1722
|
-
//
|
|
1723
|
-
//
|
|
2224
|
+
// exactly those keys, so the recorded undo inverse reverts them and nothing
|
|
2225
|
+
// else — a full-row inverse would clobber concurrent edits to unrelated
|
|
2226
|
+
// fields. `fallbackToLive: false` makes `Model.capturePreviousValues` omit
|
|
2227
|
+
// any key it cannot resolve, and `buildUndoOps` then drops an un-revertible
|
|
2228
|
+
// inverse rather than inventing one. With no `updateInput` (a full extract)
|
|
2229
|
+
// it falls back to every tracked field. `Model.capturePreviousValues` is the
|
|
2230
|
+
// single before-image source, shared with
|
|
2231
|
+
// `RecordingTransaction.snapshotFields`.
|
|
1724
2232
|
const keys = updateInput
|
|
1725
2233
|
? Object.keys(updateInput)
|
|
1726
2234
|
: [...(model.modifiedProperties instanceof Map ? model.modifiedProperties.keys() : [])];
|
|
1727
2235
|
return { id: model.id, ...model.capturePreviousValues(keys, { fallbackToLive: false }) };
|
|
1728
2236
|
}
|
|
1729
|
-
/**
|
|
1730
|
-
* Public API
|
|
1731
|
-
*/
|
|
2237
|
+
/** Returns a snapshot of queue counts and the current configuration. */
|
|
1732
2238
|
getStats() {
|
|
1733
2239
|
return {
|
|
1734
2240
|
pending: this.store.getByStatus('pending').length,
|
|
@@ -1742,8 +2248,8 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1742
2248
|
};
|
|
1743
2249
|
}
|
|
1744
2250
|
/**
|
|
1745
|
-
*
|
|
1746
|
-
*
|
|
2251
|
+
* Returns detailed internal state — pending, executing, and awaiting-delta
|
|
2252
|
+
* transactions — to help diagnose delta-confirmation issues.
|
|
1747
2253
|
*/
|
|
1748
2254
|
getDebugInfo() {
|
|
1749
2255
|
const awaitingDelta = this.store.getByStatus('awaiting_delta');
|
|
@@ -1773,18 +2279,17 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1773
2279
|
})),
|
|
1774
2280
|
};
|
|
1775
2281
|
}
|
|
1776
|
-
/**
|
|
1777
|
-
* Set configuration
|
|
1778
|
-
*/
|
|
2282
|
+
/** Merges the given options into the queue's configuration. */
|
|
1779
2283
|
setConfig(config) {
|
|
1780
2284
|
this.config = { ...this.config, ...config };
|
|
1781
2285
|
}
|
|
1782
2286
|
/**
|
|
1783
|
-
*
|
|
2287
|
+
* Re-emits an incoming sync delta on the `sync:delta` event for the store to
|
|
2288
|
+
* apply. Because rows use stable ids, no id reconciliation is needed here.
|
|
1784
2289
|
*/
|
|
1785
2290
|
handleSyncDelta(delta) {
|
|
1786
|
-
//
|
|
1787
|
-
//
|
|
2291
|
+
// Row ids are stable, so no reconciliation is needed; re-emit the delta for
|
|
2292
|
+
// the store to apply directly.
|
|
1788
2293
|
this.emit('sync:delta', {
|
|
1789
2294
|
id: delta.id,
|
|
1790
2295
|
modelName: delta.modelName,
|
|
@@ -1794,7 +2299,8 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1794
2299
|
return true;
|
|
1795
2300
|
}
|
|
1796
2301
|
/**
|
|
1797
|
-
*
|
|
2302
|
+
* Releases the queue's resources: rolls back outstanding optimistic updates,
|
|
2303
|
+
* clears all timers and stored transactions, and removes event listeners.
|
|
1798
2304
|
*/
|
|
1799
2305
|
dispose() {
|
|
1800
2306
|
// Cancel all active optimistic updates
|