@abloatai/ablo 0.26.0 → 0.27.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 +14 -0
- package/README.md +101 -85
- package/dist/BaseSyncedStore.d.ts +85 -88
- package/dist/BaseSyncedStore.js +131 -147
- package/dist/Database.d.ts +54 -68
- package/dist/Database.js +97 -113
- 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 +37 -52
- package/dist/Model.js +46 -61
- 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 +112 -112
- package/dist/SyncClient.js +165 -172
- package/dist/adapters/alwaysOnline.d.ts +6 -8
- package/dist/adapters/alwaysOnline.js +6 -8
- package/dist/adapters/inMemoryStorage.d.ts +9 -9
- package/dist/adapters/inMemoryStorage.js +9 -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 +167 -119
- package/dist/client/Ablo.d.ts +73 -73
- package/dist/client/Ablo.js +125 -160
- package/dist/client/ApiClient.d.ts +30 -19
- package/dist/client/ApiClient.js +133 -38
- 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 +14 -17
- package/dist/client/createInternalComponents.js +25 -30
- 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 +57 -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 +67 -87
- package/dist/client/options.d.ts +134 -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 +15 -20
- package/dist/client/wsMutationExecutor.js +17 -23
- 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 +12 -14
- package/dist/core/StoreManager.js +21 -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 +131 -132
- package/dist/errors.d.ts +160 -166
- package/dist/errors.js +155 -158
- package/dist/index.d.ts +30 -27
- package/dist/index.js +89 -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 +122 -131
- package/dist/mutators/UndoManager.js +145 -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 +23 -22
- package/dist/react/useAblo.js +16 -14
- 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 +2 -2
- 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 +33 -42
- 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 +10 -11
- package/dist/stores/ObjectStore.js +11 -12
- package/dist/stores/ObjectStoreContract.d.ts +12 -15
- package/dist/stores/SyncActionStore.d.ts +7 -11
- package/dist/stores/SyncActionStore.js +13 -17
- package/dist/surface.d.ts +27 -20
- package/dist/surface.js +27 -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 +139 -165
- 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/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 +3 -3
- package/dist/testing/index.js +2 -2
- 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 +26 -22
- package/dist/testing/mocks/MockWebSocket.js +22 -21
- package/dist/transactions/TransactionQueue.d.ts +181 -176
- package/dist/transactions/TransactionQueue.js +338 -350
- 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/commitPayload.d.ts +48 -52
- package/dist/transactions/commitPayload.js +48 -57
- package/dist/transactions/deltaConfirmation.d.ts +20 -22
- package/dist/transactions/deltaConfirmation.js +37 -45
- package/dist/transactions/optimisticApply.d.ts +49 -0
- package/dist/transactions/optimisticApply.js +65 -0
- package/dist/transactions/{persistedReplay.d.ts → replayValidation.d.ts} +28 -22
- package/dist/transactions/{persistedReplay.js → replayValidation.js} +33 -27
- 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/{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 +79 -86
- package/dist/wire/frames.js +26 -33
- package/dist/wire/index.d.ts +14 -12
- package/dist/wire/index.js +30 -26
- 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/coordination.md +59 -0
- package/package.json +11 -10
- 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/utils/mobx-setup.d.ts +0 -42
|
@@ -1,24 +1,27 @@
|
|
|
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';
|
|
11
15
|
import { Model } from '../Model.js';
|
|
12
16
|
import { getContext } from '../context.js';
|
|
13
|
-
import { handleMutationError } from './mutation-error-handler.js';
|
|
14
17
|
import { AbloError, AbloConnectionError, errorCodeSpec } from '../errors.js';
|
|
15
18
|
import { SyncPosition } from '../sync/syncPosition.js';
|
|
16
|
-
import { projectCommitPayload, computePriorityScore, normalizeModelKey,
|
|
19
|
+
import { projectCommitPayload, computePriorityScore, normalizeModelKey, hasStaleWriteOptions, applyWriteOptions, asTransportError, extractStatusCode, TX_TYPE_TO_MUTATION_OP, } from './commitPayload.js';
|
|
17
20
|
import { TransactionStore } from './TransactionStore.js';
|
|
18
21
|
import { entityKey, mergeUpdateData, takeUnsentCreateForModel, findCreateBarrierForDelete, deferDeleteUntilCreateSettles, releaseDeferredDeletesForCreate, } from './coalesceRules.js';
|
|
19
22
|
import { DeltaConfirmationTracker } from './deltaConfirmation.js';
|
|
20
|
-
import { deserializePersistedTransaction, isNonReplayablePersistedRow, } from './
|
|
21
|
-
import { applyOptimisticCreate, applyOptimisticUpdate, applyOptimisticDelete, rollbackOptimistic, } from './
|
|
23
|
+
import { deserializePersistedTransaction, isNonReplayablePersistedRow, } from './replayValidation.js';
|
|
24
|
+
import { applyOptimisticCreate, applyOptimisticUpdate, applyOptimisticDelete, rollbackOptimistic, } from './optimisticApply.js';
|
|
22
25
|
export class TransactionQueue extends EventEmitter {
|
|
23
26
|
store = new TransactionStore();
|
|
24
27
|
// Signature of the last permanent-error we logged at `warn`. A `create`
|
|
@@ -27,17 +30,17 @@ export class TransactionQueue extends EventEmitter {
|
|
|
27
30
|
// this, the identical cause prints on a loop. We log the first occurrence
|
|
28
31
|
// and demote exact repeats to `debug`.
|
|
29
32
|
lastPermanentErrorSig;
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
+
// The executor bound to this queue instance, set by `setMutationExecutor(...)`
|
|
34
|
+
// just after construction. When unset it falls back to the ambient executor
|
|
35
|
+
// from `getContext()`.
|
|
33
36
|
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
37
|
+
// The binding matters because the ambient executor is a module-level
|
|
38
|
+
// singleton: constructing a second client instance overwrites the first
|
|
39
|
+
// instance's executor. Without a per-instance binding, commits on one
|
|
40
|
+
// instance would dispatch through another instance's executor closure; once
|
|
41
|
+
// that other instance disposed its store, the closure would resolve no live
|
|
42
|
+
// connection and every commit here would fail with `ws_not_ready`, which the
|
|
43
|
+
// queue treats as transient and retries endlessly.
|
|
41
44
|
_mutationExecutor = null;
|
|
42
45
|
get mutationExecutor() {
|
|
43
46
|
return this._mutationExecutor ?? getContext().mutationExecutor;
|
|
@@ -46,8 +49,8 @@ export class TransactionQueue extends EventEmitter {
|
|
|
46
49
|
isProcessing = false;
|
|
47
50
|
processTimer;
|
|
48
51
|
processScheduled = false;
|
|
49
|
-
//
|
|
50
|
-
//
|
|
52
|
+
// Staging area for transactions created in the same event-loop tick. Each one
|
|
53
|
+
// lands here first, then a microtask commits them together.
|
|
51
54
|
createdTransactions = [];
|
|
52
55
|
commitScheduled = false;
|
|
53
56
|
// Per-model in-flight tracking and merge buffer
|
|
@@ -130,11 +133,12 @@ export class TransactionQueue extends EventEmitter {
|
|
|
130
133
|
releaseDeferredDeletesForCreate(createTransaction) {
|
|
131
134
|
releaseDeferredDeletesForCreate(this.deferredDeletesByCreate, this.store, (tx) => { this.enqueue(tx); }, createTransaction);
|
|
132
135
|
}
|
|
133
|
-
//
|
|
134
|
-
//
|
|
136
|
+
// Default configuration, tuned so more operations coalesce into a single
|
|
137
|
+
// commit: a larger batch size and delay give rapid operations more time to
|
|
138
|
+
// merge before the batch is sent.
|
|
135
139
|
config = {
|
|
136
|
-
maxBatchSize: 50, //
|
|
137
|
-
batchDelay: 150, //
|
|
140
|
+
maxBatchSize: 50, // send up to this many operations per commit
|
|
141
|
+
batchDelay: 150, // milliseconds to wait for more operations before sending
|
|
138
142
|
maxRetries: 3,
|
|
139
143
|
conflictResolution: {
|
|
140
144
|
strategy: 'last-write-wins',
|
|
@@ -151,19 +155,21 @@ export class TransactionQueue extends EventEmitter {
|
|
|
151
155
|
};
|
|
152
156
|
// Track executing transactions for backpressure
|
|
153
157
|
executingCount = 0;
|
|
154
|
-
// Optimistic update tracking. The entry shape
|
|
155
|
-
// in `./
|
|
158
|
+
// Optimistic update tracking. The entry shape and apply/rollback rules live
|
|
159
|
+
// in `./optimisticApply.js`; the map itself stays on the queue because the
|
|
156
160
|
// completion paths, `getStats`, and `dispose` all read it.
|
|
157
161
|
optimisticUpdates = new Map();
|
|
158
|
-
// Stale-context notifications
|
|
159
|
-
//
|
|
160
|
-
//
|
|
162
|
+
// Stale-context notifications, keyed by transaction id. When the server
|
|
163
|
+
// accepts a commit but reports that an operation's read premise had moved,
|
|
164
|
+
// the notification lands here from the commit acknowledgement and is drained
|
|
165
|
+
// by `waitForCommitReceipt`, so the receipt can carry it back to the caller.
|
|
161
166
|
commitNotifications = new Map();
|
|
162
167
|
// Delta-confirmation tracking (ack watermark advance + the awaiting_delta
|
|
163
168
|
// timeout/retry maps) lives in `./deltaConfirmation.js`. Constructed in the
|
|
164
169
|
// constructor once `position` is bound.
|
|
165
170
|
deltaConfirmation;
|
|
166
|
-
// Connection
|
|
171
|
+
// Connection-state check, supplied by the client, used to hold off rollbacks
|
|
172
|
+
// while disconnected.
|
|
167
173
|
isConnectedFn = () => true;
|
|
168
174
|
// Grace timer that, when fired, fails any commit-lane transaction
|
|
169
175
|
// still awaiting an ack. Started on `setConnectionState('disconnected')`,
|
|
@@ -171,11 +177,11 @@ export class TransactionQueue extends EventEmitter {
|
|
|
171
177
|
// is preserved for brief blips; this only catches persistent disconnects.
|
|
172
178
|
commitOfflineGraceTimer = null;
|
|
173
179
|
/**
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
* queue advances `acked`
|
|
177
|
-
* `applied
|
|
178
|
-
*
|
|
180
|
+
* This client's place in the global order of sync deltas. The instance is
|
|
181
|
+
* shared: the client injects one, and a standalone queue creates its own. The
|
|
182
|
+
* queue advances the `acked` cursor as commit responses arrive, the store
|
|
183
|
+
* advances `applied` and `persisted`, and snapshots and claims read
|
|
184
|
+
* `readFloor`. See `../sync/syncPosition.js` for the full contract.
|
|
179
185
|
*/
|
|
180
186
|
position;
|
|
181
187
|
/** Applied-cursor alias, kept so the many internal read sites stay legible. */
|
|
@@ -244,22 +250,18 @@ export class TransactionQueue extends EventEmitter {
|
|
|
244
250
|
});
|
|
245
251
|
}
|
|
246
252
|
/**
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
* outcomes for the routing-helper grace-window use case).
|
|
253
|
+
* Returns the in-flight confirmation promise for a given model and id. When
|
|
254
|
+
* several transactions match, it returns the most recent one's promise; when
|
|
255
|
+
* none is open it resolves immediately, which covers both "already confirmed"
|
|
256
|
+
* and "never staged".
|
|
252
257
|
*
|
|
253
|
-
*
|
|
254
|
-
*
|
|
255
|
-
*
|
|
256
|
-
*
|
|
257
|
-
*
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
* Distinct from `tx.confirmation` on a known transaction — used by
|
|
261
|
-
* call sites that hold a Model reference (returned by
|
|
262
|
-
* `ablo.<model>.create()`) but never see the underlying transaction.
|
|
258
|
+
* It considers the three non-terminal statuses in which the write can still
|
|
259
|
+
* be rolled back — `pending`, `executing`, and `awaiting_delta` — and ignores
|
|
260
|
+
* `completed` (already settled) and `failed`/`rolled_back` (already
|
|
261
|
+
* rejected). This complements the `confirmation` promise carried on a known
|
|
262
|
+
* {@link Transaction}: use this method at call sites that hold a model
|
|
263
|
+
* returned by `ablo.<model>.create()` but never see the underlying
|
|
264
|
+
* transaction.
|
|
263
265
|
*/
|
|
264
266
|
confirmationFor(modelName, modelId) {
|
|
265
267
|
const candidates = [
|
|
@@ -275,15 +277,15 @@ export class TransactionQueue extends EventEmitter {
|
|
|
275
277
|
return latest.confirmation ?? Promise.resolve();
|
|
276
278
|
}
|
|
277
279
|
/**
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
*
|
|
281
|
-
*
|
|
280
|
+
* Attaches a `confirmation` promise to a newly created transaction. Call this
|
|
281
|
+
* before the transaction is staged so a caller can `await tx.confirmation`
|
|
282
|
+
* immediately after a create, update, or delete returns. It is idempotent and
|
|
283
|
+
* returns early if one is already attached.
|
|
282
284
|
*
|
|
283
|
-
*
|
|
284
|
-
*
|
|
285
|
-
* rejection.
|
|
286
|
-
* `.then`/`.catch
|
|
285
|
+
* It also attaches a no-op rejection handler. Most callers never await the
|
|
286
|
+
* confirmation, and without this the runtime would report an unhandled
|
|
287
|
+
* rejection when a write fails. Callers that do want to observe failure simply
|
|
288
|
+
* attach their own `.then`/`.catch`.
|
|
287
289
|
*/
|
|
288
290
|
attachConfirmation(tx) {
|
|
289
291
|
if (tx.confirmation)
|
|
@@ -292,34 +294,30 @@ export class TransactionQueue extends EventEmitter {
|
|
|
292
294
|
this.confirmationResolvers.set(tx.id, { resolve, reject });
|
|
293
295
|
});
|
|
294
296
|
tx.confirmation.catch(() => {
|
|
295
|
-
// Swallow unhandled rejections
|
|
296
|
-
// handler; silent failure is the leak we're already fixing elsewhere.
|
|
297
|
+
// Swallow unhandled rejections; callers that care attach their own handler.
|
|
297
298
|
});
|
|
298
299
|
}
|
|
299
300
|
/**
|
|
300
|
-
*
|
|
301
|
-
*
|
|
301
|
+
* Registers a predicate the queue uses to check whether it is connected.
|
|
302
|
+
* While disconnected, confirmation timeouts re-schedule themselves instead of
|
|
303
|
+
* escalating, so a transaction is never rolled back merely because the client
|
|
304
|
+
* was briefly offline.
|
|
302
305
|
*/
|
|
303
306
|
setConnectionChecker(fn) {
|
|
304
307
|
this.isConnectedFn = fn;
|
|
305
308
|
}
|
|
306
309
|
/**
|
|
307
|
-
*
|
|
308
|
-
*
|
|
309
|
-
* On `'disconnected'`: start a one-shot timer of
|
|
310
|
-
* `config.commitOfflineGraceMs`. If the timer fires (disconnect
|
|
311
|
-
* persisted past grace), iterate every commit-lane transaction with
|
|
312
|
-
* `status ∈ {'pending', 'executing'}` and emit
|
|
313
|
-
* `transaction:failed:${id}` with an `AbloConnectionError`. That
|
|
314
|
-
* lets `waitForCommitReceipt` reject in seconds instead of hanging
|
|
315
|
-
* forever — which is what wedged the 2026-05-15 subagent run.
|
|
310
|
+
* Drives the offline-grace timer for in-flight commit-lane transactions.
|
|
316
311
|
*
|
|
317
|
-
* On `'
|
|
318
|
-
*
|
|
319
|
-
*
|
|
312
|
+
* On `'disconnected'` it starts a one-shot timer of
|
|
313
|
+
* `config.commitOfflineGraceMs`. If that timer fires — meaning the disconnect
|
|
314
|
+
* outlasted the grace window — every commit-lane transaction still `pending`
|
|
315
|
+
* or `executing` is failed with an {@link AbloConnectionError}, so
|
|
316
|
+
* {@link waitForCommitReceipt} rejects within seconds instead of hanging.
|
|
320
317
|
*
|
|
321
|
-
*
|
|
322
|
-
*
|
|
318
|
+
* On `'connected'` it clears any pending grace timer. Brief disconnects are
|
|
319
|
+
* absorbed transparently; {@link processCommitLane} and
|
|
320
|
+
* {@link flushOfflineQueue} resume the work on reconnect.
|
|
323
321
|
*/
|
|
324
322
|
setConnectionState(state) {
|
|
325
323
|
if (state === 'connected') {
|
|
@@ -347,8 +345,8 @@ export class TransactionQueue extends EventEmitter {
|
|
|
347
345
|
}
|
|
348
346
|
if (inFlight.length === 0)
|
|
349
347
|
return;
|
|
350
|
-
//
|
|
351
|
-
//
|
|
348
|
+
// Each failed commit reaches the consumer through its own rejection path,
|
|
349
|
+
// so this aggregate line is forensic and logged at debug rather than warn.
|
|
352
350
|
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
351
|
for (const id of inFlight) {
|
|
354
352
|
const tx = this.commitStore.get(id);
|
|
@@ -361,39 +359,41 @@ export class TransactionQueue extends EventEmitter {
|
|
|
361
359
|
}
|
|
362
360
|
}
|
|
363
361
|
/**
|
|
364
|
-
*
|
|
365
|
-
* right after
|
|
366
|
-
*
|
|
367
|
-
*
|
|
362
|
+
* Binds the mutation executor for this queue instance. The owning client
|
|
363
|
+
* calls this right after construction, so commits made here always dispatch
|
|
364
|
+
* through this instance's connection even when several client instances exist
|
|
365
|
+
* in the same process.
|
|
368
366
|
*/
|
|
369
367
|
setMutationExecutor(executor) {
|
|
370
368
|
this._mutationExecutor = executor;
|
|
371
369
|
}
|
|
372
370
|
// ============================================================================
|
|
373
|
-
//
|
|
371
|
+
// Microtask-based transaction staging
|
|
374
372
|
// ============================================================================
|
|
375
373
|
//
|
|
376
|
-
//
|
|
377
|
-
// A microtask commits them
|
|
378
|
-
//
|
|
374
|
+
// Every transaction lands in the `createdTransactions` staging area first.
|
|
375
|
+
// A microtask then commits them together under one batch index, so a bulk
|
|
376
|
+
// operation such as importing a hundred rows is sent efficiently.
|
|
379
377
|
//
|
|
380
378
|
// Flow:
|
|
381
|
-
// 1. create()/update()/delete() calls stageTransaction()
|
|
382
|
-
// 2. stageTransaction() adds to createdTransactions and schedules microtask
|
|
383
|
-
// 3.
|
|
384
|
-
//
|
|
379
|
+
// 1. create()/update()/delete() calls stageTransaction().
|
|
380
|
+
// 2. stageTransaction() adds to createdTransactions and schedules a microtask.
|
|
381
|
+
// 3. The microtask runs commitCreatedTransactions() once the current
|
|
382
|
+
// synchronous code finishes.
|
|
383
|
+
// 4. All staged transactions share one batch index and move to the execution
|
|
384
|
+
// queue.
|
|
385
385
|
// ============================================================================
|
|
386
386
|
/**
|
|
387
|
-
*
|
|
388
|
-
*
|
|
387
|
+
* Stages a transaction for commit. Transactions staged within the same
|
|
388
|
+
* event-loop tick are committed together.
|
|
389
389
|
*/
|
|
390
390
|
stageTransaction(transaction) {
|
|
391
391
|
this.createdTransactions.push(transaction);
|
|
392
392
|
this.scheduleCommit();
|
|
393
393
|
}
|
|
394
394
|
/**
|
|
395
|
-
*
|
|
396
|
-
*
|
|
395
|
+
* Schedules the staged transactions to commit on a microtask, so all
|
|
396
|
+
* transactions created synchronously within one tick are batched together.
|
|
397
397
|
*/
|
|
398
398
|
scheduleCommit() {
|
|
399
399
|
if (this.commitScheduled)
|
|
@@ -409,8 +409,8 @@ export class TransactionQueue extends EventEmitter {
|
|
|
409
409
|
});
|
|
410
410
|
}
|
|
411
411
|
/**
|
|
412
|
-
*
|
|
413
|
-
*
|
|
412
|
+
* Moves all staged transactions onto the execution queue, assigning them a
|
|
413
|
+
* single shared batch index so they commit together.
|
|
414
414
|
*/
|
|
415
415
|
commitCreatedTransactions() {
|
|
416
416
|
this.commitScheduled = false;
|
|
@@ -434,11 +434,15 @@ export class TransactionQueue extends EventEmitter {
|
|
|
434
434
|
this.enqueue(transaction);
|
|
435
435
|
}
|
|
436
436
|
}
|
|
437
|
-
|
|
437
|
+
/**
|
|
438
|
+
* Flushes every pending transaction in one commit, the fast path taken on
|
|
439
|
+
* reconnect. If the batch fails, each transaction falls back to normal,
|
|
440
|
+
* one-by-one processing.
|
|
441
|
+
*/
|
|
438
442
|
async flushOfflineQueue() {
|
|
439
|
-
// Kick the commit lane too
|
|
440
|
-
//
|
|
441
|
-
//
|
|
443
|
+
// Kick the commit lane too: atomic envelopes from `commits.create()` may
|
|
444
|
+
// have been left at the head of the lane while the connection was down.
|
|
445
|
+
// Fire-and-forget; processCommitLane serializes itself.
|
|
442
446
|
void this.processCommitLane();
|
|
443
447
|
// Collect pending transactions in created order
|
|
444
448
|
const pending = this.store.getByStatus('pending').sort((a, b) => a.createdAt - b.createdAt);
|
|
@@ -489,7 +493,9 @@ export class TransactionQueue extends EventEmitter {
|
|
|
489
493
|
}
|
|
490
494
|
}
|
|
491
495
|
/**
|
|
492
|
-
*
|
|
496
|
+
* Records a create and applies it optimistically, then stages it for the next
|
|
497
|
+
* batched commit. Returns the {@link Transaction}, whose `confirmation`
|
|
498
|
+
* promise settles once the server confirms the write.
|
|
493
499
|
*/
|
|
494
500
|
async create(model, context, writeOptions) {
|
|
495
501
|
const actualModelName = model.getModelName();
|
|
@@ -501,8 +507,8 @@ export class TransactionQueue extends EventEmitter {
|
|
|
501
507
|
modelKey: normalizeModelKey(actualModelName),
|
|
502
508
|
priorityScore: this.computePriorityScore('create', actualModelName),
|
|
503
509
|
data: this.extractCreateData(model),
|
|
504
|
-
//
|
|
505
|
-
// restore
|
|
510
|
+
// Rolling back a create removes the row, so there is no prior state to
|
|
511
|
+
// restore and no snapshot is captured here.
|
|
506
512
|
previousData: null,
|
|
507
513
|
context,
|
|
508
514
|
status: 'pending',
|
|
@@ -516,16 +522,18 @@ export class TransactionQueue extends EventEmitter {
|
|
|
516
522
|
if (this.config.enableOptimistic) {
|
|
517
523
|
this.applyOptimisticCreate(model, transaction);
|
|
518
524
|
}
|
|
519
|
-
//
|
|
520
|
-
//
|
|
521
|
-
// `commitCreatedTransactions`. No batch
|
|
525
|
+
// The microtask coalescer (`scheduleCommit`) collapses all creates in this
|
|
526
|
+
// tick into one commit under a single `batchIndex` — see
|
|
527
|
+
// `commitCreatedTransactions`. No batch call is needed at the call site.
|
|
522
528
|
this.stageTransaction(transaction);
|
|
523
529
|
this.emit('transaction:created', transaction);
|
|
524
530
|
return transaction;
|
|
525
531
|
}
|
|
526
532
|
/**
|
|
527
|
-
*
|
|
528
|
-
*
|
|
533
|
+
* Records an update and applies it optimistically, then stages it for the next
|
|
534
|
+
* batched commit. Rapid updates to the same entity coalesce into a single wire
|
|
535
|
+
* operation.
|
|
536
|
+
* @param precomputedChanges - Optional pre-captured changes, used instead of re-reading them from the model.
|
|
529
537
|
*/
|
|
530
538
|
async update(model, context, precomputedChanges, writeOptions) {
|
|
531
539
|
const actualModelName = model.getModelName();
|
|
@@ -534,12 +542,11 @@ export class TransactionQueue extends EventEmitter {
|
|
|
534
542
|
? this.mapChangesToInput(actualModelName, precomputedChanges)
|
|
535
543
|
: this.extractUpdateData(model);
|
|
536
544
|
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
|
|
545
|
+
// Advance the per-field baseline for the keys just frozen into this
|
|
546
|
+
// transaction. The model records the first old value per field and clears it
|
|
547
|
+
// only on sync acknowledgement, so a second update to the same field before
|
|
548
|
+
// the first is acknowledged would otherwise re-capture the original value
|
|
549
|
+
// instead of this update's result, corrupting the recorded undo inverse. The
|
|
543
550
|
// wire payload is already frozen in `transaction.data`, so dropping the
|
|
544
551
|
// consumed entries is safe.
|
|
545
552
|
model.consumeModifiedFields(Object.keys(updateInput));
|
|
@@ -567,18 +574,22 @@ export class TransactionQueue extends EventEmitter {
|
|
|
567
574
|
if (this.config.enableOptimistic) {
|
|
568
575
|
this.applyOptimisticUpdate(model, transaction);
|
|
569
576
|
}
|
|
570
|
-
//
|
|
571
|
-
//
|
|
572
|
-
//
|
|
577
|
+
// Stage the transaction for the microtask commit; updates made in the same
|
|
578
|
+
// tick are batched together, and enqueue() still coalesces same-entity
|
|
579
|
+
// updates.
|
|
573
580
|
this.stageTransaction(transaction);
|
|
574
581
|
this.emit('transaction:created', transaction);
|
|
575
582
|
return transaction;
|
|
576
583
|
}
|
|
577
584
|
/**
|
|
578
|
-
*
|
|
585
|
+
* Records a delete and applies it optimistically. If the row's own create has
|
|
586
|
+
* not yet been sent, both are cancelled locally rather than sending a create
|
|
587
|
+
* followed by a delete; if the create is already in flight, the delete waits
|
|
588
|
+
* until it settles so the server never sees a delete before the create.
|
|
579
589
|
*/
|
|
580
590
|
async delete(model, context, writeOptions) {
|
|
581
|
-
//
|
|
591
|
+
// Use getModelName() rather than constructor.name, which is unreliable once
|
|
592
|
+
// class names are minified.
|
|
582
593
|
const actualModelName = model.getModelName();
|
|
583
594
|
// Skip Activity delete transactions - activities are permanent audit records
|
|
584
595
|
if (actualModelName === 'Activity') {
|
|
@@ -651,30 +662,32 @@ export class TransactionQueue extends EventEmitter {
|
|
|
651
662
|
this.deferDeleteUntilCreateSettles(createBarrier, transaction);
|
|
652
663
|
}
|
|
653
664
|
else {
|
|
654
|
-
//
|
|
655
|
-
//
|
|
665
|
+
// Stage the transaction for the microtask commit; deletes in the same
|
|
666
|
+
// tick are batched together.
|
|
656
667
|
this.stageTransaction(transaction);
|
|
657
668
|
}
|
|
658
669
|
this.emit('transaction:created', transaction);
|
|
659
670
|
return transaction;
|
|
660
671
|
}
|
|
661
672
|
/**
|
|
662
|
-
*
|
|
673
|
+
* Uploads a single attachment, delegating to the mutation executor.
|
|
663
674
|
*/
|
|
664
675
|
async uploadAttachment(_file, options, _context) {
|
|
665
676
|
return this.mutationExecutor.uploadAttachment?.(options.id, options) ?? null;
|
|
666
677
|
}
|
|
667
678
|
/**
|
|
668
|
-
*
|
|
679
|
+
* Uploads several attachments in one call, delegating to the mutation executor.
|
|
669
680
|
*/
|
|
670
681
|
async batchUploadAttachments(_files, items, _context) {
|
|
671
682
|
return this.mutationExecutor.batchUploadAttachments?.(items.map(i => ({ id: i.id, input: i }))) ?? [];
|
|
672
683
|
}
|
|
673
684
|
/**
|
|
674
|
-
*
|
|
685
|
+
* Records an archive and applies it optimistically, then stages it for the
|
|
686
|
+
* next batched commit.
|
|
675
687
|
*/
|
|
676
688
|
async archive(model, context, writeOptions) {
|
|
677
|
-
//
|
|
689
|
+
// Use getModelName() rather than constructor.name, which is unreliable once
|
|
690
|
+
// class names are minified.
|
|
678
691
|
const actualModelName = model.getModelName();
|
|
679
692
|
const modelKey = normalizeModelKey(actualModelName);
|
|
680
693
|
const priorityScore = this.computePriorityScore('archive', actualModelName);
|
|
@@ -695,16 +708,18 @@ export class TransactionQueue extends EventEmitter {
|
|
|
695
708
|
};
|
|
696
709
|
this.attachConfirmation(transaction);
|
|
697
710
|
this.store.add(transaction);
|
|
698
|
-
//
|
|
711
|
+
// Stage the transaction for the microtask commit.
|
|
699
712
|
this.stageTransaction(transaction);
|
|
700
713
|
this.emit('transaction:created', transaction);
|
|
701
714
|
return transaction;
|
|
702
715
|
}
|
|
703
716
|
/**
|
|
704
|
-
*
|
|
717
|
+
* Records an unarchive and applies it optimistically, then stages it for the
|
|
718
|
+
* next batched commit.
|
|
705
719
|
*/
|
|
706
720
|
async unarchive(model, context) {
|
|
707
|
-
//
|
|
721
|
+
// Use getModelName() rather than constructor.name, which is unreliable once
|
|
722
|
+
// class names are minified.
|
|
708
723
|
const actualModelName = model.getModelName();
|
|
709
724
|
const modelKey = normalizeModelKey(actualModelName);
|
|
710
725
|
const priorityScore = this.computePriorityScore('unarchive', actualModelName);
|
|
@@ -724,30 +739,29 @@ export class TransactionQueue extends EventEmitter {
|
|
|
724
739
|
};
|
|
725
740
|
this.attachConfirmation(transaction);
|
|
726
741
|
this.store.add(transaction);
|
|
727
|
-
//
|
|
742
|
+
// Stage the transaction for the microtask commit.
|
|
728
743
|
this.stageTransaction(transaction);
|
|
729
744
|
this.emit('transaction:created', transaction);
|
|
730
745
|
return transaction;
|
|
731
746
|
}
|
|
732
747
|
/**
|
|
733
|
-
*
|
|
748
|
+
* Places a transaction on the execution queue, coalescing it into an existing
|
|
749
|
+
* same-entity update where possible so redundant writes collapse.
|
|
734
750
|
*/
|
|
735
751
|
enqueue(transaction) {
|
|
736
752
|
this.ensureDerivedFields(transaction);
|
|
737
753
|
const modelKey = `${transaction.modelName}:${transaction.modelId}`;
|
|
738
|
-
//
|
|
739
|
-
//
|
|
740
|
-
//
|
|
754
|
+
// Coalescing for updates. Staging already batches everything created in one
|
|
755
|
+
// event-loop tick, so only two cases remain here: merging into an in-flight
|
|
756
|
+
// update, and merging into a pending same-entity update.
|
|
741
757
|
//
|
|
742
|
-
//
|
|
743
|
-
// re-enqueued from `handleFailure` while its own
|
|
744
|
-
// `inFlightByModel
|
|
745
|
-
//
|
|
746
|
-
// the
|
|
747
|
-
//
|
|
748
|
-
//
|
|
749
|
-
// infinite ~`batchDelay` resend storm of self-laundering transaction
|
|
750
|
-
// clones (found by the claims journey, 2026-06-10).
|
|
758
|
+
// Retries (attempts > 0) must not take either merge path. A retry is
|
|
759
|
+
// re-enqueued from `handleFailure` while its own model key is still marked
|
|
760
|
+
// in `inFlightByModel`, so treating it as a concurrent edit would move its
|
|
761
|
+
// data into `pendingMergeByModel`, remove the transaction, and then mint a
|
|
762
|
+
// fresh follow-up with the attempt counter reset to zero. The counter would
|
|
763
|
+
// never accumulate, `maxRetries` would never trip, and the write would
|
|
764
|
+
// resend in an endless loop.
|
|
751
765
|
if (transaction.type === 'update' && transaction.attempts === 0) {
|
|
752
766
|
const preserveWatermark = hasStaleWriteOptions(transaction.writeOptions);
|
|
753
767
|
// If there is an in-flight update for this model, merge into post-flight buffer
|
|
@@ -782,8 +796,9 @@ export class TransactionQueue extends EventEmitter {
|
|
|
782
796
|
scheduleProcessing(immediate = false) {
|
|
783
797
|
if (this.processScheduled)
|
|
784
798
|
return;
|
|
785
|
-
//
|
|
786
|
-
//
|
|
799
|
+
// Backpressure: don't schedule another batch while too many transactions
|
|
800
|
+
// are already executing, so the server is not flooded with concurrent
|
|
801
|
+
// requests.
|
|
787
802
|
if (this.executingCount >= this.config.maxExecutingTransactions) {
|
|
788
803
|
getContext().logger.debug('[TransactionQueue] Backpressure: delaying batch, too many executing', {
|
|
789
804
|
executingCount: this.executingCount,
|
|
@@ -810,13 +825,11 @@ export class TransactionQueue extends EventEmitter {
|
|
|
810
825
|
}, delay);
|
|
811
826
|
}
|
|
812
827
|
/**
|
|
813
|
-
*
|
|
814
|
-
*
|
|
815
|
-
*
|
|
816
|
-
*
|
|
817
|
-
*
|
|
818
|
-
*
|
|
819
|
-
* This reduces N round-trips to 1, dramatically improving batch latency.
|
|
828
|
+
* Processes one batch of transactions in a single commit. Rather than calling
|
|
829
|
+
* the server once per operation type or model, it collects every batchable
|
|
830
|
+
* operation and sends them together; the server applies the mixed operations
|
|
831
|
+
* atomically within one transaction. This turns many round trips into one and
|
|
832
|
+
* greatly reduces batch latency.
|
|
820
833
|
*/
|
|
821
834
|
async processBatch() {
|
|
822
835
|
const batchStart = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
@@ -828,9 +841,9 @@ export class TransactionQueue extends EventEmitter {
|
|
|
828
841
|
let batch = [];
|
|
829
842
|
await getContext().observability.startSpanAsync('sync.batch', 'sync.transaction.batch', async () => {
|
|
830
843
|
try {
|
|
831
|
-
// Sort
|
|
832
|
-
//
|
|
833
|
-
//
|
|
844
|
+
// Sort the execution queue by foreign-key priority before selecting a
|
|
845
|
+
// batch, so a parent row is always committed before its children, even
|
|
846
|
+
// across batch boundaries.
|
|
834
847
|
this.executionQueue.sort((a, b) => {
|
|
835
848
|
// Ensure derived fields exist (covers restored/persisted transactions)
|
|
836
849
|
this.ensureDerivedFields(a);
|
|
@@ -854,15 +867,15 @@ export class TransactionQueue extends EventEmitter {
|
|
|
854
867
|
this.inFlightByModel.add(key);
|
|
855
868
|
this.store.updateStatus(tx.id, 'executing');
|
|
856
869
|
}
|
|
857
|
-
// Build
|
|
870
|
+
// Build every operation for one unified commit (a single round trip).
|
|
858
871
|
const batchOps = [];
|
|
859
872
|
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
|
|
873
|
+
// The per-operation `transactionId` carries the local transaction id
|
|
874
|
+
// over the wire so the server can stamp it on the resulting sync
|
|
875
|
+
// delta. The receive path matches it through
|
|
876
|
+
// {@link UnconfirmedWrites.consumeEcho} to avoid applying the
|
|
877
|
+
// client's own optimistic change twice. This is separate from the
|
|
878
|
+
// batch-level idempotency key recorded in `mutation_log`.
|
|
866
879
|
const op = applyWriteOptions({
|
|
867
880
|
type: TX_TYPE_TO_MUTATION_OP[tx.type],
|
|
868
881
|
model: tx.modelKey,
|
|
@@ -872,28 +885,27 @@ export class TransactionQueue extends EventEmitter {
|
|
|
872
885
|
}, tx);
|
|
873
886
|
batchOps.push({ tx, op });
|
|
874
887
|
}
|
|
875
|
-
// Execute unified commit for
|
|
888
|
+
// Execute the unified commit for every operation in one round trip.
|
|
876
889
|
if (batchOps.length > 0) {
|
|
877
890
|
const operations = batchOps.map(({ op }) => op);
|
|
878
891
|
try {
|
|
879
|
-
//
|
|
892
|
+
// Capture lastSyncId from the server response for threshold-based
|
|
893
|
+
// confirmation.
|
|
880
894
|
//
|
|
881
|
-
// Idempotency
|
|
882
|
-
//
|
|
883
|
-
//
|
|
884
|
-
//
|
|
885
|
-
//
|
|
886
|
-
// boundary here. Keeping this path await-free preserves
|
|
887
|
-
// the coalescing test's tight bound on batch count.
|
|
895
|
+
// Idempotency: the default HTTP executor derives a stable
|
|
896
|
+
// idempotency key from the operations array itself (a sorted
|
|
897
|
+
// SHA-256), so retrying the same batch reaches the server's
|
|
898
|
+
// `mutation_log` replay path without threading a key through the
|
|
899
|
+
// microtask boundary here.
|
|
888
900
|
const result = await this.mutationExecutor.commit(operations);
|
|
889
901
|
const lastSyncId = result?.lastSyncId ?? 0;
|
|
890
902
|
this.noteAck(lastSyncId);
|
|
891
|
-
//
|
|
892
|
-
//
|
|
893
|
-
//
|
|
894
|
-
// must be rolled back here
|
|
895
|
-
//
|
|
896
|
-
// receipt.
|
|
903
|
+
// The server returned stale-context notifications for operations
|
|
904
|
+
// marked `onStale: 'notify'` whose read premise had moved. Each
|
|
905
|
+
// notified operation was held rather than written, so its
|
|
906
|
+
// optimistic state must be rolled back here — no delta will ever
|
|
907
|
+
// confirm it — and the signal is stamped so
|
|
908
|
+
// {@link waitForCommitReceipt} can carry it onto the receipt.
|
|
897
909
|
const notifications = result?.notifications;
|
|
898
910
|
const heldIds = new Set((notifications ?? []).map((n) => n.id));
|
|
899
911
|
for (const { tx } of batchOps) {
|
|
@@ -902,15 +914,16 @@ export class TransactionQueue extends EventEmitter {
|
|
|
902
914
|
this.commitNotifications.set(tx.id, txNotifs);
|
|
903
915
|
}
|
|
904
916
|
}
|
|
905
|
-
//
|
|
917
|
+
// A lastSyncId of 0 means the mutation succeeded but the server
|
|
918
|
+
// emitted no sync delta; record that anomaly for observability.
|
|
906
919
|
if (lastSyncId === 0) {
|
|
907
920
|
getContext().observability.captureCommitZeroSyncId({
|
|
908
921
|
operationCount: operations.length,
|
|
909
922
|
operations: operations.map((op) => `${op.type}:${op.model}:${op.id?.slice(0, 8) ?? '?'}`),
|
|
910
923
|
});
|
|
911
924
|
}
|
|
912
|
-
//
|
|
913
|
-
//
|
|
925
|
+
// Mark each transaction with the sync-id threshold that will
|
|
926
|
+
// confirm it: any delta whose id is at least lastSyncId.
|
|
914
927
|
for (const { tx } of batchOps) {
|
|
915
928
|
// Held op ('notify'): the server withheld the write, so no delta
|
|
916
929
|
// will confirm it. Roll back the optimistic update (server
|
|
@@ -942,17 +955,17 @@ export class TransactionQueue extends EventEmitter {
|
|
|
942
955
|
});
|
|
943
956
|
continue;
|
|
944
957
|
}
|
|
945
|
-
//
|
|
946
|
-
// real watermark means the server durably
|
|
947
|
-
// that
|
|
948
|
-
// contract
|
|
949
|
-
//
|
|
950
|
-
//
|
|
951
|
-
//
|
|
952
|
-
//
|
|
953
|
-
//
|
|
954
|
-
//
|
|
955
|
-
//
|
|
958
|
+
// Acknowledgement-based confirmation. A successful commit
|
|
959
|
+
// response carrying a real watermark means the server durably
|
|
960
|
+
// applied the write, and that is the confirmation the
|
|
961
|
+
// `wait: 'confirmed'` contract promises. The delta echo is not
|
|
962
|
+
// an acknowledgement channel: it exists to replicate the change
|
|
963
|
+
// to other clients, and this client's own echo is suppressed by
|
|
964
|
+
// the {@link UnconfirmedWrites} regardless. Waiting on the
|
|
965
|
+
// echo would tie "did my write land?" to the health of the
|
|
966
|
+
// subscription stream, so a client with no live delta stream
|
|
967
|
+
// would wait forever in `awaiting_delta` for a write the server
|
|
968
|
+
// had already applied.
|
|
956
969
|
if (lastSyncId > 0) {
|
|
957
970
|
this.store.updateStatus(tx.id, 'completed');
|
|
958
971
|
this.emit('transaction:completed', tx);
|
|
@@ -966,12 +979,11 @@ export class TransactionQueue extends EventEmitter {
|
|
|
966
979
|
});
|
|
967
980
|
}
|
|
968
981
|
else {
|
|
969
|
-
// lastSyncId
|
|
970
|
-
// commit but emitted no delta
|
|
971
|
-
//
|
|
972
|
-
//
|
|
973
|
-
//
|
|
974
|
-
// confirming a write with no watermark.
|
|
982
|
+
// A lastSyncId of 0 on a non-delete: the server accepted the
|
|
983
|
+
// commit but emitted no delta, which is an anomaly (already
|
|
984
|
+
// captured above). Keep the delta wait and reconciliation
|
|
985
|
+
// timeout only for this case, so the anomaly surfaces instead
|
|
986
|
+
// of silently confirming a write that carries no watermark.
|
|
975
987
|
this.store.updateStatus(tx.id, 'awaiting_delta');
|
|
976
988
|
getContext().logger.debug('tx:awaiting_delta', {
|
|
977
989
|
txId: tx.id.slice(0, 8),
|
|
@@ -986,11 +998,11 @@ export class TransactionQueue extends EventEmitter {
|
|
|
986
998
|
}
|
|
987
999
|
catch (error) {
|
|
988
1000
|
const errorMessage = error.message || '';
|
|
989
|
-
// Surface the raw server rejection for the whole batch so
|
|
990
|
-
// cascaded
|
|
991
|
-
// back a
|
|
992
|
-
// cause
|
|
993
|
-
//
|
|
1001
|
+
// Surface the raw server rejection for the whole batch so a
|
|
1002
|
+
// cascaded failure — for example a foreign-key violation that
|
|
1003
|
+
// rolls back a multi-operation transaction — is attributable to a
|
|
1004
|
+
// specific cause rather than showing as a generic permanent error
|
|
1005
|
+
// on each operation.
|
|
994
1006
|
const abloErr = error instanceof AbloError ? error : undefined;
|
|
995
1007
|
// SyncWebSocket attaches a `diagnostics` snapshot to its
|
|
996
1008
|
// "not connected" / "closed while in flight" rejections.
|
|
@@ -1026,16 +1038,17 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1026
1038
|
message: errorMessage,
|
|
1027
1039
|
diagnostics,
|
|
1028
1040
|
});
|
|
1029
|
-
//
|
|
1030
|
-
//
|
|
1031
|
-
//
|
|
1041
|
+
// Handle "no rows in result set" gracefully: the row was already
|
|
1042
|
+
// deleted, so for update and delete operations this is effectively
|
|
1043
|
+
// success — the intended end state already holds — and the
|
|
1044
|
+
// transaction is treated as completed.
|
|
1032
1045
|
if (errorMessage.includes('no rows in result set')) {
|
|
1033
1046
|
getContext().logger.info('[TransactionQueue] Graceful handling: entity already deleted', {
|
|
1034
1047
|
batchSize: batchOps.length,
|
|
1035
1048
|
});
|
|
1036
1049
|
for (const { tx, op } of batchOps) {
|
|
1037
1050
|
if (op.type === 'UPDATE' || op.type === 'DELETE') {
|
|
1038
|
-
//
|
|
1051
|
+
// Row already gone: the intended state holds, mark completed.
|
|
1039
1052
|
this.store.updateStatus(tx.id, 'completed');
|
|
1040
1053
|
this.emit('transaction:completed', tx);
|
|
1041
1054
|
getContext().logger.debug('[TransactionQueue] Orphaned transaction treated as success', {
|
|
@@ -1106,22 +1119,23 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1106
1119
|
}, { batchSize: this.executionQueue.length + (batch?.length ?? 0) });
|
|
1107
1120
|
}
|
|
1108
1121
|
/**
|
|
1109
|
-
*
|
|
1110
|
-
*
|
|
1111
|
-
*
|
|
1112
|
-
* @param syncId - The sync
|
|
1122
|
+
* Confirms every awaiting transaction whose sync-id threshold this delta meets
|
|
1123
|
+
* or exceeds. The confirmation policy and timeout tracking live in
|
|
1124
|
+
* {@link DeltaConfirmationTracker} (`./deltaConfirmation.js`).
|
|
1125
|
+
* @param syncId - The sync id of the received delta.
|
|
1113
1126
|
*/
|
|
1114
1127
|
onDeltaReceived(syncId) {
|
|
1115
1128
|
this.deltaConfirmation.onDeltaReceived(syncId);
|
|
1116
1129
|
}
|
|
1117
|
-
//
|
|
1118
|
-
//
|
|
1130
|
+
// Schedule the retry-and-reconciliation wait for a transaction's confirming
|
|
1131
|
+
// delta; see {@link DeltaConfirmationTracker} in `./deltaConfirmation.js`.
|
|
1119
1132
|
scheduleDeltaConfirmationTimeout(tx, timeoutMs) {
|
|
1120
1133
|
this.deltaConfirmation.scheduleDeltaConfirmationTimeout(tx, timeoutMs);
|
|
1121
1134
|
}
|
|
1122
1135
|
/**
|
|
1123
|
-
*
|
|
1124
|
-
*
|
|
1136
|
+
* Resolves once the given transaction is confirmed and rejects if it fails.
|
|
1137
|
+
* The confirming delta's timeout is handled by
|
|
1138
|
+
* {@link scheduleDeltaConfirmationTimeout}.
|
|
1125
1139
|
*/
|
|
1126
1140
|
waitForConfirmation(transactionId) {
|
|
1127
1141
|
return new Promise((resolve, reject) => {
|
|
@@ -1148,17 +1162,18 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1148
1162
|
this.on(`transaction:failed:${transactionId}`, onFailed);
|
|
1149
1163
|
});
|
|
1150
1164
|
}
|
|
1151
|
-
//
|
|
1165
|
+
// Reports whether a client mutation id is known to this queue, which helps
|
|
1166
|
+
// identify a delta as this client's own echo.
|
|
1152
1167
|
hasClientMutationId(id) {
|
|
1153
1168
|
return !!this.store.get(id) || this.commitStore.has(id);
|
|
1154
1169
|
}
|
|
1155
1170
|
/**
|
|
1156
|
-
*
|
|
1157
|
-
* path
|
|
1158
|
-
*
|
|
1159
|
-
*
|
|
1160
|
-
*
|
|
1161
|
-
*
|
|
1171
|
+
* Enqueues a pre-built, multi-operation atomic commit — the
|
|
1172
|
+
* `ablo.commits.create()` path. The caller supplies the operations; the queue
|
|
1173
|
+
* only retries on reconnect and de-duplicates, and does not apply the change
|
|
1174
|
+
* optimistically or reorder for foreign keys. A duplicate `clientTxId`
|
|
1175
|
+
* already in flight is ignored: the server's `mutation_log` de-duplicates
|
|
1176
|
+
* across sessions, and this guard covers a double-enqueue within one session.
|
|
1162
1177
|
*/
|
|
1163
1178
|
enqueueCommit(clientTxId, operations, options = {}) {
|
|
1164
1179
|
if (this.commitStore.has(clientTxId))
|
|
@@ -1175,19 +1190,19 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1175
1190
|
};
|
|
1176
1191
|
this.commitStore.set(clientTxId, tx);
|
|
1177
1192
|
this.commitLane.push(tx);
|
|
1178
|
-
//
|
|
1179
|
-
// commit-lane writes
|
|
1180
|
-
//
|
|
1181
|
-
//
|
|
1182
|
-
// tracker, and commit-lane ops have no optimistic pool apply to echo.
|
|
1193
|
+
// Emit the envelope on its own event so the undo stream can record
|
|
1194
|
+
// commit-lane writes as well. This deliberately avoids `transaction:created`,
|
|
1195
|
+
// which also feeds the optimistic-echo tracker: commit-lane operations apply
|
|
1196
|
+
// nothing optimistically and so have no echo to suppress.
|
|
1183
1197
|
this.emit('commit:created', { clientTxId, operations: tx.operations });
|
|
1184
1198
|
void this.processCommitLane();
|
|
1185
1199
|
}
|
|
1186
1200
|
/**
|
|
1187
|
-
*
|
|
1188
|
-
*
|
|
1189
|
-
*
|
|
1190
|
-
*
|
|
1201
|
+
* Drains the pending commit-lane envelopes one at a time. A transient
|
|
1202
|
+
* failure, such as a network error, leaves the envelope at the head of the
|
|
1203
|
+
* lane in `pending` and stops; reconnect re-kicks it through
|
|
1204
|
+
* {@link flushOfflineQueue}. A permanent failure emits
|
|
1205
|
+
* `transaction:failed:<id>` and drops the envelope.
|
|
1191
1206
|
*/
|
|
1192
1207
|
async processCommitLane() {
|
|
1193
1208
|
if (this.commitProcessing)
|
|
@@ -1220,8 +1235,9 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1220
1235
|
catch (err) {
|
|
1221
1236
|
const error = err instanceof Error ? err : new Error(String(err));
|
|
1222
1237
|
if (!this.isPermanentError(error)) {
|
|
1223
|
-
// Transient
|
|
1224
|
-
// next enqueueCommit)
|
|
1238
|
+
// Transient: leave it at the head and retry on the next kick
|
|
1239
|
+
// (reconnect or the next enqueueCommit) rather than tight-looping
|
|
1240
|
+
// while the connection is down.
|
|
1225
1241
|
tx.status = 'pending';
|
|
1226
1242
|
getContext().logger.debug('[TransactionQueue] commit lane transient', {
|
|
1227
1243
|
txId: tx.id.slice(0, 12),
|
|
@@ -1233,8 +1249,9 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1233
1249
|
tx.status = 'failed';
|
|
1234
1250
|
tx.error = error;
|
|
1235
1251
|
this.commitLane.shift();
|
|
1236
|
-
// Internal bookkeeping
|
|
1237
|
-
// 'transaction:failed' and surfaced by the permanent-error headline
|
|
1252
|
+
// Internal bookkeeping; the consumer-facing rejection is emitted on
|
|
1253
|
+
// 'transaction:failed' and surfaced by the permanent-error headline,
|
|
1254
|
+
// so this line stays at debug.
|
|
1238
1255
|
getContext().logger.debug('[TransactionQueue] commit lane permanent error', {
|
|
1239
1256
|
txId: tx.id.slice(0, 12),
|
|
1240
1257
|
attempts: tx.attempts,
|
|
@@ -1250,10 +1267,10 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1250
1267
|
}
|
|
1251
1268
|
}
|
|
1252
1269
|
/**
|
|
1253
|
-
*
|
|
1254
|
-
*
|
|
1255
|
-
*
|
|
1256
|
-
*
|
|
1270
|
+
* Resolves once a commit-lane transaction is confirmed, returning the server's
|
|
1271
|
+
* `lastSyncId` and any stale-context notifications; rejects on permanent
|
|
1272
|
+
* failure. This backs the `wait: 'confirmed'` semantics of
|
|
1273
|
+
* `ablo.commits.create()`.
|
|
1257
1274
|
*/
|
|
1258
1275
|
waitForCommitReceipt(clientTxId) {
|
|
1259
1276
|
// Drain any stale-context notifications stamped for this tx on the ack.
|
|
@@ -1296,20 +1313,20 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1296
1313
|
return 'order' in data || 'orderKey' in data || 'position' in data;
|
|
1297
1314
|
}
|
|
1298
1315
|
/**
|
|
1299
|
-
*
|
|
1316
|
+
* Classifies an error as transient (worth retrying) or permanent. The
|
|
1317
|
+
* approach is deliberately conservative: only known-transient errors are
|
|
1318
|
+
* retried, and anything unrecognized is treated as permanent so a failing
|
|
1319
|
+
* write cannot loop forever.
|
|
1300
1320
|
*
|
|
1301
|
-
*
|
|
1302
|
-
*
|
|
1321
|
+
* Transient (retried):
|
|
1322
|
+
* - Network failures, connection errors, and timeouts.
|
|
1323
|
+
* - Server errors (HTTP 5xx).
|
|
1324
|
+
* - Rate limiting (HTTP 429).
|
|
1303
1325
|
*
|
|
1304
|
-
*
|
|
1305
|
-
* -
|
|
1306
|
-
* -
|
|
1307
|
-
* -
|
|
1308
|
-
*
|
|
1309
|
-
* Permanent errors (won't retry - includes but not limited to):
|
|
1310
|
-
* - Validation errors, constraint violations
|
|
1311
|
-
* - Not found, unauthorized, forbidden
|
|
1312
|
-
* - Any other business logic error from the server
|
|
1326
|
+
* Permanent (not retried), among others:
|
|
1327
|
+
* - Validation errors and constraint violations.
|
|
1328
|
+
* - Not found, unauthorized, and forbidden.
|
|
1329
|
+
* - Any other business-logic error from the server.
|
|
1313
1330
|
*/
|
|
1314
1331
|
isPermanentError(error) {
|
|
1315
1332
|
// Typed connection error (e.g. ws_not_ready, transport timeout) is
|
|
@@ -1367,18 +1384,20 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1367
1384
|
return true;
|
|
1368
1385
|
}
|
|
1369
1386
|
/**
|
|
1370
|
-
*
|
|
1387
|
+
* Handles a failed transaction: retries transient failures with backoff and
|
|
1388
|
+
* rolls back permanent ones, settling the transaction's confirmation promise
|
|
1389
|
+
* either way.
|
|
1371
1390
|
*/
|
|
1372
1391
|
async handleFailure(transaction, error) {
|
|
1373
1392
|
transaction.attempts++;
|
|
1374
|
-
// Check
|
|
1393
|
+
// Check whether this is a permanent error that should not be retried.
|
|
1375
1394
|
if (this.isPermanentError(error)) {
|
|
1376
|
-
//
|
|
1377
|
-
//
|
|
1378
|
-
//
|
|
1379
|
-
//
|
|
1380
|
-
//
|
|
1381
|
-
//
|
|
1395
|
+
// Logged at warn: a permanent error means the server rejected the write,
|
|
1396
|
+
// so the developer should see the reason in the console. The typed
|
|
1397
|
+
// AbloError fields (`type`, `code`, `httpStatus`) are included so the
|
|
1398
|
+
// cause is visible — for example a foreign-key violation
|
|
1399
|
+
// (AbloValidationError) versus expired authentication
|
|
1400
|
+
// (AbloAuthenticationError).
|
|
1382
1401
|
try {
|
|
1383
1402
|
const abloErr = error instanceof AbloError ? error : undefined;
|
|
1384
1403
|
const details = {
|
|
@@ -1407,17 +1426,16 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1407
1426
|
const isRepeat = sig === this.lastPermanentErrorSig;
|
|
1408
1427
|
this.lastPermanentErrorSig = sig;
|
|
1409
1428
|
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.
|
|
1429
|
+
// Two registers from one call site, split by log level (the default
|
|
1430
|
+
// logger is gated at `warn`, so `debug` stays hidden unless
|
|
1431
|
+
// ABLO_LOG_LEVEL=debug is set to inspect the engine):
|
|
1432
|
+
// - the default-visible line speaks the application developer's
|
|
1433
|
+
// language: their verb (such as `update`), their model, the typed
|
|
1434
|
+
// error's own message, and the wire `code` for searching. It uses
|
|
1435
|
+
// no engine jargon and prints no JSON dump, which would alarm
|
|
1436
|
+
// without helping.
|
|
1437
|
+
// - the forensic `details` ride a companion `debug` line for anyone
|
|
1438
|
+
// debugging the engine internals.
|
|
1421
1439
|
const revertNote = this.config.enableOptimistic
|
|
1422
1440
|
? ' The local change was reverted.'
|
|
1423
1441
|
: '';
|
|
@@ -1453,15 +1471,11 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1453
1471
|
return;
|
|
1454
1472
|
}
|
|
1455
1473
|
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.
|
|
1474
|
+
// Exponential backoff with full jitter on every transient retry:
|
|
1475
|
+
// `sleep = random(0, min(cap, base * 2^attempt))`. Throttling responses
|
|
1476
|
+
// (429/503) use a longer base than other transient errors. The re-enqueue
|
|
1477
|
+
// is scheduled rather than awaited, so one backing-off transaction cannot
|
|
1478
|
+
// stall unrelated commits.
|
|
1465
1479
|
const { baseMs, capMs } = this.config.retryBackoff;
|
|
1466
1480
|
let base = baseMs;
|
|
1467
1481
|
try {
|
|
@@ -1493,7 +1507,9 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1493
1507
|
}
|
|
1494
1508
|
}
|
|
1495
1509
|
/**
|
|
1496
|
-
*
|
|
1510
|
+
* Resolves a conflict against server data using the configured strategy:
|
|
1511
|
+
* last-write-wins rolls the local change back, merge and reject re-enqueue it,
|
|
1512
|
+
* and custom applies the caller's resolver.
|
|
1497
1513
|
*/
|
|
1498
1514
|
async handleConflict(transaction, serverData) {
|
|
1499
1515
|
const { strategy, resolver } = this.config.conflictResolution;
|
|
@@ -1523,8 +1539,8 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1523
1539
|
}
|
|
1524
1540
|
}
|
|
1525
1541
|
/**
|
|
1526
|
-
* Optimistic updates
|
|
1527
|
-
* these
|
|
1542
|
+
* Optimistic updates. The apply and rollback rules live in `./optimisticApply.js`;
|
|
1543
|
+
* these methods bind them to the queue's own tracking map and event emitter.
|
|
1528
1544
|
*/
|
|
1529
1545
|
applyOptimisticCreate(model, transaction) {
|
|
1530
1546
|
applyOptimisticCreate(this.optimisticUpdates, this, model, transaction);
|
|
@@ -1539,25 +1555,9 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1539
1555
|
await rollbackOptimistic(this.optimisticUpdates, this, transaction, reason, error);
|
|
1540
1556
|
}
|
|
1541
1557
|
/**
|
|
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
|
|
1558
|
+
* Loads transactions persisted from a previous session and re-enqueues them,
|
|
1559
|
+
* so writes made while offline survive a restart. Does nothing when
|
|
1560
|
+
* persistence is disabled.
|
|
1561
1561
|
*/
|
|
1562
1562
|
async loadPersistedTransactions(database) {
|
|
1563
1563
|
if (!this.config.enablePersistence)
|
|
@@ -1580,11 +1580,10 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1580
1580
|
}
|
|
1581
1581
|
}
|
|
1582
1582
|
/**
|
|
1583
|
-
*
|
|
1584
|
-
*
|
|
1585
|
-
*
|
|
1586
|
-
*
|
|
1587
|
-
* DROPPED with an observability capture instead of replayed as commits.
|
|
1583
|
+
* Validates and rehydrates one persisted row. Rows written to the same store
|
|
1584
|
+
* by other subsystems are skipped, and rows that fail the persisted
|
|
1585
|
+
* transaction schema — from an older version or corruption — are dropped and
|
|
1586
|
+
* reported rather than replayed as commits.
|
|
1588
1587
|
*/
|
|
1589
1588
|
deserializeTransaction(data) {
|
|
1590
1589
|
if (isNonReplayablePersistedRow(data))
|
|
@@ -1606,7 +1605,9 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1606
1605
|
return transaction;
|
|
1607
1606
|
}
|
|
1608
1607
|
/**
|
|
1609
|
-
*
|
|
1608
|
+
* Cancels every pending or executing transaction for a given model id,
|
|
1609
|
+
* optionally limited to one operation type, rolling back their optimistic
|
|
1610
|
+
* state. Returns the cancelled transactions.
|
|
1610
1611
|
*/
|
|
1611
1612
|
cancelTransactionsForModel(modelId, transactionType) {
|
|
1612
1613
|
const cancelledTransactions = [];
|
|
@@ -1634,16 +1635,14 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1634
1635
|
return cancelledTransactions;
|
|
1635
1636
|
}
|
|
1636
1637
|
/**
|
|
1637
|
-
*
|
|
1638
|
-
*
|
|
1639
|
-
*
|
|
1640
|
-
* This keeps FK relationship knowledge in ModelRegistry/SyncedStore,
|
|
1641
|
-
* while TransactionQueue just handles the cancellation mechanics.
|
|
1638
|
+
* Cancels pending transactions for child rows that reference a deleted parent,
|
|
1639
|
+
* used to cascade a parent deletion. The caller supplies the foreign-key
|
|
1640
|
+
* relationship; this method performs the cancellation.
|
|
1642
1641
|
*
|
|
1643
|
-
* @param childModelName - The child model type (
|
|
1644
|
-
* @param foreignKey - The
|
|
1645
|
-
* @param parentId - The deleted parent's
|
|
1646
|
-
* @returns
|
|
1642
|
+
* @param childModelName - The child model type (for example 'SlideLayer').
|
|
1643
|
+
* @param foreignKey - The foreign-key property name (for example 'slideId').
|
|
1644
|
+
* @param parentId - The deleted parent's id.
|
|
1645
|
+
* @returns The number of transactions cancelled.
|
|
1647
1646
|
*/
|
|
1648
1647
|
cancelTransactionsByForeignKey(childModelName, foreignKey, parentId) {
|
|
1649
1648
|
let cancelled = 0;
|
|
@@ -1677,14 +1676,12 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1677
1676
|
return cancelled;
|
|
1678
1677
|
}
|
|
1679
1678
|
/**
|
|
1680
|
-
*
|
|
1679
|
+
* Returns the number of transactions still pending or executing.
|
|
1681
1680
|
*/
|
|
1682
1681
|
getOutstandingTransactionCount() {
|
|
1683
1682
|
return this.store.getByStatus('pending').length + this.store.getByStatus('executing').length;
|
|
1684
1683
|
}
|
|
1685
|
-
/**
|
|
1686
|
-
* Utilities
|
|
1687
|
-
*/
|
|
1684
|
+
/** Generates a unique local transaction id. */
|
|
1688
1685
|
generateId() {
|
|
1689
1686
|
return `tx_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
|
|
1690
1687
|
}
|
|
@@ -1700,35 +1697,26 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1700
1697
|
extractUpdateData(model) {
|
|
1701
1698
|
return projectCommitPayload(model.getModelName(), model.getChanges(), { dropUndefined: true });
|
|
1702
1699
|
}
|
|
1703
|
-
buildUpdateInput(modelName, changes) {
|
|
1704
|
-
return projectCommitPayload(modelName, changes, { dropUndefined: true });
|
|
1705
|
-
}
|
|
1706
1700
|
// 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.
|
|
1701
|
+
// Model-specific special cases do not belong here; a model that needs to
|
|
1702
|
+
// surface previous state beyond `modifiedProperties` should expose a typed
|
|
1703
|
+
// `getPreviousData()` accessor for this method to call.
|
|
1714
1704
|
extractPreviousData(model, updateInput) {
|
|
1715
1705
|
// 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
|
-
//
|
|
1706
|
+
// exactly those keys, so the recorded undo inverse reverts them and nothing
|
|
1707
|
+
// else — a full-row inverse would clobber concurrent edits to unrelated
|
|
1708
|
+
// fields. `fallbackToLive: false` makes `Model.capturePreviousValues` omit
|
|
1709
|
+
// any key it cannot resolve, and `buildUndoOps` then drops an un-revertible
|
|
1710
|
+
// inverse rather than inventing one. With no `updateInput` (a full extract)
|
|
1711
|
+
// it falls back to every tracked field. `Model.capturePreviousValues` is the
|
|
1712
|
+
// single before-image source, shared with
|
|
1713
|
+
// `RecordingTransaction.snapshotFields`.
|
|
1724
1714
|
const keys = updateInput
|
|
1725
1715
|
? Object.keys(updateInput)
|
|
1726
1716
|
: [...(model.modifiedProperties instanceof Map ? model.modifiedProperties.keys() : [])];
|
|
1727
1717
|
return { id: model.id, ...model.capturePreviousValues(keys, { fallbackToLive: false }) };
|
|
1728
1718
|
}
|
|
1729
|
-
/**
|
|
1730
|
-
* Public API
|
|
1731
|
-
*/
|
|
1719
|
+
/** Returns a snapshot of queue counts and the current configuration. */
|
|
1732
1720
|
getStats() {
|
|
1733
1721
|
return {
|
|
1734
1722
|
pending: this.store.getByStatus('pending').length,
|
|
@@ -1742,8 +1730,8 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1742
1730
|
};
|
|
1743
1731
|
}
|
|
1744
1732
|
/**
|
|
1745
|
-
*
|
|
1746
|
-
*
|
|
1733
|
+
* Returns detailed internal state — pending, executing, and awaiting-delta
|
|
1734
|
+
* transactions — to help diagnose delta-confirmation issues.
|
|
1747
1735
|
*/
|
|
1748
1736
|
getDebugInfo() {
|
|
1749
1737
|
const awaitingDelta = this.store.getByStatus('awaiting_delta');
|
|
@@ -1773,18 +1761,17 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1773
1761
|
})),
|
|
1774
1762
|
};
|
|
1775
1763
|
}
|
|
1776
|
-
/**
|
|
1777
|
-
* Set configuration
|
|
1778
|
-
*/
|
|
1764
|
+
/** Merges the given options into the queue's configuration. */
|
|
1779
1765
|
setConfig(config) {
|
|
1780
1766
|
this.config = { ...this.config, ...config };
|
|
1781
1767
|
}
|
|
1782
1768
|
/**
|
|
1783
|
-
*
|
|
1769
|
+
* Re-emits an incoming sync delta on the `sync:delta` event for the store to
|
|
1770
|
+
* apply. Because rows use stable ids, no id reconciliation is needed here.
|
|
1784
1771
|
*/
|
|
1785
1772
|
handleSyncDelta(delta) {
|
|
1786
|
-
//
|
|
1787
|
-
//
|
|
1773
|
+
// Row ids are stable, so no reconciliation is needed; re-emit the delta for
|
|
1774
|
+
// the store to apply directly.
|
|
1788
1775
|
this.emit('sync:delta', {
|
|
1789
1776
|
id: delta.id,
|
|
1790
1777
|
modelName: delta.modelName,
|
|
@@ -1794,7 +1781,8 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1794
1781
|
return true;
|
|
1795
1782
|
}
|
|
1796
1783
|
/**
|
|
1797
|
-
*
|
|
1784
|
+
* Releases the queue's resources: rolls back outstanding optimistic updates,
|
|
1785
|
+
* clears all timers and stored transactions, and removes event listeners.
|
|
1798
1786
|
*/
|
|
1799
1787
|
dispose() {
|
|
1800
1788
|
// Cancel all active optimistic updates
|