@abloatai/ablo 0.25.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/AGENTS.md +5 -3
- package/CHANGELOG.md +34 -0
- package/README.md +104 -88
- package/dist/BaseSyncedStore.d.ts +140 -266
- package/dist/BaseSyncedStore.js +338 -739
- package/dist/Database.d.ts +62 -77
- package/dist/Database.js +106 -127
- package/dist/{ObjectPool.d.ts → InstanceCache.d.ts} +10 -13
- package/dist/{ObjectPool.js → InstanceCache.js} +91 -83
- package/dist/LazyReferenceCollection.d.ts +11 -15
- package/dist/LazyReferenceCollection.js +16 -15
- package/dist/Model.d.ts +37 -52
- package/dist/Model.js +52 -69
- package/dist/ModelRegistry.d.ts +46 -25
- package/dist/ModelRegistry.js +32 -30
- package/dist/NetworkMonitor.d.ts +5 -6
- package/dist/NetworkMonitor.js +6 -7
- package/dist/SyncClient.d.ts +119 -109
- package/dist/SyncClient.js +303 -224
- package/dist/SyncEngineContext.d.ts +1 -3
- package/dist/SyncEngineContext.js +1 -2
- 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 +39 -31
- package/dist/agent/Agent.js +35 -23
- 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} +30 -31
- package/dist/ai-sdk/index.d.ts +25 -22
- package/dist/ai-sdk/index.js +25 -22
- package/dist/ai-sdk/wrap.d.ts +7 -8
- package/dist/ai-sdk/wrap.js +2 -2
- package/dist/auth/credentialPolicy.d.ts +74 -71
- package/dist/auth/credentialPolicy.js +51 -56
- package/dist/auth/credentialSource.d.ts +7 -18
- package/dist/auth/credentialSource.js +10 -18
- package/dist/auth/index.d.ts +59 -58
- package/dist/auth/index.js +34 -40
- 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 +483 -369
- package/dist/client/Ablo.d.ts +107 -836
- package/dist/client/Ablo.js +174 -833
- package/dist/client/ApiClient.d.ts +44 -20
- package/dist/client/ApiClient.js +193 -44
- package/dist/client/auth.d.ts +51 -60
- package/dist/client/auth.js +137 -110
- package/dist/client/claimHeartbeatLoop.d.ts +50 -0
- package/dist/client/claimHeartbeatLoop.js +88 -0
- package/dist/client/consoleLogger.d.ts +35 -0
- package/dist/client/consoleLogger.js +44 -0
- package/dist/client/createInternalComponents.d.ts +14 -17
- package/dist/client/createInternalComponents.js +26 -31
- package/dist/client/createModelProxy.d.ts +130 -120
- package/dist/client/createModelProxy.js +158 -124
- package/dist/client/credentialEndpoint.d.ts +61 -0
- package/dist/client/credentialEndpoint.js +86 -0
- package/dist/client/functionalUpdate.d.ts +29 -27
- package/dist/client/functionalUpdate.js +21 -21
- package/dist/client/hostedEndpoints.d.ts +21 -0
- package/dist/client/hostedEndpoints.js +21 -0
- package/dist/client/httpClient.d.ts +58 -54
- package/dist/client/httpClient.js +29 -31
- package/dist/client/identity.d.ts +15 -20
- package/dist/client/identity.js +49 -59
- package/dist/client/modelRegistration.d.ts +10 -0
- package/dist/client/modelRegistration.js +301 -0
- package/dist/client/options.d.ts +373 -0
- package/dist/client/options.js +6 -0
- package/dist/client/registerDataSource.d.ts +9 -9
- package/dist/client/registerDataSource.js +15 -16
- package/dist/client/resourceTypes.d.ts +333 -0
- package/dist/client/resourceTypes.js +7 -0
- package/dist/client/schemaConfig.d.ts +44 -0
- package/dist/client/schemaConfig.js +176 -0
- package/dist/client/sessionMint.d.ts +17 -13
- package/dist/client/sessionMint.js +26 -31
- package/dist/client/validateAbloOptions.d.ts +12 -14
- package/dist/client/validateAbloOptions.js +9 -10
- package/dist/client/writeOptionsSchema.d.ts +18 -16
- package/dist/client/writeOptionsSchema.js +23 -20
- package/dist/client/wsMutationExecutor.d.ts +28 -0
- package/dist/client/wsMutationExecutor.js +71 -0
- package/dist/context.d.ts +6 -4
- package/dist/context.js +6 -7
- package/dist/coordination/index.d.ts +13 -4
- package/dist/coordination/index.js +29 -4
- package/dist/coordination/schema.d.ts +176 -128
- package/dist/coordination/schema.js +197 -133
- package/dist/coordination/trace.d.ts +9 -11
- package/dist/coordination/trace.js +13 -15
- package/dist/core/DatabaseManager.d.ts +5 -8
- package/dist/core/DatabaseManager.js +38 -40
- package/dist/core/QueryProcessor.d.ts +7 -9
- package/dist/core/QueryProcessor.js +27 -34
- package/dist/core/QueryView.d.ts +17 -5
- package/dist/core/QueryView.js +6 -7
- package/dist/core/StoreManager.d.ts +14 -16
- package/dist/core/StoreManager.js +26 -25
- package/dist/core/ViewRegistry.d.ts +5 -5
- package/dist/core/ViewRegistry.js +4 -4
- package/dist/core/index.d.ts +18 -13
- package/dist/core/index.js +32 -26
- package/dist/core/openIDBWithTimeout.d.ts +38 -36
- package/dist/core/openIDBWithTimeout.js +57 -54
- package/dist/core/queryUtils.d.ts +45 -0
- package/dist/core/queryUtils.js +69 -0
- package/dist/core/storeContract.d.ts +145 -0
- package/dist/core/storeContract.js +12 -0
- package/dist/environment.d.ts +28 -0
- package/dist/environment.js +21 -0
- package/dist/errorCodes.d.ts +118 -101
- package/dist/errorCodes.js +277 -260
- package/dist/errors.d.ts +170 -165
- package/dist/errors.js +161 -151
- package/dist/index.d.ts +30 -27
- package/dist/index.js +90 -82
- package/dist/interfaces/index.d.ts +108 -133
- package/dist/interfaces/index.js +5 -4
- package/dist/keys/index.d.ts +27 -29
- package/dist/keys/index.js +59 -49
- 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 +149 -155
- package/dist/mutators/defineMutators.d.ts +24 -37
- 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 +105 -101
- package/dist/policy/types.js +67 -66
- package/dist/query/client.d.ts +32 -16
- package/dist/query/client.js +103 -72
- package/dist/query/types.d.ts +37 -60
- package/dist/query/types.js +13 -33
- package/dist/react/AbloProvider.d.ts +7 -11
- package/dist/react/AbloProvider.js +24 -17
- package/dist/react/context.d.ts +27 -146
- 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 +17 -15
- 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 +11 -12
- package/dist/react/useMutationFailureListener.d.ts +8 -8
- package/dist/react/useMutationFailureListener.js +9 -9
- package/dist/react/useMutators.d.ts +11 -11
- package/dist/react/useMutators.js +10 -4
- package/dist/react/useReactive.js +2 -3
- package/dist/react/useSyncStatus.d.ts +4 -6
- package/dist/react/useUndoScope.d.ts +7 -9
- package/dist/react/useUndoScope.js +3 -3
- 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 +35 -0
- package/dist/schema/ddlLock.js +46 -0
- 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 +36 -49
- package/dist/schema/generate.d.ts +12 -12
- package/dist/schema/generate.js +12 -12
- package/dist/schema/index.d.ts +5 -4
- package/dist/schema/index.js +29 -21
- package/dist/schema/model.d.ts +121 -146
- package/dist/schema/model.js +24 -35
- package/dist/schema/openapi.d.ts +10 -9
- package/dist/schema/openapi.js +7 -1
- package/dist/schema/queries.d.ts +30 -32
- package/dist/schema/queries.js +24 -25
- package/dist/schema/relation.d.ts +89 -99
- package/dist/schema/relation.js +13 -13
- package/dist/schema/residency.d.ts +38 -0
- package/dist/schema/residency.js +30 -0
- package/dist/schema/roles.d.ts +45 -27
- package/dist/schema/roles.js +52 -21
- package/dist/schema/schema.d.ts +36 -45
- package/dist/schema/schema.js +42 -39
- package/dist/schema/select.d.ts +13 -13
- package/dist/schema/select.js +13 -13
- package/dist/schema/serialize.d.ts +36 -39
- 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} +27 -50
- 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 +31 -26
- package/dist/source/adapter.js +10 -10
- package/dist/source/adapters/drizzle.d.ts +28 -23
- package/dist/source/adapters/drizzle.js +34 -28
- package/dist/source/adapters/kysely.d.ts +27 -25
- package/dist/source/adapters/kysely.js +28 -26
- package/dist/source/adapters/memory.d.ts +8 -7
- package/dist/source/adapters/memory.js +10 -9
- package/dist/source/adapters/prisma.d.ts +13 -12
- package/dist/source/adapters/prisma.js +27 -29
- package/dist/source/conformance.d.ts +18 -11
- package/dist/source/conformance.js +27 -19
- package/dist/source/connector.d.ts +31 -32
- package/dist/source/connector.js +30 -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 +94 -0
- package/dist/source/factory.js +268 -0
- package/dist/source/index.d.ts +10 -462
- package/dist/source/index.js +17 -421
- package/dist/source/migrations.d.ts +9 -9
- package/dist/source/migrations.js +9 -9
- package/dist/source/next.d.ts +10 -11
- package/dist/source/next.js +7 -8
- package/dist/source/pushQueue.d.ts +70 -48
- package/dist/source/pushQueue.js +36 -29
- package/dist/source/signing.d.ts +88 -0
- package/dist/source/signing.js +159 -0
- package/dist/source/types.d.ts +351 -0
- package/dist/source/types.js +43 -0
- package/dist/stores/ObjectStore.d.ts +11 -12
- package/dist/stores/ObjectStore.js +34 -35
- package/dist/stores/ObjectStoreContract.d.ts +12 -15
- package/dist/stores/SyncActionStore.d.ts +8 -12
- package/dist/stores/SyncActionStore.js +77 -46
- package/dist/surface.d.ts +28 -21
- package/dist/surface.js +28 -20
- package/dist/sync/{BootstrapHelper.d.ts → BootstrapFetcher.d.ts} +37 -45
- package/dist/sync/{BootstrapHelper.js → BootstrapFetcher.js} +101 -80
- package/dist/sync/ConnectionManager.d.ts +47 -50
- package/dist/sync/ConnectionManager.js +74 -70
- package/dist/sync/NetworkProbe.d.ts +27 -31
- package/dist/sync/NetworkProbe.js +67 -72
- package/dist/sync/{HydrationCoordinator.d.ts → OnDemandLoader.d.ts} +49 -36
- package/dist/sync/{HydrationCoordinator.js → OnDemandLoader.js} +79 -54
- package/dist/sync/{AreaOfInterestManager.d.ts → SubscriptionManager.d.ts} +45 -59
- package/dist/sync/{AreaOfInterestManager.js → SubscriptionManager.js} +44 -52
- package/dist/sync/SyncWebSocket.d.ts +175 -250
- package/dist/sync/SyncWebSocket.js +431 -769
- package/dist/sync/awaitClaimGrant.d.ts +18 -18
- package/dist/sync/awaitClaimGrant.js +38 -30
- package/dist/sync/bootstrapApply.d.ts +70 -0
- package/dist/sync/bootstrapApply.js +73 -0
- package/dist/sync/commitFrames.d.ts +44 -0
- package/dist/sync/commitFrames.js +94 -0
- package/dist/sync/createClaimStream.d.ts +23 -22
- package/dist/sync/createClaimStream.js +108 -25
- package/dist/sync/createPresenceStream.d.ts +19 -18
- package/dist/sync/createPresenceStream.js +25 -26
- package/dist/sync/createSnapshot.d.ts +13 -17
- package/dist/sync/createSnapshot.js +20 -26
- package/dist/sync/credentialLifecycle.d.ts +175 -0
- package/dist/sync/credentialLifecycle.js +322 -0
- package/dist/sync/deltaPipeline.d.ts +113 -0
- package/dist/sync/deltaPipeline.js +261 -0
- package/dist/sync/groupChange.d.ts +113 -0
- package/dist/sync/groupChange.js +242 -0
- package/dist/sync/heartbeat.d.ts +63 -0
- package/dist/sync/heartbeat.js +91 -0
- package/dist/sync/participants.d.ts +27 -27
- package/dist/sync/schemas.d.ts +3 -2
- package/dist/sync/schemas.js +14 -10
- package/dist/sync/syncCursor.d.ts +40 -0
- package/dist/sync/syncCursor.js +55 -0
- package/dist/sync/syncPlan.d.ts +54 -0
- package/dist/sync/syncPlan.js +50 -0
- package/dist/sync/syncPosition.d.ts +54 -49
- package/dist/sync/syncPosition.js +57 -52
- package/dist/sync/wsFrameHandlers.d.ts +116 -0
- package/dist/sync/wsFrameHandlers.js +374 -0
- package/dist/testing/fixtures/bootstrap.d.ts +21 -17
- package/dist/testing/fixtures/bootstrap.js +12 -6
- package/dist/testing/fixtures/deltas.d.ts +31 -34
- package/dist/testing/fixtures/deltas.js +30 -33
- package/dist/testing/fixtures/models.d.ts +11 -10
- package/dist/testing/fixtures/models.js +12 -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 -18
- package/dist/testing/helpers/{sync-engine-harness.js → syncEngineHarness.js} +14 -11
- package/dist/testing/helpers/wait.d.ts +13 -8
- package/dist/testing/helpers/wait.js +13 -8
- package/dist/testing/index.d.ts +4 -4
- package/dist/testing/index.js +3 -3
- 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 +21 -34
- package/dist/testing/mocks/MockSyncContext.js +16 -45
- package/dist/testing/mocks/MockSyncStore.d.ts +14 -14
- package/dist/testing/mocks/MockSyncStore.js +11 -11
- package/dist/testing/mocks/MockWebSocket.d.ts +28 -24
- package/dist/testing/mocks/MockWebSocket.js +22 -21
- package/dist/transactions/TransactionQueue.d.ts +190 -221
- package/dist/transactions/TransactionQueue.js +424 -822
- package/dist/transactions/TransactionStore.d.ts +20 -0
- package/dist/transactions/TransactionStore.js +53 -0
- package/dist/transactions/UnconfirmedWrites.d.ts +82 -0
- package/dist/transactions/UnconfirmedWrites.js +104 -0
- package/dist/transactions/coalesceRules.d.ts +58 -0
- package/dist/transactions/coalesceRules.js +140 -0
- package/dist/transactions/commitPayload.d.ts +130 -0
- package/dist/transactions/commitPayload.js +143 -0
- package/dist/transactions/deltaConfirmation.d.ts +58 -0
- package/dist/transactions/deltaConfirmation.js +215 -0
- package/dist/transactions/optimisticApply.d.ts +49 -0
- package/dist/transactions/optimisticApply.js +65 -0
- package/dist/transactions/replayValidation.d.ts +99 -0
- package/dist/transactions/replayValidation.js +111 -0
- package/dist/types/global.d.ts +46 -41
- package/dist/types/global.js +20 -19
- package/dist/types/index.d.ts +74 -80
- package/dist/types/index.js +22 -27
- package/dist/types/modelData.d.ts +10 -0
- package/dist/types/modelData.js +9 -0
- package/dist/types/participant.d.ts +20 -0
- package/dist/types/participant.js +10 -0
- package/dist/types/streams.d.ts +216 -209
- 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} +44 -100
- 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 +35 -27
- package/dist/wire/errorEnvelope.js +38 -32
- package/dist/wire/frames.d.ts +150 -67
- package/dist/wire/frames.js +48 -1
- package/dist/wire/index.d.ts +18 -13
- package/dist/wire/index.js +36 -13
- package/dist/wire/listEnvelope.d.ts +16 -23
- package/dist/wire/listEnvelope.js +7 -6
- package/dist/wire/protocol.d.ts +38 -0
- package/dist/wire/protocol.js +38 -0
- package/dist/wire/protocolVersion.d.ts +60 -0
- package/dist/wire/protocolVersion.js +67 -0
- package/docs/api-keys.md +4 -3
- package/docs/coordination.md +59 -0
- package/docs/examples/existing-python-backend.md +3 -3
- package/docs/identity.md +4 -4
- package/docs/integration-guide.md +1 -1
- package/docs/react.md +1 -1
- package/docs/sessions.md +5 -7
- package/package.json +24 -21
- package/dist/ai-sdk/coordinated-tool.d.ts +0 -101
- package/dist/ai-sdk/coordination-context.d.ts +0 -52
- package/dist/client/index.d.ts +0 -36
- package/dist/client/index.js +0 -33
- package/dist/config/index.d.ts +0 -10
- package/dist/config/index.js +0 -12
- package/dist/core/query-utils.d.ts +0 -34
- package/dist/core/query-utils.js +0 -59
- package/dist/interfaces/headless.d.ts +0 -95
- package/dist/interfaces/headless.js +0 -41
- package/dist/query/index.d.ts +0 -6
- package/dist/query/index.js +0 -5
- package/dist/realtime/index.d.ts +0 -10
- package/dist/realtime/index.js +0 -9
- package/dist/schema/plane.d.ts +0 -23
- package/dist/schema/plane.js +0 -19
- package/dist/schema/sync-delta-row.js +0 -103
- package/dist/schema/sync-delta-wire.js +0 -102
- package/dist/server/next.d.ts +0 -51
- package/dist/server/next.js +0 -47
- 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 -1
- package/dist/server/storage-mode.js +0 -18
- package/dist/source/connector-protocol.d.ts +0 -159
- package/dist/source/connector-protocol.js +0 -161
- package/dist/sync/OfflineFlush.d.ts +0 -9
- package/dist/sync/OfflineFlush.js +0 -22
- package/dist/sync/OfflineTransactionStore.d.ts +0 -37
- package/dist/sync/OfflineTransactionStore.js +0 -263
- package/dist/transactions/OptimisticEchoTracker.d.ts +0 -82
- package/dist/transactions/OptimisticEchoTracker.js +0 -104
- package/dist/transactions/index.d.ts +0 -16
- package/dist/transactions/index.js +0 -7
- package/dist/transactions/mutation-error-handler.d.ts +0 -5
- package/dist/transactions/mutation-error-handler.js +0 -39
- package/dist/utils/mobx-setup.d.ts +0 -42
|
@@ -1,202 +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';
|
|
15
|
+
import { Model } from '../Model.js';
|
|
11
16
|
import { getContext } from '../context.js';
|
|
12
|
-
import { getActiveRegistry } from '../ModelRegistry.js';
|
|
13
|
-
import { MutationOperationType } from '../types/index.js';
|
|
14
|
-
import { handleMutationError } from './mutation-error-handler.js';
|
|
15
17
|
import { AbloError, AbloConnectionError, errorCodeSpec } from '../errors.js';
|
|
16
18
|
import { SyncPosition } from '../sync/syncPosition.js';
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Project a Model's serialized data onto its schema-declared fields
|
|
25
|
-
* and return a wire-safe commit payload. Two jobs:
|
|
26
|
-
*
|
|
27
|
-
* 1. Drop framework internals (`__class`, `__typename`, `clientId`,
|
|
28
|
-
* `syncStatus`) and anything not declared on the model's schema.
|
|
29
|
-
* 2. JSON.stringify values typed as `field.json()` — TEXT columns
|
|
30
|
-
* storing JSON need explicit stringification; postgres.js won't
|
|
31
|
-
* auto-serialize for non-JSONB columns.
|
|
32
|
-
*
|
|
33
|
-
* For updates (`dropUndefined: true`), `undefined` values are also
|
|
34
|
-
* stripped so they don't translate to `SET column = NULL` on the
|
|
35
|
-
* server side.
|
|
36
|
-
*
|
|
37
|
-
* Fields are read from `ModelRegistry`, populated by
|
|
38
|
-
* `registerModelsFromSchema` at SDK initialization. If the model
|
|
39
|
-
* isn't registered with field metadata (edge case — e.g., tests or
|
|
40
|
-
* manually registered models), projection falls back to identity and
|
|
41
|
-
* the caller gets whatever the Model serialized.
|
|
42
|
-
*/
|
|
43
|
-
function projectCommitPayload(modelName, source, opts) {
|
|
44
|
-
const metadata = getActiveRegistry().getMetadata(modelName);
|
|
45
|
-
const fields = metadata?.fields;
|
|
46
|
-
const out = {};
|
|
47
|
-
if (!fields) {
|
|
48
|
-
// Unknown registration — strip framework keys and ship the rest.
|
|
49
|
-
for (const [k, v] of Object.entries(source)) {
|
|
50
|
-
if (FRAMEWORK_KEYS.has(k))
|
|
51
|
-
continue;
|
|
52
|
-
if (opts.dropUndefined && v === undefined)
|
|
53
|
-
continue;
|
|
54
|
-
out[k] = v;
|
|
55
|
-
}
|
|
56
|
-
return out;
|
|
57
|
-
}
|
|
58
|
-
for (const [key, meta] of Object.entries(fields)) {
|
|
59
|
-
if (!(key in source))
|
|
60
|
-
continue;
|
|
61
|
-
const value = source[key];
|
|
62
|
-
if (opts.dropUndefined && value === undefined)
|
|
63
|
-
continue;
|
|
64
|
-
// JSON-typed fields (`jsonb` on the server): ship as OBJECTS over
|
|
65
|
-
// the wire, not pre-stringified strings. Previously we stringified
|
|
66
|
-
// here, which round-tripped incorrectly:
|
|
67
|
-
//
|
|
68
|
-
// 1. Client stringifies `position: {x, y}` → `'{"x":...}'`
|
|
69
|
-
// 2. Server writes to jsonb column (parses string → jsonb object, fine)
|
|
70
|
-
// 3. Server's delta echoes `data: JSON.stringify(op.input)` where
|
|
71
|
-
// `op.input.position` is still the STRING from step 1
|
|
72
|
-
// 4. Client merges delta → `model.position = "{...}"` (STRING)
|
|
73
|
-
// 5. Next drag: `{ ...layer.position, x, y }` spreads the STRING
|
|
74
|
-
// char-by-char, producing corrupted char-indexed objects like
|
|
75
|
-
// `{"0":"{","1":"\"","2":"x",...,"x":null,"y":null,...}`
|
|
76
|
-
// 6. That corrupt object lands in the next commit, stored in jsonb.
|
|
77
|
-
//
|
|
78
|
-
// Sending objects avoids the round-trip mismatch: the wire carries
|
|
79
|
-
// the object through delta + commit unchanged, and `postgres-js`
|
|
80
|
-
// serializes JS objects to jsonb correctly via its own
|
|
81
|
-
// `json.serialize` (triggered by Postgres's ParameterDescription
|
|
82
|
-
// response identifying the column as type 3802 / jsonb).
|
|
83
|
-
out[key] = value;
|
|
84
|
-
}
|
|
85
|
-
return out;
|
|
86
|
-
}
|
|
87
|
-
const normalizeModelKey = (modelName) => modelName.replace('Model', '').toLowerCase();
|
|
88
|
-
const stripModelSuffix = (modelName) => modelName.replace('Model', '');
|
|
89
|
-
/**
|
|
90
|
-
* FK-ordered create priority.
|
|
91
|
-
*
|
|
92
|
-
* Reads `config.modelCreatePriority` out of the runtime SyncEngineContext —
|
|
93
|
-
* this map is populated once at `createSyncEngine(...)` time by walking the
|
|
94
|
-
* schema's `belongsTo` graph (see `computeFKDepthPriority` in
|
|
95
|
-
* `client/createSyncEngine.ts`). The queue stays schema-agnostic: no model
|
|
96
|
-
* names appear here, and consumer applications can override specific
|
|
97
|
-
* priorities via `configOverrides.modelCreatePriority` without touching the
|
|
98
|
-
* SDK.
|
|
99
|
-
*
|
|
100
|
-
* Non-create ops (update/delete/archive/unarchive) don't need FK ordering
|
|
101
|
-
* because the row already exists, so they all share
|
|
102
|
-
* `config.defaultNonCreatePriority`.
|
|
103
|
-
*/
|
|
104
|
-
const computePriorityScore = (type, modelName) => {
|
|
105
|
-
const { modelCreatePriority, defaultCreatePriority, defaultNonCreatePriority } = getContext().config;
|
|
106
|
-
if (type !== 'create')
|
|
107
|
-
return defaultNonCreatePriority;
|
|
108
|
-
return modelCreatePriority.get(modelName) ?? defaultCreatePriority;
|
|
109
|
-
};
|
|
110
|
-
const TX_TYPE_TO_MUTATION_OP = {
|
|
111
|
-
create: MutationOperationType.CREATE,
|
|
112
|
-
update: MutationOperationType.UPDATE,
|
|
113
|
-
delete: MutationOperationType.DELETE,
|
|
114
|
-
archive: MutationOperationType.ARCHIVE,
|
|
115
|
-
unarchive: MutationOperationType.UNARCHIVE,
|
|
116
|
-
};
|
|
117
|
-
function hasStaleWriteOptions(options) {
|
|
118
|
-
return (options?.readAt !== undefined ||
|
|
119
|
-
options?.onStale !== undefined);
|
|
120
|
-
}
|
|
121
|
-
/**
|
|
122
|
-
* Project a transaction's `writeOptions` onto the wire operation. Stale
|
|
123
|
-
* guards (`readAt`/`onStale`) ride at the op root; `idempotencyKey`/`label`
|
|
124
|
-
* ride in the op's `options` slot (`MutationOperation.options` — the
|
|
125
|
-
* mutation_log cache key + audit tag). This is the single place the
|
|
126
|
-
* caller-supplied write vocabulary crosses onto the wire.
|
|
127
|
-
*/
|
|
128
|
-
function applyWriteOptions(op, transaction) {
|
|
129
|
-
const operation = op;
|
|
130
|
-
const writeOptions = transaction.writeOptions;
|
|
131
|
-
if (!writeOptions)
|
|
132
|
-
return operation;
|
|
133
|
-
if (writeOptions.readAt !== undefined) {
|
|
134
|
-
operation.readAt = writeOptions.readAt;
|
|
135
|
-
}
|
|
136
|
-
if (writeOptions.onStale !== undefined) {
|
|
137
|
-
operation.onStale = writeOptions.onStale;
|
|
138
|
-
}
|
|
139
|
-
if (writeOptions.idempotencyKey != null || writeOptions.label !== undefined) {
|
|
140
|
-
operation.options = {
|
|
141
|
-
...(writeOptions.idempotencyKey != null
|
|
142
|
-
? { idempotencyKey: writeOptions.idempotencyKey }
|
|
143
|
-
: {}),
|
|
144
|
-
...(writeOptions.label !== undefined ? { label: writeOptions.label } : {}),
|
|
145
|
-
};
|
|
146
|
-
}
|
|
147
|
-
return operation;
|
|
148
|
-
}
|
|
149
|
-
function asTransportError(value) {
|
|
150
|
-
return (value && typeof value === 'object' ? value : {});
|
|
151
|
-
}
|
|
152
|
-
function extractStatusCode(error) {
|
|
153
|
-
return asTransportError(error).response?.status;
|
|
154
|
-
}
|
|
155
|
-
class TransactionStore {
|
|
156
|
-
transactions = new Map();
|
|
157
|
-
byStatus = new Map();
|
|
158
|
-
add(transaction) {
|
|
159
|
-
this.transactions.set(transaction.id, transaction);
|
|
160
|
-
if (!this.byStatus.has(transaction.status)) {
|
|
161
|
-
this.byStatus.set(transaction.status, new Set());
|
|
162
|
-
}
|
|
163
|
-
this.byStatus.get(transaction.status).add(transaction.id);
|
|
164
|
-
}
|
|
165
|
-
get(id) {
|
|
166
|
-
return this.transactions.get(id);
|
|
167
|
-
}
|
|
168
|
-
updateStatus(id, newStatus) {
|
|
169
|
-
const tx = this.transactions.get(id);
|
|
170
|
-
if (!tx)
|
|
171
|
-
return;
|
|
172
|
-
this.byStatus.get(tx.status)?.delete(id);
|
|
173
|
-
tx.status = newStatus;
|
|
174
|
-
if (!this.byStatus.has(newStatus)) {
|
|
175
|
-
this.byStatus.set(newStatus, new Set());
|
|
176
|
-
}
|
|
177
|
-
this.byStatus.get(newStatus).add(id);
|
|
178
|
-
}
|
|
179
|
-
getByStatus(status) {
|
|
180
|
-
const ids = this.byStatus.get(status) || new Set();
|
|
181
|
-
return Array.from(ids)
|
|
182
|
-
.map((id) => this.transactions.get(id))
|
|
183
|
-
.filter(Boolean);
|
|
184
|
-
}
|
|
185
|
-
remove(id) {
|
|
186
|
-
const tx = this.transactions.get(id);
|
|
187
|
-
if (!tx)
|
|
188
|
-
return;
|
|
189
|
-
this.transactions.delete(id);
|
|
190
|
-
this.byStatus.get(tx.status)?.delete(id);
|
|
191
|
-
}
|
|
192
|
-
clear() {
|
|
193
|
-
this.transactions.clear();
|
|
194
|
-
this.byStatus.clear();
|
|
195
|
-
}
|
|
196
|
-
getAll() {
|
|
197
|
-
return Array.from(this.transactions.values());
|
|
198
|
-
}
|
|
199
|
-
}
|
|
19
|
+
import { projectCommitPayload, computePriorityScore, normalizeModelKey, hasStaleWriteOptions, applyWriteOptions, asTransportError, extractStatusCode, TX_TYPE_TO_MUTATION_OP, } from './commitPayload.js';
|
|
20
|
+
import { TransactionStore } from './TransactionStore.js';
|
|
21
|
+
import { entityKey, mergeUpdateData, takeUnsentCreateForModel, findCreateBarrierForDelete, deferDeleteUntilCreateSettles, releaseDeferredDeletesForCreate, } from './coalesceRules.js';
|
|
22
|
+
import { DeltaConfirmationTracker } from './deltaConfirmation.js';
|
|
23
|
+
import { deserializePersistedTransaction, isNonReplayablePersistedRow, } from './replayValidation.js';
|
|
24
|
+
import { applyOptimisticCreate, applyOptimisticUpdate, applyOptimisticDelete, rollbackOptimistic, } from './optimisticApply.js';
|
|
200
25
|
export class TransactionQueue extends EventEmitter {
|
|
201
26
|
store = new TransactionStore();
|
|
202
27
|
// Signature of the last permanent-error we logged at `warn`. A `create`
|
|
@@ -205,17 +30,17 @@ export class TransactionQueue extends EventEmitter {
|
|
|
205
30
|
// this, the identical cause prints on a loop. We log the first occurrence
|
|
206
31
|
// and demote exact repeats to `debug`.
|
|
207
32
|
lastPermanentErrorSig;
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
//
|
|
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()`.
|
|
211
36
|
//
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
//
|
|
218
|
-
//
|
|
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.
|
|
219
44
|
_mutationExecutor = null;
|
|
220
45
|
get mutationExecutor() {
|
|
221
46
|
return this._mutationExecutor ?? getContext().mutationExecutor;
|
|
@@ -224,8 +49,8 @@ export class TransactionQueue extends EventEmitter {
|
|
|
224
49
|
isProcessing = false;
|
|
225
50
|
processTimer;
|
|
226
51
|
processScheduled = false;
|
|
227
|
-
//
|
|
228
|
-
//
|
|
52
|
+
// Staging area for transactions created in the same event-loop tick. Each one
|
|
53
|
+
// lands here first, then a microtask commits them together.
|
|
229
54
|
createdTransactions = [];
|
|
230
55
|
commitScheduled = false;
|
|
231
56
|
// Per-model in-flight tracking and merge buffer
|
|
@@ -250,10 +75,7 @@ export class TransactionQueue extends EventEmitter {
|
|
|
250
75
|
}
|
|
251
76
|
}
|
|
252
77
|
entityKey(modelName, modelId) {
|
|
253
|
-
return
|
|
254
|
-
}
|
|
255
|
-
isTransactionForModel(transaction, modelName, modelId) {
|
|
256
|
-
return transaction.modelName === modelName && transaction.modelId === modelId;
|
|
78
|
+
return entityKey(modelName, modelId);
|
|
257
79
|
}
|
|
258
80
|
resolveConfirmation(transaction) {
|
|
259
81
|
const resolver = this.confirmationResolvers.get(transaction.id);
|
|
@@ -263,19 +85,7 @@ export class TransactionQueue extends EventEmitter {
|
|
|
263
85
|
resolver.resolve();
|
|
264
86
|
}
|
|
265
87
|
takeUnsentCreateForModel(modelName, modelId) {
|
|
266
|
-
|
|
267
|
-
tx.status === 'pending' &&
|
|
268
|
-
tx.attempts === 0 &&
|
|
269
|
-
this.isTransactionForModel(tx, modelName, modelId);
|
|
270
|
-
const stagedIndex = this.createdTransactions.findIndex(isUnsentCreate);
|
|
271
|
-
if (stagedIndex >= 0) {
|
|
272
|
-
return this.createdTransactions.splice(stagedIndex, 1)[0];
|
|
273
|
-
}
|
|
274
|
-
const queuedIndex = this.executionQueue.findIndex(isUnsentCreate);
|
|
275
|
-
if (queuedIndex >= 0) {
|
|
276
|
-
return this.executionQueue.splice(queuedIndex, 1)[0];
|
|
277
|
-
}
|
|
278
|
-
return this.store.getByStatus('pending').find(isUnsentCreate);
|
|
88
|
+
return takeUnsentCreateForModel(this.createdTransactions, this.executionQueue, this.store, modelName, modelId);
|
|
279
89
|
}
|
|
280
90
|
async cancelUnsentCreateForDelete(transaction) {
|
|
281
91
|
this.store.updateStatus(transaction.id, 'rolled_back');
|
|
@@ -285,17 +95,7 @@ export class TransactionQueue extends EventEmitter {
|
|
|
285
95
|
this.resolveConfirmation(transaction);
|
|
286
96
|
}
|
|
287
97
|
findCreateBarrierForDelete(modelName, modelId) {
|
|
288
|
-
|
|
289
|
-
...this.store.getByStatus('pending'),
|
|
290
|
-
...this.store.getByStatus('executing'),
|
|
291
|
-
...this.store.getByStatus('awaiting_delta'),
|
|
292
|
-
].filter((tx) => tx.type === 'create' &&
|
|
293
|
-
this.isTransactionForModel(tx, modelName, modelId) &&
|
|
294
|
-
// A never-attempted pending create can be cancelled instead. Once the
|
|
295
|
-
// create has been sent, even a retry-pending state is a causal barrier:
|
|
296
|
-
// the server may already have applied it and only the response was lost.
|
|
297
|
-
(tx.status !== 'pending' || tx.attempts > 0));
|
|
298
|
-
return liveCreates.sort((a, b) => b.createdAt - a.createdAt)[0];
|
|
98
|
+
return findCreateBarrierForDelete(this.store, modelName, modelId);
|
|
299
99
|
}
|
|
300
100
|
completeLocalDelete(model, context, writeOptions) {
|
|
301
101
|
const actualModelName = model.getModelName();
|
|
@@ -328,75 +128,17 @@ export class TransactionQueue extends EventEmitter {
|
|
|
328
128
|
return transaction;
|
|
329
129
|
}
|
|
330
130
|
deferDeleteUntilCreateSettles(createTransaction, deleteTransaction) {
|
|
331
|
-
|
|
332
|
-
const deferred = this.deferredDeletesByCreate.get(key) ?? [];
|
|
333
|
-
deferred.push(deleteTransaction);
|
|
334
|
-
this.deferredDeletesByCreate.set(key, deferred);
|
|
131
|
+
deferDeleteUntilCreateSettles(this.deferredDeletesByCreate, createTransaction, deleteTransaction);
|
|
335
132
|
}
|
|
336
133
|
releaseDeferredDeletesForCreate(createTransaction) {
|
|
337
|
-
|
|
338
|
-
const deferred = this.deferredDeletesByCreate.get(key);
|
|
339
|
-
if (!deferred || deferred.length === 0)
|
|
340
|
-
return;
|
|
341
|
-
this.deferredDeletesByCreate.delete(key);
|
|
342
|
-
for (const deleteTransaction of deferred) {
|
|
343
|
-
if (this.store.get(deleteTransaction.id)?.status !== 'pending')
|
|
344
|
-
continue;
|
|
345
|
-
this.enqueue(deleteTransaction);
|
|
346
|
-
}
|
|
134
|
+
releaseDeferredDeletesForCreate(this.deferredDeletesByCreate, this.store, (tx) => { this.enqueue(tx); }, createTransaction);
|
|
347
135
|
}
|
|
348
|
-
//
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
const src = right || {};
|
|
352
|
-
for (const key of Object.keys(src)) {
|
|
353
|
-
// Special case: metadata payloads may be JSON strings; merge objects instead of clobbering
|
|
354
|
-
if (key === 'metadata') {
|
|
355
|
-
const l = out.metadata;
|
|
356
|
-
const r = src.metadata;
|
|
357
|
-
// If both sides undefined/null, continue
|
|
358
|
-
if (l == null && r == null) {
|
|
359
|
-
continue;
|
|
360
|
-
}
|
|
361
|
-
// Normalize to objects
|
|
362
|
-
const toObj = (v) => {
|
|
363
|
-
if (v == null)
|
|
364
|
-
return {};
|
|
365
|
-
if (typeof v === 'string') {
|
|
366
|
-
try {
|
|
367
|
-
return JSON.parse(v);
|
|
368
|
-
}
|
|
369
|
-
catch {
|
|
370
|
-
return {};
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
if (typeof v === 'object')
|
|
374
|
-
return v;
|
|
375
|
-
return {};
|
|
376
|
-
};
|
|
377
|
-
const lobj = toObj(l);
|
|
378
|
-
const robj = toObj(r);
|
|
379
|
-
const merged = { ...lobj, ...robj };
|
|
380
|
-
// Re-stringify to match schema input type
|
|
381
|
-
try {
|
|
382
|
-
out.metadata = JSON.stringify(merged);
|
|
383
|
-
}
|
|
384
|
-
catch {
|
|
385
|
-
// Fallback to right-hand side if stringify fails
|
|
386
|
-
out.metadata = typeof r === 'string' ? r : JSON.stringify(robj || {});
|
|
387
|
-
}
|
|
388
|
-
continue;
|
|
389
|
-
}
|
|
390
|
-
// Default: shallow overwrite with right-hand value
|
|
391
|
-
out[key] = src[key];
|
|
392
|
-
}
|
|
393
|
-
return out;
|
|
394
|
-
}
|
|
395
|
-
// Configuration - tuned for LINEAR-style batching
|
|
396
|
-
// Higher batch size and delay allows more operations to coalesce into single HTTP call
|
|
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.
|
|
397
139
|
config = {
|
|
398
|
-
maxBatchSize: 50, //
|
|
399
|
-
batchDelay: 150, //
|
|
140
|
+
maxBatchSize: 50, // send up to this many operations per commit
|
|
141
|
+
batchDelay: 150, // milliseconds to wait for more operations before sending
|
|
400
142
|
maxRetries: 3,
|
|
401
143
|
conflictResolution: {
|
|
402
144
|
strategy: 'last-write-wins',
|
|
@@ -413,18 +155,21 @@ export class TransactionQueue extends EventEmitter {
|
|
|
413
155
|
};
|
|
414
156
|
// Track executing transactions for backpressure
|
|
415
157
|
executingCount = 0;
|
|
416
|
-
// Optimistic update tracking
|
|
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
|
|
160
|
+
// completion paths, `getStats`, and `dispose` all read it.
|
|
417
161
|
optimisticUpdates = new Map();
|
|
418
|
-
// Stale-context notifications
|
|
419
|
-
//
|
|
420
|
-
//
|
|
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.
|
|
421
166
|
commitNotifications = new Map();
|
|
422
|
-
//
|
|
423
|
-
//
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
//
|
|
167
|
+
// Delta-confirmation tracking (ack watermark advance + the awaiting_delta
|
|
168
|
+
// timeout/retry maps) lives in `./deltaConfirmation.js`. Constructed in the
|
|
169
|
+
// constructor once `position` is bound.
|
|
170
|
+
deltaConfirmation;
|
|
171
|
+
// Connection-state check, supplied by the client, used to hold off rollbacks
|
|
172
|
+
// while disconnected.
|
|
428
173
|
isConnectedFn = () => true;
|
|
429
174
|
// Grace timer that, when fired, fails any commit-lane transaction
|
|
430
175
|
// still awaiting an ack. Started on `setConnectionState('disconnected')`,
|
|
@@ -432,11 +177,11 @@ export class TransactionQueue extends EventEmitter {
|
|
|
432
177
|
// is preserved for brief blips; this only catches persistent disconnects.
|
|
433
178
|
commitOfflineGraceTimer = null;
|
|
434
179
|
/**
|
|
435
|
-
*
|
|
436
|
-
*
|
|
437
|
-
* queue advances `acked`
|
|
438
|
-
* `applied
|
|
439
|
-
*
|
|
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.
|
|
440
185
|
*/
|
|
441
186
|
position;
|
|
442
187
|
/** Applied-cursor alias, kept so the many internal read sites stay legible. */
|
|
@@ -444,15 +189,8 @@ export class TransactionQueue extends EventEmitter {
|
|
|
444
189
|
return this.position.applied;
|
|
445
190
|
}
|
|
446
191
|
noteAck(lastSyncId) {
|
|
447
|
-
this.
|
|
448
|
-
}
|
|
449
|
-
// Delta confirmation retry config (Replicache-style exponential backoff)
|
|
450
|
-
// Max retries before requesting full reconciliation
|
|
451
|
-
static DELTA_MAX_RETRIES = 5;
|
|
452
|
-
// Initial timeout (first attempt)
|
|
453
|
-
static DELTA_INITIAL_TIMEOUT_MS = 30_000;
|
|
454
|
-
// Max timeout cap (like Replicache's maxDelayMs of 60s)
|
|
455
|
-
static DELTA_MAX_TIMEOUT_MS = 120_000;
|
|
192
|
+
this.deltaConfirmation.noteAck(lastSyncId);
|
|
193
|
+
}
|
|
456
194
|
// Batch management
|
|
457
195
|
batchIndex = 0;
|
|
458
196
|
/**
|
|
@@ -466,6 +204,18 @@ export class TransactionQueue extends EventEmitter {
|
|
|
466
204
|
constructor(config) {
|
|
467
205
|
super();
|
|
468
206
|
this.position = config?.position ?? new SyncPosition();
|
|
207
|
+
// Bind the confirmation tracker to this queue's store/ledger/events.
|
|
208
|
+
// `isConnected` closes over `isConnectedFn` so `setConnectionChecker`
|
|
209
|
+
// swaps stay visible to in-flight timeouts.
|
|
210
|
+
this.deltaConfirmation = new DeltaConfirmationTracker({
|
|
211
|
+
store: this.store,
|
|
212
|
+
optimisticUpdates: this.optimisticUpdates,
|
|
213
|
+
emit: (event, payload) => {
|
|
214
|
+
this.emit(event, payload);
|
|
215
|
+
},
|
|
216
|
+
isConnected: () => this.isConnectedFn(),
|
|
217
|
+
position: this.position,
|
|
218
|
+
});
|
|
469
219
|
if (config) {
|
|
470
220
|
this.config = { ...this.config, ...config };
|
|
471
221
|
}
|
|
@@ -500,22 +250,18 @@ export class TransactionQueue extends EventEmitter {
|
|
|
500
250
|
});
|
|
501
251
|
}
|
|
502
252
|
/**
|
|
503
|
-
*
|
|
504
|
-
*
|
|
505
|
-
*
|
|
506
|
-
*
|
|
507
|
-
* outcomes for the routing-helper grace-window use case).
|
|
508
|
-
*
|
|
509
|
-
* Looks across `pending`, `executing`, and `awaiting_delta` — these
|
|
510
|
-
* are the three non-terminal statuses where rollback is still
|
|
511
|
-
* possible. Skips `completed` (already settled) and `failed` /
|
|
512
|
-
* `rolled_back` (already rejected; the call site missed the
|
|
513
|
-
* `confirmation` window and should rely on `onMutationFailure` toast
|
|
514
|
-
* instead).
|
|
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".
|
|
515
257
|
*
|
|
516
|
-
*
|
|
517
|
-
*
|
|
518
|
-
* `
|
|
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.
|
|
519
265
|
*/
|
|
520
266
|
confirmationFor(modelName, modelId) {
|
|
521
267
|
const candidates = [
|
|
@@ -526,18 +272,20 @@ export class TransactionQueue extends EventEmitter {
|
|
|
526
272
|
if (candidates.length === 0)
|
|
527
273
|
return Promise.resolve();
|
|
528
274
|
const latest = candidates.sort((a, b) => b.createdAt - a.createdAt)[0];
|
|
275
|
+
if (!latest)
|
|
276
|
+
return Promise.resolve();
|
|
529
277
|
return latest.confirmation ?? Promise.resolve();
|
|
530
278
|
}
|
|
531
279
|
/**
|
|
532
|
-
*
|
|
533
|
-
*
|
|
534
|
-
*
|
|
535
|
-
*
|
|
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.
|
|
536
284
|
*
|
|
537
|
-
*
|
|
538
|
-
*
|
|
539
|
-
* rejection.
|
|
540
|
-
* `.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`.
|
|
541
289
|
*/
|
|
542
290
|
attachConfirmation(tx) {
|
|
543
291
|
if (tx.confirmation)
|
|
@@ -546,34 +294,30 @@ export class TransactionQueue extends EventEmitter {
|
|
|
546
294
|
this.confirmationResolvers.set(tx.id, { resolve, reject });
|
|
547
295
|
});
|
|
548
296
|
tx.confirmation.catch(() => {
|
|
549
|
-
// Swallow unhandled rejections
|
|
550
|
-
// handler; silent failure is the leak we're already fixing elsewhere.
|
|
297
|
+
// Swallow unhandled rejections; callers that care attach their own handler.
|
|
551
298
|
});
|
|
552
299
|
}
|
|
553
300
|
/**
|
|
554
|
-
*
|
|
555
|
-
*
|
|
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.
|
|
556
305
|
*/
|
|
557
306
|
setConnectionChecker(fn) {
|
|
558
307
|
this.isConnectedFn = fn;
|
|
559
308
|
}
|
|
560
309
|
/**
|
|
561
|
-
*
|
|
562
|
-
*
|
|
563
|
-
* On `'disconnected'`: start a one-shot timer of
|
|
564
|
-
* `config.commitOfflineGraceMs`. If the timer fires (disconnect
|
|
565
|
-
* persisted past grace), iterate every commit-lane transaction with
|
|
566
|
-
* `status ∈ {'pending', 'executing'}` and emit
|
|
567
|
-
* `transaction:failed:${id}` with an `AbloConnectionError`. That
|
|
568
|
-
* lets `waitForCommitReceipt` reject in seconds instead of hanging
|
|
569
|
-
* forever — which is what wedged the 2026-05-15 subagent run.
|
|
310
|
+
* Drives the offline-grace timer for in-flight commit-lane transactions.
|
|
570
311
|
*
|
|
571
|
-
* On `'
|
|
572
|
-
*
|
|
573
|
-
*
|
|
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.
|
|
574
317
|
*
|
|
575
|
-
*
|
|
576
|
-
*
|
|
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.
|
|
577
321
|
*/
|
|
578
322
|
setConnectionState(state) {
|
|
579
323
|
if (state === 'connected') {
|
|
@@ -601,8 +345,8 @@ export class TransactionQueue extends EventEmitter {
|
|
|
601
345
|
}
|
|
602
346
|
if (inFlight.length === 0)
|
|
603
347
|
return;
|
|
604
|
-
//
|
|
605
|
-
//
|
|
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.
|
|
606
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)) });
|
|
607
351
|
for (const id of inFlight) {
|
|
608
352
|
const tx = this.commitStore.get(id);
|
|
@@ -615,39 +359,41 @@ export class TransactionQueue extends EventEmitter {
|
|
|
615
359
|
}
|
|
616
360
|
}
|
|
617
361
|
/**
|
|
618
|
-
*
|
|
619
|
-
* right after
|
|
620
|
-
*
|
|
621
|
-
*
|
|
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.
|
|
622
366
|
*/
|
|
623
367
|
setMutationExecutor(executor) {
|
|
624
368
|
this._mutationExecutor = executor;
|
|
625
369
|
}
|
|
626
370
|
// ============================================================================
|
|
627
|
-
//
|
|
371
|
+
// Microtask-based transaction staging
|
|
628
372
|
// ============================================================================
|
|
629
373
|
//
|
|
630
|
-
//
|
|
631
|
-
// A microtask commits them
|
|
632
|
-
//
|
|
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.
|
|
633
377
|
//
|
|
634
378
|
// Flow:
|
|
635
|
-
// 1. create()/update()/delete() calls stageTransaction()
|
|
636
|
-
// 2. stageTransaction() adds to createdTransactions and schedules microtask
|
|
637
|
-
// 3.
|
|
638
|
-
//
|
|
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.
|
|
639
385
|
// ============================================================================
|
|
640
386
|
/**
|
|
641
|
-
*
|
|
642
|
-
*
|
|
387
|
+
* Stages a transaction for commit. Transactions staged within the same
|
|
388
|
+
* event-loop tick are committed together.
|
|
643
389
|
*/
|
|
644
390
|
stageTransaction(transaction) {
|
|
645
391
|
this.createdTransactions.push(transaction);
|
|
646
392
|
this.scheduleCommit();
|
|
647
393
|
}
|
|
648
394
|
/**
|
|
649
|
-
*
|
|
650
|
-
*
|
|
395
|
+
* Schedules the staged transactions to commit on a microtask, so all
|
|
396
|
+
* transactions created synchronously within one tick are batched together.
|
|
651
397
|
*/
|
|
652
398
|
scheduleCommit() {
|
|
653
399
|
if (this.commitScheduled)
|
|
@@ -663,8 +409,8 @@ export class TransactionQueue extends EventEmitter {
|
|
|
663
409
|
});
|
|
664
410
|
}
|
|
665
411
|
/**
|
|
666
|
-
*
|
|
667
|
-
*
|
|
412
|
+
* Moves all staged transactions onto the execution queue, assigning them a
|
|
413
|
+
* single shared batch index so they commit together.
|
|
668
414
|
*/
|
|
669
415
|
commitCreatedTransactions() {
|
|
670
416
|
this.commitScheduled = false;
|
|
@@ -688,11 +434,15 @@ export class TransactionQueue extends EventEmitter {
|
|
|
688
434
|
this.enqueue(transaction);
|
|
689
435
|
}
|
|
690
436
|
}
|
|
691
|
-
|
|
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
|
+
*/
|
|
692
442
|
async flushOfflineQueue() {
|
|
693
|
-
// Kick the commit lane too
|
|
694
|
-
//
|
|
695
|
-
//
|
|
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.
|
|
696
446
|
void this.processCommitLane();
|
|
697
447
|
// Collect pending transactions in created order
|
|
698
448
|
const pending = this.store.getByStatus('pending').sort((a, b) => a.createdAt - b.createdAt);
|
|
@@ -743,7 +493,9 @@ export class TransactionQueue extends EventEmitter {
|
|
|
743
493
|
}
|
|
744
494
|
}
|
|
745
495
|
/**
|
|
746
|
-
*
|
|
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.
|
|
747
499
|
*/
|
|
748
500
|
async create(model, context, writeOptions) {
|
|
749
501
|
const actualModelName = model.getModelName();
|
|
@@ -755,8 +507,8 @@ export class TransactionQueue extends EventEmitter {
|
|
|
755
507
|
modelKey: normalizeModelKey(actualModelName),
|
|
756
508
|
priorityScore: this.computePriorityScore('create', actualModelName),
|
|
757
509
|
data: this.extractCreateData(model),
|
|
758
|
-
//
|
|
759
|
-
// restore
|
|
510
|
+
// Rolling back a create removes the row, so there is no prior state to
|
|
511
|
+
// restore and no snapshot is captured here.
|
|
760
512
|
previousData: null,
|
|
761
513
|
context,
|
|
762
514
|
status: 'pending',
|
|
@@ -770,16 +522,18 @@ export class TransactionQueue extends EventEmitter {
|
|
|
770
522
|
if (this.config.enableOptimistic) {
|
|
771
523
|
this.applyOptimisticCreate(model, transaction);
|
|
772
524
|
}
|
|
773
|
-
//
|
|
774
|
-
//
|
|
775
|
-
// `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.
|
|
776
528
|
this.stageTransaction(transaction);
|
|
777
529
|
this.emit('transaction:created', transaction);
|
|
778
530
|
return transaction;
|
|
779
531
|
}
|
|
780
532
|
/**
|
|
781
|
-
*
|
|
782
|
-
*
|
|
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.
|
|
783
537
|
*/
|
|
784
538
|
async update(model, context, precomputedChanges, writeOptions) {
|
|
785
539
|
const actualModelName = model.getModelName();
|
|
@@ -788,12 +542,11 @@ export class TransactionQueue extends EventEmitter {
|
|
|
788
542
|
? this.mapChangesToInput(actualModelName, precomputedChanges)
|
|
789
543
|
: this.extractUpdateData(model);
|
|
790
544
|
const previousData = this.extractPreviousData(model, updateInput);
|
|
791
|
-
// Advance the per-field baseline for the keys
|
|
792
|
-
// transaction.
|
|
793
|
-
// sync
|
|
794
|
-
// first
|
|
795
|
-
// instead of
|
|
796
|
-
// 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
|
|
797
550
|
// wire payload is already frozen in `transaction.data`, so dropping the
|
|
798
551
|
// consumed entries is safe.
|
|
799
552
|
model.consumeModifiedFields(Object.keys(updateInput));
|
|
@@ -821,18 +574,22 @@ export class TransactionQueue extends EventEmitter {
|
|
|
821
574
|
if (this.config.enableOptimistic) {
|
|
822
575
|
this.applyOptimisticUpdate(model, transaction);
|
|
823
576
|
}
|
|
824
|
-
//
|
|
825
|
-
//
|
|
826
|
-
//
|
|
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.
|
|
827
580
|
this.stageTransaction(transaction);
|
|
828
581
|
this.emit('transaction:created', transaction);
|
|
829
582
|
return transaction;
|
|
830
583
|
}
|
|
831
584
|
/**
|
|
832
|
-
*
|
|
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.
|
|
833
589
|
*/
|
|
834
590
|
async delete(model, context, writeOptions) {
|
|
835
|
-
//
|
|
591
|
+
// Use getModelName() rather than constructor.name, which is unreliable once
|
|
592
|
+
// class names are minified.
|
|
836
593
|
const actualModelName = model.getModelName();
|
|
837
594
|
// Skip Activity delete transactions - activities are permanent audit records
|
|
838
595
|
if (actualModelName === 'Activity') {
|
|
@@ -905,30 +662,32 @@ export class TransactionQueue extends EventEmitter {
|
|
|
905
662
|
this.deferDeleteUntilCreateSettles(createBarrier, transaction);
|
|
906
663
|
}
|
|
907
664
|
else {
|
|
908
|
-
//
|
|
909
|
-
//
|
|
665
|
+
// Stage the transaction for the microtask commit; deletes in the same
|
|
666
|
+
// tick are batched together.
|
|
910
667
|
this.stageTransaction(transaction);
|
|
911
668
|
}
|
|
912
669
|
this.emit('transaction:created', transaction);
|
|
913
670
|
return transaction;
|
|
914
671
|
}
|
|
915
672
|
/**
|
|
916
|
-
*
|
|
673
|
+
* Uploads a single attachment, delegating to the mutation executor.
|
|
917
674
|
*/
|
|
918
675
|
async uploadAttachment(_file, options, _context) {
|
|
919
676
|
return this.mutationExecutor.uploadAttachment?.(options.id, options) ?? null;
|
|
920
677
|
}
|
|
921
678
|
/**
|
|
922
|
-
*
|
|
679
|
+
* Uploads several attachments in one call, delegating to the mutation executor.
|
|
923
680
|
*/
|
|
924
681
|
async batchUploadAttachments(_files, items, _context) {
|
|
925
682
|
return this.mutationExecutor.batchUploadAttachments?.(items.map(i => ({ id: i.id, input: i }))) ?? [];
|
|
926
683
|
}
|
|
927
684
|
/**
|
|
928
|
-
*
|
|
685
|
+
* Records an archive and applies it optimistically, then stages it for the
|
|
686
|
+
* next batched commit.
|
|
929
687
|
*/
|
|
930
688
|
async archive(model, context, writeOptions) {
|
|
931
|
-
//
|
|
689
|
+
// Use getModelName() rather than constructor.name, which is unreliable once
|
|
690
|
+
// class names are minified.
|
|
932
691
|
const actualModelName = model.getModelName();
|
|
933
692
|
const modelKey = normalizeModelKey(actualModelName);
|
|
934
693
|
const priorityScore = this.computePriorityScore('archive', actualModelName);
|
|
@@ -949,16 +708,18 @@ export class TransactionQueue extends EventEmitter {
|
|
|
949
708
|
};
|
|
950
709
|
this.attachConfirmation(transaction);
|
|
951
710
|
this.store.add(transaction);
|
|
952
|
-
//
|
|
711
|
+
// Stage the transaction for the microtask commit.
|
|
953
712
|
this.stageTransaction(transaction);
|
|
954
713
|
this.emit('transaction:created', transaction);
|
|
955
714
|
return transaction;
|
|
956
715
|
}
|
|
957
716
|
/**
|
|
958
|
-
*
|
|
717
|
+
* Records an unarchive and applies it optimistically, then stages it for the
|
|
718
|
+
* next batched commit.
|
|
959
719
|
*/
|
|
960
720
|
async unarchive(model, context) {
|
|
961
|
-
//
|
|
721
|
+
// Use getModelName() rather than constructor.name, which is unreliable once
|
|
722
|
+
// class names are minified.
|
|
962
723
|
const actualModelName = model.getModelName();
|
|
963
724
|
const modelKey = normalizeModelKey(actualModelName);
|
|
964
725
|
const priorityScore = this.computePriorityScore('unarchive', actualModelName);
|
|
@@ -978,36 +739,35 @@ export class TransactionQueue extends EventEmitter {
|
|
|
978
739
|
};
|
|
979
740
|
this.attachConfirmation(transaction);
|
|
980
741
|
this.store.add(transaction);
|
|
981
|
-
//
|
|
742
|
+
// Stage the transaction for the microtask commit.
|
|
982
743
|
this.stageTransaction(transaction);
|
|
983
744
|
this.emit('transaction:created', transaction);
|
|
984
745
|
return transaction;
|
|
985
746
|
}
|
|
986
747
|
/**
|
|
987
|
-
*
|
|
748
|
+
* Places a transaction on the execution queue, coalescing it into an existing
|
|
749
|
+
* same-entity update where possible so redundant writes collapse.
|
|
988
750
|
*/
|
|
989
751
|
enqueue(transaction) {
|
|
990
752
|
this.ensureDerivedFields(transaction);
|
|
991
753
|
const modelKey = `${transaction.modelName}:${transaction.modelId}`;
|
|
992
|
-
//
|
|
993
|
-
//
|
|
994
|
-
//
|
|
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.
|
|
995
757
|
//
|
|
996
|
-
//
|
|
997
|
-
// re-enqueued from `handleFailure` while its own
|
|
998
|
-
// `inFlightByModel
|
|
999
|
-
//
|
|
1000
|
-
// the
|
|
1001
|
-
//
|
|
1002
|
-
//
|
|
1003
|
-
// infinite ~`batchDelay` resend storm of self-laundering transaction
|
|
1004
|
-
// 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.
|
|
1005
765
|
if (transaction.type === 'update' && transaction.attempts === 0) {
|
|
1006
766
|
const preserveWatermark = hasStaleWriteOptions(transaction.writeOptions);
|
|
1007
767
|
// If there is an in-flight update for this model, merge into post-flight buffer
|
|
1008
768
|
if (!preserveWatermark && this.inFlightByModel.has(modelKey)) {
|
|
1009
769
|
const prev = this.pendingMergeByModel.get(modelKey) || {};
|
|
1010
|
-
const merged =
|
|
770
|
+
const merged = mergeUpdateData(prev, transaction.data || {}, transaction.modelName);
|
|
1011
771
|
this.pendingMergeByModel.set(modelKey, merged);
|
|
1012
772
|
this.store.remove(transaction.id);
|
|
1013
773
|
return;
|
|
@@ -1019,7 +779,7 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1019
779
|
t.modelName === transaction.modelName &&
|
|
1020
780
|
!hasStaleWriteOptions(t.writeOptions));
|
|
1021
781
|
if (!preserveWatermark && pendingInQueue) {
|
|
1022
|
-
pendingInQueue.data =
|
|
782
|
+
pendingInQueue.data = mergeUpdateData(pendingInQueue.data || {}, transaction.data || {}, transaction.modelName);
|
|
1023
783
|
this.store.remove(transaction.id);
|
|
1024
784
|
return;
|
|
1025
785
|
}
|
|
@@ -1036,8 +796,9 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1036
796
|
scheduleProcessing(immediate = false) {
|
|
1037
797
|
if (this.processScheduled)
|
|
1038
798
|
return;
|
|
1039
|
-
//
|
|
1040
|
-
//
|
|
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.
|
|
1041
802
|
if (this.executingCount >= this.config.maxExecutingTransactions) {
|
|
1042
803
|
getContext().logger.debug('[TransactionQueue] Backpressure: delaying batch, too many executing', {
|
|
1043
804
|
executingCount: this.executingCount,
|
|
@@ -1064,13 +825,11 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1064
825
|
}, delay);
|
|
1065
826
|
}
|
|
1066
827
|
/**
|
|
1067
|
-
*
|
|
1068
|
-
*
|
|
1069
|
-
*
|
|
1070
|
-
*
|
|
1071
|
-
*
|
|
1072
|
-
*
|
|
1073
|
-
* 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.
|
|
1074
833
|
*/
|
|
1075
834
|
async processBatch() {
|
|
1076
835
|
const batchStart = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
@@ -1082,9 +841,9 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1082
841
|
let batch = [];
|
|
1083
842
|
await getContext().observability.startSpanAsync('sync.batch', 'sync.transaction.batch', async () => {
|
|
1084
843
|
try {
|
|
1085
|
-
// Sort
|
|
1086
|
-
//
|
|
1087
|
-
//
|
|
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.
|
|
1088
847
|
this.executionQueue.sort((a, b) => {
|
|
1089
848
|
// Ensure derived fields exist (covers restored/persisted transactions)
|
|
1090
849
|
this.ensureDerivedFields(a);
|
|
@@ -1108,15 +867,15 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1108
867
|
this.inFlightByModel.add(key);
|
|
1109
868
|
this.store.updateStatus(tx.id, 'executing');
|
|
1110
869
|
}
|
|
1111
|
-
// Build
|
|
870
|
+
// Build every operation for one unified commit (a single round trip).
|
|
1112
871
|
const batchOps = [];
|
|
1113
872
|
for (const tx of batch) {
|
|
1114
|
-
//
|
|
1115
|
-
// the wire so the server can stamp it on the resulting
|
|
1116
|
-
//
|
|
1117
|
-
//
|
|
1118
|
-
//
|
|
1119
|
-
// 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`.
|
|
1120
879
|
const op = applyWriteOptions({
|
|
1121
880
|
type: TX_TYPE_TO_MUTATION_OP[tx.type],
|
|
1122
881
|
model: tx.modelKey,
|
|
@@ -1126,28 +885,27 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1126
885
|
}, tx);
|
|
1127
886
|
batchOps.push({ tx, op });
|
|
1128
887
|
}
|
|
1129
|
-
// Execute unified commit for
|
|
888
|
+
// Execute the unified commit for every operation in one round trip.
|
|
1130
889
|
if (batchOps.length > 0) {
|
|
1131
890
|
const operations = batchOps.map(({ op }) => op);
|
|
1132
891
|
try {
|
|
1133
|
-
//
|
|
892
|
+
// Capture lastSyncId from the server response for threshold-based
|
|
893
|
+
// confirmation.
|
|
1134
894
|
//
|
|
1135
|
-
// Idempotency
|
|
1136
|
-
//
|
|
1137
|
-
//
|
|
1138
|
-
//
|
|
1139
|
-
//
|
|
1140
|
-
// boundary here. Keeping this path await-free preserves
|
|
1141
|
-
// 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.
|
|
1142
900
|
const result = await this.mutationExecutor.commit(operations);
|
|
1143
901
|
const lastSyncId = result?.lastSyncId ?? 0;
|
|
1144
902
|
this.noteAck(lastSyncId);
|
|
1145
|
-
//
|
|
1146
|
-
//
|
|
1147
|
-
//
|
|
1148
|
-
// must be rolled back here
|
|
1149
|
-
//
|
|
1150
|
-
// 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.
|
|
1151
909
|
const notifications = result?.notifications;
|
|
1152
910
|
const heldIds = new Set((notifications ?? []).map((n) => n.id));
|
|
1153
911
|
for (const { tx } of batchOps) {
|
|
@@ -1156,15 +914,16 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1156
914
|
this.commitNotifications.set(tx.id, txNotifs);
|
|
1157
915
|
}
|
|
1158
916
|
}
|
|
1159
|
-
//
|
|
917
|
+
// A lastSyncId of 0 means the mutation succeeded but the server
|
|
918
|
+
// emitted no sync delta; record that anomaly for observability.
|
|
1160
919
|
if (lastSyncId === 0) {
|
|
1161
920
|
getContext().observability.captureCommitZeroSyncId({
|
|
1162
921
|
operationCount: operations.length,
|
|
1163
922
|
operations: operations.map((op) => `${op.type}:${op.model}:${op.id?.slice(0, 8) ?? '?'}`),
|
|
1164
923
|
});
|
|
1165
924
|
}
|
|
1166
|
-
//
|
|
1167
|
-
//
|
|
925
|
+
// Mark each transaction with the sync-id threshold that will
|
|
926
|
+
// confirm it: any delta whose id is at least lastSyncId.
|
|
1168
927
|
for (const { tx } of batchOps) {
|
|
1169
928
|
// Held op ('notify'): the server withheld the write, so no delta
|
|
1170
929
|
// will confirm it. Roll back the optimistic update (server
|
|
@@ -1196,17 +955,17 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1196
955
|
});
|
|
1197
956
|
continue;
|
|
1198
957
|
}
|
|
1199
|
-
//
|
|
1200
|
-
// real watermark means the server durably
|
|
1201
|
-
// that
|
|
1202
|
-
// contract
|
|
1203
|
-
//
|
|
1204
|
-
//
|
|
1205
|
-
//
|
|
1206
|
-
//
|
|
1207
|
-
//
|
|
1208
|
-
//
|
|
1209
|
-
//
|
|
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.
|
|
1210
969
|
if (lastSyncId > 0) {
|
|
1211
970
|
this.store.updateStatus(tx.id, 'completed');
|
|
1212
971
|
this.emit('transaction:completed', tx);
|
|
@@ -1220,12 +979,11 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1220
979
|
});
|
|
1221
980
|
}
|
|
1222
981
|
else {
|
|
1223
|
-
// lastSyncId
|
|
1224
|
-
// commit but emitted no delta
|
|
1225
|
-
//
|
|
1226
|
-
//
|
|
1227
|
-
//
|
|
1228
|
-
// 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.
|
|
1229
987
|
this.store.updateStatus(tx.id, 'awaiting_delta');
|
|
1230
988
|
getContext().logger.debug('tx:awaiting_delta', {
|
|
1231
989
|
txId: tx.id.slice(0, 8),
|
|
@@ -1240,11 +998,11 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1240
998
|
}
|
|
1241
999
|
catch (error) {
|
|
1242
1000
|
const errorMessage = error.message || '';
|
|
1243
|
-
// Surface the raw server rejection for the whole batch so
|
|
1244
|
-
// cascaded
|
|
1245
|
-
// back a
|
|
1246
|
-
// cause
|
|
1247
|
-
//
|
|
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.
|
|
1248
1006
|
const abloErr = error instanceof AbloError ? error : undefined;
|
|
1249
1007
|
// SyncWebSocket attaches a `diagnostics` snapshot to its
|
|
1250
1008
|
// "not connected" / "closed while in flight" rejections.
|
|
@@ -1258,7 +1016,7 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1258
1016
|
// wrapping in Ablo.commit() and any future wrappers.
|
|
1259
1017
|
for (let i = 0; i < 3 && cur && typeof cur === 'object'; i++) {
|
|
1260
1018
|
if ('diagnostics' in cur && cur.diagnostics) {
|
|
1261
|
-
return cur.diagnostics;
|
|
1019
|
+
return (cur).diagnostics;
|
|
1262
1020
|
}
|
|
1263
1021
|
cur = cur.cause;
|
|
1264
1022
|
}
|
|
@@ -1280,16 +1038,17 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1280
1038
|
message: errorMessage,
|
|
1281
1039
|
diagnostics,
|
|
1282
1040
|
});
|
|
1283
|
-
//
|
|
1284
|
-
//
|
|
1285
|
-
//
|
|
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.
|
|
1286
1045
|
if (errorMessage.includes('no rows in result set')) {
|
|
1287
1046
|
getContext().logger.info('[TransactionQueue] Graceful handling: entity already deleted', {
|
|
1288
1047
|
batchSize: batchOps.length,
|
|
1289
1048
|
});
|
|
1290
1049
|
for (const { tx, op } of batchOps) {
|
|
1291
1050
|
if (op.type === 'UPDATE' || op.type === 'DELETE') {
|
|
1292
|
-
//
|
|
1051
|
+
// Row already gone: the intended state holds, mark completed.
|
|
1293
1052
|
this.store.updateStatus(tx.id, 'completed');
|
|
1294
1053
|
this.emit('transaction:completed', tx);
|
|
1295
1054
|
getContext().logger.debug('[TransactionQueue] Orphaned transaction treated as success', {
|
|
@@ -1360,179 +1119,23 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1360
1119
|
}, { batchSize: this.executionQueue.length + (batch?.length ?? 0) });
|
|
1361
1120
|
}
|
|
1362
1121
|
/**
|
|
1363
|
-
*
|
|
1364
|
-
*
|
|
1365
|
-
* @
|
|
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.
|
|
1366
1126
|
*/
|
|
1367
1127
|
onDeltaReceived(syncId) {
|
|
1368
|
-
|
|
1369
|
-
// position.advanceApplied / advancePersisted); this hook only resolves
|
|
1370
|
-
// confirmation thresholds against the incoming id.
|
|
1371
|
-
const awaitingTxs = this.store.getByStatus('awaiting_delta');
|
|
1372
|
-
const executingTxs = this.store.getByStatus('executing');
|
|
1373
|
-
// Debug: Show state when delta arrives
|
|
1374
|
-
if (awaitingTxs.length > 0 || executingTxs.length > 0) {
|
|
1375
|
-
getContext().logger.debug('tx:delta_received', {
|
|
1376
|
-
syncId,
|
|
1377
|
-
lastSeenSyncId: this.lastSeenSyncId,
|
|
1378
|
-
awaitingCount: awaitingTxs.length,
|
|
1379
|
-
executingCount: executingTxs.length,
|
|
1380
|
-
awaitingThresholds: awaitingTxs.map((tx) => ({
|
|
1381
|
-
txId: tx.id.slice(0, 8),
|
|
1382
|
-
model: tx.modelName,
|
|
1383
|
-
needed: tx.syncIdNeededForCompletion,
|
|
1384
|
-
willConfirm: tx.syncIdNeededForCompletion !== undefined && syncId >= tx.syncIdNeededForCompletion,
|
|
1385
|
-
})),
|
|
1386
|
-
});
|
|
1387
|
-
}
|
|
1388
|
-
// Fast path: no awaiting transactions
|
|
1389
|
-
if (awaitingTxs.length === 0)
|
|
1390
|
-
return;
|
|
1391
|
-
let confirmedCount = 0;
|
|
1392
|
-
for (const tx of awaitingTxs) {
|
|
1393
|
-
// Confirm if this delta's ID meets or exceeds the threshold
|
|
1394
|
-
if (tx.syncIdNeededForCompletion !== undefined && syncId >= tx.syncIdNeededForCompletion) {
|
|
1395
|
-
this.cancelDeltaConfirmationTimeout(tx.id);
|
|
1396
|
-
this.store.updateStatus(tx.id, 'completed');
|
|
1397
|
-
this.emit('transaction:completed', tx);
|
|
1398
|
-
this.emit(`transaction:completed:${tx.id}`, tx);
|
|
1399
|
-
this.optimisticUpdates.delete(tx.id);
|
|
1400
|
-
confirmedCount++;
|
|
1401
|
-
getContext().logger.debug('tx:confirm_via_delta', {
|
|
1402
|
-
txId: tx.id.slice(0, 8),
|
|
1403
|
-
model: tx.modelName,
|
|
1404
|
-
neededSyncId: tx.syncIdNeededForCompletion,
|
|
1405
|
-
receivedSyncId: syncId,
|
|
1406
|
-
});
|
|
1407
|
-
}
|
|
1408
|
-
}
|
|
1409
|
-
// Log batch summary only if we confirmed something
|
|
1410
|
-
if (confirmedCount > 0) {
|
|
1411
|
-
// Use warn for staging visibility when transactions confirm
|
|
1412
|
-
getContext().observability.breadcrumb('Transactions confirmed via delta', 'sync.transaction', 'info', {
|
|
1413
|
-
count: confirmedCount,
|
|
1414
|
-
syncId,
|
|
1415
|
-
remainingAwaiting: awaitingTxs.length - confirmedCount,
|
|
1416
|
-
});
|
|
1417
|
-
}
|
|
1128
|
+
this.deltaConfirmation.onDeltaReceived(syncId);
|
|
1418
1129
|
}
|
|
1419
|
-
//
|
|
1420
|
-
//
|
|
1421
|
-
// retry with exponential backoff and request reconciliation to catch up on missed deltas.
|
|
1422
|
-
// Only rollback on explicit server rejection, never on timeout.
|
|
1130
|
+
// Schedule the retry-and-reconciliation wait for a transaction's confirming
|
|
1131
|
+
// delta; see {@link DeltaConfirmationTracker} in `./deltaConfirmation.js`.
|
|
1423
1132
|
scheduleDeltaConfirmationTimeout(tx, timeoutMs) {
|
|
1424
|
-
|
|
1425
|
-
this.cancelDeltaConfirmationTimeout(tx.id);
|
|
1426
|
-
const timeoutHandle = setTimeout(async () => {
|
|
1427
|
-
const currentTx = this.store.get(tx.id);
|
|
1428
|
-
if (!currentTx || currentTx.status !== 'awaiting_delta') {
|
|
1429
|
-
this.deltaConfirmationRetries.delete(tx.id);
|
|
1430
|
-
return; // Already confirmed or failed
|
|
1431
|
-
}
|
|
1432
|
-
// If disconnected, re-schedule with same timeout (no backoff while offline)
|
|
1433
|
-
if (!this.isConnectedFn()) {
|
|
1434
|
-
// Self-healing: re-schedule the confirmation wait while offline, no
|
|
1435
|
-
// consumer action needed → debug.
|
|
1436
|
-
getContext().logger.debug('[TransactionQueue] Timeout fired while disconnected - re-scheduling', {
|
|
1437
|
-
txId: tx.id.slice(0, 8),
|
|
1438
|
-
model: tx.modelName,
|
|
1439
|
-
});
|
|
1440
|
-
this.deltaConfirmationTimeouts.delete(tx.id);
|
|
1441
|
-
this.scheduleDeltaConfirmationTimeout(tx, timeoutMs);
|
|
1442
|
-
return;
|
|
1443
|
-
}
|
|
1444
|
-
const retryCount = this.deltaConfirmationRetries.get(tx.id) ?? 0;
|
|
1445
|
-
const diagnosis = this.lastSeenSyncId === 0
|
|
1446
|
-
? 'No deltas received - delta pipeline may be broken'
|
|
1447
|
-
: currentTx.syncIdNeededForCompletion &&
|
|
1448
|
-
this.lastSeenSyncId < currentTx.syncIdNeededForCompletion
|
|
1449
|
-
? 'Delta not yet received - may be lost or delayed'
|
|
1450
|
-
: 'Delta should have confirmed - possible race condition';
|
|
1451
|
-
getContext().observability.captureReconciliation({
|
|
1452
|
-
reason: 'delta_timeout',
|
|
1453
|
-
model: tx.modelName,
|
|
1454
|
-
modelId: tx.modelId,
|
|
1455
|
-
syncIdNeeded: currentTx.syncIdNeededForCompletion,
|
|
1456
|
-
lastSeenSyncId: this.lastSeenSyncId,
|
|
1457
|
-
retryCount,
|
|
1458
|
-
connectionState: this.isConnectedFn() ? 'connected' : 'disconnected',
|
|
1459
|
-
});
|
|
1460
|
-
if (retryCount < TransactionQueue.DELTA_MAX_RETRIES) {
|
|
1461
|
-
// RETRY: Request reconciliation and re-schedule with exponential backoff
|
|
1462
|
-
// The server already committed this mutation — we just need the delta to arrive
|
|
1463
|
-
this.deltaConfirmationRetries.set(tx.id, retryCount + 1);
|
|
1464
|
-
this.deltaConfirmationTimeouts.delete(tx.id);
|
|
1465
|
-
// Exponential backoff: 30s → 60s → 120s → 120s → 120s (capped)
|
|
1466
|
-
const nextTimeout = Math.min(timeoutMs * 2, TransactionQueue.DELTA_MAX_TIMEOUT_MS);
|
|
1467
|
-
// Emit reconciliation request so SyncedStore can cycle the WebSocket
|
|
1468
|
-
// to trigger delta catch-up from the server
|
|
1469
|
-
this.emit('reconciliation:needed', {
|
|
1470
|
-
reason: 'delta_confirmation_timeout',
|
|
1471
|
-
txId: tx.id,
|
|
1472
|
-
model: tx.modelName,
|
|
1473
|
-
modelId: tx.modelId,
|
|
1474
|
-
syncIdNeeded: currentTx.syncIdNeededForCompletion,
|
|
1475
|
-
lastSeenSyncId: this.lastSeenSyncId,
|
|
1476
|
-
retryCount: retryCount + 1,
|
|
1477
|
-
});
|
|
1478
|
-
// Self-healing retry with backoff — the server already committed; we're
|
|
1479
|
-
// just waiting on the delta. No consumer action → debug.
|
|
1480
|
-
getContext().logger.debug('[TransactionQueue] Re-scheduling with backoff', {
|
|
1481
|
-
txId: tx.id.slice(0, 8),
|
|
1482
|
-
model: tx.modelName,
|
|
1483
|
-
nextTimeoutMs: nextTimeout,
|
|
1484
|
-
retry: retryCount + 1,
|
|
1485
|
-
});
|
|
1486
|
-
this.scheduleDeltaConfirmationTimeout(tx, nextTimeout);
|
|
1487
|
-
}
|
|
1488
|
-
else {
|
|
1489
|
-
// LINEAR PATTERN: Retries exhausted — persist to IndexedDB instead of rolling back.
|
|
1490
|
-
// The transaction succeeded on the server (HTTP 200), so the data exists server-side.
|
|
1491
|
-
// Persist the awaiting state so it survives tab close. On next session, the WebSocket
|
|
1492
|
-
// reconnect + delta catch-up will naturally confirm it (like Linear's IndexedDB caching).
|
|
1493
|
-
this.deltaConfirmationRetries.delete(tx.id);
|
|
1494
|
-
this.deltaConfirmationTimeouts.delete(tx.id);
|
|
1495
|
-
getContext().observability.captureDeltaRetryExhausted({
|
|
1496
|
-
txId: tx.id,
|
|
1497
|
-
model: tx.modelName,
|
|
1498
|
-
modelId: tx.modelId,
|
|
1499
|
-
retryCount: TransactionQueue.DELTA_MAX_RETRIES,
|
|
1500
|
-
syncIdNeeded: currentTx.syncIdNeededForCompletion,
|
|
1501
|
-
});
|
|
1502
|
-
// Emit persist event — SyncClient handles the IDB write
|
|
1503
|
-
this.emit('transaction:persist_awaiting', {
|
|
1504
|
-
txId: tx.id,
|
|
1505
|
-
model: tx.modelName,
|
|
1506
|
-
modelId: tx.modelId,
|
|
1507
|
-
operationType: tx.type,
|
|
1508
|
-
syncIdNeeded: currentTx.syncIdNeededForCompletion,
|
|
1509
|
-
});
|
|
1510
|
-
// Also request one final reconciliation cycle
|
|
1511
|
-
this.emit('reconciliation:needed', {
|
|
1512
|
-
reason: 'delta_retries_exhausted',
|
|
1513
|
-
txId: tx.id,
|
|
1514
|
-
model: tx.modelName,
|
|
1515
|
-
modelId: tx.modelId,
|
|
1516
|
-
syncIdNeeded: currentTx.syncIdNeededForCompletion,
|
|
1517
|
-
lastSeenSyncId: this.lastSeenSyncId,
|
|
1518
|
-
retryCount: TransactionQueue.DELTA_MAX_RETRIES,
|
|
1519
|
-
});
|
|
1520
|
-
}
|
|
1521
|
-
}, timeoutMs);
|
|
1522
|
-
this.deltaConfirmationTimeouts.set(tx.id, timeoutHandle);
|
|
1523
|
-
}
|
|
1524
|
-
// Cancel a pending delta confirmation timeout and clean up retry tracking
|
|
1525
|
-
cancelDeltaConfirmationTimeout(id) {
|
|
1526
|
-
const timeoutHandle = this.deltaConfirmationTimeouts.get(id);
|
|
1527
|
-
if (timeoutHandle) {
|
|
1528
|
-
clearTimeout(timeoutHandle);
|
|
1529
|
-
this.deltaConfirmationTimeouts.delete(id);
|
|
1530
|
-
}
|
|
1531
|
-
this.deltaConfirmationRetries.delete(id);
|
|
1133
|
+
this.deltaConfirmation.scheduleDeltaConfirmationTimeout(tx, timeoutMs);
|
|
1532
1134
|
}
|
|
1533
1135
|
/**
|
|
1534
|
-
*
|
|
1535
|
-
*
|
|
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}.
|
|
1536
1139
|
*/
|
|
1537
1140
|
waitForConfirmation(transactionId) {
|
|
1538
1141
|
return new Promise((resolve, reject) => {
|
|
@@ -1559,17 +1162,18 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1559
1162
|
this.on(`transaction:failed:${transactionId}`, onFailed);
|
|
1560
1163
|
});
|
|
1561
1164
|
}
|
|
1562
|
-
//
|
|
1165
|
+
// Reports whether a client mutation id is known to this queue, which helps
|
|
1166
|
+
// identify a delta as this client's own echo.
|
|
1563
1167
|
hasClientMutationId(id) {
|
|
1564
1168
|
return !!this.store.get(id) || this.commitStore.has(id);
|
|
1565
1169
|
}
|
|
1566
1170
|
/**
|
|
1567
|
-
*
|
|
1568
|
-
* path
|
|
1569
|
-
*
|
|
1570
|
-
*
|
|
1571
|
-
*
|
|
1572
|
-
*
|
|
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.
|
|
1573
1177
|
*/
|
|
1574
1178
|
enqueueCommit(clientTxId, operations, options = {}) {
|
|
1575
1179
|
if (this.commitStore.has(clientTxId))
|
|
@@ -1586,19 +1190,19 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1586
1190
|
};
|
|
1587
1191
|
this.commitStore.set(clientTxId, tx);
|
|
1588
1192
|
this.commitLane.push(tx);
|
|
1589
|
-
//
|
|
1590
|
-
// commit-lane writes
|
|
1591
|
-
//
|
|
1592
|
-
//
|
|
1593
|
-
// 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.
|
|
1594
1197
|
this.emit('commit:created', { clientTxId, operations: tx.operations });
|
|
1595
1198
|
void this.processCommitLane();
|
|
1596
1199
|
}
|
|
1597
1200
|
/**
|
|
1598
|
-
*
|
|
1599
|
-
*
|
|
1600
|
-
*
|
|
1601
|
-
*
|
|
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.
|
|
1602
1206
|
*/
|
|
1603
1207
|
async processCommitLane() {
|
|
1604
1208
|
if (this.commitProcessing)
|
|
@@ -1607,6 +1211,8 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1607
1211
|
try {
|
|
1608
1212
|
while (this.commitLane.length > 0) {
|
|
1609
1213
|
const tx = this.commitLane[0];
|
|
1214
|
+
if (!tx)
|
|
1215
|
+
break;
|
|
1610
1216
|
if (tx.status !== 'pending') {
|
|
1611
1217
|
this.commitLane.shift();
|
|
1612
1218
|
continue;
|
|
@@ -1629,8 +1235,9 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1629
1235
|
catch (err) {
|
|
1630
1236
|
const error = err instanceof Error ? err : new Error(String(err));
|
|
1631
1237
|
if (!this.isPermanentError(error)) {
|
|
1632
|
-
// Transient
|
|
1633
|
-
// 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.
|
|
1634
1241
|
tx.status = 'pending';
|
|
1635
1242
|
getContext().logger.debug('[TransactionQueue] commit lane transient', {
|
|
1636
1243
|
txId: tx.id.slice(0, 12),
|
|
@@ -1642,8 +1249,9 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1642
1249
|
tx.status = 'failed';
|
|
1643
1250
|
tx.error = error;
|
|
1644
1251
|
this.commitLane.shift();
|
|
1645
|
-
// Internal bookkeeping
|
|
1646
|
-
// '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.
|
|
1647
1255
|
getContext().logger.debug('[TransactionQueue] commit lane permanent error', {
|
|
1648
1256
|
txId: tx.id.slice(0, 12),
|
|
1649
1257
|
attempts: tx.attempts,
|
|
@@ -1659,10 +1267,10 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1659
1267
|
}
|
|
1660
1268
|
}
|
|
1661
1269
|
/**
|
|
1662
|
-
*
|
|
1663
|
-
*
|
|
1664
|
-
*
|
|
1665
|
-
*
|
|
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()`.
|
|
1666
1274
|
*/
|
|
1667
1275
|
waitForCommitReceipt(clientTxId) {
|
|
1668
1276
|
// Drain any stale-context notifications stamped for this tx on the ack.
|
|
@@ -1705,20 +1313,20 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1705
1313
|
return 'order' in data || 'orderKey' in data || 'position' in data;
|
|
1706
1314
|
}
|
|
1707
1315
|
/**
|
|
1708
|
-
*
|
|
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.
|
|
1709
1320
|
*
|
|
1710
|
-
*
|
|
1711
|
-
*
|
|
1321
|
+
* Transient (retried):
|
|
1322
|
+
* - Network failures, connection errors, and timeouts.
|
|
1323
|
+
* - Server errors (HTTP 5xx).
|
|
1324
|
+
* - Rate limiting (HTTP 429).
|
|
1712
1325
|
*
|
|
1713
|
-
*
|
|
1714
|
-
* -
|
|
1715
|
-
* -
|
|
1716
|
-
* -
|
|
1717
|
-
*
|
|
1718
|
-
* Permanent errors (won't retry - includes but not limited to):
|
|
1719
|
-
* - Validation errors, constraint violations
|
|
1720
|
-
* - Not found, unauthorized, forbidden
|
|
1721
|
-
* - 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.
|
|
1722
1330
|
*/
|
|
1723
1331
|
isPermanentError(error) {
|
|
1724
1332
|
// Typed connection error (e.g. ws_not_ready, transport timeout) is
|
|
@@ -1776,18 +1384,20 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1776
1384
|
return true;
|
|
1777
1385
|
}
|
|
1778
1386
|
/**
|
|
1779
|
-
*
|
|
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.
|
|
1780
1390
|
*/
|
|
1781
1391
|
async handleFailure(transaction, error) {
|
|
1782
1392
|
transaction.attempts++;
|
|
1783
|
-
// Check
|
|
1393
|
+
// Check whether this is a permanent error that should not be retried.
|
|
1784
1394
|
if (this.isPermanentError(error)) {
|
|
1785
|
-
//
|
|
1786
|
-
//
|
|
1787
|
-
//
|
|
1788
|
-
//
|
|
1789
|
-
//
|
|
1790
|
-
//
|
|
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).
|
|
1791
1401
|
try {
|
|
1792
1402
|
const abloErr = error instanceof AbloError ? error : undefined;
|
|
1793
1403
|
const details = {
|
|
@@ -1816,17 +1426,16 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1816
1426
|
const isRepeat = sig === this.lastPermanentErrorSig;
|
|
1817
1427
|
this.lastPermanentErrorSig = sig;
|
|
1818
1428
|
const logger = getContext().logger;
|
|
1819
|
-
// Two registers
|
|
1820
|
-
//
|
|
1821
|
-
//
|
|
1822
|
-
//
|
|
1823
|
-
// their verb (`update`), their model, the typed
|
|
1824
|
-
// message, and the wire `code` for
|
|
1825
|
-
//
|
|
1826
|
-
//
|
|
1827
|
-
//
|
|
1828
|
-
//
|
|
1829
|
-
// 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.
|
|
1830
1439
|
const revertNote = this.config.enableOptimistic
|
|
1831
1440
|
? ' The local change was reverted.'
|
|
1832
1441
|
: '';
|
|
@@ -1862,15 +1471,11 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1862
1471
|
return;
|
|
1863
1472
|
}
|
|
1864
1473
|
if (transaction.attempts < this.config.maxRetries) {
|
|
1865
|
-
// Exponential backoff with
|
|
1866
|
-
//
|
|
1867
|
-
//
|
|
1868
|
-
//
|
|
1869
|
-
//
|
|
1870
|
-
// AND the sleep blocked the whole batch loop — every other failure
|
|
1871
|
-
// retried at raw `batchDelay` cadence. The re-enqueue is scheduled
|
|
1872
|
-
// (never awaited) so one backing-off transaction can't stall
|
|
1873
|
-
// 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.
|
|
1874
1479
|
const { baseMs, capMs } = this.config.retryBackoff;
|
|
1875
1480
|
let base = baseMs;
|
|
1876
1481
|
try {
|
|
@@ -1902,7 +1507,9 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1902
1507
|
}
|
|
1903
1508
|
}
|
|
1904
1509
|
/**
|
|
1905
|
-
*
|
|
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.
|
|
1906
1513
|
*/
|
|
1907
1514
|
async handleConflict(transaction, serverData) {
|
|
1908
1515
|
const { strategy, resolver } = this.config.conflictResolution;
|
|
@@ -1932,65 +1539,25 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1932
1539
|
}
|
|
1933
1540
|
}
|
|
1934
1541
|
/**
|
|
1935
|
-
* Optimistic updates
|
|
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.
|
|
1936
1544
|
*/
|
|
1937
1545
|
applyOptimisticCreate(model, transaction) {
|
|
1938
|
-
this.optimisticUpdates
|
|
1939
|
-
model,
|
|
1940
|
-
previousState: null,
|
|
1941
|
-
transaction,
|
|
1942
|
-
});
|
|
1943
|
-
this.emit('optimistic:create', { model, transaction });
|
|
1546
|
+
applyOptimisticCreate(this.optimisticUpdates, this, model, transaction);
|
|
1944
1547
|
}
|
|
1945
1548
|
applyOptimisticUpdate(model, transaction) {
|
|
1946
|
-
this.optimisticUpdates
|
|
1947
|
-
model,
|
|
1948
|
-
previousState: transaction.previousData,
|
|
1949
|
-
transaction,
|
|
1950
|
-
});
|
|
1951
|
-
this.emit('optimistic:update', { model, transaction });
|
|
1549
|
+
applyOptimisticUpdate(this.optimisticUpdates, this, model, transaction);
|
|
1952
1550
|
}
|
|
1953
1551
|
applyOptimisticDelete(model, transaction) {
|
|
1954
|
-
this.optimisticUpdates
|
|
1955
|
-
model,
|
|
1956
|
-
previousState: transaction.previousData,
|
|
1957
|
-
transaction,
|
|
1958
|
-
});
|
|
1959
|
-
this.emit('optimistic:delete', { model, transaction });
|
|
1552
|
+
applyOptimisticDelete(this.optimisticUpdates, this, model, transaction);
|
|
1960
1553
|
}
|
|
1961
1554
|
async rollbackOptimistic(transaction, reason, error) {
|
|
1962
|
-
|
|
1963
|
-
if (!optimistic)
|
|
1964
|
-
return;
|
|
1965
|
-
this.emit('optimistic:rollback', {
|
|
1966
|
-
model: optimistic.model,
|
|
1967
|
-
previousState: optimistic.previousState,
|
|
1968
|
-
transaction,
|
|
1969
|
-
reason: reason ?? 'unknown',
|
|
1970
|
-
error,
|
|
1971
|
-
});
|
|
1972
|
-
this.optimisticUpdates.delete(transaction.id);
|
|
1973
|
-
}
|
|
1974
|
-
/**
|
|
1975
|
-
* Execute individual transaction via the unified commit path
|
|
1976
|
-
*/
|
|
1977
|
-
async executeTransaction(transaction) {
|
|
1978
|
-
const { type, modelName, modelId, data } = transaction;
|
|
1979
|
-
const schemaName = stripModelSuffix(modelName);
|
|
1980
|
-
const mutationType = TX_TYPE_TO_MUTATION_OP[type];
|
|
1981
|
-
const model = normalizeModelKey(modelName);
|
|
1982
|
-
const input = (type === 'create' || type === 'update') ? data : undefined;
|
|
1983
|
-
try {
|
|
1984
|
-
await this.mutationExecutor.commit([
|
|
1985
|
-
applyWriteOptions({ type: mutationType, model, id: modelId, input }, transaction),
|
|
1986
|
-
]);
|
|
1987
|
-
}
|
|
1988
|
-
catch (error) {
|
|
1989
|
-
handleMutationError(error, `${type}-mutation`, schemaName, modelId);
|
|
1990
|
-
}
|
|
1555
|
+
await rollbackOptimistic(this.optimisticUpdates, this, transaction, reason, error);
|
|
1991
1556
|
}
|
|
1992
1557
|
/**
|
|
1993
|
-
*
|
|
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.
|
|
1994
1561
|
*/
|
|
1995
1562
|
async loadPersistedTransactions(database) {
|
|
1996
1563
|
if (!this.config.enablePersistence)
|
|
@@ -1999,6 +1566,8 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1999
1566
|
const persisted = await database.getPersistedTransactions();
|
|
2000
1567
|
for (const data of persisted) {
|
|
2001
1568
|
const transaction = this.deserializeTransaction(data);
|
|
1569
|
+
if (!transaction)
|
|
1570
|
+
continue;
|
|
2002
1571
|
this.store.add(transaction);
|
|
2003
1572
|
this.enqueue(transaction);
|
|
2004
1573
|
}
|
|
@@ -2010,11 +1579,35 @@ export class TransactionQueue extends EventEmitter {
|
|
|
2010
1579
|
});
|
|
2011
1580
|
}
|
|
2012
1581
|
}
|
|
1582
|
+
/**
|
|
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.
|
|
1587
|
+
*/
|
|
2013
1588
|
deserializeTransaction(data) {
|
|
2014
|
-
|
|
1589
|
+
if (isNonReplayablePersistedRow(data))
|
|
1590
|
+
return null;
|
|
1591
|
+
const transaction = deserializePersistedTransaction(data);
|
|
1592
|
+
if (!transaction) {
|
|
1593
|
+
const rowId = typeof data === 'object' && data !== null && typeof data.id === 'string'
|
|
1594
|
+
? data.id
|
|
1595
|
+
: undefined;
|
|
1596
|
+
getContext().logger.debug('[TransactionQueue] Dropping malformed persisted transaction', {
|
|
1597
|
+
rowId,
|
|
1598
|
+
});
|
|
1599
|
+
getContext().observability.captureTransactionFailure({
|
|
1600
|
+
context: 'deserialize-persisted-transaction',
|
|
1601
|
+
error: `Persisted transaction failed schema validation${rowId ? ` (id: ${rowId})` : ''}`,
|
|
1602
|
+
});
|
|
1603
|
+
return null;
|
|
1604
|
+
}
|
|
1605
|
+
return transaction;
|
|
2015
1606
|
}
|
|
2016
1607
|
/**
|
|
2017
|
-
*
|
|
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.
|
|
2018
1611
|
*/
|
|
2019
1612
|
cancelTransactionsForModel(modelId, transactionType) {
|
|
2020
1613
|
const cancelledTransactions = [];
|
|
@@ -2027,23 +1620,29 @@ export class TransactionQueue extends EventEmitter {
|
|
|
2027
1620
|
if (!transactionType || transaction.type === transactionType) {
|
|
2028
1621
|
cancelledTransactions.push(transaction);
|
|
2029
1622
|
this.store.updateStatus(transaction.id, 'rolled_back');
|
|
2030
|
-
|
|
1623
|
+
// Sync caller: a rejected rollback (throwing optimistic:rollback
|
|
1624
|
+
// listener) must surface, not vanish — the status flip above is
|
|
1625
|
+
// already committed either way.
|
|
1626
|
+
void this.rollbackOptimistic(transaction, 'model_cancelled').catch((error) => {
|
|
1627
|
+
getContext().observability.captureTransactionFailure({
|
|
1628
|
+
context: 'rollback-model-cancelled',
|
|
1629
|
+
error: error instanceof Error ? error : String(error),
|
|
1630
|
+
});
|
|
1631
|
+
});
|
|
2031
1632
|
}
|
|
2032
1633
|
}
|
|
2033
1634
|
}
|
|
2034
1635
|
return cancelledTransactions;
|
|
2035
1636
|
}
|
|
2036
1637
|
/**
|
|
2037
|
-
*
|
|
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.
|
|
2038
1641
|
*
|
|
2039
|
-
*
|
|
2040
|
-
*
|
|
2041
|
-
*
|
|
2042
|
-
*
|
|
2043
|
-
* @param childModelName - The child model type (e.g., 'SlideLayer')
|
|
2044
|
-
* @param foreignKey - The FK property name (e.g., 'slideId')
|
|
2045
|
-
* @param parentId - The deleted parent's ID
|
|
2046
|
-
* @returns Number of transactions cancelled
|
|
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.
|
|
2047
1646
|
*/
|
|
2048
1647
|
cancelTransactionsByForeignKey(childModelName, foreignKey, parentId) {
|
|
2049
1648
|
let cancelled = 0;
|
|
@@ -2058,7 +1657,12 @@ export class TransactionQueue extends EventEmitter {
|
|
|
2058
1657
|
const fkValue = transaction.data?.[foreignKey];
|
|
2059
1658
|
if (fkValue === parentId) {
|
|
2060
1659
|
this.store.updateStatus(transaction.id, 'rolled_back');
|
|
2061
|
-
this.rollbackOptimistic(transaction, 'cascade_parent_deleted')
|
|
1660
|
+
void this.rollbackOptimistic(transaction, 'cascade_parent_deleted').catch((error) => {
|
|
1661
|
+
getContext().observability.captureTransactionFailure({
|
|
1662
|
+
context: 'rollback-cascade-parent-deleted',
|
|
1663
|
+
error: error instanceof Error ? error : String(error),
|
|
1664
|
+
});
|
|
1665
|
+
});
|
|
2062
1666
|
cancelled++;
|
|
2063
1667
|
getContext().logger.debug('[TransactionQueue] Cascade cancelled orphaned transaction', {
|
|
2064
1668
|
txId: transaction.id.slice(0, 12),
|
|
@@ -2072,14 +1676,12 @@ export class TransactionQueue extends EventEmitter {
|
|
|
2072
1676
|
return cancelled;
|
|
2073
1677
|
}
|
|
2074
1678
|
/**
|
|
2075
|
-
*
|
|
1679
|
+
* Returns the number of transactions still pending or executing.
|
|
2076
1680
|
*/
|
|
2077
1681
|
getOutstandingTransactionCount() {
|
|
2078
1682
|
return this.store.getByStatus('pending').length + this.store.getByStatus('executing').length;
|
|
2079
1683
|
}
|
|
2080
|
-
/**
|
|
2081
|
-
* Utilities
|
|
2082
|
-
*/
|
|
1684
|
+
/** Generates a unique local transaction id. */
|
|
2083
1685
|
generateId() {
|
|
2084
1686
|
return `tx_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
|
|
2085
1687
|
}
|
|
@@ -2095,35 +1697,26 @@ export class TransactionQueue extends EventEmitter {
|
|
|
2095
1697
|
extractUpdateData(model) {
|
|
2096
1698
|
return projectCommitPayload(model.getModelName(), model.getChanges(), { dropUndefined: true });
|
|
2097
1699
|
}
|
|
2098
|
-
buildUpdateInput(modelName, changes) {
|
|
2099
|
-
return projectCommitPayload(modelName, changes, { dropUndefined: true });
|
|
2100
|
-
}
|
|
2101
1700
|
// Derive previous values for changed fields to support accurate rollback.
|
|
2102
|
-
//
|
|
2103
|
-
//
|
|
2104
|
-
//
|
|
2105
|
-
// code (`_data`), no `Slide` class still defines either field, and
|
|
2106
|
-
// hardcoded model-name checks don't belong in a generic queue. If a
|
|
2107
|
-
// model ever needs to surface previous-state outside `modifiedProperties`,
|
|
2108
|
-
// 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.
|
|
2109
1704
|
extractPreviousData(model, updateInput) {
|
|
2110
1705
|
// When the update's written keys are known, capture a before-image for
|
|
2111
|
-
//
|
|
2112
|
-
//
|
|
2113
|
-
// fields
|
|
2114
|
-
// any key it
|
|
2115
|
-
//
|
|
2116
|
-
//
|
|
2117
|
-
//
|
|
2118
|
-
//
|
|
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`.
|
|
2119
1714
|
const keys = updateInput
|
|
2120
1715
|
? Object.keys(updateInput)
|
|
2121
1716
|
: [...(model.modifiedProperties instanceof Map ? model.modifiedProperties.keys() : [])];
|
|
2122
1717
|
return { id: model.id, ...model.capturePreviousValues(keys, { fallbackToLive: false }) };
|
|
2123
1718
|
}
|
|
2124
|
-
/**
|
|
2125
|
-
* Public API
|
|
2126
|
-
*/
|
|
1719
|
+
/** Returns a snapshot of queue counts and the current configuration. */
|
|
2127
1720
|
getStats() {
|
|
2128
1721
|
return {
|
|
2129
1722
|
pending: this.store.getByStatus('pending').length,
|
|
@@ -2137,8 +1730,8 @@ export class TransactionQueue extends EventEmitter {
|
|
|
2137
1730
|
};
|
|
2138
1731
|
}
|
|
2139
1732
|
/**
|
|
2140
|
-
*
|
|
2141
|
-
*
|
|
1733
|
+
* Returns detailed internal state — pending, executing, and awaiting-delta
|
|
1734
|
+
* transactions — to help diagnose delta-confirmation issues.
|
|
2142
1735
|
*/
|
|
2143
1736
|
getDebugInfo() {
|
|
2144
1737
|
const awaitingDelta = this.store.getByStatus('awaiting_delta');
|
|
@@ -2168,18 +1761,17 @@ export class TransactionQueue extends EventEmitter {
|
|
|
2168
1761
|
})),
|
|
2169
1762
|
};
|
|
2170
1763
|
}
|
|
2171
|
-
/**
|
|
2172
|
-
* Set configuration
|
|
2173
|
-
*/
|
|
1764
|
+
/** Merges the given options into the queue's configuration. */
|
|
2174
1765
|
setConfig(config) {
|
|
2175
1766
|
this.config = { ...this.config, ...config };
|
|
2176
1767
|
}
|
|
2177
1768
|
/**
|
|
2178
|
-
*
|
|
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.
|
|
2179
1771
|
*/
|
|
2180
1772
|
handleSyncDelta(delta) {
|
|
2181
|
-
//
|
|
2182
|
-
//
|
|
1773
|
+
// Row ids are stable, so no reconciliation is needed; re-emit the delta for
|
|
1774
|
+
// the store to apply directly.
|
|
2183
1775
|
this.emit('sync:delta', {
|
|
2184
1776
|
id: delta.id,
|
|
2185
1777
|
modelName: delta.modelName,
|
|
@@ -2189,7 +1781,8 @@ export class TransactionQueue extends EventEmitter {
|
|
|
2189
1781
|
return true;
|
|
2190
1782
|
}
|
|
2191
1783
|
/**
|
|
2192
|
-
*
|
|
1784
|
+
* Releases the queue's resources: rolls back outstanding optimistic updates,
|
|
1785
|
+
* clears all timers and stored transactions, and removes event listeners.
|
|
2193
1786
|
*/
|
|
2194
1787
|
dispose() {
|
|
2195
1788
|
// Cancel all active optimistic updates
|
|
@@ -2205,6 +1798,15 @@ export class TransactionQueue extends EventEmitter {
|
|
|
2205
1798
|
if (this.processTimer) {
|
|
2206
1799
|
clearTimeout(this.processTimer);
|
|
2207
1800
|
}
|
|
1801
|
+
// Clear every armed delta-confirmation timer (one per in-flight tx,
|
|
1802
|
+
// 30–120s each) — a disposed queue must not keep the process alive or
|
|
1803
|
+
// fire confirmation callbacks against the cleared store below.
|
|
1804
|
+
this.deltaConfirmation.dispose();
|
|
1805
|
+
// Clear the offline-grace timer armed by setConnectionState('disconnected').
|
|
1806
|
+
if (this.commitOfflineGraceTimer !== null) {
|
|
1807
|
+
clearTimeout(this.commitOfflineGraceTimer);
|
|
1808
|
+
this.commitOfflineGraceTimer = null;
|
|
1809
|
+
}
|
|
2208
1810
|
// Clear store
|
|
2209
1811
|
this.store.clear();
|
|
2210
1812
|
this.optimisticUpdates.clear();
|