@abloatai/humans 0.37.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/README.md +52 -0
- package/dist/Ablo.d.ts +208 -0
- package/dist/Ablo.js +120 -0
- package/dist/client.d.ts +317 -0
- package/dist/client.js +13 -0
- package/dist/core.d.ts +35 -0
- package/dist/core.js +48 -0
- package/dist/humans.d.ts +28 -0
- package/dist/humans.js +34 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +6 -0
- package/dist/local/BaseSyncedStore.d.ts +807 -0
- package/dist/local/BaseSyncedStore.js +1516 -0
- package/dist/local/Database.d.ts +322 -0
- package/dist/local/Database.js +1589 -0
- package/dist/local/InstanceCache.d.ts +255 -0
- package/dist/local/InstanceCache.js +1263 -0
- package/dist/local/LazyReferenceCollection.d.ts +177 -0
- package/dist/local/LazyReferenceCollection.js +461 -0
- package/dist/local/Model.d.ts +475 -0
- package/dist/local/Model.js +950 -0
- package/dist/local/ModelRegistry.d.ts +225 -0
- package/dist/local/ModelRegistry.js +539 -0
- package/dist/local/NetworkMonitor.d.ts +28 -0
- package/dist/local/NetworkMonitor.js +79 -0
- package/dist/local/RuntimeContext.d.ts +52 -0
- package/dist/local/RuntimeContext.js +80 -0
- package/dist/local/SyncClient.d.ts +516 -0
- package/dist/local/SyncClient.js +1754 -0
- package/dist/local/adapters/alwaysOnline.d.ts +14 -0
- package/dist/local/adapters/alwaysOnline.js +17 -0
- package/dist/local/adapters/inMemoryStorage.d.ts +37 -0
- package/dist/local/adapters/inMemoryStorage.js +122 -0
- package/dist/local/client/clientPrelude.d.ts +52 -0
- package/dist/local/client/clientPrelude.js +60 -0
- package/dist/local/client/consoleLogger.d.ts +35 -0
- package/dist/local/client/consoleLogger.js +44 -0
- package/dist/local/client/createInternalComponents.d.ts +50 -0
- package/dist/local/client/createInternalComponents.js +98 -0
- package/dist/local/client/createModelProxy.d.ts +248 -0
- package/dist/local/client/createModelProxy.js +880 -0
- package/dist/local/client/modelRegistration.d.ts +10 -0
- package/dist/local/client/modelRegistration.js +316 -0
- package/dist/local/client/options.d.ts +440 -0
- package/dist/local/client/options.js +7 -0
- package/dist/local/client/reactiveEngine.d.ts +53 -0
- package/dist/local/client/reactiveEngine.js +705 -0
- package/dist/local/client/resourceTypes.d.ts +12 -0
- package/dist/local/client/resourceTypes.js +10 -0
- package/dist/local/client/schemaConfig.d.ts +44 -0
- package/dist/local/client/schemaConfig.js +185 -0
- package/dist/local/client/storeCluster.d.ts +46 -0
- package/dist/local/client/storeCluster.js +133 -0
- package/dist/local/client/storeLifecycle.d.ts +61 -0
- package/dist/local/client/storeLifecycle.js +236 -0
- package/dist/local/client/validateAbloOptions.d.ts +42 -0
- package/dist/local/client/validateAbloOptions.js +43 -0
- package/dist/local/client/wsMutationExecutor.d.ts +27 -0
- package/dist/local/client/wsMutationExecutor.js +72 -0
- package/dist/local/context.d.ts +42 -0
- package/dist/local/context.js +81 -0
- package/dist/local/coordination/ClaimLog.d.ts +26 -0
- package/dist/local/coordination/ClaimLog.js +32 -0
- package/dist/local/interfaces/index.d.ts +311 -0
- package/dist/local/interfaces/index.js +9 -0
- package/dist/local/localModelContract.d.ts +14 -0
- package/dist/local/localModelContract.js +1 -0
- package/dist/local/logPosition.d.ts +31 -0
- package/dist/local/logPosition.js +53 -0
- package/dist/local/mutationPersistence.d.ts +6 -0
- package/dist/local/mutationPersistence.js +1 -0
- package/dist/local/mutators/RecordingMutation.d.ts +36 -0
- package/dist/local/mutators/RecordingMutation.js +182 -0
- package/dist/local/mutators/Transaction.d.ts +40 -0
- package/dist/local/mutators/Transaction.js +58 -0
- package/dist/local/mutators/UndoManager.d.ts +258 -0
- package/dist/local/mutators/UndoManager.js +665 -0
- package/dist/local/mutators/defineMutators.d.ts +60 -0
- package/dist/local/mutators/defineMutators.js +18 -0
- package/dist/local/mutators/inverseOp.d.ts +126 -0
- package/dist/local/mutators/inverseOp.js +71 -0
- package/dist/local/mutators/mutateActions.d.ts +45 -0
- package/dist/local/mutators/mutateActions.js +105 -0
- package/dist/local/mutators/readerActions.d.ts +33 -0
- package/dist/local/mutators/readerActions.js +57 -0
- package/dist/local/mutators/undoApply.d.ts +51 -0
- package/dist/local/mutators/undoApply.js +117 -0
- package/dist/local/persistence.d.ts +7 -0
- package/dist/local/persistence.js +9 -0
- package/dist/local/query/QueryProcessor.d.ts +75 -0
- package/dist/local/query/QueryProcessor.js +255 -0
- package/dist/local/query/client.d.ts +64 -0
- package/dist/local/query/client.js +138 -0
- package/dist/local/query/types.d.ts +85 -0
- package/dist/local/query/types.js +16 -0
- package/dist/local/schema/serialize.d.ts +1 -0
- package/dist/local/schema/serialize.js +1 -0
- package/dist/local/store/queryApi.d.ts +13 -0
- package/dist/local/store/queryApi.js +35 -0
- package/dist/local/storeContract.d.ts +145 -0
- package/dist/local/storeContract.js +12 -0
- package/dist/local/stores/DatabaseManager.d.ts +112 -0
- package/dist/local/stores/DatabaseManager.js +400 -0
- package/dist/local/stores/ObjectStore.d.ts +115 -0
- package/dist/local/stores/ObjectStore.js +393 -0
- package/dist/local/stores/ObjectStoreContract.d.ts +38 -0
- package/dist/local/stores/ObjectStoreContract.js +1 -0
- package/dist/local/stores/StoreManager.d.ts +114 -0
- package/dist/local/stores/StoreManager.js +304 -0
- package/dist/local/stores/SyncActionStore.d.ts +99 -0
- package/dist/local/stores/SyncActionStore.js +506 -0
- package/dist/local/stores/openIDBWithTimeout.d.ts +65 -0
- package/dist/local/stores/openIDBWithTimeout.js +153 -0
- package/dist/local/stores/persistenceCleanup.d.ts +7 -0
- package/dist/local/stores/persistenceCleanup.js +26 -0
- package/dist/local/stores/persistenceIdentity.d.ts +27 -0
- package/dist/local/stores/persistenceIdentity.js +38 -0
- package/dist/local/stores/syncAction.d.ts +26 -0
- package/dist/local/stores/syncAction.js +16 -0
- package/dist/local/stores/v1PersistenceDeletion.d.ts +8 -0
- package/dist/local/stores/v1PersistenceDeletion.js +16 -0
- package/dist/local/sync/BootstrapFetcher.d.ts +284 -0
- package/dist/local/sync/BootstrapFetcher.js +964 -0
- package/dist/local/sync/ConnectionManager.d.ts +8 -0
- package/dist/local/sync/ConnectionManager.js +8 -0
- package/dist/local/sync/OnDemandLoader.d.ts +231 -0
- package/dist/local/sync/OnDemandLoader.js +743 -0
- package/dist/local/sync/SubscriptionManager.d.ts +159 -0
- package/dist/local/sync/SubscriptionManager.js +243 -0
- package/dist/local/sync/SyncWebSocket.d.ts +173 -0
- package/dist/local/sync/SyncWebSocket.js +438 -0
- package/dist/local/sync/bootstrapApply.d.ts +73 -0
- package/dist/local/sync/bootstrapApply.js +73 -0
- package/dist/local/sync/commitFrames.d.ts +8 -0
- package/dist/local/sync/commitFrames.js +8 -0
- package/dist/local/sync/connectionManagerLifecycle.d.ts +23 -0
- package/dist/local/sync/connectionManagerLifecycle.js +126 -0
- package/dist/local/sync/contextPorts.d.ts +18 -0
- package/dist/local/sync/contextPorts.js +31 -0
- package/dist/local/sync/createClaimStream.d.ts +64 -0
- package/dist/local/sync/createClaimStream.js +475 -0
- package/dist/local/sync/createSnapshot.d.ts +29 -0
- package/dist/local/sync/createSnapshot.js +116 -0
- package/dist/local/sync/credentialLifecycle.d.ts +7 -0
- package/dist/local/sync/credentialLifecycle.js +7 -0
- package/dist/local/sync/deltaPipeline.d.ts +116 -0
- package/dist/local/sync/deltaPipeline.js +357 -0
- package/dist/local/sync/groupChange.d.ts +116 -0
- package/dist/local/sync/groupChange.js +244 -0
- package/dist/local/sync/initialize.d.ts +27 -0
- package/dist/local/sync/initialize.js +137 -0
- package/dist/local/sync/participants.d.ts +132 -0
- package/dist/local/sync/participants.js +342 -0
- package/dist/local/sync/persistedPrefix.d.ts +12 -0
- package/dist/local/sync/persistedPrefix.js +22 -0
- package/dist/local/sync/reconnect.d.ts +23 -0
- package/dist/local/sync/reconnect.js +55 -0
- package/dist/local/sync/schemaDrift.d.ts +55 -0
- package/dist/local/sync/schemaDrift.js +53 -0
- package/dist/local/sync/schemas.d.ts +71 -0
- package/dist/local/sync/schemas.js +94 -0
- package/dist/local/sync/socketEventWiring.d.ts +31 -0
- package/dist/local/sync/socketEventWiring.js +130 -0
- package/dist/local/sync/syncCursor.d.ts +40 -0
- package/dist/local/sync/syncCursor.js +55 -0
- package/dist/local/sync/syncPlan.d.ts +54 -0
- package/dist/local/sync/syncPlan.js +50 -0
- package/dist/local/sync/terminalSessionLifecycle.d.ts +20 -0
- package/dist/local/sync/terminalSessionLifecycle.js +50 -0
- package/dist/local/sync/wsFrameHandlers.d.ts +8 -0
- package/dist/local/sync/wsFrameHandlers.js +8 -0
- package/dist/local/transactions/databaseCommitOutbox.d.ts +15 -0
- package/dist/local/transactions/databaseCommitOutbox.js +16 -0
- package/dist/local/transactions/localMutation.d.ts +10 -0
- package/dist/local/transactions/localMutation.js +37 -0
- package/dist/local/transactions/mutations/MutationQueue.d.ts +511 -0
- package/dist/local/transactions/mutations/MutationQueue.js +1498 -0
- package/dist/local/transactions/mutations/MutationStore.d.ts +20 -0
- package/dist/local/transactions/mutations/MutationStore.js +53 -0
- package/dist/local/transactions/mutations/UnconfirmedWrites.d.ts +82 -0
- package/dist/local/transactions/mutations/UnconfirmedWrites.js +104 -0
- package/dist/local/transactions/mutations/batchProcessing.d.ts +64 -0
- package/dist/local/transactions/mutations/batchProcessing.js +349 -0
- package/dist/local/transactions/mutations/coalesceRules.d.ts +58 -0
- package/dist/local/transactions/mutations/coalesceRules.js +140 -0
- package/dist/local/transactions/mutations/commitApi.d.ts +19 -0
- package/dist/local/transactions/mutations/commitApi.js +74 -0
- package/dist/local/transactions/mutations/commitLane.d.ts +70 -0
- package/dist/local/transactions/mutations/commitLane.js +140 -0
- package/dist/local/transactions/mutations/commitLatency.d.ts +52 -0
- package/dist/local/transactions/mutations/commitLatency.js +130 -0
- package/dist/local/transactions/mutations/commitPayload.d.ts +165 -0
- package/dist/local/transactions/mutations/commitPayload.js +152 -0
- package/dist/local/transactions/mutations/commitTransport.d.ts +36 -0
- package/dist/local/transactions/mutations/commitTransport.js +104 -0
- package/dist/local/transactions/mutations/deltaConfirmation.d.ts +63 -0
- package/dist/local/transactions/mutations/deltaConfirmation.js +235 -0
- package/dist/local/transactions/mutations/durableCommitRestore.d.ts +17 -0
- package/dist/local/transactions/mutations/durableCommitRestore.js +95 -0
- package/dist/local/transactions/mutations/durableWriteStore.d.ts +14 -0
- package/dist/local/transactions/mutations/durableWriteStore.js +12 -0
- package/dist/local/transactions/mutations/executionSelection.d.ts +6 -0
- package/dist/local/transactions/mutations/executionSelection.js +42 -0
- package/dist/local/transactions/mutations/failureHandling.d.ts +16 -0
- package/dist/local/transactions/mutations/failureHandling.js +130 -0
- package/dist/local/transactions/mutations/failurePolicy.d.ts +13 -0
- package/dist/local/transactions/mutations/failurePolicy.js +48 -0
- package/dist/local/transactions/mutations/localMutation.d.ts +58 -0
- package/dist/local/transactions/mutations/localMutation.js +75 -0
- package/dist/local/transactions/mutations/modelOperations.d.ts +44 -0
- package/dist/local/transactions/mutations/modelOperations.js +144 -0
- package/dist/local/transactions/mutations/mutationPersistence.d.ts +25 -0
- package/dist/local/transactions/mutations/mutationPersistence.js +188 -0
- package/dist/local/transactions/mutations/pendingDrain.d.ts +33 -0
- package/dist/local/transactions/mutations/pendingDrain.js +112 -0
- package/dist/local/transactions/mutations/processingScheduler.d.ts +14 -0
- package/dist/local/transactions/mutations/processingScheduler.js +24 -0
- package/dist/local/transactions/mutations/queueCoalescing.d.ts +13 -0
- package/dist/local/transactions/mutations/queueCoalescing.js +35 -0
- package/dist/local/transactions/mutations/replayValidation.d.ts +187 -0
- package/dist/local/transactions/mutations/replayValidation.js +164 -0
- package/dist/local/transactions/reconnectDrain.d.ts +11 -0
- package/dist/local/transactions/reconnectDrain.js +13 -0
- package/dist/local/utils/mobxSetup.d.ts +53 -0
- package/dist/local/utils/mobxSetup.js +330 -0
- package/dist/local/views/QueryView.d.ts +79 -0
- package/dist/local/views/QueryView.js +218 -0
- package/dist/local/views/ViewRegistry.d.ts +20 -0
- package/dist/local/views/ViewRegistry.js +57 -0
- package/dist/local/views/incrementalView.d.ts +45 -0
- package/dist/local/views/incrementalView.js +69 -0
- package/dist/plugin.d.ts +285 -0
- package/dist/plugin.js +106 -0
- package/dist/presenceStream.d.ts +69 -0
- package/dist/presenceStream.js +200 -0
- package/dist/react/AbloProvider.d.ts +242 -0
- package/dist/react/AbloProvider.js +456 -0
- package/dist/react/ClientSideSuspense.d.ts +36 -0
- package/dist/react/ClientSideSuspense.js +17 -0
- package/dist/react/DefaultFallback.d.ts +24 -0
- package/dist/react/DefaultFallback.js +43 -0
- package/dist/react/context.d.ts +55 -0
- package/dist/react/context.js +29 -0
- package/dist/react/createAbloReact.d.ts +50 -0
- package/dist/react/createAbloReact.js +48 -0
- package/dist/react/internalContext.d.ts +33 -0
- package/dist/react/internalContext.js +3 -0
- package/dist/react/useAblo.d.ts +96 -0
- package/dist/react/useAblo.js +120 -0
- package/dist/react/useCurrentUserId.d.ts +2 -0
- package/dist/react/useCurrentUserId.js +12 -0
- package/dist/react/useErrorListener.d.ts +2 -0
- package/dist/react/useErrorListener.js +14 -0
- package/dist/react/useMutationFailureListener.d.ts +8 -0
- package/dist/react/useMutationFailureListener.js +19 -0
- package/dist/react/useMutators.d.ts +56 -0
- package/dist/react/useMutators.js +84 -0
- package/dist/react/useSyncStatus.d.ts +19 -0
- package/dist/react/useSyncStatus.js +37 -0
- package/dist/react/useUndoScope.d.ts +34 -0
- package/dist/react/useUndoScope.js +73 -0
- package/dist/react.d.ts +18 -0
- package/dist/react.js +14 -0
- package/dist/reactRuntime.d.ts +4 -0
- package/dist/reactRuntime.js +2 -0
- package/dist/surface.d.ts +36 -0
- package/dist/surface.js +77 -0
- package/dist/useReactive.d.ts +6 -0
- package/dist/useReactive.js +43 -0
- package/package.json +119 -0
- package/src/Ablo.ts +456 -0
- package/src/client.ts +374 -0
- package/src/core.ts +104 -0
- package/src/humans.ts +61 -0
- package/src/index.ts +40 -0
- package/src/local/BaseSyncedStore.ts +1991 -0
- package/src/local/Database.ts +2052 -0
- package/src/local/InstanceCache.ts +1503 -0
- package/src/local/LazyReferenceCollection.ts +563 -0
- package/src/local/Model.ts +1124 -0
- package/src/local/ModelRegistry.ts +762 -0
- package/src/local/NetworkMonitor.ts +88 -0
- package/src/local/RuntimeContext.ts +141 -0
- package/src/local/SyncClient.ts +2131 -0
- package/src/local/adapters/alwaysOnline.ts +20 -0
- package/src/local/adapters/inMemoryStorage.ts +141 -0
- package/src/local/client/clientPrelude.ts +112 -0
- package/src/local/client/consoleLogger.ts +60 -0
- package/src/local/client/createInternalComponents.ts +163 -0
- package/src/local/client/createModelProxy.ts +1390 -0
- package/src/local/client/modelRegistration.ts +350 -0
- package/src/local/client/options.ts +526 -0
- package/src/local/client/reactiveEngine.ts +935 -0
- package/src/local/client/resourceTypes.ts +37 -0
- package/src/local/client/schemaConfig.ts +194 -0
- package/src/local/client/storeCluster.ts +172 -0
- package/src/local/client/storeLifecycle.ts +343 -0
- package/src/local/client/validateAbloOptions.ts +95 -0
- package/src/local/client/wsMutationExecutor.ts +110 -0
- package/src/local/context.ts +96 -0
- package/src/local/coordination/ClaimLog.ts +39 -0
- package/src/local/interfaces/index.ts +468 -0
- package/src/local/localModelContract.ts +15 -0
- package/src/local/logPosition.ts +84 -0
- package/src/local/mutationPersistence.ts +7 -0
- package/src/local/mutators/RecordingMutation.ts +222 -0
- package/src/local/mutators/Transaction.ts +97 -0
- package/src/local/mutators/UndoManager.ts +741 -0
- package/src/local/mutators/defineMutators.ts +76 -0
- package/src/local/mutators/inverseOp.ts +83 -0
- package/src/local/mutators/mutateActions.ts +167 -0
- package/src/local/mutators/readerActions.ts +99 -0
- package/src/local/mutators/undoApply.ts +141 -0
- package/src/local/persistence.ts +16 -0
- package/src/local/query/QueryProcessor.ts +347 -0
- package/src/local/query/client.ts +197 -0
- package/src/local/query/types.ts +102 -0
- package/src/local/schema/serialize.ts +1 -0
- package/src/local/store/queryApi.ts +56 -0
- package/src/local/storeContract.ts +146 -0
- package/src/local/stores/DatabaseManager.ts +507 -0
- package/src/local/stores/ObjectStore.ts +449 -0
- package/src/local/stores/ObjectStoreContract.ts +48 -0
- package/src/local/stores/StoreManager.ts +388 -0
- package/src/local/stores/SyncActionStore.ts +579 -0
- package/src/local/stores/openIDBWithTimeout.ts +195 -0
- package/src/local/stores/persistenceCleanup.ts +43 -0
- package/src/local/stores/persistenceIdentity.ts +83 -0
- package/src/local/stores/syncAction.ts +21 -0
- package/src/local/stores/v1PersistenceDeletion.ts +21 -0
- package/src/local/sync/BootstrapFetcher.ts +1224 -0
- package/src/local/sync/ConnectionManager.ts +15 -0
- package/src/local/sync/OnDemandLoader.ts +927 -0
- package/src/local/sync/SubscriptionManager.ts +300 -0
- package/src/local/sync/SyncWebSocket.ts +584 -0
- package/src/local/sync/bootstrapApply.ts +130 -0
- package/src/local/sync/commitFrames.ts +16 -0
- package/src/local/sync/connectionManagerLifecycle.ts +158 -0
- package/src/local/sync/contextPorts.ts +37 -0
- package/src/local/sync/createClaimStream.ts +668 -0
- package/src/local/sync/createSnapshot.ts +160 -0
- package/src/local/sync/credentialLifecycle.ts +18 -0
- package/src/local/sync/deltaPipeline.ts +473 -0
- package/src/local/sync/groupChange.ts +343 -0
- package/src/local/sync/initialize.ts +205 -0
- package/src/local/sync/participants.ts +564 -0
- package/src/local/sync/persistedPrefix.ts +27 -0
- package/src/local/sync/reconnect.ts +88 -0
- package/src/local/sync/schemaDrift.ts +86 -0
- package/src/local/sync/schemas.ts +118 -0
- package/src/local/sync/socketEventWiring.ts +196 -0
- package/src/local/sync/syncCursor.ts +62 -0
- package/src/local/sync/syncPlan.ts +89 -0
- package/src/local/sync/terminalSessionLifecycle.ts +66 -0
- package/src/local/sync/wsFrameHandlers.ts +20 -0
- package/src/local/transactions/databaseCommitOutbox.ts +33 -0
- package/src/local/transactions/localMutation.ts +58 -0
- package/src/local/transactions/mutations/MutationQueue.ts +1998 -0
- package/src/local/transactions/mutations/MutationStore.ts +65 -0
- package/src/local/transactions/mutations/UnconfirmedWrites.ts +133 -0
- package/src/local/transactions/mutations/batchProcessing.ts +469 -0
- package/src/local/transactions/mutations/coalesceRules.ts +192 -0
- package/src/local/transactions/mutations/commitApi.ts +97 -0
- package/src/local/transactions/mutations/commitLane.ts +191 -0
- package/src/local/transactions/mutations/commitLatency.ts +164 -0
- package/src/local/transactions/mutations/commitPayload.ts +281 -0
- package/src/local/transactions/mutations/commitTransport.ts +174 -0
- package/src/local/transactions/mutations/deltaConfirmation.ts +298 -0
- package/src/local/transactions/mutations/durableCommitRestore.ts +128 -0
- package/src/local/transactions/mutations/durableWriteStore.ts +21 -0
- package/src/local/transactions/mutations/executionSelection.ts +42 -0
- package/src/local/transactions/mutations/failureHandling.ts +154 -0
- package/src/local/transactions/mutations/failurePolicy.ts +61 -0
- package/src/local/transactions/mutations/localMutation.ts +135 -0
- package/src/local/transactions/mutations/modelOperations.ts +210 -0
- package/src/local/transactions/mutations/mutationPersistence.ts +231 -0
- package/src/local/transactions/mutations/pendingDrain.ts +160 -0
- package/src/local/transactions/mutations/processingScheduler.ts +35 -0
- package/src/local/transactions/mutations/queueCoalescing.ts +45 -0
- package/src/local/transactions/mutations/replayValidation.ts +192 -0
- package/src/local/transactions/reconnectDrain.ts +24 -0
- package/src/local/utils/mobxSetup.ts +388 -0
- package/src/local/views/QueryView.ts +311 -0
- package/src/local/views/ViewRegistry.ts +61 -0
- package/src/local/views/incrementalView.ts +92 -0
- package/src/plugin.ts +396 -0
- package/src/presenceStream.ts +279 -0
- package/src/react/AbloProvider.tsx +744 -0
- package/src/react/ClientSideSuspense.tsx +57 -0
- package/src/react/DefaultFallback.tsx +60 -0
- package/src/react/context.ts +89 -0
- package/src/react/createAbloReact.ts +116 -0
- package/src/react/internalContext.ts +38 -0
- package/src/react/useAblo.ts +280 -0
- package/src/react/useCurrentUserId.ts +17 -0
- package/src/react/useErrorListener.ts +22 -0
- package/src/react/useMutationFailureListener.ts +34 -0
- package/src/react/useMutators.ts +184 -0
- package/src/react/useSyncStatus.ts +42 -0
- package/src/react/useUndoScope.ts +143 -0
- package/src/react.ts +69 -0
- package/src/reactRuntime.ts +10 -0
- package/src/surface.ts +106 -0
- package/src/useReactive.ts +51 -0
|
@@ -0,0 +1,1998 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MutationQueue 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.
|
|
6
|
+
*
|
|
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.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { EventEmitter } from 'events';
|
|
16
|
+
import { v4 as uuid } from 'uuid';
|
|
17
|
+
import type { LocalModel } from '../../localModelContract.js';
|
|
18
|
+
import type { MutationPersistencePort } from '../../mutationPersistence.js';
|
|
19
|
+
import { globalRuntime } from '../../context.js';
|
|
20
|
+
import type { RuntimeContext } from '../../RuntimeContext.js';
|
|
21
|
+
import type { MutationOperationType } from '@abloatai/transaction/types';
|
|
22
|
+
import {
|
|
23
|
+
AbloError,
|
|
24
|
+
AbloConnectionError,
|
|
25
|
+
AbloIdempotencyError,
|
|
26
|
+
AbloNotFoundError,
|
|
27
|
+
AbloValidationError,
|
|
28
|
+
errorCodeSpec,
|
|
29
|
+
} from '@abloatai/transaction/errors';
|
|
30
|
+
import {
|
|
31
|
+
LogPosition,
|
|
32
|
+
type LogPositionPort,
|
|
33
|
+
} from '../../logPosition.js';
|
|
34
|
+
import type { WriteOptions } from '../../interfaces/index.js';
|
|
35
|
+
import type { OnStaleMode, StaleNotification, ReadDependency, TrackDependency } from '@abloatai/transaction/coordination/schema';
|
|
36
|
+
import {
|
|
37
|
+
mutationCommitResultSchema,
|
|
38
|
+
type MutationCommitResult,
|
|
39
|
+
} from '@abloatai/transaction/wire/commit';
|
|
40
|
+
import {
|
|
41
|
+
projectCommitPayload,
|
|
42
|
+
computePriorityScore,
|
|
43
|
+
normalizeModelKey,
|
|
44
|
+
// Includes stale guards as well as request identity/audit barriers.
|
|
45
|
+
hasCommitCoalescingBarrier,
|
|
46
|
+
applyWriteOptions,
|
|
47
|
+
asTransportError,
|
|
48
|
+
extractStatusCode,
|
|
49
|
+
TX_TYPE_TO_MUTATION_OP,
|
|
50
|
+
type MutationInput,
|
|
51
|
+
type QueuedMutation,
|
|
52
|
+
type UserContext,
|
|
53
|
+
type WriteOperationFields,
|
|
54
|
+
} from './commitPayload.js';
|
|
55
|
+
import { MutationStore } from './MutationStore.js';
|
|
56
|
+
import {
|
|
57
|
+
entityKey,
|
|
58
|
+
mergeUpdateData,
|
|
59
|
+
takeUnsentCreateForModel,
|
|
60
|
+
findCreateBarrierForDelete,
|
|
61
|
+
deferDeleteUntilCreateSettles,
|
|
62
|
+
releaseDeferredDeletesForCreate,
|
|
63
|
+
} from './coalesceRules.js';
|
|
64
|
+
import { DeltaConfirmationTracker } from './deltaConfirmation.js';
|
|
65
|
+
import {
|
|
66
|
+
deserializePersistedTransaction,
|
|
67
|
+
isNonReplayablePersistedRow,
|
|
68
|
+
pendingMutationRecordId,
|
|
69
|
+
legacyPendingMutationRecordSchema,
|
|
70
|
+
pendingMutationRecordSchema,
|
|
71
|
+
persistedMutationSchema,
|
|
72
|
+
} from './replayValidation.js';
|
|
73
|
+
import {
|
|
74
|
+
deserializeLegacyPendingMutation,
|
|
75
|
+
loadPersistedTransactions,
|
|
76
|
+
persistQueuedTransaction,
|
|
77
|
+
removePersistedTransaction,
|
|
78
|
+
settlePersistedFailure,
|
|
79
|
+
type MutationPersistenceContext,
|
|
80
|
+
} from './mutationPersistence.js';
|
|
81
|
+
import {
|
|
82
|
+
createCommitEnvelopeMember,
|
|
83
|
+
createDurableCommitEnvelope,
|
|
84
|
+
commitEnvelopeRecordId,
|
|
85
|
+
durableCommitEnvelopeSchema,
|
|
86
|
+
type DurableCommitEnvelope,
|
|
87
|
+
type DurableCommitOperation,
|
|
88
|
+
type DurableCommitOperationInput,
|
|
89
|
+
type CommitOutboxScope,
|
|
90
|
+
} from '@abloatai/transaction/transactions/settlement/commitEnvelope';
|
|
91
|
+
import type { DurableWriteStore } from './durableWriteStore.js';
|
|
92
|
+
import { stableStringify } from '@abloatai/transaction/utils/json';
|
|
93
|
+
import {
|
|
94
|
+
createLocalMutationPort,
|
|
95
|
+
type LocalMutationPort,
|
|
96
|
+
} from './localMutation.js';
|
|
97
|
+
import {
|
|
98
|
+
dispatchCommitBounded,
|
|
99
|
+
parseMutationCommitResult,
|
|
100
|
+
persistDurableCommitAcceptance,
|
|
101
|
+
removeDurableCommit,
|
|
102
|
+
sealDurableCommit,
|
|
103
|
+
type CommitTransportContext,
|
|
104
|
+
type SealDurableCommitInput,
|
|
105
|
+
} from './commitTransport.js';
|
|
106
|
+
import {
|
|
107
|
+
processCommitLane,
|
|
108
|
+
waitForCommitReceipt,
|
|
109
|
+
type CommitLaneContext,
|
|
110
|
+
type CommitReceiptContext,
|
|
111
|
+
type CommitTransaction,
|
|
112
|
+
} from './commitLane.js';
|
|
113
|
+
import { enqueueCommit, type CommitApiContext } from './commitApi.js';
|
|
114
|
+
import {
|
|
115
|
+
archive as archiveModel,
|
|
116
|
+
create as createModel,
|
|
117
|
+
remove as deleteModel,
|
|
118
|
+
unarchive as unarchiveModel,
|
|
119
|
+
update as updateModel,
|
|
120
|
+
type ModelMutationContext,
|
|
121
|
+
} from './modelOperations.js';
|
|
122
|
+
import { enqueueTransaction, type QueueCoalescingContext } from './queueCoalescing.js';
|
|
123
|
+
import { processBatch, type BatchProcessingContext } from './batchProcessing.js';
|
|
124
|
+
import { handleFailure, type FailureHandlingContext } from './failureHandling.js';
|
|
125
|
+
import { handleConflict as resolveConflict, isPermanentError as classifyPermanentError, isDefinitiveRejection as classifyDefinitiveRejection, type ConflictPolicyContext } from './failurePolicy.js';
|
|
126
|
+
import { takeNextExecutionBatch as selectExecutionBatch, takePendingDrainBatch as selectPendingDrainBatch } from './executionSelection.js';
|
|
127
|
+
import { scheduleProcessing as scheduleProcessingExternal, type ProcessingSchedulerContext } from './processingScheduler.js';
|
|
128
|
+
import {
|
|
129
|
+
drainPendingSettlements,
|
|
130
|
+
type PendingDrainContext,
|
|
131
|
+
} from './pendingDrain.js';
|
|
132
|
+
import { restoreDurableCommits as restoreDurableCommitsExternal, type DurableCommitRestoreContext } from './durableCommitRestore.js';
|
|
133
|
+
|
|
134
|
+
// The queue is split across sibling modules (`commitPayload`,
|
|
135
|
+
// `MutationStore`, `coalesceRules`, `deltaConfirmation`, `optimistic`).
|
|
136
|
+
// Re-export the shared public types here so importers can continue to reach
|
|
137
|
+
// them through this module.
|
|
138
|
+
export type { QueuedMutation, UserContext } from './commitPayload.js';
|
|
139
|
+
/**
|
|
140
|
+
* A pre-built, multi-operation commit submitted through
|
|
141
|
+
* `ablo.commits.create()`. Unlike the per-model {@link QueuedMutation} (see
|
|
142
|
+
* `./commitPayload.js`), the caller supplies the operations and the whole
|
|
143
|
+
* envelope commits atomically: the queue does not coalesce it, reorder its
|
|
144
|
+
* operations for foreign keys, or apply it optimistically. It runs through the
|
|
145
|
+
* same `mutationExecutor.commit()` as the model batch path, so its
|
|
146
|
+
* retry-on-reconnect behaviour is identical.
|
|
147
|
+
*/
|
|
148
|
+
interface ConflictResolution {
|
|
149
|
+
strategy: 'last-write-wins' | 'merge' | 'reject' | 'custom';
|
|
150
|
+
resolver?: (local: MutationInput | undefined, remote: MutationInput) => MutationInput;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export interface MutationQueueConfig {
|
|
154
|
+
/** Shared client position (see logPosition.ts). One per client. */
|
|
155
|
+
position?: LogPositionPort;
|
|
156
|
+
/** The owning client's runtime. Defaults to the module-global bridge. */
|
|
157
|
+
runtime?: RuntimeContext;
|
|
158
|
+
maxBatchSize: number;
|
|
159
|
+
batchDelay: number;
|
|
160
|
+
maxRetries: number;
|
|
161
|
+
conflictResolution: ConflictResolution;
|
|
162
|
+
enablePersistence: boolean;
|
|
163
|
+
enableOptimistic: boolean;
|
|
164
|
+
/** Local adapter for applying and rolling back local writes. */
|
|
165
|
+
localMutationPort?: LocalMutationPort;
|
|
166
|
+
maxExecutingTransactions: number;
|
|
167
|
+
// How long to wait, in milliseconds, for a change's confirming sync delta
|
|
168
|
+
// before the retry-and-reconciliation cycle begins. For a source-forwarded
|
|
169
|
+
// write this is also the public `wait: 'confirmed'` deadline: expiry rejects
|
|
170
|
+
// the waiter with `replication_lag_timeout` while the accepted write remains
|
|
171
|
+
// pending. Defaults to 30000 (30 seconds); raise it for slow networks.
|
|
172
|
+
deltaConfirmationTimeout: number;
|
|
173
|
+
/**
|
|
174
|
+
* Exponential backoff for retryable server responses (HTTP 429/503).
|
|
175
|
+
* `baseMs` is the first retry delay; each subsequent attempt doubles
|
|
176
|
+
* up to `capMs`. Final delay = min(capMs, baseMs * 2^(attempt-1)) +
|
|
177
|
+
* up to 100ms of jitter. Defaults: 200ms / 1500ms.
|
|
178
|
+
*/
|
|
179
|
+
retryBackoff: {
|
|
180
|
+
baseMs: number;
|
|
181
|
+
capMs: number;
|
|
182
|
+
};
|
|
183
|
+
/**
|
|
184
|
+
* How long, in milliseconds, to wait after the connection drops before
|
|
185
|
+
* failing any in-flight commit-lane transaction with an
|
|
186
|
+
* {@link AbloConnectionError}. Brief disconnects, such as a server restart
|
|
187
|
+
* or mobile network jitter, are absorbed transparently; only a disconnect
|
|
188
|
+
* that outlasts this window surfaces as a failure. Set it lower for
|
|
189
|
+
* interactive use (for example 10 seconds for chat) and higher for
|
|
190
|
+
* background batch work. Defaults to 30 seconds.
|
|
191
|
+
*
|
|
192
|
+
* Without this deadline, `commits.create({ wait: 'confirmed' })` would wait
|
|
193
|
+
* forever if the connection died while a commit was in flight.
|
|
194
|
+
*/
|
|
195
|
+
commitOfflineGraceMs: number;
|
|
196
|
+
/**
|
|
197
|
+
* How long, in milliseconds, to wait for the transport to acknowledge a
|
|
198
|
+
* dispatched commit before treating the silence as a retryable no-receipt
|
|
199
|
+
* failure. A transport that never answers — a half-open socket, a dropped
|
|
200
|
+
* response frame — would otherwise hold the commit in flight forever, and
|
|
201
|
+
* with it the client's staged-batch lock: every later write in the session
|
|
202
|
+
* queues silently behind the unanswered one. The envelope's idempotency key
|
|
203
|
+
* is stable across retries, so a commit the server DID apply before the
|
|
204
|
+
* timeout is deduplicated on the retry rather than double-applied.
|
|
205
|
+
* Defaults to 30 seconds. Set `0` to disable the bound.
|
|
206
|
+
*/
|
|
207
|
+
commitDispatchTimeoutMs: number;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export class MutationQueue extends EventEmitter {
|
|
211
|
+
// Keep one hour of clock/network margin inside the server's 24-hour ledger.
|
|
212
|
+
private static readonly DURABLE_REPLAY_WINDOW_MS = 23 * 60 * 60 * 1000;
|
|
213
|
+
private store = new MutationStore();
|
|
214
|
+
// Signature of the last permanent-error we logged at `warn`. A `create`
|
|
215
|
+
// whose id already exists (`unique_violation`) is a permanent rejection
|
|
216
|
+
// that a pending-work drain re-drives after reconnect/bootstrap — without
|
|
217
|
+
// this, the identical cause prints on a loop. We log the first occurrence
|
|
218
|
+
// and demote exact repeats to `debug`.
|
|
219
|
+
private lastPermanentErrorSig?: string;
|
|
220
|
+
// The executor bound to this queue instance, set by `setMutationExecutor(...)`
|
|
221
|
+
// just after construction. When unset it falls back to the ambient executor
|
|
222
|
+
// from `getContext()`.
|
|
223
|
+
//
|
|
224
|
+
// The binding matters because the ambient executor is a module-level
|
|
225
|
+
// singleton: constructing a second client instance overwrites the first
|
|
226
|
+
// instance's executor. Without a per-instance binding, commits on one
|
|
227
|
+
// instance would dispatch through another instance's executor closure; once
|
|
228
|
+
// that other instance disposed its store, the closure would resolve no live
|
|
229
|
+
// connection and every commit here would fail with `ws_not_ready`, which the
|
|
230
|
+
// queue treats as transient and retries endlessly.
|
|
231
|
+
private _mutationExecutor: import('../../interfaces/index.js').MutationExecutor | null = null;
|
|
232
|
+
private get mutationExecutor() {
|
|
233
|
+
return this._mutationExecutor ?? this.runtime.mutationExecutor;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
private readonly runtime: RuntimeContext;
|
|
237
|
+
/** Durable transaction journal owned by this queue, before commit sealing. */
|
|
238
|
+
private persistence: MutationPersistencePort | null = null;
|
|
239
|
+
private deferredMutations: Array<{
|
|
240
|
+
type: 'create' | 'update' | 'delete' | 'archive';
|
|
241
|
+
model: LocalModel;
|
|
242
|
+
capturedChanges?: Record<string, unknown>;
|
|
243
|
+
writeOptions?: WriteOptions;
|
|
244
|
+
}> = [];
|
|
245
|
+
private pendingPersistenceStages: Array<{
|
|
246
|
+
transaction: QueuedMutation;
|
|
247
|
+
modelData: Record<string, unknown>;
|
|
248
|
+
resolve: () => void;
|
|
249
|
+
reject: (error: Error) => void;
|
|
250
|
+
}> = [];
|
|
251
|
+
private persistenceStageScheduled = false;
|
|
252
|
+
private pendingDrainPromise: Promise<void> | null = null;
|
|
253
|
+
|
|
254
|
+
private executionQueue: QueuedMutation[] = [];
|
|
255
|
+
private isProcessing = false;
|
|
256
|
+
private processTimer?: NodeJS.Timeout;
|
|
257
|
+
private processScheduled = false;
|
|
258
|
+
|
|
259
|
+
// Staging area for transactions created in the same event-loop tick. Each one
|
|
260
|
+
// lands here first, then a microtask commits them together.
|
|
261
|
+
private createdTransactions: QueuedMutation[] = [];
|
|
262
|
+
private commitScheduled = false;
|
|
263
|
+
|
|
264
|
+
// Per-model in-flight tracking and merge buffer
|
|
265
|
+
private inFlightByModel = new Set<string>();
|
|
266
|
+
private pendingMergeByModel = new Map<
|
|
267
|
+
string,
|
|
268
|
+
{ data: MutationInput; sourceMutationIds: string[] }
|
|
269
|
+
>();
|
|
270
|
+
private deferredDeletesByCreate = new Map<string, QueuedMutation[]>();
|
|
271
|
+
|
|
272
|
+
// Commit lane: pre-built atomic multi-op envelopes from `ablo.commits.create()`.
|
|
273
|
+
// Drained serially (one envelope at a time) since each is atomic; no
|
|
274
|
+
// coalescing with model-proxy transactions.
|
|
275
|
+
private commitLane: CommitTransaction[] = [];
|
|
276
|
+
private commitStore = new Map<string, CommitTransaction>();
|
|
277
|
+
/**
|
|
278
|
+
* Small race buffer for authoritative echoes that arrive before the queued
|
|
279
|
+
* mutation receipt. The forward and WAL stream are independent channels, so
|
|
280
|
+
* either can win without changing the settlement result.
|
|
281
|
+
*/
|
|
282
|
+
private recentDeltaCorrelations = new Map<string, number>();
|
|
283
|
+
/**
|
|
284
|
+
* Client-facing confirmation deadlines for source-forwarded writes. These
|
|
285
|
+
* timers settle only the caller waiting for `confirmed`; the accepted write
|
|
286
|
+
* remains in `awaiting_delta`, with its durable envelope intact, until the
|
|
287
|
+
* authoritative WAL echo arrives.
|
|
288
|
+
*/
|
|
289
|
+
private replicationLagTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
|
|
290
|
+
private replicationLagErrors = new Map<string, AbloConnectionError>();
|
|
291
|
+
private commitProcessing = false;
|
|
292
|
+
private lastCommitSequence = 0;
|
|
293
|
+
private durableReplayBlock: AbloIdempotencyError | null = null;
|
|
294
|
+
/** Browser-backed strict outbox; absent for standalone/in-memory consumers. */
|
|
295
|
+
private commitOutbox: DurableWriteStore | null = null;
|
|
296
|
+
private commitOutboxScope: CommitOutboxScope | null = null;
|
|
297
|
+
|
|
298
|
+
private get commitTransportContext(): CommitTransportContext {
|
|
299
|
+
return {
|
|
300
|
+
runtime: this.runtime,
|
|
301
|
+
config: {
|
|
302
|
+
enablePersistence: this.config.enablePersistence,
|
|
303
|
+
commitDispatchTimeoutMs: this.config.commitDispatchTimeoutMs,
|
|
304
|
+
},
|
|
305
|
+
commitOutbox: this.commitOutbox,
|
|
306
|
+
commitOutboxScope: this.commitOutboxScope,
|
|
307
|
+
mutationExecutor: this.mutationExecutor,
|
|
308
|
+
emitCommitLifecycle: (event, payload) => this.emitCommitLifecycle(event, payload),
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
private get commitLaneContext(): CommitLaneContext {
|
|
313
|
+
return {
|
|
314
|
+
runtime: this.runtime,
|
|
315
|
+
config: { maxRetries: this.config.maxRetries },
|
|
316
|
+
commitLane: this.commitLane,
|
|
317
|
+
commitNotifications: this.commitNotifications,
|
|
318
|
+
commitMissingIds: this.commitMissingIds,
|
|
319
|
+
commitProcessing: this.commitProcessing,
|
|
320
|
+
setCommitProcessing: (value) => { this.commitProcessing = value; },
|
|
321
|
+
durableReplayBlock: this.durableReplayBlock,
|
|
322
|
+
sealDurableCommit: (input) => this.sealDurableCommit(input),
|
|
323
|
+
assertEnvelopeInsideReplayWindow: (envelope) => this.assertEnvelopeInsideReplayWindow(envelope),
|
|
324
|
+
dispatchCommit: async (envelope) => this.parseMutationCommitResult(
|
|
325
|
+
await this.dispatchCommitBounded(envelope.operations, {
|
|
326
|
+
idempotencyKey: envelope.idempotencyKey,
|
|
327
|
+
...(envelope.commitOptions.reads ? { reads: envelope.commitOptions.reads } : {}),
|
|
328
|
+
...(envelope.commitOptions.track ? { track: envelope.commitOptions.track } : {}),
|
|
329
|
+
}),
|
|
330
|
+
),
|
|
331
|
+
persistDurableCommitAcceptance: (envelope, result) => this.persistDurableCommitAcceptance(envelope, result),
|
|
332
|
+
removeDurableCommit: (idempotencyKey) => this.removeDurableCommit(idempotencyKey),
|
|
333
|
+
queuedCommitEchoSyncId: (transaction) => this.queuedCommitEchoSyncId(transaction),
|
|
334
|
+
completeQueuedCommit: (transaction, syncId) => this.completeQueuedCommit(transaction, syncId),
|
|
335
|
+
scheduleReplicationLagTimeout: (transactionId, clientTxId, correlationId) => this.scheduleReplicationLagTimeout(transactionId, clientTxId, correlationId),
|
|
336
|
+
noteAck: (syncId) => this.noteAck(syncId),
|
|
337
|
+
isDefinitiveRejection: (error) => this.isDefinitiveRejection(error),
|
|
338
|
+
isPermanentError: (error) => this.isPermanentError(error),
|
|
339
|
+
emitCommitLifecycle: (event, payload) => this.emitCommitLifecycle(event, payload),
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
private get commitReceiptContext(): CommitReceiptContext {
|
|
344
|
+
return {
|
|
345
|
+
commitStore: this.commitStore,
|
|
346
|
+
commitNotifications: this.commitNotifications,
|
|
347
|
+
commitMissingIds: this.commitMissingIds,
|
|
348
|
+
replicationLagErrors: this.replicationLagErrors,
|
|
349
|
+
on: (event, listener) => this.on(event, listener),
|
|
350
|
+
off: (event, listener) => this.off(event, listener),
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
private get commitApiContext(): CommitApiContext {
|
|
355
|
+
return {
|
|
356
|
+
assertDurableReplayOpen: () => this.assertDurableReplayOpen(),
|
|
357
|
+
commitStore: this.commitStore,
|
|
358
|
+
commitLane: this.commitLane,
|
|
359
|
+
replicationLagErrors: this.replicationLagErrors,
|
|
360
|
+
clearReplicationLagState: (transactionId) => this.clearReplicationLagState(transactionId),
|
|
361
|
+
nextCommitSequence: () => this.nextCommitSequence(),
|
|
362
|
+
sealDurableCommit: (input) => this.sealDurableCommit(input),
|
|
363
|
+
processCommitLane: () => this.processCommitLane(),
|
|
364
|
+
emitCommitLifecycle: (event, payload) => this.emitCommitLifecycle(event, payload),
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
private get modelMutationContext(): ModelMutationContext {
|
|
369
|
+
return {
|
|
370
|
+
enableOptimistic: this.config.enableOptimistic,
|
|
371
|
+
persistenceReady: !!this.persistence && !!this.commitOutboxScope,
|
|
372
|
+
assertDurableReplayOpen: () => this.assertDurableReplayOpen(),
|
|
373
|
+
generateId: () => this.generateId(),
|
|
374
|
+
normalizeModelKey,
|
|
375
|
+
computePriorityScore: (type, modelName) => this.computePriorityScore(type, modelName),
|
|
376
|
+
extractCreateData: (model) => this.extractCreateData(model),
|
|
377
|
+
extractUpdateData: (model) => this.extractUpdateData(model),
|
|
378
|
+
extractPreviousData: (model, input) => this.extractPreviousData(model, input),
|
|
379
|
+
mapChangesToInput: (modelName, changes) => this.mapChangesToInput(modelName, changes),
|
|
380
|
+
isReorderPayload: (input) => this.isReorderPayload(input),
|
|
381
|
+
attachConfirmation: (transaction) => this.attachConfirmation(transaction),
|
|
382
|
+
add: (transaction) => this.store.add(transaction),
|
|
383
|
+
applyOptimisticCreate: (model, transaction) => this.applyOptimisticCreate(model, transaction),
|
|
384
|
+
applyOptimisticUpdate: (model, transaction) => this.applyOptimisticUpdate(model, transaction),
|
|
385
|
+
applyOptimisticDelete: (model, transaction) => this.applyOptimisticDelete(model, transaction),
|
|
386
|
+
takeUnsentCreateForModel: (modelName, modelId) => this.takeUnsentCreateForModel(modelName, modelId),
|
|
387
|
+
cancelUnsentCreateForDelete: (transaction) => this.cancelUnsentCreateForDelete(transaction),
|
|
388
|
+
completeLocalDelete: (model, context, writeOptions, sourceMutationIds) => this.completeLocalDelete(model, context, writeOptions, sourceMutationIds),
|
|
389
|
+
cancelTransactionsForModel: (modelId, type) => this.cancelTransactionsForModel(modelId, type),
|
|
390
|
+
pendingMergeByModel: this.pendingMergeByModel,
|
|
391
|
+
inFlightByModel: this.inFlightByModel,
|
|
392
|
+
findCreateBarrierForDelete: (modelName, modelId) => this.findCreateBarrierForDelete(modelName, modelId),
|
|
393
|
+
deferDeleteUntilCreateSettles: (create, transaction) => this.deferDeleteUntilCreateSettles(create, transaction),
|
|
394
|
+
logger: this.runtime.logger,
|
|
395
|
+
persistAndStage: (transaction, modelData) => this.persistAndStage(transaction, modelData),
|
|
396
|
+
persistQueuedTransaction: (transaction, modelData) => this.persistQueuedTransaction(transaction, modelData),
|
|
397
|
+
stageTransaction: (transaction) => this.stageTransaction(transaction),
|
|
398
|
+
emit: (event, payload) => this.emit(event, payload),
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
private get queueCoalescingContext(): QueueCoalescingContext {
|
|
403
|
+
return {
|
|
404
|
+
executionQueue: this.executionQueue,
|
|
405
|
+
inFlightByModel: this.inFlightByModel,
|
|
406
|
+
pendingMergeByModel: this.pendingMergeByModel,
|
|
407
|
+
ensureDerivedFields: (transaction) => this.ensureDerivedFields(transaction),
|
|
408
|
+
scheduleProcessing: (immediate) => this.scheduleProcessing(immediate),
|
|
409
|
+
storeRemove: (transactionId) => this.store.remove(transactionId),
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
private get batchProcessingContext(): BatchProcessingContext {
|
|
414
|
+
return {
|
|
415
|
+
runtime: this.runtime,
|
|
416
|
+
config: this.config,
|
|
417
|
+
durableReplayBlock: this.durableReplayBlock,
|
|
418
|
+
executionQueue: this.executionQueue,
|
|
419
|
+
isProcessing: this.isProcessing,
|
|
420
|
+
setIsProcessing: (value) => { this.isProcessing = value; },
|
|
421
|
+
takeNextExecutionBatch: () => this.takeNextExecutionBatch(),
|
|
422
|
+
ensureDerivedFields: (transaction) => this.ensureDerivedFields(transaction),
|
|
423
|
+
ensureCommitEnvelope: (batch) => this.ensureCommitEnvelope([...batch]),
|
|
424
|
+
executingCount: this.executingCount,
|
|
425
|
+
setExecutingCount: (value) => { this.executingCount = value; },
|
|
426
|
+
inFlightByModel: this.inFlightByModel,
|
|
427
|
+
pendingMergeByModel: this.pendingMergeByModel,
|
|
428
|
+
generateId: () => this.generateId(),
|
|
429
|
+
computePriorityScore: (type, modelName) => this.computePriorityScore(type, modelName),
|
|
430
|
+
store: this.store,
|
|
431
|
+
enqueue: (transaction) => this.enqueue(transaction),
|
|
432
|
+
optimisticUpdates: this.localMutationPort.updates,
|
|
433
|
+
commitNotifications: this.commitNotifications,
|
|
434
|
+
commitMissingIds: this.commitMissingIds,
|
|
435
|
+
sourceMutationIdsFor: (batch) => this.sourceMutationIdsFor(batch),
|
|
436
|
+
dispatchCommitBounded: (...args) => this.dispatchCommitBounded(...args),
|
|
437
|
+
parseMutationCommitResult: (value) => this.parseMutationCommitResult(value),
|
|
438
|
+
persistDurableCommitAcceptance: (envelope, result) => this.persistDurableCommitAcceptance(envelope, result),
|
|
439
|
+
removeDurableCommit: (idempotencyKey) => this.removeDurableCommit(idempotencyKey),
|
|
440
|
+
assertEnvelopeInsideReplayWindow: (envelope) => this.assertEnvelopeInsideReplayWindow(envelope),
|
|
441
|
+
sealDurableCommit: (input) => this.sealDurableCommit(input),
|
|
442
|
+
noteAck: (syncId) => this.noteAck(syncId),
|
|
443
|
+
classifyReceiptNotifications: (operations, notifications) => this.classifyReceiptNotifications(operations, notifications),
|
|
444
|
+
receiptTargetKey: (modelName, modelId) => this.receiptTargetKey(modelName, modelId),
|
|
445
|
+
scheduleReplicationLagTimeout: (transactionId, clientTxId, correlationId) => this.scheduleReplicationLagTimeout(transactionId, clientTxId, correlationId),
|
|
446
|
+
scheduleDeltaConfirmationTimeout: (transaction, timeoutMs) => this.scheduleDeltaConfirmationTimeout(transaction, timeoutMs),
|
|
447
|
+
clearReplicationLagState: (transactionId) => this.clearReplicationLagState(transactionId),
|
|
448
|
+
completeQueuedCommit: (transaction, syncId) => this.completeQueuedCommit(transaction, syncId),
|
|
449
|
+
queuedCommitMatchesCorrelation: (transaction, correlationId) => this.queuedCommitMatchesCorrelation(transaction, correlationId),
|
|
450
|
+
recentDeltaCorrelations: this.recentDeltaCorrelations,
|
|
451
|
+
lastSeenSyncId: this.lastSeenSyncId,
|
|
452
|
+
scheduleProcessing: (immediate) => this.scheduleProcessing(immediate),
|
|
453
|
+
handleFailure: (transaction, error) => this.handleFailure(transaction, error),
|
|
454
|
+
isDefinitiveRejection: (error) => this.isDefinitiveRejection(error),
|
|
455
|
+
emitCommitLifecycle: (event, payload) => this.emitCommitLifecycle(event, payload),
|
|
456
|
+
emit: (event, payload) => this.emit(event, payload),
|
|
457
|
+
rollbackOptimistic: (transaction, reason, error) => this.rollbackOptimistic(transaction, reason, error),
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
private get failureHandlingContext(): FailureHandlingContext {
|
|
462
|
+
return {
|
|
463
|
+
runtime: this.runtime,
|
|
464
|
+
config: this.config,
|
|
465
|
+
store: this.store,
|
|
466
|
+
isPermanentError: (error) => this.isPermanentError(error),
|
|
467
|
+
rollbackOptimistic: (transaction, reason, error) => this.rollbackOptimistic(transaction, reason, error),
|
|
468
|
+
enqueue: (transaction) => this.enqueue(transaction),
|
|
469
|
+
getLastPermanentErrorSignature: () => this.lastPermanentErrorSig,
|
|
470
|
+
setLastPermanentErrorSignature: (signature) => { this.lastPermanentErrorSig = signature; },
|
|
471
|
+
emit: (event, payload) => this.emit(event, payload),
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
private get conflictPolicyContext(): ConflictPolicyContext {
|
|
476
|
+
return {
|
|
477
|
+
config: this.config,
|
|
478
|
+
store: this.store,
|
|
479
|
+
rollbackOptimistic: (transaction, reason) => this.rollbackOptimistic(transaction, reason),
|
|
480
|
+
mergeData: (local, remote) => this.mergeData(local, remote),
|
|
481
|
+
enqueue: (transaction) => this.enqueue(transaction),
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
private get processingSchedulerContext(): ProcessingSchedulerContext {
|
|
486
|
+
return {
|
|
487
|
+
processScheduled: this.processScheduled,
|
|
488
|
+
setProcessScheduled: (value) => { this.processScheduled = value; },
|
|
489
|
+
processTimer: this.processTimer,
|
|
490
|
+
setProcessTimer: (timer) => { this.processTimer = timer; },
|
|
491
|
+
executingCount: this.executingCount,
|
|
492
|
+
maxExecutingTransactions: this.config.maxExecutingTransactions,
|
|
493
|
+
batchDelay: this.config.batchDelay,
|
|
494
|
+
processBatch: () => { void this.processBatch(); },
|
|
495
|
+
logger: this.runtime.logger,
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
private get pendingDrainContext(): PendingDrainContext {
|
|
500
|
+
return {
|
|
501
|
+
runtime: this.runtime,
|
|
502
|
+
config: { deltaConfirmationTimeout: this.config.deltaConfirmationTimeout },
|
|
503
|
+
store: this.store,
|
|
504
|
+
executionQueue: this.executionQueue,
|
|
505
|
+
optimisticUpdates: this.localMutationPort.updates,
|
|
506
|
+
assertDurableReplayOpen: () => this.assertDurableReplayOpen(),
|
|
507
|
+
processCommitLane: () => this.processCommitLane(),
|
|
508
|
+
takePendingDrainBatch: (pending) => this.takePendingDrainBatch(pending),
|
|
509
|
+
ensureCommitEnvelope: (batch) => this.ensureCommitEnvelope(batch),
|
|
510
|
+
ensureDerivedFields: (transaction) => this.ensureDerivedFields(transaction),
|
|
511
|
+
sourceMutationIdsFor: (batch) => this.sourceMutationIdsFor(batch),
|
|
512
|
+
sealDurableCommit: (input) => this.sealDurableCommit(input),
|
|
513
|
+
assertEnvelopeInsideReplayWindow: (envelope) => this.assertEnvelopeInsideReplayWindow(envelope),
|
|
514
|
+
parseMutationCommitResult: (value) => this.parseMutationCommitResult(value),
|
|
515
|
+
dispatchCommitBounded: (...args) => this.dispatchCommitBounded(...args),
|
|
516
|
+
persistDurableCommitAcceptance: (envelope, result) => this.persistDurableCommitAcceptance(envelope, result),
|
|
517
|
+
removeDurableCommit: (idempotencyKey) => this.removeDurableCommit(idempotencyKey),
|
|
518
|
+
scheduleReplicationLagTimeout: (transactionId, clientTxId, correlationId) => this.scheduleReplicationLagTimeout(transactionId, clientTxId, correlationId),
|
|
519
|
+
scheduleDeltaConfirmationTimeout: (transaction, timeoutMs) => this.scheduleDeltaConfirmationTimeout(transaction, timeoutMs),
|
|
520
|
+
enqueue: (transaction) => this.enqueue(transaction),
|
|
521
|
+
recentDeltaCorrelations: this.recentDeltaCorrelations,
|
|
522
|
+
emit: (event, payload) => this.emit(event, payload),
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
private get durableCommitRestoreContext(): DurableCommitRestoreContext {
|
|
527
|
+
return {
|
|
528
|
+
config: this.config,
|
|
529
|
+
commitOutbox: this.commitOutbox,
|
|
530
|
+
commitOutboxScope: this.commitOutboxScope,
|
|
531
|
+
commitStore: this.commitStore,
|
|
532
|
+
commitLane: this.commitLane,
|
|
533
|
+
runtime: this.runtime,
|
|
534
|
+
processCommitLane: () => this.processCommitLane(),
|
|
535
|
+
durableReplayWindowMs: MutationQueue.DURABLE_REPLAY_WINDOW_MS,
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
private get persistenceContext(): MutationPersistenceContext {
|
|
540
|
+
return {
|
|
541
|
+
runtime: this.runtime,
|
|
542
|
+
persistence: this.persistence,
|
|
543
|
+
commitOutboxScope: this.commitOutboxScope,
|
|
544
|
+
config: this.config,
|
|
545
|
+
store: this.store,
|
|
546
|
+
enqueue: (transaction) => this.enqueue(transaction),
|
|
547
|
+
computePriorityScore: (type, modelName) => this.computePriorityScore(type, modelName),
|
|
548
|
+
deserializeTransaction: (data) => this.deserializeTransaction(data),
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
private nextCommitSequence(): number {
|
|
553
|
+
const wallSequence = Date.now() * 1_000;
|
|
554
|
+
this.lastCommitSequence = Math.max(wallSequence, this.lastCommitSequence + 1);
|
|
555
|
+
return this.lastCommitSequence;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
private emitCommitLifecycle(event: string, payload: unknown): void {
|
|
559
|
+
try {
|
|
560
|
+
this.emit(event, payload);
|
|
561
|
+
} catch (error) {
|
|
562
|
+
this.runtime.observability.captureMutationFailure({
|
|
563
|
+
context: `commit-lifecycle-listener:${event}`,
|
|
564
|
+
error: error instanceof Error ? error : String(error),
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
private assertDurableReplayOpen(): void {
|
|
570
|
+
if (this.durableReplayBlock) throw this.durableReplayBlock;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
private assertEnvelopeInsideReplayWindow(
|
|
574
|
+
envelope: Pick<DurableCommitEnvelope, 'sealedAt' | 'acceptedAt'>,
|
|
575
|
+
): void {
|
|
576
|
+
this.assertDurableReplayOpen();
|
|
577
|
+
if (
|
|
578
|
+
envelope.acceptedAt === undefined &&
|
|
579
|
+
Date.now() - envelope.sealedAt >=
|
|
580
|
+
MutationQueue.DURABLE_REPLAY_WINDOW_MS
|
|
581
|
+
) {
|
|
582
|
+
this.durableReplayBlock = new AbloIdempotencyError(
|
|
583
|
+
'A pending commit is older than the server idempotency window; newer writes are blocked until it is reviewed.',
|
|
584
|
+
{ code: 'idempotency_conflict' },
|
|
585
|
+
);
|
|
586
|
+
// This gate stops EVERY subsequent write on this client, and each of
|
|
587
|
+
// those rejections is captured to observability rather than surfaced to
|
|
588
|
+
// the caller — without this line the session degrades into "nothing
|
|
589
|
+
// saves and nothing errors". One loud line at the moment the block
|
|
590
|
+
// engages is the only visible trace.
|
|
591
|
+
this.runtime.logger.warn(
|
|
592
|
+
'sync paused: a saved write from an earlier session is older than the server replay window, so newer writes are held until it is reviewed',
|
|
593
|
+
{ sealedAt: envelope.sealedAt },
|
|
594
|
+
);
|
|
595
|
+
throw this.durableReplayBlock;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
private computePriorityScore(type: QueuedMutation['type'], modelName: string): number {
|
|
600
|
+
return computePriorityScore(type, modelName, this.runtime);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
private ensureDerivedFields(transaction: QueuedMutation): void {
|
|
604
|
+
if (!transaction.modelKey) {
|
|
605
|
+
transaction.modelKey = normalizeModelKey(transaction.modelName);
|
|
606
|
+
}
|
|
607
|
+
if (transaction.priorityScore === undefined) {
|
|
608
|
+
transaction.priorityScore = this.computePriorityScore(
|
|
609
|
+
transaction.type,
|
|
610
|
+
transaction.modelName
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
private entityKey(modelName: string, modelId: string): string {
|
|
616
|
+
return entityKey(modelName, modelId);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/** Collision-safe receipt target identity across models sharing a row id. */
|
|
620
|
+
private receiptTargetKey(modelName: string, modelId: string): string {
|
|
621
|
+
return stableStringify([normalizeModelKey(modelName), modelId]);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Relates stale notifications back to write targets without assuming the
|
|
626
|
+
* server's canonical model name uses the same spelling as the public schema
|
|
627
|
+
* key (`Task` versus `tasks`). Exact `(model,id)` wins; a globally unique id
|
|
628
|
+
* is the compatibility fallback. An ambiguous same-id cross-model mismatch
|
|
629
|
+
* is deliberately left unclassified, so it cannot falsely settle a queued
|
|
630
|
+
* write. A notification with no write-target id (or an explicit group) is a
|
|
631
|
+
* declared-read conflict and holds the whole batch.
|
|
632
|
+
*/
|
|
633
|
+
private classifyReceiptNotifications(
|
|
634
|
+
operations: readonly { model: string; id: string }[],
|
|
635
|
+
notifications: readonly StaleNotification[],
|
|
636
|
+
): {
|
|
637
|
+
holdsEntireBatch: boolean;
|
|
638
|
+
heldTargets: Set<string>;
|
|
639
|
+
notificationsByTarget: Map<string, StaleNotification[]>;
|
|
640
|
+
} {
|
|
641
|
+
const targets = operations.map((operation) => ({
|
|
642
|
+
id: operation.id,
|
|
643
|
+
key: this.receiptTargetKey(operation.model, operation.id),
|
|
644
|
+
}));
|
|
645
|
+
const heldTargets = new Set<string>();
|
|
646
|
+
const notificationsByTarget = new Map<string, StaleNotification[]>();
|
|
647
|
+
let holdsEntireBatch = false;
|
|
648
|
+
|
|
649
|
+
for (const notification of notifications) {
|
|
650
|
+
const candidates = targets.filter((target) => target.id === notification.id);
|
|
651
|
+
const notificationKey = this.receiptTargetKey(
|
|
652
|
+
notification.model,
|
|
653
|
+
notification.id,
|
|
654
|
+
);
|
|
655
|
+
const exactTargets = candidates.filter(
|
|
656
|
+
(target) => target.key === notificationKey,
|
|
657
|
+
);
|
|
658
|
+
const candidateKeys = new Set(
|
|
659
|
+
(exactTargets.length > 0 ? exactTargets : candidates).map(
|
|
660
|
+
(target) => target.key,
|
|
661
|
+
),
|
|
662
|
+
);
|
|
663
|
+
|
|
664
|
+
if (notification.group || candidates.length === 0) {
|
|
665
|
+
holdsEntireBatch = true;
|
|
666
|
+
continue;
|
|
667
|
+
}
|
|
668
|
+
if (candidateKeys.size !== 1) {
|
|
669
|
+
// Same id across multiple differently named models with no exact match:
|
|
670
|
+
// the id-only compatibility fallback is ambiguous. Await the echo.
|
|
671
|
+
continue;
|
|
672
|
+
}
|
|
673
|
+
const [targetKey] = candidateKeys;
|
|
674
|
+
if (!targetKey) continue;
|
|
675
|
+
heldTargets.add(targetKey);
|
|
676
|
+
const targetNotifications = notificationsByTarget.get(targetKey) ?? [];
|
|
677
|
+
targetNotifications.push(notification);
|
|
678
|
+
notificationsByTarget.set(targetKey, targetNotifications);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
return { holdsEntireBatch, heldTargets, notificationsByTarget };
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
private resolveConfirmation(transaction: QueuedMutation): void {
|
|
685
|
+
const resolver = this.confirmationResolvers.get(transaction.id);
|
|
686
|
+
if (!resolver) return;
|
|
687
|
+
this.confirmationResolvers.delete(transaction.id);
|
|
688
|
+
resolver.resolve();
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
private takeUnsentCreateForModel(modelName: string, modelId: string): QueuedMutation | undefined {
|
|
692
|
+
return takeUnsentCreateForModel(
|
|
693
|
+
this.createdTransactions,
|
|
694
|
+
this.executionQueue,
|
|
695
|
+
this.store,
|
|
696
|
+
modelName,
|
|
697
|
+
modelId,
|
|
698
|
+
);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
private async cancelUnsentCreateForDelete(transaction: QueuedMutation): Promise<void> {
|
|
702
|
+
this.store.updateStatus(transaction.id, 'rolled_back');
|
|
703
|
+
if (this.config.enableOptimistic) {
|
|
704
|
+
await this.rollbackOptimistic(transaction, 'model_cancelled');
|
|
705
|
+
}
|
|
706
|
+
this.resolveConfirmation(transaction);
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
private findCreateBarrierForDelete(modelName: string, modelId: string): QueuedMutation | undefined {
|
|
710
|
+
return findCreateBarrierForDelete(this.store, modelName, modelId);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
private completeLocalDelete(
|
|
714
|
+
model: LocalModel,
|
|
715
|
+
context: UserContext,
|
|
716
|
+
writeOptions: WriteOptions | undefined,
|
|
717
|
+
sourceMutationIds: readonly string[] = [],
|
|
718
|
+
): QueuedMutation {
|
|
719
|
+
const actualModelName = model.getModelName();
|
|
720
|
+
const modelKey = normalizeModelKey(actualModelName);
|
|
721
|
+
const transaction: QueuedMutation = {
|
|
722
|
+
id: this.generateId(),
|
|
723
|
+
type: 'delete',
|
|
724
|
+
modelName: actualModelName,
|
|
725
|
+
modelId: model.id,
|
|
726
|
+
modelKey,
|
|
727
|
+
priorityScore: this.computePriorityScore('delete', actualModelName),
|
|
728
|
+
previousData: model.toJSON ? model.toJSON() : { ...model },
|
|
729
|
+
context,
|
|
730
|
+
status: 'completed',
|
|
731
|
+
createdAt: Date.now(),
|
|
732
|
+
attempts: 0,
|
|
733
|
+
priority: 'high',
|
|
734
|
+
writeOptions,
|
|
735
|
+
...(sourceMutationIds.length > 0
|
|
736
|
+
? { sourceMutationIds: [...new Set(sourceMutationIds)] }
|
|
737
|
+
: {}),
|
|
738
|
+
localOnly: true,
|
|
739
|
+
};
|
|
740
|
+
|
|
741
|
+
this.attachConfirmation(transaction);
|
|
742
|
+
this.store.add(transaction);
|
|
743
|
+
|
|
744
|
+
if (this.config.enableOptimistic) {
|
|
745
|
+
this.applyOptimisticDelete(model, transaction);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
this.emit('transaction:created', transaction);
|
|
749
|
+
this.emit('transaction:completed', transaction);
|
|
750
|
+
this.emit(`transaction:completed:${transaction.id}`, transaction);
|
|
751
|
+
this.localMutationPort.updates.delete(transaction.id);
|
|
752
|
+
return transaction;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
private deferDeleteUntilCreateSettles(createTransaction: QueuedMutation, deleteTransaction: QueuedMutation): void {
|
|
756
|
+
deferDeleteUntilCreateSettles(this.deferredDeletesByCreate, createTransaction, deleteTransaction);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
private releaseDeferredDeletesForCreate(createTransaction: QueuedMutation): void {
|
|
760
|
+
releaseDeferredDeletesForCreate(
|
|
761
|
+
this.deferredDeletesByCreate,
|
|
762
|
+
this.store,
|
|
763
|
+
(tx) => { this.enqueue(tx); },
|
|
764
|
+
createTransaction,
|
|
765
|
+
);
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// Default configuration, tuned so more operations coalesce into a single
|
|
769
|
+
// commit: a larger batch size and delay give rapid operations more time to
|
|
770
|
+
// merge before the batch is sent.
|
|
771
|
+
private config: MutationQueueConfig = {
|
|
772
|
+
maxBatchSize: 50, // send up to this many operations per commit
|
|
773
|
+
batchDelay: 150, // milliseconds to wait for more operations before sending
|
|
774
|
+
maxRetries: 3,
|
|
775
|
+
conflictResolution: {
|
|
776
|
+
strategy: 'last-write-wins',
|
|
777
|
+
},
|
|
778
|
+
enablePersistence: true,
|
|
779
|
+
enableOptimistic: true,
|
|
780
|
+
maxExecutingTransactions: 100,
|
|
781
|
+
deltaConfirmationTimeout: 30000,
|
|
782
|
+
retryBackoff: { baseMs: 200, capMs: 1500 },
|
|
783
|
+
commitOfflineGraceMs: 30_000,
|
|
784
|
+
commitDispatchTimeoutMs: 30_000,
|
|
785
|
+
};
|
|
786
|
+
|
|
787
|
+
// Track executing transactions for backpressure
|
|
788
|
+
private executingCount = 0;
|
|
789
|
+
|
|
790
|
+
private readonly localMutationPort: LocalMutationPort;
|
|
791
|
+
|
|
792
|
+
// Stale-context notifications, keyed by transaction id. When the server
|
|
793
|
+
// accepts a commit but reports that an operation's premise had moved,
|
|
794
|
+
// the notification lands here from the commit acknowledgement and is drained
|
|
795
|
+
// by `waitForCommitReceipt`, so the receipt can carry it back to the caller.
|
|
796
|
+
private commitNotifications = new Map<string, StaleNotification[]>();
|
|
797
|
+
/** Zero-row targets returned on a successful atomic commit receipt. */
|
|
798
|
+
private commitMissingIds = new Map<string, string[]>();
|
|
799
|
+
|
|
800
|
+
// Delta-confirmation tracking (ack watermark advance + the awaiting_delta
|
|
801
|
+
// timeout/retry maps) lives in `./deltaConfirmation.js`. Constructed in the
|
|
802
|
+
// constructor once `position` is bound.
|
|
803
|
+
private readonly deltaConfirmation: DeltaConfirmationTracker;
|
|
804
|
+
|
|
805
|
+
// Connection-state check, supplied by the client, used to hold off rollbacks
|
|
806
|
+
// while disconnected.
|
|
807
|
+
private isConnectedFn: () => boolean = () => true;
|
|
808
|
+
|
|
809
|
+
// Grace timer that, when fired, fails any commit-lane transaction
|
|
810
|
+
// still awaiting an ack. Started on `setConnectionState('disconnected')`,
|
|
811
|
+
// cleared on `'connected'`. The reconnect-retry behavior of the queue
|
|
812
|
+
// is preserved for brief blips; this only catches persistent disconnects.
|
|
813
|
+
private commitOfflineGraceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
814
|
+
|
|
815
|
+
/**
|
|
816
|
+
* This client's place in the global order of sync deltas. The instance is
|
|
817
|
+
* shared: the client injects one, and a standalone queue creates its own. The
|
|
818
|
+
* queue advances the `acked` cursor as commit responses arrive, the store
|
|
819
|
+
* advances `applied` and `persisted`, and snapshots and claims read
|
|
820
|
+
* `readFloor`. See `../logPosition.js` for the full contract.
|
|
821
|
+
*/
|
|
822
|
+
readonly position: LogPositionPort;
|
|
823
|
+
|
|
824
|
+
/** Applied-cursor alias, kept so the many internal read sites stay legible. */
|
|
825
|
+
private get lastSeenSyncId(): number {
|
|
826
|
+
return this.position.applied;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
private noteAck(lastSyncId: number | undefined): void {
|
|
830
|
+
this.deltaConfirmation.noteAck(lastSyncId);
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// Batch management
|
|
834
|
+
private batchIndex = 0;
|
|
835
|
+
|
|
836
|
+
/** Mints the request identity once; retry paths only read the stored value. */
|
|
837
|
+
private generateCommitIdempotencyKey(): string {
|
|
838
|
+
return `commit_${uuid()}`;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/**
|
|
842
|
+
* Binds an ordered transaction batch to one wire-level idempotency key.
|
|
843
|
+
* Existing envelopes are validated and restored to their original order;
|
|
844
|
+
* they are never extended with newly queued work.
|
|
845
|
+
*/
|
|
846
|
+
private ensureCommitEnvelope(batch: QueuedMutation[]): string {
|
|
847
|
+
const firstTransaction = batch[0];
|
|
848
|
+
if (!firstTransaction) {
|
|
849
|
+
throw new Error('Cannot create an idempotency envelope for an empty commit');
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
const existingKeys = new Set(
|
|
853
|
+
batch
|
|
854
|
+
.map((tx) => tx.commitEnvelope?.idempotencyKey)
|
|
855
|
+
.filter((key) => key !== undefined),
|
|
856
|
+
);
|
|
857
|
+
if (existingKeys.size > 1) {
|
|
858
|
+
throw new Error('Cannot combine transactions from different commit envelopes');
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
const existingKey = existingKeys.values().next().value;
|
|
862
|
+
if (existingKey) {
|
|
863
|
+
const expectedCount = firstTransaction.commitEnvelope?.operationCount;
|
|
864
|
+
const indexes = new Set(batch.map((tx) => tx.commitEnvelope?.operationIndex));
|
|
865
|
+
const isCompleteEnvelope =
|
|
866
|
+
expectedCount === batch.length &&
|
|
867
|
+
indexes.size === batch.length &&
|
|
868
|
+
batch.every(
|
|
869
|
+
(tx) =>
|
|
870
|
+
tx.commitEnvelope?.idempotencyKey === existingKey &&
|
|
871
|
+
tx.commitEnvelope.operationCount === expectedCount &&
|
|
872
|
+
tx.commitEnvelope.operationIndex < expectedCount,
|
|
873
|
+
);
|
|
874
|
+
if (!isCompleteEnvelope) {
|
|
875
|
+
throw new Error('Cannot replay a partial or malformed commit envelope');
|
|
876
|
+
}
|
|
877
|
+
const persistedSealTimes = new Set(
|
|
878
|
+
batch
|
|
879
|
+
.map((transaction) => transaction.commitEnvelope?.sealedAt)
|
|
880
|
+
.filter((sealedAt) => sealedAt !== undefined),
|
|
881
|
+
);
|
|
882
|
+
if (persistedSealTimes.size > 1) {
|
|
883
|
+
throw new Error('Cannot replay a commit envelope with inconsistent seal times');
|
|
884
|
+
}
|
|
885
|
+
const sealedAt = persistedSealTimes.values().next().value ?? Date.now();
|
|
886
|
+
for (const transaction of batch) {
|
|
887
|
+
if (transaction.commitEnvelope) transaction.commitEnvelope.sealedAt = sealedAt;
|
|
888
|
+
}
|
|
889
|
+
batch.sort(
|
|
890
|
+
(a, b) =>
|
|
891
|
+
(a.commitEnvelope?.operationIndex ?? 0) -
|
|
892
|
+
(b.commitEnvelope?.operationIndex ?? 0),
|
|
893
|
+
);
|
|
894
|
+
return existingKey;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// An explicit public key owns its request. Transactions carrying one are
|
|
898
|
+
// selected as solo batches by takeNextExecutionBatch().
|
|
899
|
+
const explicitKey =
|
|
900
|
+
batch.length === 1
|
|
901
|
+
? firstTransaction.writeOptions?.idempotencyKey
|
|
902
|
+
: undefined;
|
|
903
|
+
const idempotencyKey =
|
|
904
|
+
typeof explicitKey === 'string' && explicitKey.length > 0
|
|
905
|
+
? explicitKey
|
|
906
|
+
: this.generateCommitIdempotencyKey();
|
|
907
|
+
const sealedAt = Date.now();
|
|
908
|
+
const sequence = this.nextCommitSequence();
|
|
909
|
+
|
|
910
|
+
batch.forEach((tx, operationIndex) => {
|
|
911
|
+
tx.commitEnvelope = createCommitEnvelopeMember({
|
|
912
|
+
idempotencyKey,
|
|
913
|
+
operationIndex,
|
|
914
|
+
operationCount: batch.length,
|
|
915
|
+
sealedAt,
|
|
916
|
+
sequence,
|
|
917
|
+
});
|
|
918
|
+
});
|
|
919
|
+
return idempotencyKey;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
/** Bind the strict local outbox used before any mutation reaches the wire. */
|
|
923
|
+
setCommitOutbox(outbox: DurableWriteStore): void {
|
|
924
|
+
this.commitOutbox = outbox;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
async setCommitOutboxScope(scope: CommitOutboxScope): Promise<void> {
|
|
928
|
+
this.commitOutboxScope = scope;
|
|
929
|
+
const deferred = this.deferredMutations;
|
|
930
|
+
this.deferredMutations = [];
|
|
931
|
+
await Promise.all(deferred.map((mutation) =>
|
|
932
|
+
this.enqueueModelMutation(
|
|
933
|
+
mutation.type,
|
|
934
|
+
mutation.model,
|
|
935
|
+
{ userId: scope.participantId, organizationId: scope.organizationId },
|
|
936
|
+
mutation.capturedChanges,
|
|
937
|
+
mutation.writeOptions,
|
|
938
|
+
),
|
|
939
|
+
));
|
|
940
|
+
}
|
|
941
|
+
deferMutation(
|
|
942
|
+
type: 'create' | 'update' | 'delete' | 'archive',
|
|
943
|
+
model: LocalModel,
|
|
944
|
+
capturedChanges?: Record<string, unknown>,
|
|
945
|
+
writeOptions?: WriteOptions,
|
|
946
|
+
): void {
|
|
947
|
+
this.runtime.logger.debug('[MutationQueue] Deferring mutation until identity is resolved', { type, model: model.getModelName(), modelId: model.id });
|
|
948
|
+
this.deferredMutations.push({ type, model, capturedChanges, writeOptions });
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
async enqueueModelMutation(
|
|
952
|
+
type: 'create' | 'update' | 'delete' | 'archive',
|
|
953
|
+
model: LocalModel,
|
|
954
|
+
context: UserContext,
|
|
955
|
+
capturedChanges?: Record<string, unknown>,
|
|
956
|
+
writeOptions?: WriteOptions,
|
|
957
|
+
): Promise<QueuedMutation> {
|
|
958
|
+
switch (type) {
|
|
959
|
+
case 'create': return this.create(model, context, writeOptions);
|
|
960
|
+
case 'update': return this.update(model, context, capturedChanges, writeOptions);
|
|
961
|
+
case 'delete': return this.delete(model, context, writeOptions);
|
|
962
|
+
case 'archive': return this.archive(model, context, writeOptions);
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
/** Attach the durable journal supplied by the local/materializer layer. */
|
|
967
|
+
setPersistence(persistence: MutationPersistencePort): void {
|
|
968
|
+
this.persistence = persistence;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
private async persistQueuedTransaction(transaction: QueuedMutation, modelData?: Record<string, unknown>): Promise<void> {
|
|
972
|
+
await persistQueuedTransaction(this.persistenceContext, transaction, modelData);
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
private persistAndStage(
|
|
976
|
+
transaction: QueuedMutation,
|
|
977
|
+
modelData: Record<string, unknown>,
|
|
978
|
+
): Promise<void> {
|
|
979
|
+
if (!this.persistence || !this.commitOutboxScope) {
|
|
980
|
+
this.stageTransaction(transaction);
|
|
981
|
+
return Promise.resolve();
|
|
982
|
+
}
|
|
983
|
+
return new Promise<void>((resolve, reject) => {
|
|
984
|
+
this.pendingPersistenceStages.push({ transaction, modelData, resolve, reject });
|
|
985
|
+
if (this.persistenceStageScheduled) return;
|
|
986
|
+
this.persistenceStageScheduled = true;
|
|
987
|
+
queueMicrotask(async () => {
|
|
988
|
+
this.persistenceStageScheduled = false;
|
|
989
|
+
const batch = this.pendingPersistenceStages;
|
|
990
|
+
this.pendingPersistenceStages = [];
|
|
991
|
+
const results = await Promise.allSettled(
|
|
992
|
+
batch.map(({ transaction: queued, modelData: data }) =>
|
|
993
|
+
this.persistQueuedTransaction(queued, data),
|
|
994
|
+
),
|
|
995
|
+
);
|
|
996
|
+
for (const [index, result] of results.entries()) {
|
|
997
|
+
const item = batch[index];
|
|
998
|
+
if (!item) continue;
|
|
999
|
+
if (result.status === 'fulfilled') {
|
|
1000
|
+
this.stageTransaction(item.transaction);
|
|
1001
|
+
item.resolve();
|
|
1002
|
+
} else {
|
|
1003
|
+
item.reject(result.reason instanceof Error ? result.reason : new Error(String(result.reason)));
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
});
|
|
1007
|
+
});
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
private removePersistedTransaction(transactionId: string): void {
|
|
1011
|
+
removePersistedTransaction(this.persistenceContext, transactionId);
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
private settlePersistedFailure(transaction: { id?: string; sourceMutationIds?: string[] }): void {
|
|
1015
|
+
settlePersistedFailure(this.persistenceContext, transaction);
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
private sourceMutationIdsFor(batch: readonly QueuedMutation[]): string[] {
|
|
1019
|
+
return [...new Set(batch.flatMap((transaction) => transaction.sourceMutationIds ?? []))];
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
/** Atomically seals a commit before it is allowed onto the dispatch lane. */
|
|
1023
|
+
private async sealDurableCommit(input: Parameters<typeof sealDurableCommit>[1]): Promise<DurableCommitEnvelope> {
|
|
1024
|
+
return sealDurableCommit(this.commitTransportContext, input, pendingMutationRecordId);
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
private async removeDurableCommit(idempotencyKey: string): Promise<void> {
|
|
1028
|
+
return removeDurableCommit(this.commitTransportContext, idempotencyKey);
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
private async persistDurableCommitAcceptance(
|
|
1032
|
+
envelope: DurableCommitEnvelope,
|
|
1033
|
+
result: MutationCommitResult,
|
|
1034
|
+
): Promise<DurableCommitEnvelope> {
|
|
1035
|
+
return persistDurableCommitAcceptance(this.commitTransportContext, envelope, result);
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
private parseMutationCommitResult(
|
|
1039
|
+
value: Awaited<ReturnType<import('../../interfaces/index.js').MutationExecutor['commit']>>,
|
|
1040
|
+
): MutationCommitResult {
|
|
1041
|
+
return parseMutationCommitResult(value);
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
private dispatchCommitBounded(
|
|
1045
|
+
...args: Parameters<import('../../interfaces/index.js').MutationExecutor['commit']>
|
|
1046
|
+
): ReturnType<import('../../interfaces/index.js').MutationExecutor['commit']> {
|
|
1047
|
+
return dispatchCommitBounded(this.commitTransportContext, ...args);
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
private clearReplicationLagState(transactionId: string): void {
|
|
1051
|
+
const timeout = this.replicationLagTimeouts.get(transactionId);
|
|
1052
|
+
if (timeout) clearTimeout(timeout);
|
|
1053
|
+
this.replicationLagTimeouts.delete(transactionId);
|
|
1054
|
+
this.replicationLagErrors.delete(transactionId);
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
/**
|
|
1058
|
+
* Bounds the public `wait: 'confirmed'` promise without changing the
|
|
1059
|
+
* accepted write's lifecycle. A lag timeout is not a rejection from the
|
|
1060
|
+
* source database, so it must never emit `transaction:failed`, roll back
|
|
1061
|
+
* optimistic state, or remove the durable replay envelope.
|
|
1062
|
+
*/
|
|
1063
|
+
private scheduleReplicationLagTimeout(
|
|
1064
|
+
transactionId: string,
|
|
1065
|
+
clientTxId = transactionId,
|
|
1066
|
+
correlationId?: string,
|
|
1067
|
+
): void {
|
|
1068
|
+
const previous = this.replicationLagTimeouts.get(transactionId);
|
|
1069
|
+
if (previous) clearTimeout(previous);
|
|
1070
|
+
this.replicationLagErrors.delete(transactionId);
|
|
1071
|
+
|
|
1072
|
+
const timeoutMs = this.config.deltaConfirmationTimeout;
|
|
1073
|
+
const timeout = setTimeout(() => {
|
|
1074
|
+
this.replicationLagTimeouts.delete(transactionId);
|
|
1075
|
+
const modelTx = this.store.get(transactionId);
|
|
1076
|
+
const commitTx = this.commitStore.get(transactionId);
|
|
1077
|
+
if (
|
|
1078
|
+
modelTx?.status !== 'awaiting_delta' &&
|
|
1079
|
+
commitTx?.status !== 'awaiting_delta'
|
|
1080
|
+
) return;
|
|
1081
|
+
|
|
1082
|
+
const error = new AbloConnectionError(
|
|
1083
|
+
`The source accepted commit ${clientTxId}, but its replication echo did not arrive within ${timeoutMs}ms.`,
|
|
1084
|
+
{
|
|
1085
|
+
code: 'replication_lag_timeout',
|
|
1086
|
+
httpStatus: 504,
|
|
1087
|
+
details: {
|
|
1088
|
+
clientTxId,
|
|
1089
|
+
...(correlationId ? { correlationId } : {}),
|
|
1090
|
+
timeoutMs,
|
|
1091
|
+
accepted: true,
|
|
1092
|
+
},
|
|
1093
|
+
},
|
|
1094
|
+
);
|
|
1095
|
+
this.replicationLagErrors.set(transactionId, error);
|
|
1096
|
+
|
|
1097
|
+
// Model-proxy writes expose their waiter through the resolver table.
|
|
1098
|
+
// Reject that promise without moving the transaction out of
|
|
1099
|
+
// `awaiting_delta`; the eventual echo still completes it normally.
|
|
1100
|
+
const resolver = this.confirmationResolvers.get(transactionId);
|
|
1101
|
+
if (resolver) {
|
|
1102
|
+
this.confirmationResolvers.delete(transactionId);
|
|
1103
|
+
resolver.reject(error);
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
this.emitCommitLifecycle('transaction:confirmation_lagged', {
|
|
1107
|
+
transactionId,
|
|
1108
|
+
error,
|
|
1109
|
+
});
|
|
1110
|
+
this.emitCommitLifecycle(`transaction:confirmation_lagged:${transactionId}`, {
|
|
1111
|
+
error,
|
|
1112
|
+
});
|
|
1113
|
+
if (commitTx) {
|
|
1114
|
+
const firstOperation = commitTx.operations[0];
|
|
1115
|
+
this.emitCommitLifecycle('reconciliation:needed', {
|
|
1116
|
+
reason: 'replication_lag_timeout',
|
|
1117
|
+
txId: transactionId,
|
|
1118
|
+
model: firstOperation?.model ?? 'commit',
|
|
1119
|
+
modelId: firstOperation?.id ?? transactionId,
|
|
1120
|
+
lastSeenSyncId: this.lastSeenSyncId,
|
|
1121
|
+
retryCount: 1,
|
|
1122
|
+
});
|
|
1123
|
+
}
|
|
1124
|
+
}, timeoutMs);
|
|
1125
|
+
this.replicationLagTimeouts.set(transactionId, timeout);
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
private takeNextExecutionBatch(): QueuedMutation[] {
|
|
1129
|
+
const selected = selectExecutionBatch(this.executionQueue, this.config.maxBatchSize);
|
|
1130
|
+
this.executionQueue = selected.remaining;
|
|
1131
|
+
return selected.batch;
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
private takePendingDrainBatch(pending: QueuedMutation[]): QueuedMutation[] {
|
|
1135
|
+
return selectPendingDrainBatch(pending, this.config.maxBatchSize);
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
/**
|
|
1139
|
+
* Resolvers for per-transaction `confirmation` promises. Populated in
|
|
1140
|
+
* `attachConfirmation` at staging time, consumed by the constructor-time
|
|
1141
|
+
* listeners on `transaction:completed` / `transaction:failed`. Kept off
|
|
1142
|
+
* the QueuedMutation row so the store's iteration order stays plain-data
|
|
1143
|
+
* and serialization-friendly.
|
|
1144
|
+
*/
|
|
1145
|
+
private confirmationResolvers = new Map<
|
|
1146
|
+
string,
|
|
1147
|
+
{ resolve: () => void; reject: (err: Error) => void }
|
|
1148
|
+
>();
|
|
1149
|
+
|
|
1150
|
+
constructor(config?: Partial<MutationQueueConfig>) {
|
|
1151
|
+
super();
|
|
1152
|
+
this.runtime = config?.runtime ?? globalRuntime;
|
|
1153
|
+
this.position = config?.position ?? new LogPosition();
|
|
1154
|
+
this.localMutationPort = config?.localMutationPort ?? createLocalMutationPort(this);
|
|
1155
|
+
// Bind the confirmation tracker to this queue's store/ledger/events.
|
|
1156
|
+
// `isConnected` closes over `isConnectedFn` so `setConnectionChecker`
|
|
1157
|
+
// swaps stay visible to in-flight timeouts.
|
|
1158
|
+
this.deltaConfirmation = new DeltaConfirmationTracker({
|
|
1159
|
+
store: this.store,
|
|
1160
|
+
optimisticUpdates: this.localMutationPort.updates,
|
|
1161
|
+
emit: (event, payload) => {
|
|
1162
|
+
this.emit(event, payload);
|
|
1163
|
+
},
|
|
1164
|
+
isConnected: () => this.isConnectedFn(),
|
|
1165
|
+
position: this.position,
|
|
1166
|
+
runtime: this.runtime,
|
|
1167
|
+
});
|
|
1168
|
+
|
|
1169
|
+
if (config) {
|
|
1170
|
+
this.config = { ...this.config, ...config };
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
// Centralized fan-in for `tx.confirmation`. Completion/failure are
|
|
1174
|
+
// emitted from ~10 sites (delta confirm, immediate confirm, batch
|
|
1175
|
+
// success, permanent error, max_retries_exhausted, …). Subscribing
|
|
1176
|
+
// once here keeps every emit site intact and guarantees the call-site
|
|
1177
|
+
// promise always settles, regardless of which path produced the
|
|
1178
|
+
// terminal state.
|
|
1179
|
+
this.on('transaction:completed', (tx: QueuedMutation) => {
|
|
1180
|
+
// Any successful write clears the permanent-error dedup, so a genuine
|
|
1181
|
+
// recurrence after recovery warns again instead of staying demoted.
|
|
1182
|
+
this.lastPermanentErrorSig = undefined;
|
|
1183
|
+
this.clearReplicationLagState(tx.id);
|
|
1184
|
+
const r = this.confirmationResolvers.get(tx.id);
|
|
1185
|
+
if (r) {
|
|
1186
|
+
this.confirmationResolvers.delete(tx.id);
|
|
1187
|
+
r.resolve();
|
|
1188
|
+
}
|
|
1189
|
+
if (tx.type === 'create') {
|
|
1190
|
+
this.releaseDeferredDeletesForCreate(tx);
|
|
1191
|
+
}
|
|
1192
|
+
this.removePersistedTransaction(tx.id);
|
|
1193
|
+
});
|
|
1194
|
+
this.on(
|
|
1195
|
+
'transaction:failed',
|
|
1196
|
+
({ transaction, error }: { transaction: QueuedMutation; error: Error }) => {
|
|
1197
|
+
const r = this.confirmationResolvers.get(transaction.id);
|
|
1198
|
+
if (r) {
|
|
1199
|
+
this.confirmationResolvers.delete(transaction.id);
|
|
1200
|
+
r.reject(error);
|
|
1201
|
+
}
|
|
1202
|
+
if (transaction.type === 'create') {
|
|
1203
|
+
this.releaseDeferredDeletesForCreate(transaction);
|
|
1204
|
+
}
|
|
1205
|
+
this.settlePersistedFailure(transaction);
|
|
1206
|
+
}
|
|
1207
|
+
);
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
/**
|
|
1211
|
+
* Returns the in-flight confirmation promise for a given model and id. When
|
|
1212
|
+
* several transactions match, it returns the most recent one's promise; when
|
|
1213
|
+
* none is open it resolves immediately, which covers both "already confirmed"
|
|
1214
|
+
* and "never staged".
|
|
1215
|
+
*
|
|
1216
|
+
* It considers the three non-terminal statuses in which the write can still
|
|
1217
|
+
* be rolled back — `pending`, `executing`, and `awaiting_delta` — and ignores
|
|
1218
|
+
* `completed` (already settled) and `failed`/`rolled_back` (already
|
|
1219
|
+
* rejected). This complements the `confirmation` promise carried on a known
|
|
1220
|
+
* {@link QueuedMutation}: use this method at call sites that hold a model
|
|
1221
|
+
* returned by `ablo.<model>.create()` but never see the underlying
|
|
1222
|
+
* transaction.
|
|
1223
|
+
*/
|
|
1224
|
+
confirmationFor(modelName: string, modelId: string): Promise<void> {
|
|
1225
|
+
const candidates = [
|
|
1226
|
+
...this.store.getByStatus('pending'),
|
|
1227
|
+
...this.store.getByStatus('executing'),
|
|
1228
|
+
...this.store.getByStatus('awaiting_delta'),
|
|
1229
|
+
].filter(
|
|
1230
|
+
(tx) => tx.modelName === modelName && tx.modelId === modelId,
|
|
1231
|
+
);
|
|
1232
|
+
if (candidates.length === 0) return Promise.resolve();
|
|
1233
|
+
const latest = candidates.sort((a, b) => b.createdAt - a.createdAt)[0];
|
|
1234
|
+
if (!latest) return Promise.resolve();
|
|
1235
|
+
return latest.confirmation ?? Promise.resolve();
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
/**
|
|
1239
|
+
* Attaches a `confirmation` promise to a newly created transaction. Call this
|
|
1240
|
+
* before the transaction is staged so a caller can `await tx.confirmation`
|
|
1241
|
+
* immediately after a create, update, or delete returns. It is idempotent and
|
|
1242
|
+
* returns early if one is already attached.
|
|
1243
|
+
*
|
|
1244
|
+
* It also attaches a no-op rejection handler. Most callers never await the
|
|
1245
|
+
* confirmation, and without this the runtime would report an unhandled
|
|
1246
|
+
* rejection when a write fails. Callers that do want to observe failure simply
|
|
1247
|
+
* attach their own `.then`/`.catch`.
|
|
1248
|
+
*/
|
|
1249
|
+
private attachConfirmation(tx: QueuedMutation): void {
|
|
1250
|
+
if (tx.confirmation) return;
|
|
1251
|
+
tx.confirmation = new Promise<void>((resolve, reject) => {
|
|
1252
|
+
this.confirmationResolvers.set(tx.id, { resolve, reject });
|
|
1253
|
+
});
|
|
1254
|
+
tx.confirmation.catch(() => {
|
|
1255
|
+
// Swallow unhandled rejections; callers that care attach their own handler.
|
|
1256
|
+
});
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
/**
|
|
1260
|
+
* Registers a predicate the queue uses to check whether it is connected.
|
|
1261
|
+
* While disconnected, confirmation timeouts re-schedule themselves instead of
|
|
1262
|
+
* escalating, so a transaction is never rolled back merely because the client
|
|
1263
|
+
* was briefly offline.
|
|
1264
|
+
*/
|
|
1265
|
+
setConnectionChecker(fn: () => boolean): void {
|
|
1266
|
+
this.isConnectedFn = fn;
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
/**
|
|
1270
|
+
* Drives the offline-grace timer for in-flight commit-lane transactions.
|
|
1271
|
+
*
|
|
1272
|
+
* On `'disconnected'` it starts a one-shot timer of
|
|
1273
|
+
* `config.commitOfflineGraceMs`. If that timer fires — meaning the disconnect
|
|
1274
|
+
* outlasted the grace window — every commit-lane transaction still `pending`
|
|
1275
|
+
* or `executing` is failed with an {@link AbloConnectionError}, so
|
|
1276
|
+
* {@link waitForCommitReceipt} rejects within seconds instead of hanging.
|
|
1277
|
+
*
|
|
1278
|
+
* On `'connected'` it clears any pending grace timer. Brief disconnects are
|
|
1279
|
+
* absorbed transparently; {@link processCommitLane} and
|
|
1280
|
+
* {@link drainPending} resumes the work when the owner decides to drain.
|
|
1281
|
+
*/
|
|
1282
|
+
setConnectionState(state: 'connected' | 'disconnected'): void {
|
|
1283
|
+
if (state === 'connected') {
|
|
1284
|
+
if (this.commitOfflineGraceTimer !== null) {
|
|
1285
|
+
clearTimeout(this.commitOfflineGraceTimer);
|
|
1286
|
+
this.commitOfflineGraceTimer = null;
|
|
1287
|
+
}
|
|
1288
|
+
return;
|
|
1289
|
+
}
|
|
1290
|
+
// state === 'disconnected'
|
|
1291
|
+
if (this.commitOfflineGraceTimer !== null) return; // already armed
|
|
1292
|
+
const graceMs = this.config.commitOfflineGraceMs;
|
|
1293
|
+
this.commitOfflineGraceTimer = setTimeout(() => {
|
|
1294
|
+
this.commitOfflineGraceTimer = null;
|
|
1295
|
+
this.failInFlightCommitsOnOffline(graceMs);
|
|
1296
|
+
}, graceMs);
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
private failInFlightCommitsOnOffline(graceMs: number): void {
|
|
1300
|
+
const inFlight: string[] = [];
|
|
1301
|
+
for (const [id, tx] of this.commitStore.entries()) {
|
|
1302
|
+
if (tx.status === 'pending' || tx.status === 'executing') {
|
|
1303
|
+
inFlight.push(id);
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
if (inFlight.length === 0) return;
|
|
1307
|
+
// Each failed commit reaches the consumer through its own rejection path,
|
|
1308
|
+
// so this aggregate line is forensic and logged at debug rather than warn.
|
|
1309
|
+
this.runtime.logger.debug(
|
|
1310
|
+
`[MutationQueue] WS disconnected > ${graceMs}ms; failing ${inFlight.length} in-flight commit(s) with AbloConnectionError`,
|
|
1311
|
+
{ inFlightIds: inFlight.map((id) => id.slice(0, 8)) },
|
|
1312
|
+
);
|
|
1313
|
+
for (const id of inFlight) {
|
|
1314
|
+
const tx = this.commitStore.get(id);
|
|
1315
|
+
if (!tx) continue;
|
|
1316
|
+
const err = new AbloConnectionError(
|
|
1317
|
+
`commit ack abandoned after ${graceMs}ms offline`,
|
|
1318
|
+
{ code: 'commit_offline_grace_expired' },
|
|
1319
|
+
);
|
|
1320
|
+
tx.status = 'failed';
|
|
1321
|
+
tx.error = err;
|
|
1322
|
+
this.emit(`transaction:failed:${id}`, { error: err });
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
/**
|
|
1327
|
+
* Binds the mutation executor for this queue instance. The owning client
|
|
1328
|
+
* calls this right after construction, so commits made here always dispatch
|
|
1329
|
+
* through this instance's connection even when several client instances exist
|
|
1330
|
+
* in the same process.
|
|
1331
|
+
*/
|
|
1332
|
+
setMutationExecutor(executor: import('../../interfaces/index.js').MutationExecutor): void {
|
|
1333
|
+
this._mutationExecutor = executor;
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
// ============================================================================
|
|
1337
|
+
// Microtask-based transaction staging
|
|
1338
|
+
// ============================================================================
|
|
1339
|
+
//
|
|
1340
|
+
// Every transaction lands in the `createdTransactions` staging area first.
|
|
1341
|
+
// A microtask then commits them together under one batch index, so a bulk
|
|
1342
|
+
// operation such as importing a hundred rows is sent efficiently.
|
|
1343
|
+
//
|
|
1344
|
+
// Flow:
|
|
1345
|
+
// 1. create()/update()/delete() calls stageTransaction().
|
|
1346
|
+
// 2. stageTransaction() adds to createdTransactions and schedules a microtask.
|
|
1347
|
+
// 3. The microtask runs commitCreatedTransactions() once the current
|
|
1348
|
+
// synchronous code finishes.
|
|
1349
|
+
// 4. All staged transactions share one batch index and move to the execution
|
|
1350
|
+
// queue.
|
|
1351
|
+
// ============================================================================
|
|
1352
|
+
|
|
1353
|
+
/**
|
|
1354
|
+
* Stages a transaction for commit. Transactions staged within the same
|
|
1355
|
+
* event-loop tick are committed together.
|
|
1356
|
+
*/
|
|
1357
|
+
private stageTransaction(transaction: QueuedMutation): void {
|
|
1358
|
+
this.createdTransactions.push(transaction);
|
|
1359
|
+
this.scheduleCommit();
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
/**
|
|
1363
|
+
* Schedules the staged transactions to commit on a microtask, so all
|
|
1364
|
+
* transactions created synchronously within one tick are batched together.
|
|
1365
|
+
*/
|
|
1366
|
+
private scheduleCommit(): void {
|
|
1367
|
+
if (this.commitScheduled) return;
|
|
1368
|
+
this.commitScheduled = true;
|
|
1369
|
+
|
|
1370
|
+
// Use queueMicrotask to run after current sync code completes
|
|
1371
|
+
// All transactions created in same event loop will be committed together
|
|
1372
|
+
const schedule =
|
|
1373
|
+
typeof queueMicrotask === 'function'
|
|
1374
|
+
? queueMicrotask
|
|
1375
|
+
: (cb: () => void) => Promise.resolve().then(cb);
|
|
1376
|
+
|
|
1377
|
+
schedule(() => {
|
|
1378
|
+
this.commitCreatedTransactions();
|
|
1379
|
+
});
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
/**
|
|
1383
|
+
* Moves all staged transactions onto the execution queue, assigning them a
|
|
1384
|
+
* single shared batch index so they commit together.
|
|
1385
|
+
*/
|
|
1386
|
+
private commitCreatedTransactions(): void {
|
|
1387
|
+
this.commitScheduled = false;
|
|
1388
|
+
|
|
1389
|
+
if (this.createdTransactions.length === 0) return;
|
|
1390
|
+
|
|
1391
|
+
// Increment batch index - all transactions in this commit share it
|
|
1392
|
+
this.batchIndex++;
|
|
1393
|
+
const currentBatchIndex = this.batchIndex;
|
|
1394
|
+
|
|
1395
|
+
// Log batch commit for performance monitoring
|
|
1396
|
+
this.runtime.logger.debug('[MutationQueue] commitCreatedTransactions', {
|
|
1397
|
+
count: this.createdTransactions.length,
|
|
1398
|
+
batchIndex: currentBatchIndex,
|
|
1399
|
+
types: this.createdTransactions.map((t) => `${t.type}:${t.modelName}`),
|
|
1400
|
+
});
|
|
1401
|
+
|
|
1402
|
+
// Move all staged transactions to execution queue
|
|
1403
|
+
const staged = this.createdTransactions;
|
|
1404
|
+
this.createdTransactions = [];
|
|
1405
|
+
|
|
1406
|
+
for (const transaction of staged) {
|
|
1407
|
+
// Assign batch ID based on current batch index
|
|
1408
|
+
transaction.batchId = `batch_${currentBatchIndex}`;
|
|
1409
|
+
this.enqueue(transaction);
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
/**
|
|
1414
|
+
* Flushes every pending transaction in one commit, the fast path taken on
|
|
1415
|
+
* reconnect. If transport fails, the transactions retain this exact commit
|
|
1416
|
+
* envelope when they fall back to normal queue processing.
|
|
1417
|
+
*/
|
|
1418
|
+
async drainPending(): Promise<void> {
|
|
1419
|
+
if (this.pendingDrainPromise) return this.pendingDrainPromise;
|
|
1420
|
+
const drain = this.drainPendingInternal();
|
|
1421
|
+
this.pendingDrainPromise = drain.finally(() => {
|
|
1422
|
+
this.pendingDrainPromise = null;
|
|
1423
|
+
});
|
|
1424
|
+
return this.pendingDrainPromise;
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
private async drainPendingInternal(): Promise<void> {
|
|
1428
|
+
await drainPendingSettlements(this.pendingDrainContext);
|
|
1429
|
+
}
|
|
1430
|
+
async create(
|
|
1431
|
+
model: LocalModel,
|
|
1432
|
+
context: UserContext,
|
|
1433
|
+
writeOptions?: WriteOptions,
|
|
1434
|
+
sourceMutationId?: string,
|
|
1435
|
+
): Promise<QueuedMutation> {
|
|
1436
|
+
return createModel(this.modelMutationContext, model, context, writeOptions, sourceMutationId);
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
async update(
|
|
1440
|
+
model: LocalModel,
|
|
1441
|
+
context: UserContext,
|
|
1442
|
+
precomputedChanges?: Record<string, unknown>,
|
|
1443
|
+
writeOptions?: WriteOptions,
|
|
1444
|
+
sourceMutationId?: string,
|
|
1445
|
+
): Promise<QueuedMutation> {
|
|
1446
|
+
return updateModel(this.modelMutationContext, model, context, precomputedChanges, writeOptions, sourceMutationId);
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
async delete(
|
|
1450
|
+
model: LocalModel,
|
|
1451
|
+
context: UserContext,
|
|
1452
|
+
writeOptions?: WriteOptions,
|
|
1453
|
+
sourceMutationId?: string,
|
|
1454
|
+
): Promise<QueuedMutation> {
|
|
1455
|
+
return deleteModel(this.modelMutationContext, model, context, writeOptions, sourceMutationId);
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
async uploadAttachment(
|
|
1459
|
+
_file: File,
|
|
1460
|
+
options: { id: string; [key: string]: unknown },
|
|
1461
|
+
_context: UserContext // eslint-disable-line @typescript-eslint/no-unused-vars -- reserved executor context
|
|
1462
|
+
): Promise<{ url: string } | null> {
|
|
1463
|
+
return this.mutationExecutor.uploadAttachment?.(options.id, options) ?? null;
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
async batchUploadAttachments(
|
|
1467
|
+
_files: File[],
|
|
1468
|
+
items: { id: string; [key: string]: unknown }[],
|
|
1469
|
+
_context: UserContext // eslint-disable-line @typescript-eslint/no-unused-vars -- reserved executor context
|
|
1470
|
+
): Promise<{ id: string; url: string }[]> {
|
|
1471
|
+
return this.mutationExecutor.batchUploadAttachments?.(items.map(i => ({ id: i.id, input: i }))) ?? [];
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
async archive(
|
|
1475
|
+
model: LocalModel,
|
|
1476
|
+
context: UserContext,
|
|
1477
|
+
writeOptions?: WriteOptions,
|
|
1478
|
+
sourceMutationId?: string,
|
|
1479
|
+
): Promise<QueuedMutation> {
|
|
1480
|
+
return archiveModel(this.modelMutationContext, model, context, writeOptions, sourceMutationId);
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
async unarchive(model: LocalModel, context: UserContext): Promise<QueuedMutation> {
|
|
1484
|
+
return unarchiveModel(this.modelMutationContext, model, context);
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
private enqueue(transaction: QueuedMutation): void {
|
|
1488
|
+
enqueueTransaction(this.queueCoalescingContext, transaction);
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
private scheduleProcessing(immediate = false): void {
|
|
1492
|
+
scheduleProcessingExternal(this.processingSchedulerContext, immediate);
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
private async processBatch(): Promise<void> {
|
|
1496
|
+
await processBatch(this.batchProcessingContext);
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
private rememberDeltaCorrelation(correlationId: string, syncId: number): void {
|
|
1500
|
+
// Refresh insertion order when a replay repeats the same correlation id.
|
|
1501
|
+
this.recentDeltaCorrelations.delete(correlationId);
|
|
1502
|
+
this.recentDeltaCorrelations.set(correlationId, syncId);
|
|
1503
|
+
if (this.recentDeltaCorrelations.size <= 2_048) return;
|
|
1504
|
+
const oldest = this.recentDeltaCorrelations.keys().next().value;
|
|
1505
|
+
if (typeof oldest === 'string') this.recentDeltaCorrelations.delete(oldest);
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
private queuedCommitEchoSyncId(tx: CommitTransaction): number | undefined {
|
|
1509
|
+
return tx.correlationId
|
|
1510
|
+
? this.recentDeltaCorrelations.get(tx.correlationId)
|
|
1511
|
+
: undefined;
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
private queuedCommitMatchesCorrelation(
|
|
1515
|
+
tx: CommitTransaction,
|
|
1516
|
+
correlationId: string,
|
|
1517
|
+
): boolean {
|
|
1518
|
+
return tx.correlationId !== undefined && tx.correlationId === correlationId;
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
private completeQueuedCommit(tx: CommitTransaction, syncId: number): void {
|
|
1522
|
+
if (tx.status !== 'awaiting_delta') return;
|
|
1523
|
+
this.clearReplicationLagState(tx.id);
|
|
1524
|
+
tx.lastSyncId = syncId;
|
|
1525
|
+
tx.status = 'completed';
|
|
1526
|
+
// The queued receipt was only acceptance. The correlated source echo is
|
|
1527
|
+
// the first definitive success and therefore the point where the durable
|
|
1528
|
+
// replay envelope may be removed.
|
|
1529
|
+
void this.removeDurableCommit(tx.id);
|
|
1530
|
+
this.emitCommitLifecycle('transaction:completed', tx);
|
|
1531
|
+
this.emitCommitLifecycle(`transaction:completed:${tx.id}`, tx);
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
/**
|
|
1535
|
+
* Confirms awaiting writes. Hosted/anomaly receipts keep their sync-id
|
|
1536
|
+
* threshold semantics; queued forwards require the exact server correlation.
|
|
1537
|
+
*/
|
|
1538
|
+
onDeltaReceived(
|
|
1539
|
+
syncId: number,
|
|
1540
|
+
_transactionId?: string,
|
|
1541
|
+
correlationId?: string,
|
|
1542
|
+
): void {
|
|
1543
|
+
if (correlationId) this.rememberDeltaCorrelation(correlationId, syncId);
|
|
1544
|
+
const correlatedModelTransactions = correlationId
|
|
1545
|
+
? this.store.getByStatus('awaiting_delta').filter(
|
|
1546
|
+
(tx) =>
|
|
1547
|
+
tx.requiresCorrelatedDelta === true &&
|
|
1548
|
+
tx.correlationId !== undefined &&
|
|
1549
|
+
tx.correlationId === correlationId,
|
|
1550
|
+
)
|
|
1551
|
+
: [];
|
|
1552
|
+
this.deltaConfirmation.onDeltaReceived(syncId, correlationId);
|
|
1553
|
+
if (correlatedModelTransactions.length > 0) {
|
|
1554
|
+
const envelopeIds = new Set<string>();
|
|
1555
|
+
for (const tx of correlatedModelTransactions) {
|
|
1556
|
+
this.clearReplicationLagState(tx.id);
|
|
1557
|
+
if (tx.commitEnvelope) envelopeIds.add(tx.commitEnvelope.idempotencyKey);
|
|
1558
|
+
}
|
|
1559
|
+
for (const envelopeId of envelopeIds) {
|
|
1560
|
+
const envelopeStillAwaiting = this.store
|
|
1561
|
+
.getByStatus('awaiting_delta')
|
|
1562
|
+
.some(
|
|
1563
|
+
(tx) => tx.commitEnvelope?.idempotencyKey === envelopeId,
|
|
1564
|
+
);
|
|
1565
|
+
if (!envelopeStillAwaiting) void this.removeDurableCommit(envelopeId);
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
if (!correlationId) return;
|
|
1569
|
+
for (const tx of this.commitStore.values()) {
|
|
1570
|
+
if (tx.status !== 'awaiting_delta') continue;
|
|
1571
|
+
if (this.queuedCommitMatchesCorrelation(tx, correlationId)) {
|
|
1572
|
+
this.completeQueuedCommit(tx, syncId);
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
// Schedule the retry-and-reconciliation wait for a transaction's confirming
|
|
1578
|
+
// delta; see {@link DeltaConfirmationTracker} in `./deltaConfirmation.js`.
|
|
1579
|
+
private scheduleDeltaConfirmationTimeout(tx: QueuedMutation, timeoutMs: number): void {
|
|
1580
|
+
this.deltaConfirmation.scheduleDeltaConfirmationTimeout(tx, timeoutMs);
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
/**
|
|
1584
|
+
* Resolves once the given transaction is confirmed and rejects if it fails.
|
|
1585
|
+
* The confirming delta's timeout is handled by
|
|
1586
|
+
* {@link scheduleDeltaConfirmationTimeout}.
|
|
1587
|
+
*/
|
|
1588
|
+
waitForConfirmation(transactionId: string): Promise<void> {
|
|
1589
|
+
return new Promise((resolve, reject) => {
|
|
1590
|
+
// Check if already completed
|
|
1591
|
+
const tx = this.store.get(transactionId);
|
|
1592
|
+
if (tx?.status === 'completed') {
|
|
1593
|
+
resolve();
|
|
1594
|
+
return;
|
|
1595
|
+
}
|
|
1596
|
+
const lagError = this.replicationLagErrors.get(transactionId);
|
|
1597
|
+
if (lagError) {
|
|
1598
|
+
reject(lagError);
|
|
1599
|
+
return;
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
const onCompleted = () => {
|
|
1603
|
+
cleanup();
|
|
1604
|
+
resolve();
|
|
1605
|
+
};
|
|
1606
|
+
|
|
1607
|
+
const onFailed = ({ error }: { error: Error }) => {
|
|
1608
|
+
cleanup();
|
|
1609
|
+
reject(error);
|
|
1610
|
+
};
|
|
1611
|
+
|
|
1612
|
+
const onLagged = ({ error }: { error: Error }) => {
|
|
1613
|
+
cleanup();
|
|
1614
|
+
reject(error);
|
|
1615
|
+
};
|
|
1616
|
+
|
|
1617
|
+
const cleanup = () => {
|
|
1618
|
+
this.off(`transaction:completed:${transactionId}`, onCompleted);
|
|
1619
|
+
this.off(`transaction:failed:${transactionId}`, onFailed);
|
|
1620
|
+
this.off(`transaction:confirmation_lagged:${transactionId}`, onLagged);
|
|
1621
|
+
};
|
|
1622
|
+
|
|
1623
|
+
// Listen to existing events (timeout already handled by scheduleDeltaConfirmationTimeout)
|
|
1624
|
+
this.on(`transaction:completed:${transactionId}`, onCompleted);
|
|
1625
|
+
this.on(`transaction:failed:${transactionId}`, onFailed);
|
|
1626
|
+
this.on(`transaction:confirmation_lagged:${transactionId}`, onLagged);
|
|
1627
|
+
});
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
// Reports whether a client mutation id is known to this queue, which helps
|
|
1631
|
+
// identify a delta as this client's own echo.
|
|
1632
|
+
hasClientMutationId(id: string): boolean {
|
|
1633
|
+
return !!this.store.get(id) || this.commitStore.has(id);
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
/** Enqueues a pre-built atomic commit through the commit API coordinator. */
|
|
1637
|
+
async enqueueCommit(
|
|
1638
|
+
clientTxId: string,
|
|
1639
|
+
operations: CommitTransaction['operations'],
|
|
1640
|
+
options: { reads?: ReadDependency[] | null; track?: TrackDependency[] | null } = {},
|
|
1641
|
+
): Promise<void> {
|
|
1642
|
+
return enqueueCommit(this.commitApiContext, clientTxId, operations, options);
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
private async processCommitLane(): Promise<void> {
|
|
1646
|
+
await processCommitLane(this.commitLaneContext);
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
waitForCommitReceipt(
|
|
1650
|
+
clientTxId: string,
|
|
1651
|
+
): Promise<{
|
|
1652
|
+
lastSyncId: number;
|
|
1653
|
+
notifications?: StaleNotification[];
|
|
1654
|
+
missingIds?: string[];
|
|
1655
|
+
}> {
|
|
1656
|
+
return waitForCommitReceipt(this.commitReceiptContext, clientTxId);
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
private isReorderPayload(data: MutationInput | undefined): boolean {
|
|
1660
|
+
if (!data || typeof data !== 'object') return false;
|
|
1661
|
+
return 'order' in data || 'orderKey' in data || 'position' in data;
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
private isPermanentError(error: Error): boolean {
|
|
1665
|
+
return classifyPermanentError(error);
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
private isDefinitiveRejection(error: Error): boolean {
|
|
1669
|
+
return classifyDefinitiveRejection(error);
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
private async handleFailure(transaction: QueuedMutation, error: Error): Promise<void> {
|
|
1673
|
+
await handleFailure(this.failureHandlingContext, transaction, error);
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
async handleConflict(transaction: QueuedMutation, serverData: MutationInput): Promise<void> {
|
|
1677
|
+
await resolveConflict(this.conflictPolicyContext, transaction, serverData);
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
private applyOptimisticCreate(model: LocalModel, transaction: QueuedMutation): void {
|
|
1681
|
+
this.localMutationPort.applyCreate(model, transaction);
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
private applyOptimisticUpdate(model: LocalModel, transaction: QueuedMutation): void {
|
|
1685
|
+
this.localMutationPort.applyUpdate(model, transaction);
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
private applyOptimisticDelete(model: LocalModel, transaction: QueuedMutation): void {
|
|
1689
|
+
this.localMutationPort.applyDelete(model, transaction);
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
private async rollbackOptimistic(
|
|
1693
|
+
transaction: QueuedMutation,
|
|
1694
|
+
reason?: string,
|
|
1695
|
+
error?: Error
|
|
1696
|
+
): Promise<void> {
|
|
1697
|
+
await this.localMutationPort.rollback(transaction, reason, error);
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
private deserializeLegacyPendingMutation(row: object, fallbackMutationId?: string): QueuedMutation | null {
|
|
1701
|
+
return deserializeLegacyPendingMutation(this.persistenceContext, row, fallbackMutationId);
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
async loadPersistedTransactions(persistence: MutationPersistencePort, sealedMutationIds: ReadonlySet<string> = new Set()): Promise<void> {
|
|
1705
|
+
await loadPersistedTransactions(this.persistenceContext, persistence, sealedMutationIds);
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
async restoreDurableCommits(): Promise<Set<string>> {
|
|
1709
|
+
return restoreDurableCommitsExternal(this.durableCommitRestoreContext);
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
private deserializeTransaction(data: unknown): QueuedMutation | null {
|
|
1713
|
+
if (isNonReplayablePersistedRow(data)) return null;
|
|
1714
|
+
|
|
1715
|
+
const transaction = deserializePersistedTransaction(data, this.runtime);
|
|
1716
|
+
if (!transaction) {
|
|
1717
|
+
const rowId =
|
|
1718
|
+
typeof data === 'object' && data !== null && typeof (data as { id?: unknown }).id === 'string'
|
|
1719
|
+
? (data as { id: string }).id
|
|
1720
|
+
: undefined;
|
|
1721
|
+
this.runtime.logger.debug('[MutationQueue] Dropping malformed persisted transaction', {
|
|
1722
|
+
rowId,
|
|
1723
|
+
});
|
|
1724
|
+
this.runtime.observability.captureMutationFailure({
|
|
1725
|
+
context: 'deserialize-persisted-transaction',
|
|
1726
|
+
error: `Persisted transaction failed schema validation${rowId ? ` (id: ${rowId})` : ''}`,
|
|
1727
|
+
});
|
|
1728
|
+
return null;
|
|
1729
|
+
}
|
|
1730
|
+
return transaction;
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
cancelTransactionsForModel(modelId: string, transactionType?: string): QueuedMutation[] {
|
|
1734
|
+
const cancelledTransactions: QueuedMutation[] = [];
|
|
1735
|
+
|
|
1736
|
+
const allTransactions = [
|
|
1737
|
+
...this.store.getByStatus('pending'),
|
|
1738
|
+
...this.store.getByStatus('executing'),
|
|
1739
|
+
];
|
|
1740
|
+
|
|
1741
|
+
for (const transaction of allTransactions) {
|
|
1742
|
+
if (transaction.modelId === modelId) {
|
|
1743
|
+
if (!transactionType || transaction.type === transactionType) {
|
|
1744
|
+
cancelledTransactions.push(transaction);
|
|
1745
|
+
this.store.updateStatus(transaction.id, 'rolled_back');
|
|
1746
|
+
// Sync caller: a rejected rollback (throwing optimistic:rollback
|
|
1747
|
+
// listener) must surface, not vanish — the status flip above is
|
|
1748
|
+
// already committed either way.
|
|
1749
|
+
void this.rollbackOptimistic(transaction, 'model_cancelled').catch((error: unknown) => {
|
|
1750
|
+
this.runtime.observability.captureMutationFailure({
|
|
1751
|
+
context: 'rollback-model-cancelled',
|
|
1752
|
+
error: error instanceof Error ? error : String(error),
|
|
1753
|
+
});
|
|
1754
|
+
});
|
|
1755
|
+
}
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
return cancelledTransactions;
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
/**
|
|
1763
|
+
* Cancels pending transactions for child rows that reference a deleted parent,
|
|
1764
|
+
* used to cascade a parent deletion. The caller supplies the foreign-key
|
|
1765
|
+
* relationship; this method performs the cancellation.
|
|
1766
|
+
*
|
|
1767
|
+
* @param childModelName - The child model type (for example 'Block').
|
|
1768
|
+
* @param foreignKey - The foreign-key property name (for example 'sectionId').
|
|
1769
|
+
* @param parentId - The deleted parent's id.
|
|
1770
|
+
* @returns The number of transactions cancelled.
|
|
1771
|
+
*/
|
|
1772
|
+
cancelTransactionsByForeignKey(
|
|
1773
|
+
childModelName: string,
|
|
1774
|
+
foreignKey: string,
|
|
1775
|
+
parentId: string
|
|
1776
|
+
): number {
|
|
1777
|
+
let cancelled = 0;
|
|
1778
|
+
|
|
1779
|
+
const allTransactions = [
|
|
1780
|
+
...this.store.getByStatus('pending'),
|
|
1781
|
+
...this.store.getByStatus('executing'),
|
|
1782
|
+
...this.store.getByStatus('awaiting_delta'),
|
|
1783
|
+
];
|
|
1784
|
+
|
|
1785
|
+
for (const transaction of allTransactions) {
|
|
1786
|
+
if (transaction.modelName === childModelName) {
|
|
1787
|
+
// Check if this transaction's data contains the parent FK
|
|
1788
|
+
const fkValue = transaction.data?.[foreignKey];
|
|
1789
|
+
if (fkValue === parentId) {
|
|
1790
|
+
this.store.updateStatus(transaction.id, 'rolled_back');
|
|
1791
|
+
void this.rollbackOptimistic(transaction, 'cascade_parent_deleted').catch(
|
|
1792
|
+
(error: unknown) => {
|
|
1793
|
+
this.runtime.observability.captureMutationFailure({
|
|
1794
|
+
context: 'rollback-cascade-parent-deleted',
|
|
1795
|
+
error: error instanceof Error ? error : String(error),
|
|
1796
|
+
});
|
|
1797
|
+
}
|
|
1798
|
+
);
|
|
1799
|
+
cancelled++;
|
|
1800
|
+
|
|
1801
|
+
this.runtime.logger.debug('[MutationQueue] Cascade cancelled orphaned transaction', {
|
|
1802
|
+
txId: transaction.id.slice(0, 12),
|
|
1803
|
+
model: childModelName,
|
|
1804
|
+
foreignKey,
|
|
1805
|
+
parentId: parentId.slice(0, 12),
|
|
1806
|
+
});
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
return cancelled;
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
/**
|
|
1815
|
+
* Returns the number of transactions still pending or executing.
|
|
1816
|
+
*/
|
|
1817
|
+
getOutstandingTransactionCount(): number {
|
|
1818
|
+
return this.deferredMutations.length +
|
|
1819
|
+
this.store.getByStatus('pending').length +
|
|
1820
|
+
this.store.getByStatus('executing').length;
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
getOutstandingTransactions(): readonly QueuedMutation[] {
|
|
1824
|
+
return [
|
|
1825
|
+
...this.store.getByStatus('pending'),
|
|
1826
|
+
...this.store.getByStatus('executing'),
|
|
1827
|
+
];
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
/** Generates a unique local transaction id. */
|
|
1831
|
+
private generateId(): string {
|
|
1832
|
+
return `tx_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
private mergeData(
|
|
1836
|
+
local: MutationInput | undefined,
|
|
1837
|
+
remote: MutationInput | undefined
|
|
1838
|
+
): MutationInput {
|
|
1839
|
+
return { ...(remote || {}), ...(local || {}) };
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
private extractCreateData(model: LocalModel): MutationInput {
|
|
1843
|
+
return projectCommitPayload(model.getModelName(), model.toJSON(), { dropUndefined: false }, this.runtime);
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
private mapChangesToInput(modelName: string, changes: Record<string, unknown>): MutationInput {
|
|
1847
|
+
return projectCommitPayload(modelName, changes, { dropUndefined: true }, this.runtime);
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
private extractUpdateData(model: LocalModel): MutationInput {
|
|
1851
|
+
return projectCommitPayload(model.getModelName(), model.getChanges(), { dropUndefined: true }, this.runtime);
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
// Derive previous values for changed fields to support accurate rollback.
|
|
1855
|
+
// Model-specific special cases do not belong here; a model that needs to
|
|
1856
|
+
// surface previous state beyond `modifiedProperties` should expose a typed
|
|
1857
|
+
// `getPreviousData()` accessor for this method to call.
|
|
1858
|
+
private extractPreviousData(model: LocalModel, updateInput?: MutationInput): MutationInput {
|
|
1859
|
+
// When the update's written keys are known, capture a before-image for
|
|
1860
|
+
// exactly those keys, so the recorded undo inverse reverts them and nothing
|
|
1861
|
+
// else — a full-row inverse would clobber concurrent edits to unrelated
|
|
1862
|
+
// fields. `fallbackToLive: false` makes `Model.capturePreviousValues` omit
|
|
1863
|
+
// any key it cannot resolve, and `buildUndoOps` then drops an un-revertible
|
|
1864
|
+
// inverse rather than inventing one. With no `updateInput` (a full extract)
|
|
1865
|
+
// it falls back to every tracked field. `Model.capturePreviousValues` is the
|
|
1866
|
+
// single before-image source, shared with
|
|
1867
|
+
// `RecordingMutation.snapshotFields`.
|
|
1868
|
+
const keys = updateInput
|
|
1869
|
+
? Object.keys(updateInput)
|
|
1870
|
+
: [...(model.modifiedProperties instanceof Map ? model.modifiedProperties.keys() : [])];
|
|
1871
|
+
return { id: model.id, ...model.capturePreviousValues(keys, { fallbackToLive: false }) };
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1874
|
+
/** Returns a snapshot of queue counts and the current configuration. */
|
|
1875
|
+
getStats() {
|
|
1876
|
+
return {
|
|
1877
|
+
pending: this.store.getByStatus('pending').length,
|
|
1878
|
+
executing: this.store.getByStatus('executing').length,
|
|
1879
|
+
completed: this.store.getByStatus('completed').length,
|
|
1880
|
+
failed: this.store.getByStatus('failed').length,
|
|
1881
|
+
optimistic: this.localMutationPort.updates.size,
|
|
1882
|
+
totalTransactions: this.store.getAll().length,
|
|
1883
|
+
batchIndex: this.batchIndex,
|
|
1884
|
+
config: { ...this.config },
|
|
1885
|
+
};
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
/**
|
|
1889
|
+
* Returns detailed internal state — pending, executing, and awaiting-delta
|
|
1890
|
+
* transactions — to help diagnose delta-confirmation issues.
|
|
1891
|
+
*/
|
|
1892
|
+
getDebugInfo() {
|
|
1893
|
+
const awaitingDelta = this.store.getByStatus('awaiting_delta');
|
|
1894
|
+
return {
|
|
1895
|
+
lastSeenSyncId: this.lastSeenSyncId,
|
|
1896
|
+
awaitingDeltaCount: awaitingDelta.length,
|
|
1897
|
+
awaitingDeltaTransactions: awaitingDelta.map((tx) => ({
|
|
1898
|
+
id: tx.id.slice(0, 8),
|
|
1899
|
+
type: tx.type,
|
|
1900
|
+
modelName: tx.modelName,
|
|
1901
|
+
modelId: tx.modelId.slice(0, 8),
|
|
1902
|
+
syncIdNeeded: tx.syncIdNeededForCompletion,
|
|
1903
|
+
createdAt: tx.createdAt,
|
|
1904
|
+
age: Date.now() - tx.createdAt,
|
|
1905
|
+
})),
|
|
1906
|
+
pendingTransactions: this.store.getByStatus('pending').map((tx) => ({
|
|
1907
|
+
id: tx.id.slice(0, 8),
|
|
1908
|
+
type: tx.type,
|
|
1909
|
+
modelName: tx.modelName,
|
|
1910
|
+
modelId: tx.modelId.slice(0, 8),
|
|
1911
|
+
})),
|
|
1912
|
+
executingTransactions: this.store.getByStatus('executing').map((tx) => ({
|
|
1913
|
+
id: tx.id.slice(0, 8),
|
|
1914
|
+
type: tx.type,
|
|
1915
|
+
modelName: tx.modelName,
|
|
1916
|
+
modelId: tx.modelId.slice(0, 8),
|
|
1917
|
+
})),
|
|
1918
|
+
};
|
|
1919
|
+
}
|
|
1920
|
+
|
|
1921
|
+
/** Merges the given options into the queue's configuration. */
|
|
1922
|
+
setConfig(config: Partial<MutationQueueConfig>): void {
|
|
1923
|
+
this.config = { ...this.config, ...config };
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
/**
|
|
1927
|
+
* Re-emits an incoming sync delta on the `sync:delta` event for the store to
|
|
1928
|
+
* apply. Because rows use stable ids, no id reconciliation is needed here.
|
|
1929
|
+
*/
|
|
1930
|
+
handleSyncDelta(delta: { id: string; modelName: string; action: string; data: any }): boolean {
|
|
1931
|
+
// Row ids are stable, so no reconciliation is needed; re-emit the delta for
|
|
1932
|
+
// the store to apply directly.
|
|
1933
|
+
this.emit('sync:delta', {
|
|
1934
|
+
id: delta.id,
|
|
1935
|
+
modelName: delta.modelName,
|
|
1936
|
+
action: delta.action,
|
|
1937
|
+
data: delta.data,
|
|
1938
|
+
});
|
|
1939
|
+
|
|
1940
|
+
return true;
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
/**
|
|
1944
|
+
* Releases the queue's resources: rolls back outstanding optimistic updates,
|
|
1945
|
+
* clears all timers and stored transactions, and removes event listeners.
|
|
1946
|
+
*/
|
|
1947
|
+
dispose(): void {
|
|
1948
|
+
// Cancel all active optimistic updates
|
|
1949
|
+
for (const [, optimistic] of this.localMutationPort.updates) {
|
|
1950
|
+
this.emit('optimistic:rollback', {
|
|
1951
|
+
model: optimistic.model,
|
|
1952
|
+
previousState: optimistic.previousState,
|
|
1953
|
+
transaction: optimistic.transaction,
|
|
1954
|
+
reason: 'dispose',
|
|
1955
|
+
});
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
// Clear processing
|
|
1959
|
+
if (this.processTimer) {
|
|
1960
|
+
clearTimeout(this.processTimer);
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
// Clear every armed delta-confirmation timer (one per in-flight tx,
|
|
1964
|
+
// 30–120s each) — a disposed queue must not keep the process alive or
|
|
1965
|
+
// fire confirmation callbacks against the cleared store below.
|
|
1966
|
+
this.deltaConfirmation.dispose();
|
|
1967
|
+
for (const timeout of this.replicationLagTimeouts.values()) {
|
|
1968
|
+
clearTimeout(timeout);
|
|
1969
|
+
}
|
|
1970
|
+
this.replicationLagTimeouts.clear();
|
|
1971
|
+
this.replicationLagErrors.clear();
|
|
1972
|
+
|
|
1973
|
+
// Clear the offline-grace timer armed by setConnectionState('disconnected').
|
|
1974
|
+
if (this.commitOfflineGraceTimer !== null) {
|
|
1975
|
+
clearTimeout(this.commitOfflineGraceTimer);
|
|
1976
|
+
this.commitOfflineGraceTimer = null;
|
|
1977
|
+
}
|
|
1978
|
+
|
|
1979
|
+
// Clear store
|
|
1980
|
+
this.store.clear();
|
|
1981
|
+
this.localMutationPort.updates.clear();
|
|
1982
|
+
this.executionQueue = [];
|
|
1983
|
+
this.createdTransactions = [];
|
|
1984
|
+
this.deferredDeletesByCreate.clear();
|
|
1985
|
+
this.recentDeltaCorrelations.clear();
|
|
1986
|
+
this.commitLane = [];
|
|
1987
|
+
this.commitStore.clear();
|
|
1988
|
+
this.commitNotifications.clear();
|
|
1989
|
+
this.commitMissingIds.clear();
|
|
1990
|
+
|
|
1991
|
+
// Clear event listeners
|
|
1992
|
+
this.removeAllListeners();
|
|
1993
|
+
|
|
1994
|
+
// Reset state
|
|
1995
|
+
this.isProcessing = false;
|
|
1996
|
+
this.batchIndex = 0;
|
|
1997
|
+
}
|
|
1998
|
+
}
|