@abloatai/ablo 0.36.0 → 0.37.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +2 -2
- package/CHANGELOG.md +55 -2014
- package/NOTICE +2 -2
- package/README.md +45 -63
- package/assets/banner.png +0 -0
- package/dist/auth.d.ts +2 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +2 -0
- package/dist/auth.js.map +1 -0
- package/dist/client.d.ts +3 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +3 -0
- package/dist/client.js.map +1 -0
- package/dist/coordination.d.ts +2 -0
- package/dist/coordination.d.ts.map +1 -0
- package/dist/coordination.js +2 -0
- package/dist/coordination.js.map +1 -0
- package/dist/index.d.ts +3 -114
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -163
- package/dist/index.js.map +1 -0
- package/dist/react.d.ts +4 -0
- package/dist/react.d.ts.map +1 -0
- package/dist/react.js +3 -0
- package/dist/react.js.map +1 -0
- package/dist/schema.d.ts +2 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +2 -0
- package/dist/schema.js.map +1 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +2 -0
- package/dist/server.js.map +1 -0
- package/dist/source-conformance.d.ts +2 -0
- package/dist/source-conformance.d.ts.map +1 -0
- package/dist/source-conformance.js +2 -0
- package/dist/source-conformance.js.map +1 -0
- package/dist/source-drizzle.d.ts +2 -0
- package/dist/source-drizzle.d.ts.map +1 -0
- package/dist/source-drizzle.js +2 -0
- package/dist/source-drizzle.js.map +1 -0
- package/dist/source-kysely.d.ts +2 -0
- package/dist/source-kysely.d.ts.map +1 -0
- package/dist/source-kysely.js +2 -0
- package/dist/source-kysely.js.map +1 -0
- package/dist/source-next.d.ts +2 -0
- package/dist/source-next.d.ts.map +1 -0
- package/dist/source-next.js +2 -0
- package/dist/source-next.js.map +1 -0
- package/dist/source.d.ts +2 -0
- package/dist/source.d.ts.map +1 -0
- package/dist/source.js +2 -0
- package/dist/source.js.map +1 -0
- package/dist/wire.d.ts +2 -0
- package/dist/wire.d.ts.map +1 -0
- package/dist/wire.js +2 -0
- package/dist/wire.js.map +1 -0
- package/docs/agents.md +1 -1
- package/docs/api-keys.md +7 -6
- package/docs/api.md +10 -10
- package/docs/client-behavior.md +5 -5
- package/docs/coordination.md +52 -62
- package/docs/data-sources.md +1 -1
- package/docs/examples/agent-human.md +4 -4
- package/docs/examples/ai-sdk-tool.md +1 -1
- package/docs/examples/existing-python-backend.md +15 -4
- package/docs/examples/nextjs.md +27 -6
- package/docs/examples/server-agent.md +2 -2
- package/docs/how-it-works.md +4 -4
- package/docs/identity.md +2 -1
- package/docs/integration-guide.md +24 -13
- package/docs/internal/README.md +18 -0
- package/docs/internal/agent-fleet-coordination-design.md +171 -0
- package/docs/internal/agent-orchestration.md +58 -0
- package/docs/internal/commit-identifiers.md +91 -0
- package/docs/internal/concurrency-open-decisions.md +37 -0
- package/docs/internal/data-source-reverse-channel.md +150 -0
- package/docs/internal/per-field-conflict-detection.md +165 -0
- package/docs/internal/postgres-replication.md +64 -0
- package/docs/internal/serializable-schema.md +119 -0
- package/docs/internal/structure.md +32 -0
- package/docs/mcp.md +2 -2
- package/docs/migration.md +3 -3
- package/docs/quickstart.md +2 -2
- package/docs/react.md +5 -5
- package/docs/schema-contract.md +3 -3
- package/docs/sessions.md +91 -37
- package/examples/README.md +2 -2
- package/examples/data-source/README.md +1 -1
- package/examples/data-source/ablo-driver.ts +1 -1
- package/examples/data-source/customer-server.ts +1 -1
- package/examples/data-source/run.ts +1 -1
- package/examples/data-source/schema.ts +1 -1
- package/examples/quickstart.ts +2 -2
- package/llms.txt +8 -8
- package/package.json +63 -166
- package/bin/ablo.cjs +0 -39
- package/dist/BaseSyncedStore.d.ts +0 -843
- package/dist/BaseSyncedStore.js +0 -1971
- package/dist/Database.d.ts +0 -323
- package/dist/Database.js +0 -1502
- package/dist/InstanceCache.d.ts +0 -237
- package/dist/InstanceCache.js +0 -1166
- package/dist/LazyReferenceCollection.d.ts +0 -177
- package/dist/LazyReferenceCollection.js +0 -461
- package/dist/Model.d.ts +0 -454
- package/dist/Model.js +0 -919
- package/dist/ModelRegistry.d.ts +0 -225
- package/dist/ModelRegistry.js +0 -539
- package/dist/NetworkMonitor.d.ts +0 -28
- package/dist/NetworkMonitor.js +0 -79
- package/dist/RuntimeContext.d.ts +0 -52
- package/dist/RuntimeContext.js +0 -80
- package/dist/SyncClient.d.ts +0 -541
- package/dist/SyncClient.js +0 -2202
- package/dist/adapters/alwaysOnline.d.ts +0 -14
- package/dist/adapters/alwaysOnline.js +0 -17
- package/dist/adapters/inMemoryStorage.d.ts +0 -31
- package/dist/adapters/inMemoryStorage.js +0 -110
- package/dist/ai-sdk/coordinatedTool.d.ts +0 -120
- package/dist/ai-sdk/coordinatedTool.js +0 -134
- package/dist/ai-sdk/coordinationContext.d.ts +0 -46
- package/dist/ai-sdk/coordinationContext.js +0 -106
- package/dist/ai-sdk/index.d.ts +0 -121
- package/dist/ai-sdk/index.js +0 -121
- package/dist/ai-sdk/wrap.d.ts +0 -65
- package/dist/ai-sdk/wrap.js +0 -39
- package/dist/auth/index.d.ts +0 -1
- package/dist/auth/index.js +0 -8
- package/dist/batching/index.d.ts +0 -55
- package/dist/batching/index.js +0 -147
- package/dist/client/Ablo.d.ts +0 -231
- package/dist/client/Ablo.js +0 -160
- package/dist/client/abloClient.d.ts +0 -309
- package/dist/client/abloClient.js +0 -13
- package/dist/client/clientPrelude.d.ts +0 -52
- package/dist/client/clientPrelude.js +0 -60
- package/dist/client/consoleLogger.d.ts +0 -35
- package/dist/client/consoleLogger.js +0 -44
- package/dist/client/coreClient.d.ts +0 -60
- package/dist/client/coreClient.js +0 -118
- package/dist/client/createInternalComponents.d.ts +0 -50
- package/dist/client/createInternalComponents.js +0 -98
- package/dist/client/createModelProxy.d.ts +0 -248
- package/dist/client/createModelProxy.js +0 -884
- package/dist/client/humans.d.ts +0 -69
- package/dist/client/humans.js +0 -78
- package/dist/client/modelRegistration.d.ts +0 -10
- package/dist/client/modelRegistration.js +0 -312
- package/dist/client/options.d.ts +0 -461
- package/dist/client/options.js +0 -7
- package/dist/client/reactiveEngine.d.ts +0 -53
- package/dist/client/reactiveEngine.js +0 -688
- package/dist/client/resourceTypes.d.ts +0 -12
- package/dist/client/resourceTypes.js +0 -10
- package/dist/client/schemaConfig.d.ts +0 -44
- package/dist/client/schemaConfig.js +0 -185
- package/dist/client/storeCluster.d.ts +0 -47
- package/dist/client/storeCluster.js +0 -118
- package/dist/client/storeLifecycle.d.ts +0 -61
- package/dist/client/storeLifecycle.js +0 -231
- package/dist/client/validateAbloOptions.d.ts +0 -42
- package/dist/client/validateAbloOptions.js +0 -43
- package/dist/client/wsMutationExecutor.d.ts +0 -27
- package/dist/client/wsMutationExecutor.js +0 -72
- package/dist/context.d.ts +0 -42
- package/dist/context.js +0 -81
- package/dist/coordination/ClaimLog.d.ts +0 -26
- package/dist/coordination/ClaimLog.js +0 -32
- package/dist/coordination/index.d.ts +0 -1
- package/dist/coordination/index.js +0 -8
- package/dist/core/index.d.ts +0 -33
- package/dist/core/index.js +0 -48
- package/dist/docs/catalog.d.ts +0 -72
- package/dist/docs/catalog.js +0 -230
- package/dist/docs/index.d.ts +0 -10
- package/dist/docs/index.js +0 -10
- package/dist/environment.d.ts +0 -1
- package/dist/environment.js +0 -8
- package/dist/interfaces/index.d.ts +0 -311
- package/dist/interfaces/index.js +0 -9
- package/dist/keys/index.d.ts +0 -1
- package/dist/keys/index.js +0 -8
- package/dist/mutators/RecordingMutation.d.ts +0 -36
- package/dist/mutators/RecordingMutation.js +0 -182
- package/dist/mutators/Transaction.d.ts +0 -40
- package/dist/mutators/Transaction.js +0 -58
- package/dist/mutators/UndoManager.d.ts +0 -258
- package/dist/mutators/UndoManager.js +0 -658
- package/dist/mutators/defineMutators.d.ts +0 -60
- package/dist/mutators/defineMutators.js +0 -18
- package/dist/mutators/inverseOp.d.ts +0 -126
- package/dist/mutators/inverseOp.js +0 -71
- package/dist/mutators/mutateActions.d.ts +0 -45
- package/dist/mutators/mutateActions.js +0 -105
- package/dist/mutators/readerActions.d.ts +0 -33
- package/dist/mutators/readerActions.js +0 -57
- package/dist/mutators/undoApply.d.ts +0 -51
- package/dist/mutators/undoApply.js +0 -117
- package/dist/policy/index.d.ts +0 -21
- package/dist/policy/index.js +0 -20
- package/dist/query/QueryProcessor.d.ts +0 -75
- package/dist/query/QueryProcessor.js +0 -255
- package/dist/query/client.d.ts +0 -64
- package/dist/query/client.js +0 -138
- package/dist/query/types.d.ts +0 -85
- package/dist/query/types.js +0 -16
- package/dist/react/AbloProvider.d.ts +0 -242
- package/dist/react/AbloProvider.js +0 -458
- package/dist/react/ClientSideSuspense.d.ts +0 -36
- package/dist/react/ClientSideSuspense.js +0 -17
- package/dist/react/DefaultFallback.d.ts +0 -24
- package/dist/react/DefaultFallback.js +0 -43
- package/dist/react/context.d.ts +0 -55
- package/dist/react/context.js +0 -29
- package/dist/react/createAbloReact.d.ts +0 -56
- package/dist/react/createAbloReact.js +0 -51
- package/dist/react/index.d.ts +0 -62
- package/dist/react/index.js +0 -69
- package/dist/react/internalContext.d.ts +0 -33
- package/dist/react/internalContext.js +0 -3
- package/dist/react/useAblo.d.ts +0 -82
- package/dist/react/useAblo.js +0 -120
- package/dist/react/useCurrentUserId.d.ts +0 -22
- package/dist/react/useCurrentUserId.js +0 -34
- package/dist/react/useErrorListener.d.ts +0 -20
- package/dist/react/useErrorListener.js +0 -38
- package/dist/react/useMutationFailureListener.d.ts +0 -26
- package/dist/react/useMutationFailureListener.js +0 -38
- package/dist/react/useMutators.d.ts +0 -56
- package/dist/react/useMutators.js +0 -84
- package/dist/react/useReactive.d.ts +0 -35
- package/dist/react/useReactive.js +0 -123
- package/dist/react/useSyncStatus.d.ts +0 -59
- package/dist/react/useSyncStatus.js +0 -76
- package/dist/react/useUndoScope.d.ts +0 -34
- package/dist/react/useUndoScope.js +0 -81
- package/dist/schema/coordination.d.ts +0 -112
- package/dist/schema/coordination.js +0 -133
- package/dist/schema/ddl.d.ts +0 -97
- package/dist/schema/ddl.js +0 -491
- package/dist/schema/ddlLock.d.ts +0 -35
- package/dist/schema/ddlLock.js +0 -46
- package/dist/schema/diff.d.ts +0 -225
- package/dist/schema/diff.js +0 -289
- package/dist/schema/generate.d.ts +0 -19
- package/dist/schema/generate.js +0 -86
- package/dist/schema/index.d.ts +0 -42
- package/dist/schema/index.js +0 -80
- package/dist/schema/queries.d.ts +0 -201
- package/dist/schema/queries.js +0 -144
- package/dist/schema/select.d.ts +0 -40
- package/dist/schema/select.js +0 -90
- package/dist/schema/serialize.d.ts +0 -115
- package/dist/schema/serialize.js +0 -265
- package/dist/schema/sugar.d.ts +0 -109
- package/dist/schema/sugar.js +0 -83
- package/dist/schema/syncDeltaRow.d.ts +0 -6
- package/dist/schema/syncDeltaRow.js +0 -6
- package/dist/server/adapter.d.ts +0 -173
- package/dist/server/adapter.js +0 -18
- package/dist/server/commit.d.ts +0 -107
- package/dist/server/commit.js +0 -1
- package/dist/server/index.d.ts +0 -14
- package/dist/server/index.js +0 -2
- package/dist/server/readConfig.d.ts +0 -80
- package/dist/server/readConfig.js +0 -8
- package/dist/server/storageMode.d.ts +0 -23
- package/dist/server/storageMode.js +0 -17
- package/dist/source/adapter.d.ts +0 -83
- package/dist/source/adapter.js +0 -24
- package/dist/source/adapters/drizzle.d.ts +0 -48
- package/dist/source/adapters/drizzle.js +0 -219
- package/dist/source/adapters/kysely.d.ts +0 -42
- package/dist/source/adapters/kysely.js +0 -205
- package/dist/source/adapters/kyselyMutationCore.d.ts +0 -76
- package/dist/source/adapters/kyselyMutationCore.js +0 -125
- package/dist/source/adapters/memory.d.ts +0 -13
- package/dist/source/adapters/memory.js +0 -130
- package/dist/source/adapters/prisma.d.ts +0 -63
- package/dist/source/adapters/prisma.js +0 -202
- package/dist/source/conformance.d.ts +0 -37
- package/dist/source/conformance.js +0 -215
- package/dist/source/connector.d.ts +0 -95
- package/dist/source/connector.js +0 -266
- package/dist/source/connectorProtocol.d.ts +0 -154
- package/dist/source/connectorProtocol.js +0 -163
- package/dist/source/contract.d.ts +0 -195
- package/dist/source/contract.js +0 -164
- package/dist/source/factory.d.ts +0 -92
- package/dist/source/factory.js +0 -286
- package/dist/source/idempotency.d.ts +0 -61
- package/dist/source/idempotency.js +0 -144
- package/dist/source/index.d.ts +0 -23
- package/dist/source/index.js +0 -30
- package/dist/source/migrations.d.ts +0 -21
- package/dist/source/migrations.js +0 -103
- package/dist/source/next.d.ts +0 -32
- package/dist/source/next.js +0 -25
- package/dist/source/pushQueue.d.ts +0 -134
- package/dist/source/pushQueue.js +0 -256
- package/dist/source/signing.d.ts +0 -92
- package/dist/source/signing.js +0 -162
- package/dist/source/types.d.ts +0 -401
- package/dist/source/types.js +0 -59
- package/dist/storeContract.d.ts +0 -145
- package/dist/storeContract.js +0 -12
- package/dist/stores/DatabaseManager.d.ts +0 -107
- package/dist/stores/DatabaseManager.js +0 -388
- package/dist/stores/ObjectStore.d.ts +0 -115
- package/dist/stores/ObjectStore.js +0 -393
- package/dist/stores/ObjectStoreContract.d.ts +0 -38
- package/dist/stores/ObjectStoreContract.js +0 -1
- package/dist/stores/StoreManager.d.ts +0 -114
- package/dist/stores/StoreManager.js +0 -304
- package/dist/stores/SyncActionStore.d.ts +0 -99
- package/dist/stores/SyncActionStore.js +0 -506
- package/dist/stores/openIDBWithTimeout.d.ts +0 -65
- package/dist/stores/openIDBWithTimeout.js +0 -153
- package/dist/stores/syncAction.d.ts +0 -26
- package/dist/stores/syncAction.js +0 -16
- package/dist/surface.d.ts +0 -36
- package/dist/surface.js +0 -75
- package/dist/sync/BootstrapFetcher.d.ts +0 -284
- package/dist/sync/BootstrapFetcher.js +0 -964
- package/dist/sync/ConnectionManager.d.ts +0 -8
- package/dist/sync/ConnectionManager.js +0 -8
- package/dist/sync/OnDemandLoader.d.ts +0 -231
- package/dist/sync/OnDemandLoader.js +0 -743
- package/dist/sync/SubscriptionManager.d.ts +0 -159
- package/dist/sync/SubscriptionManager.js +0 -243
- package/dist/sync/SyncWebSocket.d.ts +0 -173
- package/dist/sync/SyncWebSocket.js +0 -438
- package/dist/sync/awaitClaimGrant.d.ts +0 -6
- package/dist/sync/awaitClaimGrant.js +0 -6
- package/dist/sync/bootstrapApply.d.ts +0 -73
- package/dist/sync/bootstrapApply.js +0 -73
- package/dist/sync/commitFrames.d.ts +0 -8
- package/dist/sync/commitFrames.js +0 -8
- package/dist/sync/contextPorts.d.ts +0 -18
- package/dist/sync/contextPorts.js +0 -31
- package/dist/sync/createClaimStream.d.ts +0 -7
- package/dist/sync/createClaimStream.js +0 -7
- package/dist/sync/createPresenceStream.d.ts +0 -69
- package/dist/sync/createPresenceStream.js +0 -200
- package/dist/sync/createSnapshot.d.ts +0 -29
- package/dist/sync/createSnapshot.js +0 -118
- package/dist/sync/credentialLifecycle.d.ts +0 -7
- package/dist/sync/credentialLifecycle.js +0 -7
- package/dist/sync/deltaPipeline.d.ts +0 -114
- package/dist/sync/deltaPipeline.js +0 -278
- package/dist/sync/groupChange.d.ts +0 -116
- package/dist/sync/groupChange.js +0 -244
- package/dist/sync/participants.d.ts +0 -132
- package/dist/sync/participants.js +0 -346
- package/dist/sync/persistedPrefix.d.ts +0 -12
- package/dist/sync/persistedPrefix.js +0 -22
- package/dist/sync/schemaDrift.d.ts +0 -55
- package/dist/sync/schemaDrift.js +0 -53
- package/dist/sync/schemas.d.ts +0 -71
- package/dist/sync/schemas.js +0 -94
- package/dist/sync/syncCursor.d.ts +0 -40
- package/dist/sync/syncCursor.js +0 -55
- package/dist/sync/syncPlan.d.ts +0 -54
- package/dist/sync/syncPlan.js +0 -50
- package/dist/sync/wsFrameHandlers.d.ts +0 -8
- package/dist/sync/wsFrameHandlers.js +0 -8
- package/dist/syncLog/contract.d.ts +0 -20
- package/dist/syncLog/contract.js +0 -19
- package/dist/syncLog/index.d.ts +0 -1
- package/dist/syncLog/index.js +0 -1
- package/dist/transaction/ablo.d.ts +0 -88
- package/dist/transaction/ablo.js +0 -33
- package/dist/transaction/auth/apiKey.d.ts +0 -152
- package/dist/transaction/auth/apiKey.js +0 -419
- package/dist/transaction/auth/bootstrapScope.d.ts +0 -15
- package/dist/transaction/auth/bootstrapScope.js +0 -1
- package/dist/transaction/auth/capability.d.ts +0 -212
- package/dist/transaction/auth/capability.js +0 -224
- package/dist/transaction/auth/credentialEndpoint.d.ts +0 -61
- package/dist/transaction/auth/credentialEndpoint.js +0 -86
- package/dist/transaction/auth/credentialPolicy.d.ts +0 -148
- package/dist/transaction/auth/credentialPolicy.js +0 -125
- package/dist/transaction/auth/credentialSource.d.ts +0 -30
- package/dist/transaction/auth/credentialSource.js +0 -55
- package/dist/transaction/auth/hostedEndpoints.d.ts +0 -21
- package/dist/transaction/auth/hostedEndpoints.js +0 -21
- package/dist/transaction/auth/identity.d.ts +0 -55
- package/dist/transaction/auth/identity.js +0 -210
- package/dist/transaction/auth/index.d.ts +0 -162
- package/dist/transaction/auth/index.js +0 -304
- package/dist/transaction/auth/schemas.d.ts +0 -59
- package/dist/transaction/auth/schemas.js +0 -85
- package/dist/transaction/auth/sessionMint.d.ts +0 -28
- package/dist/transaction/auth/sessionMint.js +0 -85
- package/dist/transaction/coordination/awaitClaimGrant.d.ts +0 -56
- package/dist/transaction/coordination/awaitClaimGrant.js +0 -124
- package/dist/transaction/coordination/claimHeartbeatLoop.d.ts +0 -84
- package/dist/transaction/coordination/claimHeartbeatLoop.js +0 -108
- package/dist/transaction/coordination/claimMeta.d.ts +0 -49
- package/dist/transaction/coordination/claimMeta.js +0 -52
- package/dist/transaction/coordination/createClaimStream.d.ts +0 -64
- package/dist/transaction/coordination/createClaimStream.js +0 -475
- package/dist/transaction/coordination/events.d.ts +0 -74
- package/dist/transaction/coordination/events.js +0 -7
- package/dist/transaction/coordination/index.d.ts +0 -19
- package/dist/transaction/coordination/index.js +0 -45
- package/dist/transaction/coordination/locator.d.ts +0 -104
- package/dist/transaction/coordination/locator.js +0 -102
- package/dist/transaction/coordination/schema.d.ts +0 -1536
- package/dist/transaction/coordination/schema.js +0 -1177
- package/dist/transaction/coordination/targetConflict.d.ts +0 -2
- package/dist/transaction/coordination/targetConflict.js +0 -107
- package/dist/transaction/coordination/trace.d.ts +0 -78
- package/dist/transaction/coordination/trace.js +0 -138
- package/dist/transaction/durableWrites.d.ts +0 -62
- package/dist/transaction/durableWrites.js +0 -71
- package/dist/transaction/environment.d.ts +0 -105
- package/dist/transaction/environment.js +0 -108
- package/dist/transaction/errorCodes.d.ts +0 -403
- package/dist/transaction/errorCodes.js +0 -484
- package/dist/transaction/errors.d.ts +0 -428
- package/dist/transaction/errors.js +0 -686
- package/dist/transaction/footprint.d.ts +0 -111
- package/dist/transaction/footprint.js +0 -0
- package/dist/transaction/index.d.ts +0 -20
- package/dist/transaction/index.js +0 -20
- package/dist/transaction/keys/index.d.ts +0 -87
- package/dist/transaction/keys/index.js +0 -207
- package/dist/transaction/log/syncDeltaRow.d.ts +0 -158
- package/dist/transaction/log/syncDeltaRow.js +0 -95
- package/dist/transaction/logPosition.d.ts +0 -97
- package/dist/transaction/logPosition.js +0 -125
- package/dist/transaction/logger.d.ts +0 -16
- package/dist/transaction/logger.js +0 -7
- package/dist/transaction/observability.d.ts +0 -53
- package/dist/transaction/observability.js +0 -19
- package/dist/transaction/persistence.d.ts +0 -12
- package/dist/transaction/persistence.js +0 -11
- package/dist/transaction/plugin.d.ts +0 -285
- package/dist/transaction/plugin.js +0 -106
- package/dist/transaction/policy/types.d.ts +0 -217
- package/dist/transaction/policy/types.js +0 -126
- package/dist/transaction/resources/functionalUpdate.d.ts +0 -79
- package/dist/transaction/resources/functionalUpdate.js +0 -87
- package/dist/transaction/resources/httpResources.d.ts +0 -321
- package/dist/transaction/resources/httpResources.js +0 -7
- package/dist/transaction/resources/modelOperations.d.ts +0 -427
- package/dist/transaction/resources/modelOperations.js +0 -12
- package/dist/transaction/resources/mutationOptions.d.ts +0 -66
- package/dist/transaction/resources/mutationOptions.js +0 -9
- package/dist/transaction/resources/where.d.ts +0 -101
- package/dist/transaction/resources/where.js +0 -115
- package/dist/transaction/resources/writeOptionsSchema.d.ts +0 -47
- package/dist/transaction/resources/writeOptionsSchema.js +0 -73
- package/dist/transaction/schema/field.d.ts +0 -120
- package/dist/transaction/schema/field.js +0 -265
- package/dist/transaction/schema/fieldRef.d.ts +0 -38
- package/dist/transaction/schema/fieldRef.js +0 -11
- package/dist/transaction/schema/loadStrategy.d.ts +0 -45
- package/dist/transaction/schema/loadStrategy.js +0 -46
- package/dist/transaction/schema/model.d.ts +0 -379
- package/dist/transaction/schema/model.js +0 -123
- package/dist/transaction/schema/openapi.d.ts +0 -58
- package/dist/transaction/schema/openapi.js +0 -501
- package/dist/transaction/schema/relation.d.ts +0 -204
- package/dist/transaction/schema/relation.js +0 -104
- package/dist/transaction/schema/residency.d.ts +0 -29
- package/dist/transaction/schema/residency.js +0 -25
- package/dist/transaction/schema/roles.d.ts +0 -249
- package/dist/transaction/schema/roles.js +0 -230
- package/dist/transaction/schema/schema.d.ts +0 -351
- package/dist/transaction/schema/schema.js +0 -325
- package/dist/transaction/schema/tenancy.d.ts +0 -139
- package/dist/transaction/schema/tenancy.js +0 -190
- package/dist/transaction/transactionLayer.d.ts +0 -82
- package/dist/transaction/transactionLayer.js +0 -24
- package/dist/transaction/transactions/settlement/commitEnvelope.d.ts +0 -143
- package/dist/transaction/transactions/settlement/commitEnvelope.js +0 -161
- package/dist/transaction/transactions/settlement/httpCommitEnvelope.d.ts +0 -53
- package/dist/transaction/transactions/settlement/httpCommitEnvelope.js +0 -207
- package/dist/transaction/transactions/settlement/idempotencyKey.d.ts +0 -10
- package/dist/transaction/transactions/settlement/idempotencyKey.js +0 -9
- package/dist/transaction/transactions/settlement/pendingWrite.d.ts +0 -112
- package/dist/transaction/transactions/settlement/pendingWrite.js +0 -20
- package/dist/transaction/transport/commitFrames.d.ts +0 -90
- package/dist/transaction/transport/commitFrames.js +0 -134
- package/dist/transaction/transport/connectionManager.d.ts +0 -215
- package/dist/transaction/transport/connectionManager.js +0 -673
- package/dist/transaction/transport/credentialLifecycle.d.ts +0 -177
- package/dist/transaction/transport/credentialLifecycle.js +0 -324
- package/dist/transaction/transport/heartbeat.d.ts +0 -65
- package/dist/transaction/transport/heartbeat.js +0 -93
- package/dist/transaction/transport/httpClient.d.ts +0 -131
- package/dist/transaction/transport/httpClient.js +0 -146
- package/dist/transaction/transport/httpOptions.d.ts +0 -33
- package/dist/transaction/transport/httpOptions.js +0 -12
- package/dist/transaction/transport/httpTransport.d.ts +0 -8
- package/dist/transaction/transport/httpTransport.js +0 -1388
- package/dist/transaction/transport/networkProbe.d.ts +0 -84
- package/dist/transaction/transport/networkProbe.js +0 -207
- package/dist/transaction/transport/wsFrameHandlers.d.ts +0 -128
- package/dist/transaction/transport/wsFrameHandlers.js +0 -429
- package/dist/transaction/transport/wsTransport.d.ts +0 -574
- package/dist/transaction/transport/wsTransport.js +0 -1023
- package/dist/transaction/types/assertExact.d.ts +0 -17
- package/dist/transaction/types/assertExact.js +0 -1
- package/dist/transaction/types/global.d.ts +0 -107
- package/dist/transaction/types/global.js +0 -40
- package/dist/transaction/types/index.d.ts +0 -205
- package/dist/transaction/types/index.js +0 -56
- package/dist/transaction/types/modelData.d.ts +0 -10
- package/dist/transaction/types/modelData.js +0 -9
- package/dist/transaction/types/participant.d.ts +0 -20
- package/dist/transaction/types/participant.js +0 -10
- package/dist/transaction/types/streams.d.ts +0 -550
- package/dist/transaction/types/streams.js +0 -11
- package/dist/transaction/utils/asyncIterator.d.ts +0 -34
- package/dist/transaction/utils/asyncIterator.js +0 -135
- package/dist/transaction/utils/duration.d.ts +0 -50
- package/dist/transaction/utils/duration.js +0 -77
- package/dist/transaction/utils/json.d.ts +0 -57
- package/dist/transaction/utils/json.js +0 -276
- package/dist/transaction/wire/accountResponses.d.ts +0 -420
- package/dist/transaction/wire/accountResponses.js +0 -290
- package/dist/transaction/wire/auth.d.ts +0 -56
- package/dist/transaction/wire/auth.js +0 -63
- package/dist/transaction/wire/bootstrapReason.d.ts +0 -9
- package/dist/transaction/wire/bootstrapReason.js +0 -8
- package/dist/transaction/wire/claimEvent.d.ts +0 -76
- package/dist/transaction/wire/claimEvent.js +0 -73
- package/dist/transaction/wire/claims.d.ts +0 -530
- package/dist/transaction/wire/claims.js +0 -327
- package/dist/transaction/wire/commit.d.ts +0 -603
- package/dist/transaction/wire/commit.js +0 -321
- package/dist/transaction/wire/delta.d.ts +0 -250
- package/dist/transaction/wire/delta.js +0 -147
- package/dist/transaction/wire/errorEnvelope.d.ts +0 -72
- package/dist/transaction/wire/errorEnvelope.js +0 -123
- package/dist/transaction/wire/feedCursor.d.ts +0 -60
- package/dist/transaction/wire/feedCursor.js +0 -82
- package/dist/transaction/wire/feedEvent.d.ts +0 -204
- package/dist/transaction/wire/feedEvent.js +0 -65
- package/dist/transaction/wire/frames.d.ts +0 -194
- package/dist/transaction/wire/frames.js +0 -50
- package/dist/transaction/wire/inboundFrames.d.ts +0 -562
- package/dist/transaction/wire/inboundFrames.js +0 -116
- package/dist/transaction/wire/index.d.ts +0 -54
- package/dist/transaction/wire/index.js +0 -83
- package/dist/transaction/wire/listEnvelope.d.ts +0 -37
- package/dist/transaction/wire/listEnvelope.js +0 -42
- package/dist/transaction/wire/modelMutations.d.ts +0 -31
- package/dist/transaction/wire/modelMutations.js +0 -52
- package/dist/transaction/wire/modelResponses.d.ts +0 -85
- package/dist/transaction/wire/modelResponses.js +0 -43
- package/dist/transaction/wire/modelShape.d.ts +0 -78
- package/dist/transaction/wire/modelShape.js +0 -74
- package/dist/transaction/wire/protocol.d.ts +0 -38
- package/dist/transaction/wire/protocol.js +0 -38
- package/dist/transaction/wire/protocolVersion.d.ts +0 -73
- package/dist/transaction/wire/protocolVersion.js +0 -83
- package/dist/transactions/mutations/MutationQueue.d.ts +0 -661
- package/dist/transactions/mutations/MutationQueue.js +0 -2807
- package/dist/transactions/mutations/MutationStore.d.ts +0 -20
- package/dist/transactions/mutations/MutationStore.js +0 -53
- package/dist/transactions/mutations/UnconfirmedWrites.d.ts +0 -82
- package/dist/transactions/mutations/UnconfirmedWrites.js +0 -104
- package/dist/transactions/mutations/coalesceRules.d.ts +0 -58
- package/dist/transactions/mutations/coalesceRules.js +0 -140
- package/dist/transactions/mutations/commitLatency.d.ts +0 -52
- package/dist/transactions/mutations/commitLatency.js +0 -130
- package/dist/transactions/mutations/commitOutboxStore.d.ts +0 -28
- package/dist/transactions/mutations/commitOutboxStore.js +0 -26
- package/dist/transactions/mutations/commitPayload.d.ts +0 -165
- package/dist/transactions/mutations/commitPayload.js +0 -152
- package/dist/transactions/mutations/deltaConfirmation.d.ts +0 -63
- package/dist/transactions/mutations/deltaConfirmation.js +0 -235
- package/dist/transactions/mutations/durableWriteStore.d.ts +0 -14
- package/dist/transactions/mutations/durableWriteStore.js +0 -12
- package/dist/transactions/mutations/optimisticApply.d.ts +0 -49
- package/dist/transactions/mutations/optimisticApply.js +0 -65
- package/dist/transactions/mutations/replayValidation.d.ts +0 -187
- package/dist/transactions/mutations/replayValidation.js +0 -164
- package/dist/utils/mobxSetup.d.ts +0 -53
- package/dist/utils/mobxSetup.js +0 -330
- package/dist/views/QueryView.d.ts +0 -79
- package/dist/views/QueryView.js +0 -218
- package/dist/views/ViewRegistry.d.ts +0 -20
- package/dist/views/ViewRegistry.js +0 -55
- package/dist/views/incrementalView.d.ts +0 -45
- package/dist/views/incrementalView.js +0 -69
- package/dist/webhooks/events.d.ts +0 -43
- package/dist/webhooks/events.js +0 -42
- package/dist/webhooks/index.d.ts +0 -8
- package/dist/webhooks/index.js +0 -8
- package/dist/wire/index.d.ts +0 -1
- package/dist/wire/index.js +0 -8
|
@@ -1,2807 +0,0 @@
|
|
|
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
|
-
import { EventEmitter } from 'events';
|
|
15
|
-
import { v4 as uuid } from 'uuid';
|
|
16
|
-
import { Model } from '../../Model.js';
|
|
17
|
-
import { globalRuntime } from '../../context.js';
|
|
18
|
-
import { AbloError, AbloConnectionError, AbloIdempotencyError, AbloNotFoundError, AbloValidationError, errorCodeSpec, } from '../../transaction/errors.js';
|
|
19
|
-
import { LogPosition } from '../../transaction/logPosition.js';
|
|
20
|
-
import { mutationCommitResultSchema, } from '../../transaction/wire/commit.js';
|
|
21
|
-
import { projectCommitPayload, computePriorityScore, normalizeModelKey,
|
|
22
|
-
// Includes stale guards as well as request identity/audit barriers.
|
|
23
|
-
hasCommitCoalescingBarrier, applyWriteOptions, asTransportError, extractStatusCode, TX_TYPE_TO_MUTATION_OP, } from './commitPayload.js';
|
|
24
|
-
import { MutationStore } from './MutationStore.js';
|
|
25
|
-
import { entityKey, mergeUpdateData, takeUnsentCreateForModel, findCreateBarrierForDelete, deferDeleteUntilCreateSettles, releaseDeferredDeletesForCreate, } from './coalesceRules.js';
|
|
26
|
-
import { DeltaConfirmationTracker } from './deltaConfirmation.js';
|
|
27
|
-
import { deserializePersistedTransaction, isNonReplayablePersistedRow, pendingMutationRecordId, } from './replayValidation.js';
|
|
28
|
-
import { createCommitEnvelopeMember, createDurableCommitEnvelope, commitEnvelopeRecordId, durableCommitEnvelopeSchema, } from '../../transaction/transactions/settlement/commitEnvelope.js';
|
|
29
|
-
import { stableStringify } from '../../transaction/utils/json.js';
|
|
30
|
-
import { applyOptimisticCreate, applyOptimisticUpdate, applyOptimisticDelete, rollbackOptimistic, } from './optimisticApply.js';
|
|
31
|
-
export class MutationQueue extends EventEmitter {
|
|
32
|
-
// Keep one hour of clock/network margin inside the server's 24-hour ledger.
|
|
33
|
-
static DURABLE_REPLAY_WINDOW_MS = 23 * 60 * 60 * 1000;
|
|
34
|
-
store = new MutationStore();
|
|
35
|
-
// Signature of the last permanent-error we logged at `warn`. A `create`
|
|
36
|
-
// whose id already exists (`unique_violation`) is a permanent rejection
|
|
37
|
-
// that the offline queue re-drives on every reconnect/bootstrap — without
|
|
38
|
-
// this, the identical cause prints on a loop. We log the first occurrence
|
|
39
|
-
// and demote exact repeats to `debug`.
|
|
40
|
-
lastPermanentErrorSig;
|
|
41
|
-
// The executor bound to this queue instance, set by `setMutationExecutor(...)`
|
|
42
|
-
// just after construction. When unset it falls back to the ambient executor
|
|
43
|
-
// from `getContext()`.
|
|
44
|
-
//
|
|
45
|
-
// The binding matters because the ambient executor is a module-level
|
|
46
|
-
// singleton: constructing a second client instance overwrites the first
|
|
47
|
-
// instance's executor. Without a per-instance binding, commits on one
|
|
48
|
-
// instance would dispatch through another instance's executor closure; once
|
|
49
|
-
// that other instance disposed its store, the closure would resolve no live
|
|
50
|
-
// connection and every commit here would fail with `ws_not_ready`, which the
|
|
51
|
-
// queue treats as transient and retries endlessly.
|
|
52
|
-
_mutationExecutor = null;
|
|
53
|
-
get mutationExecutor() {
|
|
54
|
-
return this._mutationExecutor ?? this.runtime.mutationExecutor;
|
|
55
|
-
}
|
|
56
|
-
runtime;
|
|
57
|
-
executionQueue = [];
|
|
58
|
-
isProcessing = false;
|
|
59
|
-
processTimer;
|
|
60
|
-
processScheduled = false;
|
|
61
|
-
// Staging area for transactions created in the same event-loop tick. Each one
|
|
62
|
-
// lands here first, then a microtask commits them together.
|
|
63
|
-
createdTransactions = [];
|
|
64
|
-
commitScheduled = false;
|
|
65
|
-
// Per-model in-flight tracking and merge buffer
|
|
66
|
-
inFlightByModel = new Set();
|
|
67
|
-
pendingMergeByModel = new Map();
|
|
68
|
-
deferredDeletesByCreate = new Map();
|
|
69
|
-
// Commit lane: pre-built atomic multi-op envelopes from `ablo.commits.create()`.
|
|
70
|
-
// Drained serially (one envelope at a time) since each is atomic; no
|
|
71
|
-
// coalescing with model-proxy transactions.
|
|
72
|
-
commitLane = [];
|
|
73
|
-
commitStore = new Map();
|
|
74
|
-
/**
|
|
75
|
-
* Small race buffer for authoritative echoes that arrive before the queued
|
|
76
|
-
* mutation receipt. The forward and WAL stream are independent channels, so
|
|
77
|
-
* either can win without changing the settlement result.
|
|
78
|
-
*/
|
|
79
|
-
recentDeltaCorrelations = new Map();
|
|
80
|
-
/**
|
|
81
|
-
* Client-facing confirmation deadlines for source-forwarded writes. These
|
|
82
|
-
* timers settle only the caller waiting for `confirmed`; the accepted write
|
|
83
|
-
* remains in `awaiting_delta`, with its durable envelope intact, until the
|
|
84
|
-
* authoritative WAL echo arrives.
|
|
85
|
-
*/
|
|
86
|
-
replicationLagTimeouts = new Map();
|
|
87
|
-
replicationLagErrors = new Map();
|
|
88
|
-
commitProcessing = false;
|
|
89
|
-
lastCommitSequence = 0;
|
|
90
|
-
durableReplayBlock = null;
|
|
91
|
-
/** Browser-backed strict outbox; absent for standalone/in-memory consumers. */
|
|
92
|
-
commitOutbox = null;
|
|
93
|
-
commitOutboxScope = null;
|
|
94
|
-
nextCommitSequence() {
|
|
95
|
-
const wallSequence = Date.now() * 1_000;
|
|
96
|
-
this.lastCommitSequence = Math.max(wallSequence, this.lastCommitSequence + 1);
|
|
97
|
-
return this.lastCommitSequence;
|
|
98
|
-
}
|
|
99
|
-
emitCommitLifecycle(event, payload) {
|
|
100
|
-
try {
|
|
101
|
-
this.emit(event, payload);
|
|
102
|
-
}
|
|
103
|
-
catch (error) {
|
|
104
|
-
this.runtime.observability.captureMutationFailure({
|
|
105
|
-
context: `commit-lifecycle-listener:${event}`,
|
|
106
|
-
error: error instanceof Error ? error : String(error),
|
|
107
|
-
});
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
assertDurableReplayOpen() {
|
|
111
|
-
if (this.durableReplayBlock)
|
|
112
|
-
throw this.durableReplayBlock;
|
|
113
|
-
}
|
|
114
|
-
assertEnvelopeInsideReplayWindow(envelope) {
|
|
115
|
-
this.assertDurableReplayOpen();
|
|
116
|
-
if (envelope.acceptedAt === undefined &&
|
|
117
|
-
Date.now() - envelope.sealedAt >=
|
|
118
|
-
MutationQueue.DURABLE_REPLAY_WINDOW_MS) {
|
|
119
|
-
this.durableReplayBlock = new AbloIdempotencyError('A pending commit is older than the server idempotency window; newer writes are blocked until it is reviewed.', { code: 'idempotency_conflict' });
|
|
120
|
-
// This gate stops EVERY subsequent write on this client, and each of
|
|
121
|
-
// those rejections is captured to observability rather than surfaced to
|
|
122
|
-
// the caller — without this line the session degrades into "nothing
|
|
123
|
-
// saves and nothing errors". One loud line at the moment the block
|
|
124
|
-
// engages is the only visible trace.
|
|
125
|
-
this.runtime.logger.warn('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', { sealedAt: envelope.sealedAt });
|
|
126
|
-
throw this.durableReplayBlock;
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
computePriorityScore(type, modelName) {
|
|
130
|
-
return computePriorityScore(type, modelName, this.runtime);
|
|
131
|
-
}
|
|
132
|
-
ensureDerivedFields(transaction) {
|
|
133
|
-
if (!transaction.modelKey) {
|
|
134
|
-
transaction.modelKey = normalizeModelKey(transaction.modelName);
|
|
135
|
-
}
|
|
136
|
-
if (transaction.priorityScore === undefined) {
|
|
137
|
-
transaction.priorityScore = this.computePriorityScore(transaction.type, transaction.modelName);
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
entityKey(modelName, modelId) {
|
|
141
|
-
return entityKey(modelName, modelId);
|
|
142
|
-
}
|
|
143
|
-
/** Collision-safe receipt target identity across models sharing a row id. */
|
|
144
|
-
receiptTargetKey(modelName, modelId) {
|
|
145
|
-
return stableStringify([normalizeModelKey(modelName), modelId]);
|
|
146
|
-
}
|
|
147
|
-
/**
|
|
148
|
-
* Relates stale notifications back to write targets without assuming the
|
|
149
|
-
* server's canonical model name uses the same spelling as the public schema
|
|
150
|
-
* key (`Task` versus `tasks`). Exact `(model,id)` wins; a globally unique id
|
|
151
|
-
* is the compatibility fallback. An ambiguous same-id cross-model mismatch
|
|
152
|
-
* is deliberately left unclassified, so it cannot falsely settle a queued
|
|
153
|
-
* write. A notification with no write-target id (or an explicit group) is a
|
|
154
|
-
* declared-read conflict and holds the whole batch.
|
|
155
|
-
*/
|
|
156
|
-
classifyReceiptNotifications(operations, notifications) {
|
|
157
|
-
const targets = operations.map((operation) => ({
|
|
158
|
-
id: operation.id,
|
|
159
|
-
key: this.receiptTargetKey(operation.model, operation.id),
|
|
160
|
-
}));
|
|
161
|
-
const heldTargets = new Set();
|
|
162
|
-
const notificationsByTarget = new Map();
|
|
163
|
-
let holdsEntireBatch = false;
|
|
164
|
-
for (const notification of notifications) {
|
|
165
|
-
const candidates = targets.filter((target) => target.id === notification.id);
|
|
166
|
-
const notificationKey = this.receiptTargetKey(notification.model, notification.id);
|
|
167
|
-
const exactTargets = candidates.filter((target) => target.key === notificationKey);
|
|
168
|
-
const candidateKeys = new Set((exactTargets.length > 0 ? exactTargets : candidates).map((target) => target.key));
|
|
169
|
-
if (notification.group || candidates.length === 0) {
|
|
170
|
-
holdsEntireBatch = true;
|
|
171
|
-
continue;
|
|
172
|
-
}
|
|
173
|
-
if (candidateKeys.size !== 1) {
|
|
174
|
-
// Same id across multiple differently named models with no exact match:
|
|
175
|
-
// the id-only compatibility fallback is ambiguous. Await the echo.
|
|
176
|
-
continue;
|
|
177
|
-
}
|
|
178
|
-
const [targetKey] = candidateKeys;
|
|
179
|
-
if (!targetKey)
|
|
180
|
-
continue;
|
|
181
|
-
heldTargets.add(targetKey);
|
|
182
|
-
const targetNotifications = notificationsByTarget.get(targetKey) ?? [];
|
|
183
|
-
targetNotifications.push(notification);
|
|
184
|
-
notificationsByTarget.set(targetKey, targetNotifications);
|
|
185
|
-
}
|
|
186
|
-
return { holdsEntireBatch, heldTargets, notificationsByTarget };
|
|
187
|
-
}
|
|
188
|
-
resolveConfirmation(transaction) {
|
|
189
|
-
const resolver = this.confirmationResolvers.get(transaction.id);
|
|
190
|
-
if (!resolver)
|
|
191
|
-
return;
|
|
192
|
-
this.confirmationResolvers.delete(transaction.id);
|
|
193
|
-
resolver.resolve();
|
|
194
|
-
}
|
|
195
|
-
takeUnsentCreateForModel(modelName, modelId) {
|
|
196
|
-
return takeUnsentCreateForModel(this.createdTransactions, this.executionQueue, this.store, modelName, modelId);
|
|
197
|
-
}
|
|
198
|
-
async cancelUnsentCreateForDelete(transaction) {
|
|
199
|
-
this.store.updateStatus(transaction.id, 'rolled_back');
|
|
200
|
-
if (this.config.enableOptimistic) {
|
|
201
|
-
await this.rollbackOptimistic(transaction, 'model_cancelled');
|
|
202
|
-
}
|
|
203
|
-
this.resolveConfirmation(transaction);
|
|
204
|
-
}
|
|
205
|
-
findCreateBarrierForDelete(modelName, modelId) {
|
|
206
|
-
return findCreateBarrierForDelete(this.store, modelName, modelId);
|
|
207
|
-
}
|
|
208
|
-
completeLocalDelete(model, context, writeOptions, sourceMutationIds = []) {
|
|
209
|
-
const actualModelName = model.getModelName();
|
|
210
|
-
const modelKey = normalizeModelKey(actualModelName);
|
|
211
|
-
const transaction = {
|
|
212
|
-
id: this.generateId(),
|
|
213
|
-
type: 'delete',
|
|
214
|
-
modelName: actualModelName,
|
|
215
|
-
modelId: model.id,
|
|
216
|
-
modelKey,
|
|
217
|
-
priorityScore: this.computePriorityScore('delete', actualModelName),
|
|
218
|
-
previousData: model.toJSON ? model.toJSON() : { ...model },
|
|
219
|
-
context,
|
|
220
|
-
status: 'completed',
|
|
221
|
-
createdAt: Date.now(),
|
|
222
|
-
attempts: 0,
|
|
223
|
-
priority: 'high',
|
|
224
|
-
writeOptions,
|
|
225
|
-
...(sourceMutationIds.length > 0
|
|
226
|
-
? { sourceMutationIds: [...new Set(sourceMutationIds)] }
|
|
227
|
-
: {}),
|
|
228
|
-
localOnly: true,
|
|
229
|
-
};
|
|
230
|
-
this.attachConfirmation(transaction);
|
|
231
|
-
this.store.add(transaction);
|
|
232
|
-
if (this.config.enableOptimistic) {
|
|
233
|
-
this.applyOptimisticDelete(model, transaction);
|
|
234
|
-
}
|
|
235
|
-
this.emit('transaction:created', transaction);
|
|
236
|
-
this.emit('transaction:completed', transaction);
|
|
237
|
-
this.emit(`transaction:completed:${transaction.id}`, transaction);
|
|
238
|
-
this.optimisticUpdates.delete(transaction.id);
|
|
239
|
-
return transaction;
|
|
240
|
-
}
|
|
241
|
-
deferDeleteUntilCreateSettles(createTransaction, deleteTransaction) {
|
|
242
|
-
deferDeleteUntilCreateSettles(this.deferredDeletesByCreate, createTransaction, deleteTransaction);
|
|
243
|
-
}
|
|
244
|
-
releaseDeferredDeletesForCreate(createTransaction) {
|
|
245
|
-
releaseDeferredDeletesForCreate(this.deferredDeletesByCreate, this.store, (tx) => { this.enqueue(tx); }, createTransaction);
|
|
246
|
-
}
|
|
247
|
-
// Default configuration, tuned so more operations coalesce into a single
|
|
248
|
-
// commit: a larger batch size and delay give rapid operations more time to
|
|
249
|
-
// merge before the batch is sent.
|
|
250
|
-
config = {
|
|
251
|
-
maxBatchSize: 50, // send up to this many operations per commit
|
|
252
|
-
batchDelay: 150, // milliseconds to wait for more operations before sending
|
|
253
|
-
maxRetries: 3,
|
|
254
|
-
conflictResolution: {
|
|
255
|
-
strategy: 'last-write-wins',
|
|
256
|
-
},
|
|
257
|
-
enablePersistence: true,
|
|
258
|
-
enableOptimistic: true,
|
|
259
|
-
// Backpressure: don't schedule more batches if too many transactions are executing
|
|
260
|
-
maxExecutingTransactions: 100,
|
|
261
|
-
// Delta confirmation initial timeout - first retry fires at 30s
|
|
262
|
-
// On timeout: retries with exponential backoff (30s → 60s → 120s) instead of rolling back
|
|
263
|
-
deltaConfirmationTimeout: 30000,
|
|
264
|
-
retryBackoff: { baseMs: 200, capMs: 1500 },
|
|
265
|
-
commitOfflineGraceMs: 30_000,
|
|
266
|
-
commitDispatchTimeoutMs: 30_000,
|
|
267
|
-
};
|
|
268
|
-
// Track executing transactions for backpressure
|
|
269
|
-
executingCount = 0;
|
|
270
|
-
// Optimistic update tracking. The entry shape and apply/rollback rules live
|
|
271
|
-
// in `./optimisticApply.js`; the map itself stays on the queue because the
|
|
272
|
-
// completion paths, `getStats`, and `dispose` all read it.
|
|
273
|
-
optimisticUpdates = new Map();
|
|
274
|
-
// Stale-context notifications, keyed by transaction id. When the server
|
|
275
|
-
// accepts a commit but reports that an operation's premise had moved,
|
|
276
|
-
// the notification lands here from the commit acknowledgement and is drained
|
|
277
|
-
// by `waitForCommitReceipt`, so the receipt can carry it back to the caller.
|
|
278
|
-
commitNotifications = new Map();
|
|
279
|
-
/** Zero-row targets returned on a successful atomic commit receipt. */
|
|
280
|
-
commitMissingIds = new Map();
|
|
281
|
-
// Delta-confirmation tracking (ack watermark advance + the awaiting_delta
|
|
282
|
-
// timeout/retry maps) lives in `./deltaConfirmation.js`. Constructed in the
|
|
283
|
-
// constructor once `position` is bound.
|
|
284
|
-
deltaConfirmation;
|
|
285
|
-
// Connection-state check, supplied by the client, used to hold off rollbacks
|
|
286
|
-
// while disconnected.
|
|
287
|
-
isConnectedFn = () => true;
|
|
288
|
-
// Grace timer that, when fired, fails any commit-lane transaction
|
|
289
|
-
// still awaiting an ack. Started on `setConnectionState('disconnected')`,
|
|
290
|
-
// cleared on `'connected'`. The reconnect-retry behavior of the queue
|
|
291
|
-
// is preserved for brief blips; this only catches persistent disconnects.
|
|
292
|
-
commitOfflineGraceTimer = null;
|
|
293
|
-
/**
|
|
294
|
-
* This client's place in the global order of sync deltas. The instance is
|
|
295
|
-
* shared: the client injects one, and a standalone queue creates its own. The
|
|
296
|
-
* queue advances the `acked` cursor as commit responses arrive, the store
|
|
297
|
-
* advances `applied` and `persisted`, and snapshots and claims read
|
|
298
|
-
* `readFloor`. See `../logPosition.js` for the full contract.
|
|
299
|
-
*/
|
|
300
|
-
position;
|
|
301
|
-
/** Applied-cursor alias, kept so the many internal read sites stay legible. */
|
|
302
|
-
get lastSeenSyncId() {
|
|
303
|
-
return this.position.applied;
|
|
304
|
-
}
|
|
305
|
-
noteAck(lastSyncId) {
|
|
306
|
-
this.deltaConfirmation.noteAck(lastSyncId);
|
|
307
|
-
}
|
|
308
|
-
// Batch management
|
|
309
|
-
batchIndex = 0;
|
|
310
|
-
/** Mints the request identity once; retry paths only read the stored value. */
|
|
311
|
-
generateCommitIdempotencyKey() {
|
|
312
|
-
return `commit_${uuid()}`;
|
|
313
|
-
}
|
|
314
|
-
/**
|
|
315
|
-
* Binds an ordered transaction batch to one wire-level idempotency key.
|
|
316
|
-
* Existing envelopes are validated and restored to their original order;
|
|
317
|
-
* they are never extended with newly queued work.
|
|
318
|
-
*/
|
|
319
|
-
ensureCommitEnvelope(batch) {
|
|
320
|
-
const firstTransaction = batch[0];
|
|
321
|
-
if (!firstTransaction) {
|
|
322
|
-
throw new Error('Cannot create an idempotency envelope for an empty commit');
|
|
323
|
-
}
|
|
324
|
-
const existingKeys = new Set(batch
|
|
325
|
-
.map((tx) => tx.commitEnvelope?.idempotencyKey)
|
|
326
|
-
.filter((key) => key !== undefined));
|
|
327
|
-
if (existingKeys.size > 1) {
|
|
328
|
-
throw new Error('Cannot combine transactions from different commit envelopes');
|
|
329
|
-
}
|
|
330
|
-
const existingKey = existingKeys.values().next().value;
|
|
331
|
-
if (existingKey) {
|
|
332
|
-
const expectedCount = firstTransaction.commitEnvelope?.operationCount;
|
|
333
|
-
const indexes = new Set(batch.map((tx) => tx.commitEnvelope?.operationIndex));
|
|
334
|
-
const isCompleteEnvelope = expectedCount === batch.length &&
|
|
335
|
-
indexes.size === batch.length &&
|
|
336
|
-
batch.every((tx) => tx.commitEnvelope?.idempotencyKey === existingKey &&
|
|
337
|
-
tx.commitEnvelope.operationCount === expectedCount &&
|
|
338
|
-
tx.commitEnvelope.operationIndex < expectedCount);
|
|
339
|
-
if (!isCompleteEnvelope) {
|
|
340
|
-
throw new Error('Cannot replay a partial or malformed commit envelope');
|
|
341
|
-
}
|
|
342
|
-
const persistedSealTimes = new Set(batch
|
|
343
|
-
.map((transaction) => transaction.commitEnvelope?.sealedAt)
|
|
344
|
-
.filter((sealedAt) => sealedAt !== undefined));
|
|
345
|
-
if (persistedSealTimes.size > 1) {
|
|
346
|
-
throw new Error('Cannot replay a commit envelope with inconsistent seal times');
|
|
347
|
-
}
|
|
348
|
-
const sealedAt = persistedSealTimes.values().next().value ?? Date.now();
|
|
349
|
-
for (const transaction of batch) {
|
|
350
|
-
if (transaction.commitEnvelope)
|
|
351
|
-
transaction.commitEnvelope.sealedAt = sealedAt;
|
|
352
|
-
}
|
|
353
|
-
batch.sort((a, b) => (a.commitEnvelope?.operationIndex ?? 0) -
|
|
354
|
-
(b.commitEnvelope?.operationIndex ?? 0));
|
|
355
|
-
return existingKey;
|
|
356
|
-
}
|
|
357
|
-
// An explicit public key owns its request. Transactions carrying one are
|
|
358
|
-
// selected as solo batches by takeNextExecutionBatch().
|
|
359
|
-
const explicitKey = batch.length === 1
|
|
360
|
-
? firstTransaction.writeOptions?.idempotencyKey
|
|
361
|
-
: undefined;
|
|
362
|
-
const idempotencyKey = typeof explicitKey === 'string' && explicitKey.length > 0
|
|
363
|
-
? explicitKey
|
|
364
|
-
: this.generateCommitIdempotencyKey();
|
|
365
|
-
const sealedAt = Date.now();
|
|
366
|
-
const sequence = this.nextCommitSequence();
|
|
367
|
-
batch.forEach((tx, operationIndex) => {
|
|
368
|
-
tx.commitEnvelope = createCommitEnvelopeMember({
|
|
369
|
-
idempotencyKey,
|
|
370
|
-
operationIndex,
|
|
371
|
-
operationCount: batch.length,
|
|
372
|
-
sealedAt,
|
|
373
|
-
sequence,
|
|
374
|
-
});
|
|
375
|
-
});
|
|
376
|
-
return idempotencyKey;
|
|
377
|
-
}
|
|
378
|
-
/** Bind the strict local outbox used before any mutation reaches the wire. */
|
|
379
|
-
setCommitOutbox(outbox) {
|
|
380
|
-
this.commitOutbox = outbox;
|
|
381
|
-
}
|
|
382
|
-
setCommitOutboxScope(scope) {
|
|
383
|
-
this.commitOutboxScope = scope;
|
|
384
|
-
}
|
|
385
|
-
sourceMutationIdsFor(batch) {
|
|
386
|
-
return [...new Set(batch.flatMap((transaction) => transaction.sourceMutationIds ?? []))];
|
|
387
|
-
}
|
|
388
|
-
/**
|
|
389
|
-
* Atomically replaces staged mutation journal rows with one exact request.
|
|
390
|
-
* The returned operations are the JSON-normalized values that must be sent;
|
|
391
|
-
* callers never send a separately reconstructed payload after sealing.
|
|
392
|
-
*/
|
|
393
|
-
async sealDurableCommit(input) {
|
|
394
|
-
const sourceMutationIds = [...new Set(input.sourceMutationIds ?? [])];
|
|
395
|
-
const envelope = createDurableCommitEnvelope({
|
|
396
|
-
idempotencyKey: input.idempotencyKey,
|
|
397
|
-
origin: input.origin,
|
|
398
|
-
operations: [...input.operations],
|
|
399
|
-
sourceMutationIds,
|
|
400
|
-
commitOptions: {
|
|
401
|
-
...(input.commitOptions?.reads !== undefined
|
|
402
|
-
? {
|
|
403
|
-
reads: input.commitOptions.reads === null
|
|
404
|
-
? null
|
|
405
|
-
: [...input.commitOptions.reads],
|
|
406
|
-
}
|
|
407
|
-
: {}),
|
|
408
|
-
...(input.commitOptions?.track !== undefined
|
|
409
|
-
? {
|
|
410
|
-
track: input.commitOptions.track === null
|
|
411
|
-
? null
|
|
412
|
-
: [...input.commitOptions.track],
|
|
413
|
-
}
|
|
414
|
-
: {}),
|
|
415
|
-
},
|
|
416
|
-
...(this.commitOutboxScope ? { scope: this.commitOutboxScope } : {}),
|
|
417
|
-
createdAt: input.createdAt,
|
|
418
|
-
sealedAt: input.sealedAt,
|
|
419
|
-
sequence: input.sequence ?? input.sealedAt * 1_000,
|
|
420
|
-
});
|
|
421
|
-
if (this.config.enablePersistence && this.commitOutbox) {
|
|
422
|
-
try {
|
|
423
|
-
await this.commitOutbox.seal(envelope, sourceMutationIds.map(pendingMutationRecordId));
|
|
424
|
-
}
|
|
425
|
-
catch (cause) {
|
|
426
|
-
if (cause instanceof AbloError)
|
|
427
|
-
throw cause;
|
|
428
|
-
throw new AbloConnectionError('Could not persist the durable write before dispatch', {
|
|
429
|
-
code: 'db_not_opened',
|
|
430
|
-
cause,
|
|
431
|
-
});
|
|
432
|
-
}
|
|
433
|
-
this.emitCommitLifecycle('commit:envelope_persisted', {
|
|
434
|
-
idempotencyKey: envelope.idempotencyKey,
|
|
435
|
-
sourceMutationIds: envelope.sourceMutationIds,
|
|
436
|
-
});
|
|
437
|
-
}
|
|
438
|
-
return envelope;
|
|
439
|
-
}
|
|
440
|
-
/** Best-effort cleanup after a definitive rejection, confirmed ack, or echo. */
|
|
441
|
-
async removeDurableCommit(idempotencyKey) {
|
|
442
|
-
if (!this.config.enablePersistence || !this.commitOutbox)
|
|
443
|
-
return;
|
|
444
|
-
try {
|
|
445
|
-
await this.commitOutbox.remove(commitEnvelopeRecordId(idempotencyKey));
|
|
446
|
-
}
|
|
447
|
-
catch (error) {
|
|
448
|
-
this.runtime.logger.debug('[MutationQueue] Durable-write cleanup deferred', {
|
|
449
|
-
idempotencyKey,
|
|
450
|
-
error: error instanceof Error ? error.message : String(error),
|
|
451
|
-
});
|
|
452
|
-
}
|
|
453
|
-
}
|
|
454
|
-
/**
|
|
455
|
-
* Upgrade a sealed request with permanent connected-source acceptance before
|
|
456
|
-
* exposing the queued receipt. This is not completion: the envelope remains
|
|
457
|
-
* until the matching WAL correlation arrives. The upgrade only makes a
|
|
458
|
-
* crash/restart safe after the hosted 24-hour replay window has elapsed.
|
|
459
|
-
*/
|
|
460
|
-
async persistDurableCommitAcceptance(envelope, result) {
|
|
461
|
-
if (result.status !== 'queued')
|
|
462
|
-
return envelope;
|
|
463
|
-
const correlationId = result.correlationId;
|
|
464
|
-
if (!correlationId) {
|
|
465
|
-
throw new AbloConnectionError('The source accepted the commit without durable correlation evidence.', { code: 'commit_no_result' });
|
|
466
|
-
}
|
|
467
|
-
if (envelope.correlationId !== undefined &&
|
|
468
|
-
envelope.correlationId !== correlationId) {
|
|
469
|
-
throw new AbloIdempotencyError('The same commit replay returned a different source correlation.', { code: 'idempotency_conflict' });
|
|
470
|
-
}
|
|
471
|
-
if (envelope.acceptedAt !== undefined)
|
|
472
|
-
return envelope;
|
|
473
|
-
const accepted = durableCommitEnvelopeSchema.parse({
|
|
474
|
-
...envelope,
|
|
475
|
-
acceptedAt: Math.max(Date.now(), envelope.sealedAt),
|
|
476
|
-
correlationId,
|
|
477
|
-
});
|
|
478
|
-
if (this.config.enablePersistence && this.commitOutbox) {
|
|
479
|
-
try {
|
|
480
|
-
await this.commitOutbox.seal(accepted, []);
|
|
481
|
-
}
|
|
482
|
-
catch (cause) {
|
|
483
|
-
if (cause instanceof AbloError)
|
|
484
|
-
throw cause;
|
|
485
|
-
throw new AbloConnectionError('The source accepted the commit, but that acceptance could not be persisted locally.', { code: 'db_not_opened', cause });
|
|
486
|
-
}
|
|
487
|
-
}
|
|
488
|
-
return accepted;
|
|
489
|
-
}
|
|
490
|
-
/** Parse an untrusted/custom executor receipt without making ambiguity fatal. */
|
|
491
|
-
parseMutationCommitResult(value) {
|
|
492
|
-
const parsed = mutationCommitResultSchema.safeParse(value);
|
|
493
|
-
if (parsed.success)
|
|
494
|
-
return parsed.data;
|
|
495
|
-
throw new AbloConnectionError('The mutation transport returned an invalid commit receipt; its outcome remains pending and is safe to retry.', { code: 'commit_no_result', cause: parsed.error });
|
|
496
|
-
}
|
|
497
|
-
/**
|
|
498
|
-
* Dispatch a sealed envelope to the transport, bounded by
|
|
499
|
-
* `commitDispatchTimeoutMs`. Every executor call site routes through here:
|
|
500
|
-
* a transport that never answers must become a retryable no-receipt failure
|
|
501
|
-
* (the same class as a malformed receipt) instead of an eternally in-flight
|
|
502
|
-
* commit, because an unanswered commit holds the staged-batch lock and
|
|
503
|
-
* silently stalls every later write in the session. The abandoned commit's
|
|
504
|
-
* eventual result, if it ever arrives, is discarded; the retry re-sends the
|
|
505
|
-
* identical idempotency key, so the server deduplicates an already-applied
|
|
506
|
-
* write.
|
|
507
|
-
*/
|
|
508
|
-
dispatchCommitBounded(...args) {
|
|
509
|
-
const timeoutMs = this.config.commitDispatchTimeoutMs;
|
|
510
|
-
const dispatched = this.mutationExecutor.commit(...args);
|
|
511
|
-
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0)
|
|
512
|
-
return dispatched;
|
|
513
|
-
return new Promise((resolve, reject) => {
|
|
514
|
-
const timer = setTimeout(() => {
|
|
515
|
-
reject(new AbloConnectionError('The mutation transport did not acknowledge the commit in time; its outcome remains pending and is safe to retry.', { code: 'commit_no_result' }));
|
|
516
|
-
}, timeoutMs);
|
|
517
|
-
dispatched.then((value) => {
|
|
518
|
-
clearTimeout(timer);
|
|
519
|
-
resolve(value);
|
|
520
|
-
}, (error) => {
|
|
521
|
-
clearTimeout(timer);
|
|
522
|
-
reject(error instanceof Error ? error : new Error(String(error)));
|
|
523
|
-
});
|
|
524
|
-
});
|
|
525
|
-
}
|
|
526
|
-
clearReplicationLagState(transactionId) {
|
|
527
|
-
const timeout = this.replicationLagTimeouts.get(transactionId);
|
|
528
|
-
if (timeout)
|
|
529
|
-
clearTimeout(timeout);
|
|
530
|
-
this.replicationLagTimeouts.delete(transactionId);
|
|
531
|
-
this.replicationLagErrors.delete(transactionId);
|
|
532
|
-
}
|
|
533
|
-
/**
|
|
534
|
-
* Bounds the public `wait: 'confirmed'` promise without changing the
|
|
535
|
-
* accepted write's lifecycle. A lag timeout is not a rejection from the
|
|
536
|
-
* source database, so it must never emit `transaction:failed`, roll back
|
|
537
|
-
* optimistic state, or remove the durable replay envelope.
|
|
538
|
-
*/
|
|
539
|
-
scheduleReplicationLagTimeout(transactionId, clientTxId = transactionId, correlationId) {
|
|
540
|
-
const previous = this.replicationLagTimeouts.get(transactionId);
|
|
541
|
-
if (previous)
|
|
542
|
-
clearTimeout(previous);
|
|
543
|
-
this.replicationLagErrors.delete(transactionId);
|
|
544
|
-
const timeoutMs = this.config.deltaConfirmationTimeout;
|
|
545
|
-
const timeout = setTimeout(() => {
|
|
546
|
-
this.replicationLagTimeouts.delete(transactionId);
|
|
547
|
-
const modelTx = this.store.get(transactionId);
|
|
548
|
-
const commitTx = this.commitStore.get(transactionId);
|
|
549
|
-
if (modelTx?.status !== 'awaiting_delta' &&
|
|
550
|
-
commitTx?.status !== 'awaiting_delta')
|
|
551
|
-
return;
|
|
552
|
-
const error = new AbloConnectionError(`The source accepted commit ${clientTxId}, but its replication echo did not arrive within ${timeoutMs}ms.`, {
|
|
553
|
-
code: 'replication_lag_timeout',
|
|
554
|
-
httpStatus: 504,
|
|
555
|
-
details: {
|
|
556
|
-
clientTxId,
|
|
557
|
-
...(correlationId ? { correlationId } : {}),
|
|
558
|
-
timeoutMs,
|
|
559
|
-
accepted: true,
|
|
560
|
-
},
|
|
561
|
-
});
|
|
562
|
-
this.replicationLagErrors.set(transactionId, error);
|
|
563
|
-
// Model-proxy writes expose their waiter through the resolver table.
|
|
564
|
-
// Reject that promise without moving the transaction out of
|
|
565
|
-
// `awaiting_delta`; the eventual echo still completes it normally.
|
|
566
|
-
const resolver = this.confirmationResolvers.get(transactionId);
|
|
567
|
-
if (resolver) {
|
|
568
|
-
this.confirmationResolvers.delete(transactionId);
|
|
569
|
-
resolver.reject(error);
|
|
570
|
-
}
|
|
571
|
-
this.emitCommitLifecycle('transaction:confirmation_lagged', {
|
|
572
|
-
transactionId,
|
|
573
|
-
error,
|
|
574
|
-
});
|
|
575
|
-
this.emitCommitLifecycle(`transaction:confirmation_lagged:${transactionId}`, {
|
|
576
|
-
error,
|
|
577
|
-
});
|
|
578
|
-
if (commitTx) {
|
|
579
|
-
const firstOperation = commitTx.operations[0];
|
|
580
|
-
this.emitCommitLifecycle('reconciliation:needed', {
|
|
581
|
-
reason: 'replication_lag_timeout',
|
|
582
|
-
txId: transactionId,
|
|
583
|
-
model: firstOperation?.model ?? 'commit',
|
|
584
|
-
modelId: firstOperation?.id ?? transactionId,
|
|
585
|
-
lastSeenSyncId: this.lastSeenSyncId,
|
|
586
|
-
retryCount: 1,
|
|
587
|
-
});
|
|
588
|
-
}
|
|
589
|
-
}, timeoutMs);
|
|
590
|
-
this.replicationLagTimeouts.set(transactionId, timeout);
|
|
591
|
-
}
|
|
592
|
-
/**
|
|
593
|
-
* Takes either one complete retry envelope or a fresh batch. A retry waits
|
|
594
|
-
* until every original member has re-entered the queue, preventing an
|
|
595
|
-
* ambiguous A+B commit from being replayed later as A and B separately.
|
|
596
|
-
*/
|
|
597
|
-
takeNextExecutionBatch() {
|
|
598
|
-
const retryGroups = new Map();
|
|
599
|
-
for (const tx of this.executionQueue) {
|
|
600
|
-
const envelope = tx.commitEnvelope;
|
|
601
|
-
if (!envelope)
|
|
602
|
-
continue;
|
|
603
|
-
const group = retryGroups.get(envelope.idempotencyKey) ??
|
|
604
|
-
new Map();
|
|
605
|
-
group.set(tx.id, tx);
|
|
606
|
-
retryGroups.set(envelope.idempotencyKey, group);
|
|
607
|
-
}
|
|
608
|
-
for (const [idempotencyKey, byId] of retryGroups) {
|
|
609
|
-
const members = [...byId.values()];
|
|
610
|
-
const expectedCount = members[0]?.commitEnvelope?.operationCount;
|
|
611
|
-
if (expectedCount === undefined || members.length !== expectedCount)
|
|
612
|
-
continue;
|
|
613
|
-
this.executionQueue = this.executionQueue.filter((tx) => tx.commitEnvelope?.idempotencyKey !== idempotencyKey);
|
|
614
|
-
members.sort((a, b) => (a.commitEnvelope?.operationIndex ?? 0) -
|
|
615
|
-
(b.commitEnvelope?.operationIndex ?? 0));
|
|
616
|
-
return members;
|
|
617
|
-
}
|
|
618
|
-
const fresh = this.executionQueue.filter((tx) => !tx.commitEnvelope);
|
|
619
|
-
const firstFresh = fresh[0];
|
|
620
|
-
if (!firstFresh)
|
|
621
|
-
return [];
|
|
622
|
-
// A caller-supplied key describes exactly one public mutation call. Keep
|
|
623
|
-
// that transaction out of an SDK-created aggregate batch so the key maps
|
|
624
|
-
// to the request its caller intended.
|
|
625
|
-
const explicitIndex = fresh.findIndex((tx) => typeof tx.writeOptions?.idempotencyKey === 'string');
|
|
626
|
-
const selected = explicitIndex === 0
|
|
627
|
-
? [firstFresh]
|
|
628
|
-
: fresh.slice(0, Math.min(this.config.maxBatchSize, explicitIndex > 0 ? explicitIndex : fresh.length));
|
|
629
|
-
const selectedIds = new Set(selected.map((tx) => tx.id));
|
|
630
|
-
this.executionQueue = this.executionQueue.filter((tx) => !selectedIds.has(tx.id));
|
|
631
|
-
return selected;
|
|
632
|
-
}
|
|
633
|
-
/**
|
|
634
|
-
* Selects one reconnect batch without changing the request identity of work
|
|
635
|
-
* that was already attempted. Explicit caller keys remain one-call batches;
|
|
636
|
-
* an existing envelope is replayed only with all of its original members.
|
|
637
|
-
*/
|
|
638
|
-
takeOfflineFlushBatch(pending) {
|
|
639
|
-
const first = pending[0];
|
|
640
|
-
if (!first)
|
|
641
|
-
return [];
|
|
642
|
-
const envelope = first.commitEnvelope;
|
|
643
|
-
if (envelope) {
|
|
644
|
-
return pending.filter((tx) => tx.commitEnvelope?.idempotencyKey === envelope.idempotencyKey);
|
|
645
|
-
}
|
|
646
|
-
if (typeof first.writeOptions?.idempotencyKey === 'string') {
|
|
647
|
-
return [first];
|
|
648
|
-
}
|
|
649
|
-
const boundary = pending.findIndex((tx) => tx.commitEnvelope !== undefined ||
|
|
650
|
-
typeof tx.writeOptions?.idempotencyKey === 'string');
|
|
651
|
-
return pending.slice(0, Math.min(this.config.maxBatchSize, boundary > 0 ? boundary : pending.length));
|
|
652
|
-
}
|
|
653
|
-
/**
|
|
654
|
-
* Resolvers for per-transaction `confirmation` promises. Populated in
|
|
655
|
-
* `attachConfirmation` at staging time, consumed by the constructor-time
|
|
656
|
-
* listeners on `transaction:completed` / `transaction:failed`. Kept off
|
|
657
|
-
* the QueuedMutation row so the store's iteration order stays plain-data
|
|
658
|
-
* and serialization-friendly.
|
|
659
|
-
*/
|
|
660
|
-
confirmationResolvers = new Map();
|
|
661
|
-
constructor(config) {
|
|
662
|
-
super();
|
|
663
|
-
this.runtime = config?.runtime ?? globalRuntime;
|
|
664
|
-
this.position = config?.position ?? new LogPosition();
|
|
665
|
-
// Bind the confirmation tracker to this queue's store/ledger/events.
|
|
666
|
-
// `isConnected` closes over `isConnectedFn` so `setConnectionChecker`
|
|
667
|
-
// swaps stay visible to in-flight timeouts.
|
|
668
|
-
this.deltaConfirmation = new DeltaConfirmationTracker({
|
|
669
|
-
store: this.store,
|
|
670
|
-
optimisticUpdates: this.optimisticUpdates,
|
|
671
|
-
emit: (event, payload) => {
|
|
672
|
-
this.emit(event, payload);
|
|
673
|
-
},
|
|
674
|
-
isConnected: () => this.isConnectedFn(),
|
|
675
|
-
position: this.position,
|
|
676
|
-
runtime: this.runtime,
|
|
677
|
-
});
|
|
678
|
-
if (config) {
|
|
679
|
-
this.config = { ...this.config, ...config };
|
|
680
|
-
}
|
|
681
|
-
// Centralized fan-in for `tx.confirmation`. Completion/failure are
|
|
682
|
-
// emitted from ~10 sites (delta confirm, immediate confirm, batch
|
|
683
|
-
// success, permanent error, max_retries_exhausted, …). Subscribing
|
|
684
|
-
// once here keeps every emit site intact and guarantees the call-site
|
|
685
|
-
// promise always settles, regardless of which path produced the
|
|
686
|
-
// terminal state.
|
|
687
|
-
this.on('transaction:completed', (tx) => {
|
|
688
|
-
// Any successful write clears the permanent-error dedup, so a genuine
|
|
689
|
-
// recurrence after recovery warns again instead of staying demoted.
|
|
690
|
-
this.lastPermanentErrorSig = undefined;
|
|
691
|
-
this.clearReplicationLagState(tx.id);
|
|
692
|
-
const r = this.confirmationResolvers.get(tx.id);
|
|
693
|
-
if (r) {
|
|
694
|
-
this.confirmationResolvers.delete(tx.id);
|
|
695
|
-
r.resolve();
|
|
696
|
-
}
|
|
697
|
-
if (tx.type === 'create') {
|
|
698
|
-
this.releaseDeferredDeletesForCreate(tx);
|
|
699
|
-
}
|
|
700
|
-
});
|
|
701
|
-
this.on('transaction:failed', ({ transaction, error }) => {
|
|
702
|
-
const r = this.confirmationResolvers.get(transaction.id);
|
|
703
|
-
if (r) {
|
|
704
|
-
this.confirmationResolvers.delete(transaction.id);
|
|
705
|
-
r.reject(error);
|
|
706
|
-
}
|
|
707
|
-
if (transaction.type === 'create') {
|
|
708
|
-
this.releaseDeferredDeletesForCreate(transaction);
|
|
709
|
-
}
|
|
710
|
-
});
|
|
711
|
-
}
|
|
712
|
-
/**
|
|
713
|
-
* Returns the in-flight confirmation promise for a given model and id. When
|
|
714
|
-
* several transactions match, it returns the most recent one's promise; when
|
|
715
|
-
* none is open it resolves immediately, which covers both "already confirmed"
|
|
716
|
-
* and "never staged".
|
|
717
|
-
*
|
|
718
|
-
* It considers the three non-terminal statuses in which the write can still
|
|
719
|
-
* be rolled back — `pending`, `executing`, and `awaiting_delta` — and ignores
|
|
720
|
-
* `completed` (already settled) and `failed`/`rolled_back` (already
|
|
721
|
-
* rejected). This complements the `confirmation` promise carried on a known
|
|
722
|
-
* {@link QueuedMutation}: use this method at call sites that hold a model
|
|
723
|
-
* returned by `ablo.<model>.create()` but never see the underlying
|
|
724
|
-
* transaction.
|
|
725
|
-
*/
|
|
726
|
-
confirmationFor(modelName, modelId) {
|
|
727
|
-
const candidates = [
|
|
728
|
-
...this.store.getByStatus('pending'),
|
|
729
|
-
...this.store.getByStatus('executing'),
|
|
730
|
-
...this.store.getByStatus('awaiting_delta'),
|
|
731
|
-
].filter((tx) => tx.modelName === modelName && tx.modelId === modelId);
|
|
732
|
-
if (candidates.length === 0)
|
|
733
|
-
return Promise.resolve();
|
|
734
|
-
const latest = candidates.sort((a, b) => b.createdAt - a.createdAt)[0];
|
|
735
|
-
if (!latest)
|
|
736
|
-
return Promise.resolve();
|
|
737
|
-
return latest.confirmation ?? Promise.resolve();
|
|
738
|
-
}
|
|
739
|
-
/**
|
|
740
|
-
* Attaches a `confirmation` promise to a newly created transaction. Call this
|
|
741
|
-
* before the transaction is staged so a caller can `await tx.confirmation`
|
|
742
|
-
* immediately after a create, update, or delete returns. It is idempotent and
|
|
743
|
-
* returns early if one is already attached.
|
|
744
|
-
*
|
|
745
|
-
* It also attaches a no-op rejection handler. Most callers never await the
|
|
746
|
-
* confirmation, and without this the runtime would report an unhandled
|
|
747
|
-
* rejection when a write fails. Callers that do want to observe failure simply
|
|
748
|
-
* attach their own `.then`/`.catch`.
|
|
749
|
-
*/
|
|
750
|
-
attachConfirmation(tx) {
|
|
751
|
-
if (tx.confirmation)
|
|
752
|
-
return;
|
|
753
|
-
tx.confirmation = new Promise((resolve, reject) => {
|
|
754
|
-
this.confirmationResolvers.set(tx.id, { resolve, reject });
|
|
755
|
-
});
|
|
756
|
-
tx.confirmation.catch(() => {
|
|
757
|
-
// Swallow unhandled rejections; callers that care attach their own handler.
|
|
758
|
-
});
|
|
759
|
-
}
|
|
760
|
-
/**
|
|
761
|
-
* Registers a predicate the queue uses to check whether it is connected.
|
|
762
|
-
* While disconnected, confirmation timeouts re-schedule themselves instead of
|
|
763
|
-
* escalating, so a transaction is never rolled back merely because the client
|
|
764
|
-
* was briefly offline.
|
|
765
|
-
*/
|
|
766
|
-
setConnectionChecker(fn) {
|
|
767
|
-
this.isConnectedFn = fn;
|
|
768
|
-
}
|
|
769
|
-
/**
|
|
770
|
-
* Drives the offline-grace timer for in-flight commit-lane transactions.
|
|
771
|
-
*
|
|
772
|
-
* On `'disconnected'` it starts a one-shot timer of
|
|
773
|
-
* `config.commitOfflineGraceMs`. If that timer fires — meaning the disconnect
|
|
774
|
-
* outlasted the grace window — every commit-lane transaction still `pending`
|
|
775
|
-
* or `executing` is failed with an {@link AbloConnectionError}, so
|
|
776
|
-
* {@link waitForCommitReceipt} rejects within seconds instead of hanging.
|
|
777
|
-
*
|
|
778
|
-
* On `'connected'` it clears any pending grace timer. Brief disconnects are
|
|
779
|
-
* absorbed transparently; {@link processCommitLane} and
|
|
780
|
-
* {@link flushOfflineQueue} resume the work on reconnect.
|
|
781
|
-
*/
|
|
782
|
-
setConnectionState(state) {
|
|
783
|
-
if (state === 'connected') {
|
|
784
|
-
if (this.commitOfflineGraceTimer !== null) {
|
|
785
|
-
clearTimeout(this.commitOfflineGraceTimer);
|
|
786
|
-
this.commitOfflineGraceTimer = null;
|
|
787
|
-
}
|
|
788
|
-
return;
|
|
789
|
-
}
|
|
790
|
-
// state === 'disconnected'
|
|
791
|
-
if (this.commitOfflineGraceTimer !== null)
|
|
792
|
-
return; // already armed
|
|
793
|
-
const graceMs = this.config.commitOfflineGraceMs;
|
|
794
|
-
this.commitOfflineGraceTimer = setTimeout(() => {
|
|
795
|
-
this.commitOfflineGraceTimer = null;
|
|
796
|
-
this.failInFlightCommitsOnOffline(graceMs);
|
|
797
|
-
}, graceMs);
|
|
798
|
-
}
|
|
799
|
-
failInFlightCommitsOnOffline(graceMs) {
|
|
800
|
-
const inFlight = [];
|
|
801
|
-
for (const [id, tx] of this.commitStore.entries()) {
|
|
802
|
-
if (tx.status === 'pending' || tx.status === 'executing') {
|
|
803
|
-
inFlight.push(id);
|
|
804
|
-
}
|
|
805
|
-
}
|
|
806
|
-
if (inFlight.length === 0)
|
|
807
|
-
return;
|
|
808
|
-
// Each failed commit reaches the consumer through its own rejection path,
|
|
809
|
-
// so this aggregate line is forensic and logged at debug rather than warn.
|
|
810
|
-
this.runtime.logger.debug(`[MutationQueue] WS disconnected > ${graceMs}ms; failing ${inFlight.length} in-flight commit(s) with AbloConnectionError`, { inFlightIds: inFlight.map((id) => id.slice(0, 8)) });
|
|
811
|
-
for (const id of inFlight) {
|
|
812
|
-
const tx = this.commitStore.get(id);
|
|
813
|
-
if (!tx)
|
|
814
|
-
continue;
|
|
815
|
-
const err = new AbloConnectionError(`commit ack abandoned after ${graceMs}ms offline`, { code: 'commit_offline_grace_expired' });
|
|
816
|
-
tx.status = 'failed';
|
|
817
|
-
tx.error = err;
|
|
818
|
-
this.emit(`transaction:failed:${id}`, { error: err });
|
|
819
|
-
}
|
|
820
|
-
}
|
|
821
|
-
/**
|
|
822
|
-
* Binds the mutation executor for this queue instance. The owning client
|
|
823
|
-
* calls this right after construction, so commits made here always dispatch
|
|
824
|
-
* through this instance's connection even when several client instances exist
|
|
825
|
-
* in the same process.
|
|
826
|
-
*/
|
|
827
|
-
setMutationExecutor(executor) {
|
|
828
|
-
this._mutationExecutor = executor;
|
|
829
|
-
}
|
|
830
|
-
// ============================================================================
|
|
831
|
-
// Microtask-based transaction staging
|
|
832
|
-
// ============================================================================
|
|
833
|
-
//
|
|
834
|
-
// Every transaction lands in the `createdTransactions` staging area first.
|
|
835
|
-
// A microtask then commits them together under one batch index, so a bulk
|
|
836
|
-
// operation such as importing a hundred rows is sent efficiently.
|
|
837
|
-
//
|
|
838
|
-
// Flow:
|
|
839
|
-
// 1. create()/update()/delete() calls stageTransaction().
|
|
840
|
-
// 2. stageTransaction() adds to createdTransactions and schedules a microtask.
|
|
841
|
-
// 3. The microtask runs commitCreatedTransactions() once the current
|
|
842
|
-
// synchronous code finishes.
|
|
843
|
-
// 4. All staged transactions share one batch index and move to the execution
|
|
844
|
-
// queue.
|
|
845
|
-
// ============================================================================
|
|
846
|
-
/**
|
|
847
|
-
* Stages a transaction for commit. Transactions staged within the same
|
|
848
|
-
* event-loop tick are committed together.
|
|
849
|
-
*/
|
|
850
|
-
stageTransaction(transaction) {
|
|
851
|
-
this.createdTransactions.push(transaction);
|
|
852
|
-
this.scheduleCommit();
|
|
853
|
-
}
|
|
854
|
-
/**
|
|
855
|
-
* Schedules the staged transactions to commit on a microtask, so all
|
|
856
|
-
* transactions created synchronously within one tick are batched together.
|
|
857
|
-
*/
|
|
858
|
-
scheduleCommit() {
|
|
859
|
-
if (this.commitScheduled)
|
|
860
|
-
return;
|
|
861
|
-
this.commitScheduled = true;
|
|
862
|
-
// Use queueMicrotask to run after current sync code completes
|
|
863
|
-
// All transactions created in same event loop will be committed together
|
|
864
|
-
const schedule = typeof queueMicrotask === 'function'
|
|
865
|
-
? queueMicrotask
|
|
866
|
-
: (cb) => Promise.resolve().then(cb);
|
|
867
|
-
schedule(() => {
|
|
868
|
-
this.commitCreatedTransactions();
|
|
869
|
-
});
|
|
870
|
-
}
|
|
871
|
-
/**
|
|
872
|
-
* Moves all staged transactions onto the execution queue, assigning them a
|
|
873
|
-
* single shared batch index so they commit together.
|
|
874
|
-
*/
|
|
875
|
-
commitCreatedTransactions() {
|
|
876
|
-
this.commitScheduled = false;
|
|
877
|
-
if (this.createdTransactions.length === 0)
|
|
878
|
-
return;
|
|
879
|
-
// Increment batch index - all transactions in this commit share it
|
|
880
|
-
this.batchIndex++;
|
|
881
|
-
const currentBatchIndex = this.batchIndex;
|
|
882
|
-
// Log batch commit for performance monitoring
|
|
883
|
-
this.runtime.logger.debug('[MutationQueue] commitCreatedTransactions', {
|
|
884
|
-
count: this.createdTransactions.length,
|
|
885
|
-
batchIndex: currentBatchIndex,
|
|
886
|
-
types: this.createdTransactions.map((t) => `${t.type}:${t.modelName}`),
|
|
887
|
-
});
|
|
888
|
-
// Move all staged transactions to execution queue
|
|
889
|
-
const staged = this.createdTransactions;
|
|
890
|
-
this.createdTransactions = [];
|
|
891
|
-
for (const transaction of staged) {
|
|
892
|
-
// Assign batch ID based on current batch index
|
|
893
|
-
transaction.batchId = `batch_${currentBatchIndex}`;
|
|
894
|
-
this.enqueue(transaction);
|
|
895
|
-
}
|
|
896
|
-
}
|
|
897
|
-
/**
|
|
898
|
-
* Flushes every pending transaction in one commit, the fast path taken on
|
|
899
|
-
* reconnect. If transport fails, the transactions retain this exact commit
|
|
900
|
-
* envelope when they fall back to normal queue processing.
|
|
901
|
-
*/
|
|
902
|
-
async flushOfflineQueue() {
|
|
903
|
-
this.assertDurableReplayOpen();
|
|
904
|
-
// Kick the commit lane too: atomic envelopes from `commits.create()` may
|
|
905
|
-
// have been left at the head of the lane while the connection was down.
|
|
906
|
-
// Fire-and-forget; processCommitLane serializes itself.
|
|
907
|
-
void this.processCommitLane();
|
|
908
|
-
// Collect pending transactions in created order
|
|
909
|
-
const pending = this.store.getByStatus('pending').sort((a, b) => a.createdAt - b.createdAt);
|
|
910
|
-
if (pending.length === 0)
|
|
911
|
-
return;
|
|
912
|
-
const pendingIds = new Set(pending.map((tx) => tx.id));
|
|
913
|
-
// These rows may already be waiting behind the normal batch timer. The
|
|
914
|
-
// reconnect fast path takes ownership of them for this attempt so the same
|
|
915
|
-
// transaction cannot dispatch concurrently through both paths.
|
|
916
|
-
this.executionQueue = this.executionQueue.filter((tx) => !pendingIds.has(tx.id));
|
|
917
|
-
const remaining = [...pending];
|
|
918
|
-
while (remaining.length > 0) {
|
|
919
|
-
const batch = this.takeOfflineFlushBatch(remaining);
|
|
920
|
-
if (batch.length === 0)
|
|
921
|
-
break;
|
|
922
|
-
const batchIds = new Set(batch.map((tx) => tx.id));
|
|
923
|
-
const nextRemaining = remaining.filter((tx) => !batchIds.has(tx.id));
|
|
924
|
-
try {
|
|
925
|
-
const idempotencyKey = this.ensureCommitEnvelope(batch);
|
|
926
|
-
const projectedOperations = batch.map((tx) => {
|
|
927
|
-
this.ensureDerivedFields(tx);
|
|
928
|
-
return applyWriteOptions({
|
|
929
|
-
type: TX_TYPE_TO_MUTATION_OP[tx.type],
|
|
930
|
-
model: tx.modelKey,
|
|
931
|
-
id: tx.modelId,
|
|
932
|
-
input: tx.type === 'create' || tx.type === 'update' ? tx.data || {} : undefined,
|
|
933
|
-
transactionId: tx.id,
|
|
934
|
-
}, tx);
|
|
935
|
-
});
|
|
936
|
-
const durableEnvelope = await this.sealDurableCommit({
|
|
937
|
-
idempotencyKey,
|
|
938
|
-
origin: 'model_batch',
|
|
939
|
-
operations: projectedOperations,
|
|
940
|
-
sourceMutationIds: this.sourceMutationIdsFor(batch),
|
|
941
|
-
createdAt: Math.min(...batch.map((transaction) => transaction.createdAt)),
|
|
942
|
-
sealedAt: batch[0]?.commitEnvelope?.sealedAt ?? Date.now(),
|
|
943
|
-
sequence: batch[0]?.commitEnvelope?.sequence,
|
|
944
|
-
});
|
|
945
|
-
this.assertEnvelopeInsideReplayWindow(durableEnvelope);
|
|
946
|
-
const result = this.parseMutationCommitResult(await this.dispatchCommitBounded(durableEnvelope.operations, {
|
|
947
|
-
idempotencyKey,
|
|
948
|
-
}));
|
|
949
|
-
await this.persistDurableCommitAcceptance(durableEnvelope, result);
|
|
950
|
-
if (result.status === 'queued') {
|
|
951
|
-
// Reconnect flushes use the same accepted-vs-confirmed contract as
|
|
952
|
-
// the normal lane. A queued source receipt retains the envelope and
|
|
953
|
-
// waits for exact correlation; it is never promoted by the reconnect
|
|
954
|
-
// shortcut itself.
|
|
955
|
-
for (const tx of batch) {
|
|
956
|
-
tx.requiresCorrelatedDelta = true;
|
|
957
|
-
tx.syncIdNeededForCompletion = undefined;
|
|
958
|
-
tx.correlationId = result.correlationId;
|
|
959
|
-
const echoSyncId = result.correlationId
|
|
960
|
-
? this.recentDeltaCorrelations.get(result.correlationId)
|
|
961
|
-
: undefined;
|
|
962
|
-
if (echoSyncId !== undefined) {
|
|
963
|
-
this.store.updateStatus(tx.id, 'completed');
|
|
964
|
-
this.emit('transaction:completed', tx);
|
|
965
|
-
this.emit(`transaction:completed:${tx.id}`, tx);
|
|
966
|
-
this.optimisticUpdates.delete(tx.id);
|
|
967
|
-
continue;
|
|
968
|
-
}
|
|
969
|
-
this.store.updateStatus(tx.id, 'awaiting_delta');
|
|
970
|
-
this.scheduleReplicationLagTimeout(tx.id, idempotencyKey, result.correlationId);
|
|
971
|
-
this.scheduleDeltaConfirmationTimeout(tx, this.config.deltaConfirmationTimeout);
|
|
972
|
-
}
|
|
973
|
-
if (batch.every((tx) => tx.status === 'completed')) {
|
|
974
|
-
await this.removeDurableCommit(idempotencyKey);
|
|
975
|
-
}
|
|
976
|
-
}
|
|
977
|
-
else {
|
|
978
|
-
await this.removeDurableCommit(idempotencyKey);
|
|
979
|
-
// Mark this request envelope as completed before moving to the next.
|
|
980
|
-
for (const tx of batch) {
|
|
981
|
-
this.store.updateStatus(tx.id, 'completed');
|
|
982
|
-
this.emit('transaction:completed', tx);
|
|
983
|
-
this.emit(`transaction:completed:${tx.id}`, tx);
|
|
984
|
-
this.optimisticUpdates.delete(tx.id);
|
|
985
|
-
}
|
|
986
|
-
}
|
|
987
|
-
this.runtime.logger.debug('txn:commit', 0, {
|
|
988
|
-
count: batch.length,
|
|
989
|
-
lastSyncId: result.lastSyncId,
|
|
990
|
-
});
|
|
991
|
-
remaining.splice(0, remaining.length, ...nextRemaining);
|
|
992
|
-
}
|
|
993
|
-
catch (err) {
|
|
994
|
-
// If one request fails, hand it and every later request back to the
|
|
995
|
-
// normal lane. Their envelopes stay attached for safe retry.
|
|
996
|
-
const isOffline = !this.runtime.onlineStatus.isOnline();
|
|
997
|
-
const isNetworkError = err instanceof Error &&
|
|
998
|
-
(err.message.includes('Failed to fetch') ||
|
|
999
|
-
err.message.includes('Network request failed') ||
|
|
1000
|
-
err.message.includes('NetworkError'));
|
|
1001
|
-
if (!isOffline || !isNetworkError) {
|
|
1002
|
-
this.runtime.observability.breadcrumb('Batch flush fallback failed', 'sync.transaction', 'warning', {
|
|
1003
|
-
error: err instanceof Error ? err.message : String(err),
|
|
1004
|
-
});
|
|
1005
|
-
}
|
|
1006
|
-
for (const tx of [...batch, ...nextRemaining]) {
|
|
1007
|
-
this.enqueue(tx);
|
|
1008
|
-
}
|
|
1009
|
-
return;
|
|
1010
|
-
}
|
|
1011
|
-
}
|
|
1012
|
-
}
|
|
1013
|
-
/**
|
|
1014
|
-
* Records a create and applies it optimistically, then stages it for the next
|
|
1015
|
-
* batched commit. Returns the {@link QueuedMutation}, whose `confirmation`
|
|
1016
|
-
* promise settles once the server confirms the write.
|
|
1017
|
-
*/
|
|
1018
|
-
async create(model, context, writeOptions, sourceMutationId) {
|
|
1019
|
-
this.assertDurableReplayOpen();
|
|
1020
|
-
const actualModelName = model.getModelName();
|
|
1021
|
-
const transaction = {
|
|
1022
|
-
id: this.generateId(),
|
|
1023
|
-
type: 'create',
|
|
1024
|
-
modelName: actualModelName,
|
|
1025
|
-
modelId: model.id,
|
|
1026
|
-
modelKey: normalizeModelKey(actualModelName),
|
|
1027
|
-
priorityScore: this.computePriorityScore('create', actualModelName),
|
|
1028
|
-
data: this.extractCreateData(model),
|
|
1029
|
-
// Rolling back a create removes the row, so there is no prior state to
|
|
1030
|
-
// restore and no snapshot is captured here.
|
|
1031
|
-
previousData: null,
|
|
1032
|
-
context,
|
|
1033
|
-
status: 'pending',
|
|
1034
|
-
createdAt: Date.now(),
|
|
1035
|
-
attempts: 0,
|
|
1036
|
-
priority: 'normal',
|
|
1037
|
-
writeOptions,
|
|
1038
|
-
...(sourceMutationId ? { sourceMutationIds: [sourceMutationId] } : {}),
|
|
1039
|
-
};
|
|
1040
|
-
this.attachConfirmation(transaction);
|
|
1041
|
-
this.store.add(transaction);
|
|
1042
|
-
if (this.config.enableOptimistic) {
|
|
1043
|
-
this.applyOptimisticCreate(model, transaction);
|
|
1044
|
-
}
|
|
1045
|
-
// The microtask coalescer (`scheduleCommit`) collapses all creates in this
|
|
1046
|
-
// tick into one commit under a single `batchIndex` — see
|
|
1047
|
-
// `commitCreatedTransactions`. No batch call is needed at the call site.
|
|
1048
|
-
this.stageTransaction(transaction);
|
|
1049
|
-
this.emit('transaction:created', transaction);
|
|
1050
|
-
return transaction;
|
|
1051
|
-
}
|
|
1052
|
-
/**
|
|
1053
|
-
* Records an update and applies it optimistically, then stages it for the next
|
|
1054
|
-
* batched commit. Rapid updates to the same entity coalesce into a single wire
|
|
1055
|
-
* operation.
|
|
1056
|
-
* @param precomputedChanges - Optional pre-captured changes, used instead of re-reading them from the model.
|
|
1057
|
-
*/
|
|
1058
|
-
async update(model, context, precomputedChanges, writeOptions, sourceMutationId) {
|
|
1059
|
-
this.assertDurableReplayOpen();
|
|
1060
|
-
const actualModelName = model.getModelName();
|
|
1061
|
-
// Use pre-computed changes if provided, otherwise extract from model
|
|
1062
|
-
const updateInput = precomputedChanges
|
|
1063
|
-
? this.mapChangesToInput(actualModelName, precomputedChanges)
|
|
1064
|
-
: this.extractUpdateData(model);
|
|
1065
|
-
const previousData = this.extractPreviousData(model, updateInput);
|
|
1066
|
-
// Advance the per-field baseline for the keys just frozen into this
|
|
1067
|
-
// transaction. The model records the first old value per field and clears it
|
|
1068
|
-
// only on sync acknowledgement, so a second update to the same field before
|
|
1069
|
-
// the first is acknowledged would otherwise re-capture the original value
|
|
1070
|
-
// instead of this update's result, corrupting the recorded undo inverse. The
|
|
1071
|
-
// wire payload is already frozen in `transaction.data`, so dropping the
|
|
1072
|
-
// consumed entries is safe.
|
|
1073
|
-
model.consumeModifiedFields(Object.keys(updateInput));
|
|
1074
|
-
const modelKey = normalizeModelKey(actualModelName);
|
|
1075
|
-
const priorityScore = this.computePriorityScore('update', actualModelName);
|
|
1076
|
-
const transaction = {
|
|
1077
|
-
id: this.generateId(),
|
|
1078
|
-
type: 'update',
|
|
1079
|
-
modelName: actualModelName,
|
|
1080
|
-
modelId: model.id,
|
|
1081
|
-
modelKey,
|
|
1082
|
-
priorityScore,
|
|
1083
|
-
data: updateInput,
|
|
1084
|
-
previousData,
|
|
1085
|
-
context,
|
|
1086
|
-
status: 'pending',
|
|
1087
|
-
createdAt: Date.now(),
|
|
1088
|
-
attempts: 0,
|
|
1089
|
-
priority: this.isReorderPayload(updateInput) ? 'high' : 'normal',
|
|
1090
|
-
writeOptions,
|
|
1091
|
-
...(sourceMutationId ? { sourceMutationIds: [sourceMutationId] } : {}),
|
|
1092
|
-
};
|
|
1093
|
-
this.attachConfirmation(transaction);
|
|
1094
|
-
this.store.add(transaction);
|
|
1095
|
-
// Apply optimistic update
|
|
1096
|
-
if (this.config.enableOptimistic) {
|
|
1097
|
-
this.applyOptimisticUpdate(model, transaction);
|
|
1098
|
-
}
|
|
1099
|
-
// Stage the transaction for the microtask commit; updates made in the same
|
|
1100
|
-
// tick are batched together, and enqueue() still coalesces same-entity
|
|
1101
|
-
// updates.
|
|
1102
|
-
this.stageTransaction(transaction);
|
|
1103
|
-
this.emit('transaction:created', transaction);
|
|
1104
|
-
return transaction;
|
|
1105
|
-
}
|
|
1106
|
-
/**
|
|
1107
|
-
* Records a delete and applies it optimistically. If the row's own create has
|
|
1108
|
-
* not yet been sent, both are cancelled locally rather than sending a create
|
|
1109
|
-
* followed by a delete; if the create is already in flight, the delete waits
|
|
1110
|
-
* until it settles so the server never sees a delete before the create.
|
|
1111
|
-
*/
|
|
1112
|
-
async delete(model, context, writeOptions, sourceMutationId) {
|
|
1113
|
-
this.assertDurableReplayOpen();
|
|
1114
|
-
// Use getModelName() rather than constructor.name, which is unreliable once
|
|
1115
|
-
// class names are minified.
|
|
1116
|
-
const actualModelName = model.getModelName();
|
|
1117
|
-
// Skip Activity delete transactions - activities are permanent audit records
|
|
1118
|
-
if (actualModelName === 'Activity') {
|
|
1119
|
-
this.runtime.logger.debug('MutationQueue.delete() skipping Activity deletion - permanent audit records', { modelId: model.id });
|
|
1120
|
-
const modelKey = normalizeModelKey(actualModelName);
|
|
1121
|
-
const priorityScore = this.computePriorityScore('delete', actualModelName);
|
|
1122
|
-
const mockTransaction = {
|
|
1123
|
-
id: this.generateId(),
|
|
1124
|
-
type: 'delete',
|
|
1125
|
-
modelName: actualModelName,
|
|
1126
|
-
modelId: model.id,
|
|
1127
|
-
modelKey,
|
|
1128
|
-
priorityScore,
|
|
1129
|
-
previousData: model.toJSON ? model.toJSON() : { ...model },
|
|
1130
|
-
context,
|
|
1131
|
-
status: 'completed',
|
|
1132
|
-
createdAt: Date.now(),
|
|
1133
|
-
attempts: 0,
|
|
1134
|
-
priority: 'high',
|
|
1135
|
-
writeOptions,
|
|
1136
|
-
...(sourceMutationId ? { sourceMutationIds: [sourceMutationId] } : {}),
|
|
1137
|
-
localOnly: true,
|
|
1138
|
-
// Activity deletes complete synchronously (audit-record skip path).
|
|
1139
|
-
// Pre-resolved so consumers can still `await tx.confirmation` uniformly.
|
|
1140
|
-
confirmation: Promise.resolve(),
|
|
1141
|
-
};
|
|
1142
|
-
// Apply optimistic delete for UI feedback
|
|
1143
|
-
if (this.config.enableOptimistic) {
|
|
1144
|
-
this.applyOptimisticDelete(model, mockTransaction);
|
|
1145
|
-
}
|
|
1146
|
-
this.emit('transaction:created', mockTransaction);
|
|
1147
|
-
this.emit('transaction:completed', mockTransaction);
|
|
1148
|
-
return mockTransaction;
|
|
1149
|
-
}
|
|
1150
|
-
const modelKey = normalizeModelKey(actualModelName);
|
|
1151
|
-
const priorityScore = this.computePriorityScore('delete', actualModelName);
|
|
1152
|
-
const unsentCreate = this.takeUnsentCreateForModel(actualModelName, model.id);
|
|
1153
|
-
if (unsentCreate) {
|
|
1154
|
-
await this.cancelUnsentCreateForDelete(unsentCreate);
|
|
1155
|
-
return this.completeLocalDelete(model, context, writeOptions, [
|
|
1156
|
-
...(unsentCreate.sourceMutationIds ?? []),
|
|
1157
|
-
...(sourceMutationId ? [sourceMutationId] : []),
|
|
1158
|
-
]);
|
|
1159
|
-
}
|
|
1160
|
-
const transaction = {
|
|
1161
|
-
id: this.generateId(),
|
|
1162
|
-
type: 'delete',
|
|
1163
|
-
modelName: actualModelName,
|
|
1164
|
-
modelId: model.id,
|
|
1165
|
-
modelKey,
|
|
1166
|
-
priorityScore,
|
|
1167
|
-
previousData: model.toJSON ? model.toJSON() : { ...model },
|
|
1168
|
-
context,
|
|
1169
|
-
status: 'pending',
|
|
1170
|
-
createdAt: Date.now(),
|
|
1171
|
-
attempts: 0,
|
|
1172
|
-
priority: 'high', // Deletes are high priority
|
|
1173
|
-
writeOptions,
|
|
1174
|
-
...(sourceMutationId ? { sourceMutationIds: [sourceMutationId] } : {}),
|
|
1175
|
-
};
|
|
1176
|
-
this.attachConfirmation(transaction);
|
|
1177
|
-
this.store.add(transaction);
|
|
1178
|
-
// Cancel any pending/in-flight updates for this model to prevent "no rows" errors
|
|
1179
|
-
// when the delete executes before the update (race condition fix)
|
|
1180
|
-
const canceledUpdates = this.cancelTransactionsForModel(model.id, 'update');
|
|
1181
|
-
const entityKey = this.entityKey(actualModelName, model.id);
|
|
1182
|
-
const pendingMerge = this.pendingMergeByModel.get(entityKey);
|
|
1183
|
-
transaction.sourceMutationIds = [
|
|
1184
|
-
...new Set([
|
|
1185
|
-
...(transaction.sourceMutationIds ?? []),
|
|
1186
|
-
// Absorb journal sources only from updates that never sealed an
|
|
1187
|
-
// envelope. A sealed update's seal already CONSUMED its journal rows
|
|
1188
|
-
// — they belong to that envelope forever — so listing them here
|
|
1189
|
-
// poisons this delete's own seal: the new-envelope guard finds its
|
|
1190
|
-
// sources missing, rejects with `idempotency_conflict`, and the
|
|
1191
|
-
// optimistic delete reverts. That was a user-visible lost delete
|
|
1192
|
-
// (edit a layer, then delete it while the edit was on the wire).
|
|
1193
|
-
...canceledUpdates.flatMap((candidate) => candidate.commitEnvelope ? [] : (candidate.sourceMutationIds ?? [])),
|
|
1194
|
-
...(pendingMerge?.sourceMutationIds ?? []),
|
|
1195
|
-
]),
|
|
1196
|
-
];
|
|
1197
|
-
this.pendingMergeByModel.delete(entityKey);
|
|
1198
|
-
this.inFlightByModel.delete(entityKey);
|
|
1199
|
-
// Apply optimistic delete
|
|
1200
|
-
if (this.config.enableOptimistic) {
|
|
1201
|
-
this.applyOptimisticDelete(model, transaction);
|
|
1202
|
-
}
|
|
1203
|
-
const createBarrier = this.findCreateBarrierForDelete(actualModelName, model.id);
|
|
1204
|
-
if (createBarrier) {
|
|
1205
|
-
this.deferDeleteUntilCreateSettles(createBarrier, transaction);
|
|
1206
|
-
}
|
|
1207
|
-
else {
|
|
1208
|
-
// Stage the transaction for the microtask commit; deletes in the same
|
|
1209
|
-
// tick are batched together.
|
|
1210
|
-
this.stageTransaction(transaction);
|
|
1211
|
-
}
|
|
1212
|
-
this.emit('transaction:created', transaction);
|
|
1213
|
-
return transaction;
|
|
1214
|
-
}
|
|
1215
|
-
/**
|
|
1216
|
-
* Uploads a single attachment, delegating to the mutation executor.
|
|
1217
|
-
*/
|
|
1218
|
-
async uploadAttachment(_file, options, _context // eslint-disable-line @typescript-eslint/no-unused-vars -- reserved executor context
|
|
1219
|
-
) {
|
|
1220
|
-
return this.mutationExecutor.uploadAttachment?.(options.id, options) ?? null;
|
|
1221
|
-
}
|
|
1222
|
-
/**
|
|
1223
|
-
* Uploads several attachments in one call, delegating to the mutation executor.
|
|
1224
|
-
*/
|
|
1225
|
-
async batchUploadAttachments(_files, items, _context // eslint-disable-line @typescript-eslint/no-unused-vars -- reserved executor context
|
|
1226
|
-
) {
|
|
1227
|
-
return this.mutationExecutor.batchUploadAttachments?.(items.map(i => ({ id: i.id, input: i }))) ?? [];
|
|
1228
|
-
}
|
|
1229
|
-
/**
|
|
1230
|
-
* Records an archive and applies it optimistically, then stages it for the
|
|
1231
|
-
* next batched commit.
|
|
1232
|
-
*/
|
|
1233
|
-
async archive(model, context, writeOptions, sourceMutationId) {
|
|
1234
|
-
this.assertDurableReplayOpen();
|
|
1235
|
-
// Use getModelName() rather than constructor.name, which is unreliable once
|
|
1236
|
-
// class names are minified.
|
|
1237
|
-
const actualModelName = model.getModelName();
|
|
1238
|
-
const modelKey = normalizeModelKey(actualModelName);
|
|
1239
|
-
const priorityScore = this.computePriorityScore('archive', actualModelName);
|
|
1240
|
-
const transaction = {
|
|
1241
|
-
id: this.generateId(),
|
|
1242
|
-
type: 'archive',
|
|
1243
|
-
modelName: actualModelName,
|
|
1244
|
-
modelId: model.id,
|
|
1245
|
-
modelKey,
|
|
1246
|
-
priorityScore,
|
|
1247
|
-
previousData: model.toJSON ? model.toJSON() : { ...model },
|
|
1248
|
-
context,
|
|
1249
|
-
status: 'pending',
|
|
1250
|
-
createdAt: Date.now(),
|
|
1251
|
-
attempts: 0,
|
|
1252
|
-
priority: 'normal',
|
|
1253
|
-
writeOptions,
|
|
1254
|
-
...(sourceMutationId ? { sourceMutationIds: [sourceMutationId] } : {}),
|
|
1255
|
-
};
|
|
1256
|
-
this.attachConfirmation(transaction);
|
|
1257
|
-
this.store.add(transaction);
|
|
1258
|
-
// Stage the transaction for the microtask commit.
|
|
1259
|
-
this.stageTransaction(transaction);
|
|
1260
|
-
this.emit('transaction:created', transaction);
|
|
1261
|
-
return transaction;
|
|
1262
|
-
}
|
|
1263
|
-
/**
|
|
1264
|
-
* Records an unarchive and applies it optimistically, then stages it for the
|
|
1265
|
-
* next batched commit.
|
|
1266
|
-
*/
|
|
1267
|
-
async unarchive(model, context) {
|
|
1268
|
-
this.assertDurableReplayOpen();
|
|
1269
|
-
// Use getModelName() rather than constructor.name, which is unreliable once
|
|
1270
|
-
// class names are minified.
|
|
1271
|
-
const actualModelName = model.getModelName();
|
|
1272
|
-
const modelKey = normalizeModelKey(actualModelName);
|
|
1273
|
-
const priorityScore = this.computePriorityScore('unarchive', actualModelName);
|
|
1274
|
-
const transaction = {
|
|
1275
|
-
id: this.generateId(),
|
|
1276
|
-
type: 'unarchive',
|
|
1277
|
-
modelName: actualModelName,
|
|
1278
|
-
modelId: model.id,
|
|
1279
|
-
modelKey,
|
|
1280
|
-
priorityScore,
|
|
1281
|
-
previousData: model.toJSON ? model.toJSON() : { ...model },
|
|
1282
|
-
context,
|
|
1283
|
-
status: 'pending',
|
|
1284
|
-
createdAt: Date.now(),
|
|
1285
|
-
attempts: 0,
|
|
1286
|
-
priority: 'normal',
|
|
1287
|
-
};
|
|
1288
|
-
this.attachConfirmation(transaction);
|
|
1289
|
-
this.store.add(transaction);
|
|
1290
|
-
// Stage the transaction for the microtask commit.
|
|
1291
|
-
this.stageTransaction(transaction);
|
|
1292
|
-
this.emit('transaction:created', transaction);
|
|
1293
|
-
return transaction;
|
|
1294
|
-
}
|
|
1295
|
-
/**
|
|
1296
|
-
* Places a transaction on the execution queue, coalescing it into an existing
|
|
1297
|
-
* same-entity update where possible so redundant writes collapse.
|
|
1298
|
-
*/
|
|
1299
|
-
enqueue(transaction) {
|
|
1300
|
-
this.ensureDerivedFields(transaction);
|
|
1301
|
-
const modelKey = `${transaction.modelName}:${transaction.modelId}`;
|
|
1302
|
-
// Coalescing for updates. Staging already batches everything created in one
|
|
1303
|
-
// event-loop tick, so only two cases remain here: merging into an in-flight
|
|
1304
|
-
// update, and merging into a pending same-entity update.
|
|
1305
|
-
//
|
|
1306
|
-
// Retries (attempts > 0 or an already assigned commit envelope) must not
|
|
1307
|
-
// take either merge path. A retry is re-enqueued while its own model key may
|
|
1308
|
-
// still be marked in `inFlightByModel`; treating it as a concurrent edit
|
|
1309
|
-
// would change the already-attempted request, discard its stable envelope,
|
|
1310
|
-
// and either defeat maxRetries or duplicate an ambiguously committed write.
|
|
1311
|
-
if (transaction.type === 'update' &&
|
|
1312
|
-
transaction.attempts === 0 &&
|
|
1313
|
-
!transaction.commitEnvelope) {
|
|
1314
|
-
const preserveWatermark = hasCommitCoalescingBarrier(transaction.writeOptions);
|
|
1315
|
-
// If there is an in-flight update for this model, merge into post-flight buffer
|
|
1316
|
-
if (!preserveWatermark && this.inFlightByModel.has(modelKey)) {
|
|
1317
|
-
const previous = this.pendingMergeByModel.get(modelKey);
|
|
1318
|
-
const merged = mergeUpdateData(previous?.data ?? {}, transaction.data || {}, transaction.modelName);
|
|
1319
|
-
this.pendingMergeByModel.set(modelKey, {
|
|
1320
|
-
data: merged,
|
|
1321
|
-
sourceMutationIds: [
|
|
1322
|
-
...new Set([
|
|
1323
|
-
...(previous?.sourceMutationIds ?? []),
|
|
1324
|
-
...(transaction.sourceMutationIds ?? []),
|
|
1325
|
-
]),
|
|
1326
|
-
],
|
|
1327
|
-
});
|
|
1328
|
-
this.store.remove(transaction.id);
|
|
1329
|
-
return;
|
|
1330
|
-
}
|
|
1331
|
-
// If there's a pending update for same model in execution queue, merge into it
|
|
1332
|
-
const pendingInQueue = this.executionQueue.find((t) => t.id !== transaction.id &&
|
|
1333
|
-
t.type === 'update' &&
|
|
1334
|
-
t.modelId === transaction.modelId &&
|
|
1335
|
-
t.modelName === transaction.modelName &&
|
|
1336
|
-
!hasCommitCoalescingBarrier(t.writeOptions));
|
|
1337
|
-
if (!preserveWatermark && pendingInQueue) {
|
|
1338
|
-
pendingInQueue.data = mergeUpdateData(pendingInQueue.data || {}, transaction.data || {}, transaction.modelName);
|
|
1339
|
-
pendingInQueue.sourceMutationIds = [
|
|
1340
|
-
...new Set([
|
|
1341
|
-
...(pendingInQueue.sourceMutationIds ?? []),
|
|
1342
|
-
...(transaction.sourceMutationIds ?? []),
|
|
1343
|
-
]),
|
|
1344
|
-
];
|
|
1345
|
-
this.store.remove(transaction.id);
|
|
1346
|
-
return;
|
|
1347
|
-
}
|
|
1348
|
-
}
|
|
1349
|
-
// Add to execution queue based on priority
|
|
1350
|
-
if (transaction.priority === 'high') {
|
|
1351
|
-
this.executionQueue.unshift(transaction);
|
|
1352
|
-
}
|
|
1353
|
-
else {
|
|
1354
|
-
this.executionQueue.push(transaction);
|
|
1355
|
-
}
|
|
1356
|
-
this.scheduleProcessing(transaction.priority === 'high');
|
|
1357
|
-
}
|
|
1358
|
-
scheduleProcessing(immediate = false) {
|
|
1359
|
-
if (this.processScheduled)
|
|
1360
|
-
return;
|
|
1361
|
-
// Backpressure: don't schedule another batch while too many transactions
|
|
1362
|
-
// are already executing, so the server is not flooded with concurrent
|
|
1363
|
-
// requests.
|
|
1364
|
-
if (this.executingCount >= this.config.maxExecutingTransactions) {
|
|
1365
|
-
this.runtime.logger.debug('[MutationQueue] Backpressure: delaying batch, too many executing', {
|
|
1366
|
-
executingCount: this.executingCount,
|
|
1367
|
-
max: this.config.maxExecutingTransactions,
|
|
1368
|
-
});
|
|
1369
|
-
return;
|
|
1370
|
-
}
|
|
1371
|
-
this.processScheduled = true;
|
|
1372
|
-
if (immediate || (this.config.batchDelay ?? 0) <= 0) {
|
|
1373
|
-
const schedule = typeof queueMicrotask === 'function'
|
|
1374
|
-
? queueMicrotask
|
|
1375
|
-
: (cb) => Promise.resolve().then(cb);
|
|
1376
|
-
schedule(() => {
|
|
1377
|
-
this.processScheduled = false;
|
|
1378
|
-
void this.processBatch();
|
|
1379
|
-
});
|
|
1380
|
-
return;
|
|
1381
|
-
}
|
|
1382
|
-
const delay = Math.max(0, this.config.batchDelay);
|
|
1383
|
-
this.processTimer = setTimeout(() => {
|
|
1384
|
-
this.processTimer = undefined;
|
|
1385
|
-
this.processScheduled = false;
|
|
1386
|
-
void this.processBatch();
|
|
1387
|
-
}, delay);
|
|
1388
|
-
}
|
|
1389
|
-
/**
|
|
1390
|
-
* Processes one batch of transactions in a single commit. Rather than calling
|
|
1391
|
-
* the server once per operation type or model, it collects every batchable
|
|
1392
|
-
* operation and sends them together; the server applies the mixed operations
|
|
1393
|
-
* atomically within one transaction. This turns many round trips into one and
|
|
1394
|
-
* greatly reduces batch latency.
|
|
1395
|
-
*/
|
|
1396
|
-
async processBatch() {
|
|
1397
|
-
if (this.durableReplayBlock)
|
|
1398
|
-
return;
|
|
1399
|
-
const batchStart = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
1400
|
-
if (this.isProcessing || this.executionQueue.length === 0) {
|
|
1401
|
-
return;
|
|
1402
|
-
}
|
|
1403
|
-
this.isProcessing = true;
|
|
1404
|
-
// Declare batch outside try so it's accessible in finally for backpressure tracking
|
|
1405
|
-
let batch = [];
|
|
1406
|
-
await this.runtime.observability.startSpanAsync('sync.batch', 'sync.transaction.batch', async () => {
|
|
1407
|
-
try {
|
|
1408
|
-
// Sort the execution queue by foreign-key priority before selecting a
|
|
1409
|
-
// batch, so a parent row is always committed before its children, even
|
|
1410
|
-
// across batch boundaries.
|
|
1411
|
-
this.executionQueue.sort((a, b) => {
|
|
1412
|
-
// Ensure derived fields exist (covers restored/persisted transactions)
|
|
1413
|
-
this.ensureDerivedFields(a);
|
|
1414
|
-
this.ensureDerivedFields(b);
|
|
1415
|
-
if (a.modelName === b.modelName && a.modelId === b.modelId && a.type !== b.type) {
|
|
1416
|
-
if (a.type === 'create')
|
|
1417
|
-
return -1;
|
|
1418
|
-
if (b.type === 'create')
|
|
1419
|
-
return 1;
|
|
1420
|
-
}
|
|
1421
|
-
return a.priorityScore - b.priorityScore;
|
|
1422
|
-
});
|
|
1423
|
-
// Take a fresh batch or one complete retry envelope. Retry envelopes
|
|
1424
|
-
// retain both their original membership and operation order.
|
|
1425
|
-
batch = this.takeNextExecutionBatch();
|
|
1426
|
-
if (batch.length === 0)
|
|
1427
|
-
return;
|
|
1428
|
-
const commitIdempotencyKey = this.ensureCommitEnvelope(batch);
|
|
1429
|
-
// Track executing count for backpressure
|
|
1430
|
-
this.executingCount += batch.length;
|
|
1431
|
-
// Mark all as executing
|
|
1432
|
-
for (const tx of batch) {
|
|
1433
|
-
const key = `${tx.modelName}:${tx.modelId}`;
|
|
1434
|
-
if (tx.type === 'update')
|
|
1435
|
-
this.inFlightByModel.add(key);
|
|
1436
|
-
this.store.updateStatus(tx.id, 'executing');
|
|
1437
|
-
}
|
|
1438
|
-
// Build every operation for one unified commit (a single round trip).
|
|
1439
|
-
const batchOps = [];
|
|
1440
|
-
for (const tx of batch) {
|
|
1441
|
-
// The per-operation `transactionId` carries the local transaction id
|
|
1442
|
-
// over the wire so the server can stamp it on the resulting sync
|
|
1443
|
-
// delta. The receive path matches it through
|
|
1444
|
-
// {@link UnconfirmedWrites.consumeEcho} to avoid applying the
|
|
1445
|
-
// client's own optimistic change twice. This is separate from the
|
|
1446
|
-
// batch-level idempotency key recorded in `mutation_log`.
|
|
1447
|
-
const op = applyWriteOptions({
|
|
1448
|
-
type: TX_TYPE_TO_MUTATION_OP[tx.type],
|
|
1449
|
-
model: tx.modelKey,
|
|
1450
|
-
id: tx.modelId,
|
|
1451
|
-
input: tx.type === 'create' || tx.type === 'update' ? tx.data || {} : undefined,
|
|
1452
|
-
transactionId: tx.id,
|
|
1453
|
-
}, tx);
|
|
1454
|
-
batchOps.push({ tx, op });
|
|
1455
|
-
}
|
|
1456
|
-
// Execute the unified commit for every operation in one round trip.
|
|
1457
|
-
if (batchOps.length > 0) {
|
|
1458
|
-
let dispatchStarted = false;
|
|
1459
|
-
try {
|
|
1460
|
-
const durableEnvelope = await this.sealDurableCommit({
|
|
1461
|
-
idempotencyKey: commitIdempotencyKey,
|
|
1462
|
-
origin: 'model_batch',
|
|
1463
|
-
operations: batchOps.map(({ op }) => op),
|
|
1464
|
-
sourceMutationIds: this.sourceMutationIdsFor(batch),
|
|
1465
|
-
createdAt: Math.min(...batch.map((transaction) => transaction.createdAt)),
|
|
1466
|
-
sealedAt: batch[0]?.commitEnvelope?.sealedAt ?? Date.now(),
|
|
1467
|
-
sequence: batch[0]?.commitEnvelope?.sequence,
|
|
1468
|
-
});
|
|
1469
|
-
const operations = durableEnvelope.operations;
|
|
1470
|
-
// Capture lastSyncId from the server response for threshold-based
|
|
1471
|
-
// confirmation.
|
|
1472
|
-
//
|
|
1473
|
-
// The queue owns request identity. A lost acknowledgement may be
|
|
1474
|
-
// retried after a backoff or reconnect, so every retry must send
|
|
1475
|
-
// the exact key assigned before the first transport attempt.
|
|
1476
|
-
this.assertEnvelopeInsideReplayWindow(durableEnvelope);
|
|
1477
|
-
dispatchStarted = true;
|
|
1478
|
-
const result = this.parseMutationCommitResult(await this.dispatchCommitBounded(operations, {
|
|
1479
|
-
idempotencyKey: commitIdempotencyKey,
|
|
1480
|
-
}));
|
|
1481
|
-
await this.persistDurableCommitAcceptance(durableEnvelope, result);
|
|
1482
|
-
const lastSyncId = result.lastSyncId;
|
|
1483
|
-
const notifications = result.notifications;
|
|
1484
|
-
const { holdsEntireBatch, heldTargets, notificationsByTarget, } = this.classifyReceiptNotifications(batchOps.map(({ op }) => ({ model: op.model, id: op.id })), notifications ?? []);
|
|
1485
|
-
for (const { tx } of batchOps) {
|
|
1486
|
-
const txTarget = this.receiptTargetKey(tx.modelKey, tx.modelId);
|
|
1487
|
-
const txNotifs = holdsEntireBatch
|
|
1488
|
-
? notifications
|
|
1489
|
-
: notificationsByTarget.get(txTarget);
|
|
1490
|
-
if (txNotifs && txNotifs.length > 0) {
|
|
1491
|
-
this.commitNotifications.set(tx.id, txNotifs);
|
|
1492
|
-
}
|
|
1493
|
-
}
|
|
1494
|
-
const missingIds = new Set(result.missingIds ?? []);
|
|
1495
|
-
const settlingBatchOps = [];
|
|
1496
|
-
for (const entry of batchOps) {
|
|
1497
|
-
const { tx } = entry;
|
|
1498
|
-
if (missingIds.has(tx.modelId)) {
|
|
1499
|
-
await this.handleFailure(tx, new AbloNotFoundError(`${tx.modelName}/${tx.modelId} was not found or is outside this credential's scope.`, [tx.modelId]));
|
|
1500
|
-
continue;
|
|
1501
|
-
}
|
|
1502
|
-
if (holdsEntireBatch ||
|
|
1503
|
-
heldTargets.has(this.receiptTargetKey(tx.modelKey, tx.modelId))) {
|
|
1504
|
-
await this.rollbackOptimistic(tx, 'conflict_server_wins');
|
|
1505
|
-
this.store.updateStatus(tx.id, 'completed');
|
|
1506
|
-
this.emit('transaction:completed', tx);
|
|
1507
|
-
this.emit(`transaction:completed:${tx.id}`, tx);
|
|
1508
|
-
this.optimisticUpdates.delete(tx.id);
|
|
1509
|
-
continue;
|
|
1510
|
-
}
|
|
1511
|
-
settlingBatchOps.push(entry);
|
|
1512
|
-
}
|
|
1513
|
-
// A forwarded write has been accepted, but the source database
|
|
1514
|
-
// remains authoritative. It must not enter any of the confirmed
|
|
1515
|
-
// paths below: in particular, a queued DELETE with sync id 0 is
|
|
1516
|
-
// not the hosted-path idempotent-delete shortcut, and a later
|
|
1517
|
-
// unrelated delta cannot stand in for the source echo.
|
|
1518
|
-
if (result.status === 'queued') {
|
|
1519
|
-
// Keep the exact envelope crash-durable while any forwarded
|
|
1520
|
-
// member is awaiting its WAL echo. If coordination/missing-row
|
|
1521
|
-
// handling consumed the whole request, there is no echo left
|
|
1522
|
-
// to await and the durable intent is definitive.
|
|
1523
|
-
if (settlingBatchOps.length === 0) {
|
|
1524
|
-
await this.removeDurableCommit(commitIdempotencyKey);
|
|
1525
|
-
}
|
|
1526
|
-
for (const { tx } of settlingBatchOps) {
|
|
1527
|
-
tx.requiresCorrelatedDelta = true;
|
|
1528
|
-
tx.syncIdNeededForCompletion = undefined;
|
|
1529
|
-
tx.correlationId = result.correlationId;
|
|
1530
|
-
const echoSyncId = result.correlationId
|
|
1531
|
-
? this.recentDeltaCorrelations.get(result.correlationId)
|
|
1532
|
-
: undefined;
|
|
1533
|
-
if (echoSyncId !== undefined) {
|
|
1534
|
-
this.store.updateStatus(tx.id, 'completed');
|
|
1535
|
-
this.emit('transaction:completed', tx);
|
|
1536
|
-
this.emit(`transaction:completed:${tx.id}`, tx);
|
|
1537
|
-
this.optimisticUpdates.delete(tx.id);
|
|
1538
|
-
continue;
|
|
1539
|
-
}
|
|
1540
|
-
this.store.updateStatus(tx.id, 'awaiting_delta');
|
|
1541
|
-
this.scheduleReplicationLagTimeout(tx.id, commitIdempotencyKey, result.correlationId);
|
|
1542
|
-
this.runtime.logger.debug('tx:awaiting_delta', {
|
|
1543
|
-
txId: tx.id.slice(0, 8),
|
|
1544
|
-
model: tx.modelName,
|
|
1545
|
-
reason: 'queued_forward_waiting_for_correlated_echo',
|
|
1546
|
-
});
|
|
1547
|
-
this.scheduleDeltaConfirmationTimeout(tx, this.config.deltaConfirmationTimeout);
|
|
1548
|
-
}
|
|
1549
|
-
if (settlingBatchOps.length > 0 &&
|
|
1550
|
-
settlingBatchOps.every(({ tx }) => tx.status === 'completed')) {
|
|
1551
|
-
await this.removeDurableCommit(commitIdempotencyKey);
|
|
1552
|
-
}
|
|
1553
|
-
}
|
|
1554
|
-
else {
|
|
1555
|
-
await this.removeDurableCommit(commitIdempotencyKey);
|
|
1556
|
-
this.noteAck(lastSyncId);
|
|
1557
|
-
// A lastSyncId of 0 means the mutation succeeded but the server
|
|
1558
|
-
// emitted no sync delta; record that anomaly for observability.
|
|
1559
|
-
if (lastSyncId === 0) {
|
|
1560
|
-
this.runtime.observability.captureCommitZeroSyncId({
|
|
1561
|
-
operationCount: operations.length,
|
|
1562
|
-
operations: operations.map((op) => `${op.type}:${op.model}:${op.id?.slice(0, 8) ?? '?'}`),
|
|
1563
|
-
});
|
|
1564
|
-
}
|
|
1565
|
-
// Mark each remaining transaction with the sync-id threshold
|
|
1566
|
-
// that will confirm it: any delta whose id is at least
|
|
1567
|
-
// lastSyncId.
|
|
1568
|
-
for (const { tx } of settlingBatchOps) {
|
|
1569
|
-
tx.syncIdNeededForCompletion = lastSyncId;
|
|
1570
|
-
// Safety net: a confirmed zero-sync DELETE is idempotent.
|
|
1571
|
-
// This shortcut is intentionally unreachable for queued
|
|
1572
|
-
// forwards, which must wait for their correlated echo.
|
|
1573
|
-
if (lastSyncId === 0 && tx.type === 'delete') {
|
|
1574
|
-
this.store.updateStatus(tx.id, 'completed');
|
|
1575
|
-
this.emit('transaction:completed', tx);
|
|
1576
|
-
this.emit(`transaction:completed:${tx.id}`, tx);
|
|
1577
|
-
this.optimisticUpdates.delete(tx.id);
|
|
1578
|
-
this.runtime.logger.debug('tx:confirm_delete_zero_syncid', {
|
|
1579
|
-
txId: tx.id.slice(0, 8),
|
|
1580
|
-
model: tx.modelName,
|
|
1581
|
-
reason: 'delete_idempotent_no_delta',
|
|
1582
|
-
});
|
|
1583
|
-
continue;
|
|
1584
|
-
}
|
|
1585
|
-
// A real watermark is the established hosted-path
|
|
1586
|
-
// confirmation. A zero watermark on a non-delete stays on
|
|
1587
|
-
// the anomaly/reconciliation path.
|
|
1588
|
-
if (lastSyncId > 0) {
|
|
1589
|
-
this.store.updateStatus(tx.id, 'completed');
|
|
1590
|
-
this.emit('transaction:completed', tx);
|
|
1591
|
-
this.emit(`transaction:completed:${tx.id}`, tx);
|
|
1592
|
-
this.optimisticUpdates.delete(tx.id);
|
|
1593
|
-
this.runtime.logger.debug('tx:confirm_ack', {
|
|
1594
|
-
txId: tx.id.slice(0, 8),
|
|
1595
|
-
model: tx.modelName,
|
|
1596
|
-
serverSyncId: lastSyncId,
|
|
1597
|
-
lastSeenSyncId: this.lastSeenSyncId,
|
|
1598
|
-
});
|
|
1599
|
-
}
|
|
1600
|
-
else {
|
|
1601
|
-
this.store.updateStatus(tx.id, 'awaiting_delta');
|
|
1602
|
-
this.runtime.logger.debug('tx:awaiting_delta', {
|
|
1603
|
-
txId: tx.id.slice(0, 8),
|
|
1604
|
-
model: tx.modelName,
|
|
1605
|
-
neededSyncId: lastSyncId,
|
|
1606
|
-
lastSeenSyncId: this.lastSeenSyncId,
|
|
1607
|
-
reason: 'zero_sync_id_anomaly',
|
|
1608
|
-
});
|
|
1609
|
-
this.scheduleDeltaConfirmationTimeout(tx, this.config.deltaConfirmationTimeout);
|
|
1610
|
-
}
|
|
1611
|
-
}
|
|
1612
|
-
}
|
|
1613
|
-
}
|
|
1614
|
-
catch (error) {
|
|
1615
|
-
const errorMessage = error.message || '';
|
|
1616
|
-
if (dispatchStarted && this.isDefinitiveRejection(error)) {
|
|
1617
|
-
await this.removeDurableCommit(commitIdempotencyKey);
|
|
1618
|
-
}
|
|
1619
|
-
// Surface the raw server rejection for the whole batch so a
|
|
1620
|
-
// cascaded failure — for example a foreign-key violation that
|
|
1621
|
-
// rolls back a multi-operation transaction — is attributable to a
|
|
1622
|
-
// specific cause rather than showing as a generic permanent error
|
|
1623
|
-
// on each operation.
|
|
1624
|
-
const abloErr = error instanceof AbloError ? error : undefined;
|
|
1625
|
-
// SyncWebSocket attaches a `diagnostics` snapshot to its
|
|
1626
|
-
// "not connected" / "closed while in flight" rejections.
|
|
1627
|
-
// Surface it here so the warn line attributes the drop to
|
|
1628
|
-
// a specific cause (handshake reject, heartbeat zombie,
|
|
1629
|
-
// session expiry, …) instead of just "AbloConnectionError".
|
|
1630
|
-
const readDiagnostics = (e) => {
|
|
1631
|
-
let cur = e;
|
|
1632
|
-
// Walk up to 3 wrap layers (current err → its cause → its
|
|
1633
|
-
// cause's cause) so diagnostics survive AbloConnectionError
|
|
1634
|
-
// wrapping in Ablo.commit() and any future wrappers.
|
|
1635
|
-
for (let i = 0; i < 3 && cur && typeof cur === 'object'; i++) {
|
|
1636
|
-
if ('diagnostics' in cur && cur.diagnostics) {
|
|
1637
|
-
return (cur).diagnostics;
|
|
1638
|
-
}
|
|
1639
|
-
cur = cur.cause;
|
|
1640
|
-
}
|
|
1641
|
-
return undefined;
|
|
1642
|
-
};
|
|
1643
|
-
const diagnostics = readDiagnostics(error);
|
|
1644
|
-
// Mechanic-level breadcrumb. Every batch rejection — transient
|
|
1645
|
-
// (reconnect retries it) or permanent (`handleFailure` logs the
|
|
1646
|
-
// authoritative `warn` with the same typed cause) — passes
|
|
1647
|
-
// through here. Logging it at `warn` made one rejected write
|
|
1648
|
-
// surface three identical dumps; keep it at `debug`.
|
|
1649
|
-
this.runtime.logger.debug('[MutationQueue] Batch commit rejected', {
|
|
1650
|
-
batchSize: batchOps.length,
|
|
1651
|
-
models: batchOps.map(({ op }) => `${op.type}:${op.model}`),
|
|
1652
|
-
errorType: abloErr?.type ?? error?.name,
|
|
1653
|
-
errorCode: abloErr?.code,
|
|
1654
|
-
httpStatus: abloErr?.httpStatus,
|
|
1655
|
-
requestId: abloErr?.requestId,
|
|
1656
|
-
message: errorMessage,
|
|
1657
|
-
diagnostics,
|
|
1658
|
-
});
|
|
1659
|
-
// Handle "no rows in result set" gracefully: the row was already
|
|
1660
|
-
// deleted, so for update and delete operations this is effectively
|
|
1661
|
-
// success — the intended end state already holds — and the
|
|
1662
|
-
// transaction is treated as completed.
|
|
1663
|
-
if (errorMessage.includes('no rows in result set')) {
|
|
1664
|
-
if (dispatchStarted) {
|
|
1665
|
-
await this.removeDurableCommit(commitIdempotencyKey);
|
|
1666
|
-
}
|
|
1667
|
-
this.runtime.logger.info('[MutationQueue] Graceful handling: entity already deleted', {
|
|
1668
|
-
batchSize: batchOps.length,
|
|
1669
|
-
});
|
|
1670
|
-
for (const { tx, op } of batchOps) {
|
|
1671
|
-
if (op.type === 'UPDATE' || op.type === 'DELETE') {
|
|
1672
|
-
// Row already gone: the intended state holds, mark completed.
|
|
1673
|
-
this.store.updateStatus(tx.id, 'completed');
|
|
1674
|
-
this.emit('transaction:completed', tx);
|
|
1675
|
-
this.runtime.logger.debug('[MutationQueue] Orphaned transaction treated as success', {
|
|
1676
|
-
txId: tx.id.slice(0, 12),
|
|
1677
|
-
model: tx.modelName,
|
|
1678
|
-
type: op.type,
|
|
1679
|
-
});
|
|
1680
|
-
}
|
|
1681
|
-
else {
|
|
1682
|
-
// CREATE operations on non-existent parent are real failures
|
|
1683
|
-
await this.handleFailure(tx, error);
|
|
1684
|
-
}
|
|
1685
|
-
}
|
|
1686
|
-
}
|
|
1687
|
-
else {
|
|
1688
|
-
// Handle other batch failures - mark all as failed
|
|
1689
|
-
for (const { tx } of batchOps) {
|
|
1690
|
-
await this.handleFailure(tx, error);
|
|
1691
|
-
}
|
|
1692
|
-
}
|
|
1693
|
-
}
|
|
1694
|
-
}
|
|
1695
|
-
// Handle post-execution merge for updates
|
|
1696
|
-
for (const tx of batch) {
|
|
1697
|
-
const key = `${tx.modelName}:${tx.modelId}`;
|
|
1698
|
-
if (tx.type === 'update') {
|
|
1699
|
-
this.inFlightByModel.delete(key);
|
|
1700
|
-
const pending = this.pendingMergeByModel.get(key);
|
|
1701
|
-
if (pending && Object.keys(pending.data).length > 0) {
|
|
1702
|
-
// Create a single merged follow-up transaction
|
|
1703
|
-
const followUp = {
|
|
1704
|
-
id: this.generateId(),
|
|
1705
|
-
type: 'update',
|
|
1706
|
-
modelName: tx.modelName,
|
|
1707
|
-
modelId: tx.modelId,
|
|
1708
|
-
modelKey: tx.modelKey ?? normalizeModelKey(tx.modelName),
|
|
1709
|
-
data: pending.data,
|
|
1710
|
-
previousData: undefined,
|
|
1711
|
-
context: tx.context,
|
|
1712
|
-
status: 'pending',
|
|
1713
|
-
createdAt: Date.now(),
|
|
1714
|
-
attempts: 0,
|
|
1715
|
-
priority: 'normal',
|
|
1716
|
-
priorityScore: this.computePriorityScore('update', tx.modelName),
|
|
1717
|
-
sourceMutationIds: pending.sourceMutationIds,
|
|
1718
|
-
};
|
|
1719
|
-
this.pendingMergeByModel.delete(key);
|
|
1720
|
-
this.store.add(followUp);
|
|
1721
|
-
this.enqueue(followUp);
|
|
1722
|
-
}
|
|
1723
|
-
}
|
|
1724
|
-
}
|
|
1725
|
-
}
|
|
1726
|
-
finally {
|
|
1727
|
-
this.isProcessing = false;
|
|
1728
|
-
// Decrement executing count for backpressure tracking
|
|
1729
|
-
this.executingCount -= batch.length;
|
|
1730
|
-
// Process next batch if needed
|
|
1731
|
-
if (this.executionQueue.length > 0 && batch.length > 0) {
|
|
1732
|
-
this.scheduleProcessing(true);
|
|
1733
|
-
}
|
|
1734
|
-
const batchEnd = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
1735
|
-
this.runtime.logger.debug('txn:batch', batchEnd - batchStart, {
|
|
1736
|
-
maxBatchSize: this.config.maxBatchSize,
|
|
1737
|
-
remaining: this.executionQueue.length,
|
|
1738
|
-
executingCount: this.executingCount,
|
|
1739
|
-
});
|
|
1740
|
-
}
|
|
1741
|
-
}, { batchSize: this.executionQueue.length + (batch?.length ?? 0) });
|
|
1742
|
-
}
|
|
1743
|
-
rememberDeltaCorrelation(correlationId, syncId) {
|
|
1744
|
-
// Refresh insertion order when a replay repeats the same correlation id.
|
|
1745
|
-
this.recentDeltaCorrelations.delete(correlationId);
|
|
1746
|
-
this.recentDeltaCorrelations.set(correlationId, syncId);
|
|
1747
|
-
if (this.recentDeltaCorrelations.size <= 2_048)
|
|
1748
|
-
return;
|
|
1749
|
-
const oldest = this.recentDeltaCorrelations.keys().next().value;
|
|
1750
|
-
if (typeof oldest === 'string')
|
|
1751
|
-
this.recentDeltaCorrelations.delete(oldest);
|
|
1752
|
-
}
|
|
1753
|
-
queuedCommitEchoSyncId(tx) {
|
|
1754
|
-
return tx.correlationId
|
|
1755
|
-
? this.recentDeltaCorrelations.get(tx.correlationId)
|
|
1756
|
-
: undefined;
|
|
1757
|
-
}
|
|
1758
|
-
queuedCommitMatchesCorrelation(tx, correlationId) {
|
|
1759
|
-
return tx.correlationId !== undefined && tx.correlationId === correlationId;
|
|
1760
|
-
}
|
|
1761
|
-
completeQueuedCommit(tx, syncId) {
|
|
1762
|
-
if (tx.status !== 'awaiting_delta')
|
|
1763
|
-
return;
|
|
1764
|
-
this.clearReplicationLagState(tx.id);
|
|
1765
|
-
tx.lastSyncId = syncId;
|
|
1766
|
-
tx.status = 'completed';
|
|
1767
|
-
// The queued receipt was only acceptance. The correlated source echo is
|
|
1768
|
-
// the first definitive success and therefore the point where the durable
|
|
1769
|
-
// replay envelope may be removed.
|
|
1770
|
-
void this.removeDurableCommit(tx.id);
|
|
1771
|
-
this.emitCommitLifecycle('transaction:completed', tx);
|
|
1772
|
-
this.emitCommitLifecycle(`transaction:completed:${tx.id}`, tx);
|
|
1773
|
-
}
|
|
1774
|
-
/**
|
|
1775
|
-
* Confirms awaiting writes. Hosted/anomaly receipts keep their sync-id
|
|
1776
|
-
* threshold semantics; queued forwards require the exact server correlation.
|
|
1777
|
-
*/
|
|
1778
|
-
onDeltaReceived(syncId, _transactionId, correlationId) {
|
|
1779
|
-
if (correlationId)
|
|
1780
|
-
this.rememberDeltaCorrelation(correlationId, syncId);
|
|
1781
|
-
const correlatedModelTransactions = correlationId
|
|
1782
|
-
? this.store.getByStatus('awaiting_delta').filter((tx) => tx.requiresCorrelatedDelta === true &&
|
|
1783
|
-
tx.correlationId !== undefined &&
|
|
1784
|
-
tx.correlationId === correlationId)
|
|
1785
|
-
: [];
|
|
1786
|
-
this.deltaConfirmation.onDeltaReceived(syncId, correlationId);
|
|
1787
|
-
if (correlatedModelTransactions.length > 0) {
|
|
1788
|
-
const envelopeIds = new Set();
|
|
1789
|
-
for (const tx of correlatedModelTransactions) {
|
|
1790
|
-
this.clearReplicationLagState(tx.id);
|
|
1791
|
-
if (tx.commitEnvelope)
|
|
1792
|
-
envelopeIds.add(tx.commitEnvelope.idempotencyKey);
|
|
1793
|
-
}
|
|
1794
|
-
for (const envelopeId of envelopeIds) {
|
|
1795
|
-
const envelopeStillAwaiting = this.store
|
|
1796
|
-
.getByStatus('awaiting_delta')
|
|
1797
|
-
.some((tx) => tx.commitEnvelope?.idempotencyKey === envelopeId);
|
|
1798
|
-
if (!envelopeStillAwaiting)
|
|
1799
|
-
void this.removeDurableCommit(envelopeId);
|
|
1800
|
-
}
|
|
1801
|
-
}
|
|
1802
|
-
if (!correlationId)
|
|
1803
|
-
return;
|
|
1804
|
-
for (const tx of this.commitStore.values()) {
|
|
1805
|
-
if (tx.status !== 'awaiting_delta')
|
|
1806
|
-
continue;
|
|
1807
|
-
if (this.queuedCommitMatchesCorrelation(tx, correlationId)) {
|
|
1808
|
-
this.completeQueuedCommit(tx, syncId);
|
|
1809
|
-
}
|
|
1810
|
-
}
|
|
1811
|
-
}
|
|
1812
|
-
// Schedule the retry-and-reconciliation wait for a transaction's confirming
|
|
1813
|
-
// delta; see {@link DeltaConfirmationTracker} in `./deltaConfirmation.js`.
|
|
1814
|
-
scheduleDeltaConfirmationTimeout(tx, timeoutMs) {
|
|
1815
|
-
this.deltaConfirmation.scheduleDeltaConfirmationTimeout(tx, timeoutMs);
|
|
1816
|
-
}
|
|
1817
|
-
/**
|
|
1818
|
-
* Resolves once the given transaction is confirmed and rejects if it fails.
|
|
1819
|
-
* The confirming delta's timeout is handled by
|
|
1820
|
-
* {@link scheduleDeltaConfirmationTimeout}.
|
|
1821
|
-
*/
|
|
1822
|
-
waitForConfirmation(transactionId) {
|
|
1823
|
-
return new Promise((resolve, reject) => {
|
|
1824
|
-
// Check if already completed
|
|
1825
|
-
const tx = this.store.get(transactionId);
|
|
1826
|
-
if (tx?.status === 'completed') {
|
|
1827
|
-
resolve();
|
|
1828
|
-
return;
|
|
1829
|
-
}
|
|
1830
|
-
const lagError = this.replicationLagErrors.get(transactionId);
|
|
1831
|
-
if (lagError) {
|
|
1832
|
-
reject(lagError);
|
|
1833
|
-
return;
|
|
1834
|
-
}
|
|
1835
|
-
const onCompleted = () => {
|
|
1836
|
-
cleanup();
|
|
1837
|
-
resolve();
|
|
1838
|
-
};
|
|
1839
|
-
const onFailed = ({ error }) => {
|
|
1840
|
-
cleanup();
|
|
1841
|
-
reject(error);
|
|
1842
|
-
};
|
|
1843
|
-
const onLagged = ({ error }) => {
|
|
1844
|
-
cleanup();
|
|
1845
|
-
reject(error);
|
|
1846
|
-
};
|
|
1847
|
-
const cleanup = () => {
|
|
1848
|
-
this.off(`transaction:completed:${transactionId}`, onCompleted);
|
|
1849
|
-
this.off(`transaction:failed:${transactionId}`, onFailed);
|
|
1850
|
-
this.off(`transaction:confirmation_lagged:${transactionId}`, onLagged);
|
|
1851
|
-
};
|
|
1852
|
-
// Listen to existing events (timeout already handled by scheduleDeltaConfirmationTimeout)
|
|
1853
|
-
this.on(`transaction:completed:${transactionId}`, onCompleted);
|
|
1854
|
-
this.on(`transaction:failed:${transactionId}`, onFailed);
|
|
1855
|
-
this.on(`transaction:confirmation_lagged:${transactionId}`, onLagged);
|
|
1856
|
-
});
|
|
1857
|
-
}
|
|
1858
|
-
// Reports whether a client mutation id is known to this queue, which helps
|
|
1859
|
-
// identify a delta as this client's own echo.
|
|
1860
|
-
hasClientMutationId(id) {
|
|
1861
|
-
return !!this.store.get(id) || this.commitStore.has(id);
|
|
1862
|
-
}
|
|
1863
|
-
/**
|
|
1864
|
-
* Enqueues a pre-built, multi-operation atomic commit — the
|
|
1865
|
-
* `ablo.commits.create()` path. The caller supplies the operations; the queue
|
|
1866
|
-
* only retries on reconnect and de-duplicates, and does not apply the change
|
|
1867
|
-
* optimistically or reorder for foreign keys. A duplicate `clientTxId`
|
|
1868
|
-
* already in flight is ignored. After an accepted write exceeds its
|
|
1869
|
-
* replication-confirmation deadline, an explicit same-key retry reuses the
|
|
1870
|
-
* exact sealed envelope as an idempotent status probe; the server's
|
|
1871
|
-
* `mutation_log` still prevents a second logical write.
|
|
1872
|
-
*/
|
|
1873
|
-
async enqueueCommit(clientTxId, operations, options = {}) {
|
|
1874
|
-
this.assertDurableReplayOpen();
|
|
1875
|
-
const existing = this.commitStore.get(clientTxId);
|
|
1876
|
-
if (existing) {
|
|
1877
|
-
await existing.sealPromise;
|
|
1878
|
-
const existingIntent = stableStringify({
|
|
1879
|
-
operations: existing.operations,
|
|
1880
|
-
reads: existing.reads ?? null,
|
|
1881
|
-
track: existing.track ?? null,
|
|
1882
|
-
});
|
|
1883
|
-
const incomingIntent = stableStringify({
|
|
1884
|
-
operations,
|
|
1885
|
-
reads: options.reads ?? null,
|
|
1886
|
-
track: options.track ?? null,
|
|
1887
|
-
});
|
|
1888
|
-
if (existingIntent !== incomingIntent) {
|
|
1889
|
-
throw new AbloIdempotencyError('Idempotency key reused with a different atomic commit request', { code: 'idempotency_conflict' });
|
|
1890
|
-
}
|
|
1891
|
-
if (existing.status === 'awaiting_delta' &&
|
|
1892
|
-
this.replicationLagErrors.has(existing.id)) {
|
|
1893
|
-
// An explicit same-key retry after the confirmation deadline is an
|
|
1894
|
-
// idempotent status probe, not a second logical write. Reuse the exact
|
|
1895
|
-
// durable envelope and ask the server again: once the WAL echo has
|
|
1896
|
-
// materialized, mutation-log replay upgrades the old queued receipt to
|
|
1897
|
-
// confirmed. Passive awaiting remains untouched before the deadline,
|
|
1898
|
-
// so duplicate callers cannot cause eager re-dispatch.
|
|
1899
|
-
this.clearReplicationLagState(existing.id);
|
|
1900
|
-
existing.status = 'pending';
|
|
1901
|
-
this.commitLane.push(existing);
|
|
1902
|
-
}
|
|
1903
|
-
if (existing.status === 'pending')
|
|
1904
|
-
void this.processCommitLane();
|
|
1905
|
-
return;
|
|
1906
|
-
}
|
|
1907
|
-
this.emitCommitLifecycle('commit:staging', {
|
|
1908
|
-
clientTxId,
|
|
1909
|
-
operations,
|
|
1910
|
-
});
|
|
1911
|
-
const tx = {
|
|
1912
|
-
id: clientTxId,
|
|
1913
|
-
kind: 'commit',
|
|
1914
|
-
operations: [...operations],
|
|
1915
|
-
...(options.reads ? { reads: options.reads } : {}),
|
|
1916
|
-
...(options.track ? { track: options.track } : {}),
|
|
1917
|
-
status: 'pending',
|
|
1918
|
-
createdAt: Date.now(),
|
|
1919
|
-
attempts: 0,
|
|
1920
|
-
sealedAt: Date.now(),
|
|
1921
|
-
sequence: this.nextCommitSequence(),
|
|
1922
|
-
};
|
|
1923
|
-
this.commitStore.set(clientTxId, tx);
|
|
1924
|
-
tx.sealPromise = this.sealDurableCommit({
|
|
1925
|
-
idempotencyKey: tx.id,
|
|
1926
|
-
origin: 'atomic_commit',
|
|
1927
|
-
operations: tx.operations,
|
|
1928
|
-
commitOptions: {
|
|
1929
|
-
...(tx.reads ? { reads: tx.reads } : {}),
|
|
1930
|
-
...(tx.track ? { track: tx.track } : {}),
|
|
1931
|
-
},
|
|
1932
|
-
createdAt: tx.createdAt,
|
|
1933
|
-
sealedAt: tx.sealedAt,
|
|
1934
|
-
sequence: tx.sequence,
|
|
1935
|
-
}).then((envelope) => {
|
|
1936
|
-
tx.durableEnvelope = envelope;
|
|
1937
|
-
tx.operations = envelope.operations.map((operation) => ({ ...operation }));
|
|
1938
|
-
});
|
|
1939
|
-
try {
|
|
1940
|
-
await tx.sealPromise;
|
|
1941
|
-
}
|
|
1942
|
-
catch (error) {
|
|
1943
|
-
this.commitStore.delete(clientTxId);
|
|
1944
|
-
this.emitCommitLifecycle('commit:seal_failed', { clientTxId });
|
|
1945
|
-
throw error;
|
|
1946
|
-
}
|
|
1947
|
-
finally {
|
|
1948
|
-
tx.sealPromise = undefined;
|
|
1949
|
-
}
|
|
1950
|
-
this.commitLane.push(tx);
|
|
1951
|
-
// Emit the envelope on its own event so the undo stream can record
|
|
1952
|
-
// commit-lane writes as well. This deliberately avoids `transaction:created`,
|
|
1953
|
-
// which also feeds the optimistic-echo tracker: commit-lane operations apply
|
|
1954
|
-
// nothing optimistically and so have no echo to suppress.
|
|
1955
|
-
this.emitCommitLifecycle('commit:created', {
|
|
1956
|
-
clientTxId,
|
|
1957
|
-
operations: tx.operations,
|
|
1958
|
-
});
|
|
1959
|
-
void this.processCommitLane();
|
|
1960
|
-
}
|
|
1961
|
-
/**
|
|
1962
|
-
* Drains the pending commit-lane envelopes one at a time. A transient
|
|
1963
|
-
* failure, such as a network error, leaves the envelope at the head of the
|
|
1964
|
-
* lane in `pending` and stops; reconnect re-kicks it through
|
|
1965
|
-
* {@link flushOfflineQueue}. A permanent failure emits
|
|
1966
|
-
* `transaction:failed:<id>` and drops the envelope.
|
|
1967
|
-
*/
|
|
1968
|
-
async processCommitLane() {
|
|
1969
|
-
if (this.commitProcessing || this.durableReplayBlock)
|
|
1970
|
-
return;
|
|
1971
|
-
this.commitProcessing = true;
|
|
1972
|
-
try {
|
|
1973
|
-
while (this.commitLane.length > 0) {
|
|
1974
|
-
const tx = this.commitLane[0];
|
|
1975
|
-
if (!tx)
|
|
1976
|
-
break;
|
|
1977
|
-
if (tx.status !== 'pending') {
|
|
1978
|
-
this.commitLane.shift();
|
|
1979
|
-
continue;
|
|
1980
|
-
}
|
|
1981
|
-
tx.status = 'executing';
|
|
1982
|
-
tx.attempts += 1;
|
|
1983
|
-
let dispatchStarted = false;
|
|
1984
|
-
try {
|
|
1985
|
-
const durableEnvelope = tx.durableEnvelope ??
|
|
1986
|
-
(await this.sealDurableCommit({
|
|
1987
|
-
idempotencyKey: tx.id,
|
|
1988
|
-
origin: 'atomic_commit',
|
|
1989
|
-
operations: tx.operations,
|
|
1990
|
-
sourceMutationIds: tx.sourceMutationIds,
|
|
1991
|
-
commitOptions: {
|
|
1992
|
-
...(tx.reads ? { reads: tx.reads } : {}),
|
|
1993
|
-
...(tx.track ? { track: tx.track } : {}),
|
|
1994
|
-
},
|
|
1995
|
-
createdAt: tx.createdAt,
|
|
1996
|
-
sealedAt: tx.sealedAt,
|
|
1997
|
-
sequence: tx.sequence,
|
|
1998
|
-
}));
|
|
1999
|
-
tx.durableEnvelope = durableEnvelope;
|
|
2000
|
-
this.assertEnvelopeInsideReplayWindow(durableEnvelope);
|
|
2001
|
-
dispatchStarted = true;
|
|
2002
|
-
const result = this.parseMutationCommitResult(await this.dispatchCommitBounded(durableEnvelope.operations, {
|
|
2003
|
-
idempotencyKey: tx.id,
|
|
2004
|
-
...(durableEnvelope.commitOptions.reads
|
|
2005
|
-
? { reads: durableEnvelope.commitOptions.reads }
|
|
2006
|
-
: {}),
|
|
2007
|
-
...(durableEnvelope.commitOptions.track
|
|
2008
|
-
? { track: durableEnvelope.commitOptions.track }
|
|
2009
|
-
: {}),
|
|
2010
|
-
}));
|
|
2011
|
-
tx.durableEnvelope = await this.persistDurableCommitAcceptance(durableEnvelope, result);
|
|
2012
|
-
tx.lastSyncId = result.lastSyncId;
|
|
2013
|
-
const notifications = result.notifications;
|
|
2014
|
-
if (notifications && notifications.length > 0) {
|
|
2015
|
-
this.commitNotifications.set(tx.id, notifications);
|
|
2016
|
-
}
|
|
2017
|
-
const missingIds = result.missingIds;
|
|
2018
|
-
if (missingIds && missingIds.length > 0) {
|
|
2019
|
-
this.commitMissingIds.set(tx.id, missingIds);
|
|
2020
|
-
}
|
|
2021
|
-
this.commitLane.shift();
|
|
2022
|
-
if (result.status === 'queued') {
|
|
2023
|
-
tx.correlationId = result.correlationId;
|
|
2024
|
-
tx.status = 'awaiting_delta';
|
|
2025
|
-
const echoSyncId = this.queuedCommitEchoSyncId(tx);
|
|
2026
|
-
if (echoSyncId !== undefined) {
|
|
2027
|
-
this.completeQueuedCommit(tx, echoSyncId);
|
|
2028
|
-
}
|
|
2029
|
-
else {
|
|
2030
|
-
this.scheduleReplicationLagTimeout(tx.id, tx.id, result.correlationId);
|
|
2031
|
-
this.runtime.logger.debug('[MutationQueue] commit lane awaiting source echo', {
|
|
2032
|
-
txId: tx.id.slice(0, 12),
|
|
2033
|
-
});
|
|
2034
|
-
}
|
|
2035
|
-
}
|
|
2036
|
-
else {
|
|
2037
|
-
await this.removeDurableCommit(tx.id);
|
|
2038
|
-
this.noteAck(tx.lastSyncId);
|
|
2039
|
-
tx.status = 'completed';
|
|
2040
|
-
// Guarded: a throwing observer here would land in the catch below,
|
|
2041
|
-
// whose permanent branch shifts the lane a second time and rejects a
|
|
2042
|
-
// commit the server has already durably applied.
|
|
2043
|
-
this.emitCommitLifecycle('transaction:completed', tx);
|
|
2044
|
-
this.emitCommitLifecycle(`transaction:completed:${tx.id}`, tx);
|
|
2045
|
-
}
|
|
2046
|
-
}
|
|
2047
|
-
catch (err) {
|
|
2048
|
-
const error = err instanceof Error ? err : new Error(String(err));
|
|
2049
|
-
if (dispatchStarted && this.isDefinitiveRejection(error)) {
|
|
2050
|
-
await this.removeDurableCommit(tx.id);
|
|
2051
|
-
}
|
|
2052
|
-
// A transport that is DOWN is not a failing write: the envelope is
|
|
2053
|
-
// meant to wait for reconnect, which is what makes a commit survive a
|
|
2054
|
-
// dropped connection. A transient error the SERVER keeps returning is
|
|
2055
|
-
// a different thing, and this lane has no attempt bound of its own —
|
|
2056
|
-
// so a 5xx carrying no wire code reads as transient on every kick,
|
|
2057
|
-
// sits at the head of the lane, and `waitForCommitReceipt` never
|
|
2058
|
-
// settles. The caller sees a write that neither lands nor fails.
|
|
2059
|
-
//
|
|
2060
|
-
// Counting only the non-connection failures keeps offline waiting
|
|
2061
|
-
// unbounded while giving a repeating server rejection an end.
|
|
2062
|
-
if (!(error instanceof AbloConnectionError)) {
|
|
2063
|
-
tx.transientAttempts = (tx.transientAttempts ?? 0) + 1;
|
|
2064
|
-
}
|
|
2065
|
-
const exhausted = (tx.transientAttempts ?? 0) > this.config.maxRetries;
|
|
2066
|
-
if (!this.isPermanentError(error) && !exhausted) {
|
|
2067
|
-
// Transient: leave it at the head and retry on the next kick
|
|
2068
|
-
// (reconnect or the next enqueueCommit) rather than tight-looping
|
|
2069
|
-
// while the connection is down.
|
|
2070
|
-
tx.status = 'pending';
|
|
2071
|
-
this.runtime.logger.debug('[MutationQueue] commit lane transient', {
|
|
2072
|
-
txId: tx.id.slice(0, 12),
|
|
2073
|
-
attempts: tx.attempts,
|
|
2074
|
-
transientAttempts: tx.transientAttempts ?? 0,
|
|
2075
|
-
message: error.message,
|
|
2076
|
-
});
|
|
2077
|
-
break;
|
|
2078
|
-
}
|
|
2079
|
-
tx.status = 'failed';
|
|
2080
|
-
tx.error = error;
|
|
2081
|
-
this.commitLane.shift();
|
|
2082
|
-
// Internal bookkeeping; the consumer-facing rejection is emitted on
|
|
2083
|
-
// 'transaction:failed' and surfaced by the permanent-error headline,
|
|
2084
|
-
// so this line stays at debug.
|
|
2085
|
-
this.runtime.logger.debug('[MutationQueue] commit lane permanent error', {
|
|
2086
|
-
txId: tx.id.slice(0, 12),
|
|
2087
|
-
attempts: tx.attempts,
|
|
2088
|
-
message: error.message,
|
|
2089
|
-
});
|
|
2090
|
-
this.emitCommitLifecycle('transaction:failed', { transaction: tx, error, permanent: true });
|
|
2091
|
-
this.emitCommitLifecycle(`transaction:failed:${tx.id}`, { error });
|
|
2092
|
-
}
|
|
2093
|
-
}
|
|
2094
|
-
}
|
|
2095
|
-
finally {
|
|
2096
|
-
this.commitProcessing = false;
|
|
2097
|
-
}
|
|
2098
|
-
}
|
|
2099
|
-
/**
|
|
2100
|
-
* Resolves once a commit-lane transaction is confirmed, returning the server's
|
|
2101
|
-
* `lastSyncId` and any stale-context notifications; rejects on permanent
|
|
2102
|
-
* failure. This backs the `wait: 'confirmed'` semantics of
|
|
2103
|
-
* `ablo.commits.create()`.
|
|
2104
|
-
*/
|
|
2105
|
-
waitForCommitReceipt(clientTxId) {
|
|
2106
|
-
// Drain any stale-context notifications stamped for this tx on the ack.
|
|
2107
|
-
const drainNotifications = () => {
|
|
2108
|
-
const n = this.commitNotifications.get(clientTxId);
|
|
2109
|
-
if (!n)
|
|
2110
|
-
return undefined;
|
|
2111
|
-
this.commitNotifications.delete(clientTxId);
|
|
2112
|
-
return n.length > 0 ? n : undefined;
|
|
2113
|
-
};
|
|
2114
|
-
const drainMissingIds = () => {
|
|
2115
|
-
const ids = this.commitMissingIds.get(clientTxId);
|
|
2116
|
-
if (!ids)
|
|
2117
|
-
return undefined;
|
|
2118
|
-
this.commitMissingIds.delete(clientTxId);
|
|
2119
|
-
return ids.length > 0 ? ids : undefined;
|
|
2120
|
-
};
|
|
2121
|
-
const receipt = (lastSyncId) => {
|
|
2122
|
-
const notifications = drainNotifications();
|
|
2123
|
-
const missingIds = drainMissingIds();
|
|
2124
|
-
return {
|
|
2125
|
-
lastSyncId,
|
|
2126
|
-
notifications,
|
|
2127
|
-
...(missingIds ? { missingIds } : {}),
|
|
2128
|
-
};
|
|
2129
|
-
};
|
|
2130
|
-
return new Promise((resolve, reject) => {
|
|
2131
|
-
const existing = this.commitStore.get(clientTxId);
|
|
2132
|
-
if (existing?.status === 'completed') {
|
|
2133
|
-
resolve(receipt(existing.lastSyncId ?? 0));
|
|
2134
|
-
return;
|
|
2135
|
-
}
|
|
2136
|
-
if (existing?.status === 'failed' && existing.error) {
|
|
2137
|
-
reject(existing.error);
|
|
2138
|
-
return;
|
|
2139
|
-
}
|
|
2140
|
-
const lagError = this.replicationLagErrors.get(clientTxId);
|
|
2141
|
-
if (lagError) {
|
|
2142
|
-
reject(lagError);
|
|
2143
|
-
return;
|
|
2144
|
-
}
|
|
2145
|
-
const onCompleted = (tx) => {
|
|
2146
|
-
cleanup();
|
|
2147
|
-
resolve(receipt(tx.lastSyncId ?? 0));
|
|
2148
|
-
};
|
|
2149
|
-
const onFailed = ({ error }) => {
|
|
2150
|
-
cleanup();
|
|
2151
|
-
reject(error);
|
|
2152
|
-
};
|
|
2153
|
-
const onLagged = ({ error }) => {
|
|
2154
|
-
cleanup();
|
|
2155
|
-
reject(error);
|
|
2156
|
-
};
|
|
2157
|
-
const cleanup = () => {
|
|
2158
|
-
this.off(`transaction:completed:${clientTxId}`, onCompleted);
|
|
2159
|
-
this.off(`transaction:failed:${clientTxId}`, onFailed);
|
|
2160
|
-
this.off(`transaction:confirmation_lagged:${clientTxId}`, onLagged);
|
|
2161
|
-
};
|
|
2162
|
-
this.on(`transaction:completed:${clientTxId}`, onCompleted);
|
|
2163
|
-
this.on(`transaction:failed:${clientTxId}`, onFailed);
|
|
2164
|
-
this.on(`transaction:confirmation_lagged:${clientTxId}`, onLagged);
|
|
2165
|
-
});
|
|
2166
|
-
}
|
|
2167
|
-
isReorderPayload(data) {
|
|
2168
|
-
if (!data || typeof data !== 'object')
|
|
2169
|
-
return false;
|
|
2170
|
-
return 'order' in data || 'orderKey' in data || 'position' in data;
|
|
2171
|
-
}
|
|
2172
|
-
/**
|
|
2173
|
-
* Classifies an error as transient (worth retrying) or permanent. The
|
|
2174
|
-
* approach is deliberately conservative: only known-transient errors are
|
|
2175
|
-
* retried, and anything unrecognized is treated as permanent so a failing
|
|
2176
|
-
* write cannot loop forever.
|
|
2177
|
-
*
|
|
2178
|
-
* Transient (retried):
|
|
2179
|
-
* - Network failures, connection errors, and timeouts.
|
|
2180
|
-
* - Server errors (HTTP 5xx).
|
|
2181
|
-
* - Rate limiting (HTTP 429).
|
|
2182
|
-
*
|
|
2183
|
-
* Permanent (not retried), among others:
|
|
2184
|
-
* - Validation errors and constraint violations.
|
|
2185
|
-
* - Not found, unauthorized, and forbidden.
|
|
2186
|
-
* - Any other business-logic error from the server.
|
|
2187
|
-
*/
|
|
2188
|
-
isPermanentError(error) {
|
|
2189
|
-
// Typed connection error (e.g. ws_not_ready, transport timeout) is
|
|
2190
|
-
// always transient — the message text varies ("SyncWebSocket not
|
|
2191
|
-
// connected", "commit timed out after ...") and string-matching them
|
|
2192
|
-
// is brittle. Class identity is the right signal.
|
|
2193
|
-
if (error instanceof AbloConnectionError) {
|
|
2194
|
-
return false;
|
|
2195
|
-
}
|
|
2196
|
-
// Registry-driven retryability is authoritative when the error carries a
|
|
2197
|
-
// known wire code: the error contract (errorCodes.ts) decides whether the
|
|
2198
|
-
// same request can succeed on retry, not message string-matching. This is
|
|
2199
|
-
// why rejected commits must arrive as typed AbloErrors (see
|
|
2200
|
-
// `errorFromWire`) — a bare `Error` has no code and falls through to the
|
|
2201
|
-
// heuristics below. Unknown / forward-compat codes (`errorCodeSpec`
|
|
2202
|
-
// returns undefined) also fall through, preserving the safe default.
|
|
2203
|
-
if (error instanceof AbloError && error.code) {
|
|
2204
|
-
const spec = errorCodeSpec(error.code);
|
|
2205
|
-
if (spec)
|
|
2206
|
-
return !spec.retryable;
|
|
2207
|
-
}
|
|
2208
|
-
const message = error?.message?.toLowerCase() || '';
|
|
2209
|
-
// Network/connection errors are transient - retry these
|
|
2210
|
-
const isNetworkError = message.includes('failed to fetch') ||
|
|
2211
|
-
message.includes('network error') ||
|
|
2212
|
-
message.includes('networkerror') ||
|
|
2213
|
-
message.includes('connection refused') ||
|
|
2214
|
-
message.includes('connection reset') ||
|
|
2215
|
-
message.includes('timeout') ||
|
|
2216
|
-
message.includes('econnrefused') ||
|
|
2217
|
-
message.includes('econnreset') ||
|
|
2218
|
-
message.includes('etimedout') ||
|
|
2219
|
-
message.includes('socket hang up');
|
|
2220
|
-
if (isNetworkError) {
|
|
2221
|
-
return false; // Transient - retry
|
|
2222
|
-
}
|
|
2223
|
-
// Check HTTP status codes
|
|
2224
|
-
const status = extractStatusCode(error);
|
|
2225
|
-
// 5xx server errors and 429 rate limiting are transient - retry
|
|
2226
|
-
if (status !== undefined) {
|
|
2227
|
-
if (status >= 500 || status === 429) {
|
|
2228
|
-
return false; // Transient - retry
|
|
2229
|
-
}
|
|
2230
|
-
// Any other status code (4xx except 429) is permanent
|
|
2231
|
-
return true;
|
|
2232
|
-
}
|
|
2233
|
-
// GraphQL errors with HTTP 200 but error payload are permanent
|
|
2234
|
-
// These are validation/business logic errors that won't change on retry
|
|
2235
|
-
const responseErrors = asTransportError(error).response?.errors;
|
|
2236
|
-
if (Array.isArray(responseErrors) && responseErrors.length > 0) {
|
|
2237
|
-
return true; // Permanent - don't retry
|
|
2238
|
-
}
|
|
2239
|
-
// Default: treat unknown errors as permanent to prevent infinite loops
|
|
2240
|
-
// This is the safe default - better to fail fast than retry forever
|
|
2241
|
-
return true;
|
|
2242
|
-
}
|
|
2243
|
-
/** True only when the server definitively rejected before applying. */
|
|
2244
|
-
isDefinitiveRejection(error) {
|
|
2245
|
-
if (error instanceof AbloError && error.code) {
|
|
2246
|
-
const spec = errorCodeSpec(error.code);
|
|
2247
|
-
if (spec)
|
|
2248
|
-
return !spec.retryable;
|
|
2249
|
-
}
|
|
2250
|
-
const status = extractStatusCode(error);
|
|
2251
|
-
return status !== undefined && status >= 400 && status < 500 && status !== 429;
|
|
2252
|
-
}
|
|
2253
|
-
/**
|
|
2254
|
-
* Handles a failed transaction: retries transient failures with backoff and
|
|
2255
|
-
* rolls back permanent ones, settling the transaction's confirmation promise
|
|
2256
|
-
* either way.
|
|
2257
|
-
*/
|
|
2258
|
-
async handleFailure(transaction, error) {
|
|
2259
|
-
transaction.attempts++;
|
|
2260
|
-
// Check whether this is a permanent error that should not be retried.
|
|
2261
|
-
if (this.isPermanentError(error)) {
|
|
2262
|
-
// Logged at warn: a permanent error means the server rejected the write,
|
|
2263
|
-
// so the developer should see the reason in the console. The typed
|
|
2264
|
-
// AbloError fields (`type`, `code`, `httpStatus`) are included so the
|
|
2265
|
-
// cause is visible — for example a foreign-key violation
|
|
2266
|
-
// (AbloValidationError) versus expired authentication
|
|
2267
|
-
// (AbloAuthenticationError).
|
|
2268
|
-
try {
|
|
2269
|
-
const abloErr = error instanceof AbloError ? error : undefined;
|
|
2270
|
-
const details = {
|
|
2271
|
-
txId: transaction.id.slice(0, 8),
|
|
2272
|
-
type: transaction.type,
|
|
2273
|
-
model: transaction.modelName,
|
|
2274
|
-
modelId: transaction.modelId.slice(0, 12),
|
|
2275
|
-
errorType: abloErr?.type ?? error?.name,
|
|
2276
|
-
errorCode: abloErr?.code,
|
|
2277
|
-
httpStatus: abloErr?.httpStatus,
|
|
2278
|
-
requestId: abloErr?.requestId,
|
|
2279
|
-
message: error?.message,
|
|
2280
|
-
inputKeys: transaction.data ? Object.keys(transaction.data) : undefined,
|
|
2281
|
-
};
|
|
2282
|
-
// A `create` whose id already exists is the benign idempotency case:
|
|
2283
|
-
// "this row is already there." It's the least alarming permanent
|
|
2284
|
-
// error, so it doesn't warrant a `warn` — `info` keeps it visible
|
|
2285
|
-
// without crying wolf. Everything else (FK violation, auth expiry,
|
|
2286
|
-
// server 500) stays at `warn`.
|
|
2287
|
-
const isBenignIdempotent = transaction.type === 'create' &&
|
|
2288
|
-
(abloErr?.code === 'unique_violation' ||
|
|
2289
|
-
abloErr?.type === 'AbloIdempotencyError');
|
|
2290
|
-
// Demote exact repeats (same write rejected for the same reason on
|
|
2291
|
-
// each reconnect replay) to `debug` so the loop logs once.
|
|
2292
|
-
const sig = `${details.type}:${details.model}:${details.modelId}:${details.errorCode ?? details.errorType}`;
|
|
2293
|
-
const isRepeat = sig === this.lastPermanentErrorSig;
|
|
2294
|
-
this.lastPermanentErrorSig = sig;
|
|
2295
|
-
const logger = this.runtime.logger;
|
|
2296
|
-
// Two registers from one call site, split by log level (the default
|
|
2297
|
-
// logger is gated at `warn`, so `debug` stays hidden unless
|
|
2298
|
-
// ABLO_LOG_LEVEL=debug is set to inspect the engine):
|
|
2299
|
-
// - the default-visible line speaks the application developer's
|
|
2300
|
-
// language: their verb (such as `update`), their model, the typed
|
|
2301
|
-
// error's own message, and the wire `code` for searching. It uses
|
|
2302
|
-
// no engine jargon and prints no JSON dump, which would alarm
|
|
2303
|
-
// without helping.
|
|
2304
|
-
// - the forensic `details` ride a companion `debug` line for anyone
|
|
2305
|
-
// debugging the engine internals.
|
|
2306
|
-
const revertNote = this.config.enableOptimistic
|
|
2307
|
-
? ' The local change was reverted.'
|
|
2308
|
-
: '';
|
|
2309
|
-
const reason = abloErr?.message ? ` — ${abloErr.message}` : '';
|
|
2310
|
-
const code = abloErr?.code ? ` (code: ${abloErr.code})` : '';
|
|
2311
|
-
// An optimistic write resolves before the server answers, so a later
|
|
2312
|
-
// rejection has no caller left to return to and this log is the only
|
|
2313
|
-
// place it appears. That reads to an application developer as their own
|
|
2314
|
-
// save silently failing — the write showed, then vanished — and sends
|
|
2315
|
-
// them into their editor instead of here. Name the subscription that
|
|
2316
|
-
// hands them the same typed error, so the application can say what
|
|
2317
|
-
// happened rather than only the console.
|
|
2318
|
-
const channelNote = this.config.enableOptimistic
|
|
2319
|
-
? ' To surface this in your app, subscribe with `ablo.onMutationFailure(…)`.'
|
|
2320
|
-
: '';
|
|
2321
|
-
const headline = `Your ${transaction.type} to "${transaction.modelName}" was not saved${reason}${code}.${revertNote}${channelNote}`;
|
|
2322
|
-
if (isRepeat) {
|
|
2323
|
-
// Same write rejected for the same reason on each reconnect replay —
|
|
2324
|
-
// log the forensics once, stay quiet after.
|
|
2325
|
-
logger.debug('write rejected again (same reason)', details);
|
|
2326
|
-
}
|
|
2327
|
-
else if (isBenignIdempotent) {
|
|
2328
|
-
// Already-exists on a `create` is expected on replay, not a problem.
|
|
2329
|
-
logger.info(`Your ${transaction.type} to "${transaction.modelName}" was skipped — this row already exists.`);
|
|
2330
|
-
logger.debug('idempotent skip — details', details);
|
|
2331
|
-
}
|
|
2332
|
-
else {
|
|
2333
|
-
logger.warn(headline);
|
|
2334
|
-
logger.debug('write rejection — details', details);
|
|
2335
|
-
}
|
|
2336
|
-
}
|
|
2337
|
-
catch { }
|
|
2338
|
-
// Mark as failed immediately and rollback
|
|
2339
|
-
this.store.updateStatus(transaction.id, 'failed');
|
|
2340
|
-
if (this.config.enableOptimistic) {
|
|
2341
|
-
await this.rollbackOptimistic(transaction, 'permanent_error', error);
|
|
2342
|
-
}
|
|
2343
|
-
this.emit('transaction:failed', { transaction, error, permanent: true });
|
|
2344
|
-
// The id-suffixed event is what `waitForConfirmation` (the
|
|
2345
|
-
// `wait:'confirmed'` path) listens on — without it a permanently
|
|
2346
|
-
// rejected write left the caller's promise hanging forever.
|
|
2347
|
-
this.emit(`transaction:failed:${transaction.id}`, { error });
|
|
2348
|
-
return;
|
|
2349
|
-
}
|
|
2350
|
-
if (transaction.attempts < this.config.maxRetries) {
|
|
2351
|
-
// Exponential backoff with full jitter on every transient retry:
|
|
2352
|
-
// `sleep = random(0, min(cap, base * 2^attempt))`. Throttling responses
|
|
2353
|
-
// (429/503) use a longer base than other transient errors. The re-enqueue
|
|
2354
|
-
// is scheduled rather than awaited, so one backing-off transaction cannot
|
|
2355
|
-
// stall unrelated commits.
|
|
2356
|
-
const { baseMs, capMs } = this.config.retryBackoff;
|
|
2357
|
-
let base = baseMs;
|
|
2358
|
-
try {
|
|
2359
|
-
const status = extractStatusCode(error);
|
|
2360
|
-
if (status === 429 || status === 503)
|
|
2361
|
-
base = Math.max(baseMs, 1_000);
|
|
2362
|
-
}
|
|
2363
|
-
catch { }
|
|
2364
|
-
const ceiling = Math.min(capMs, base * Math.pow(2, transaction.attempts - 1));
|
|
2365
|
-
const delay = Math.floor(Math.random() * ceiling);
|
|
2366
|
-
this.store.updateStatus(transaction.id, 'pending');
|
|
2367
|
-
setTimeout(() => {
|
|
2368
|
-
// The queue may have shut down or the tx may have been settled
|
|
2369
|
-
// (e.g. delta-confirmed) while we backed off.
|
|
2370
|
-
if (this.store.get(transaction.id)?.status !== 'pending')
|
|
2371
|
-
return;
|
|
2372
|
-
this.enqueue(transaction);
|
|
2373
|
-
}, delay);
|
|
2374
|
-
}
|
|
2375
|
-
else {
|
|
2376
|
-
// Mark as failed and rollback
|
|
2377
|
-
this.store.updateStatus(transaction.id, 'failed');
|
|
2378
|
-
if (this.config.enableOptimistic) {
|
|
2379
|
-
await this.rollbackOptimistic(transaction, 'max_retries_exhausted', error);
|
|
2380
|
-
}
|
|
2381
|
-
this.emit('transaction:failed', { transaction, error });
|
|
2382
|
-
// Settle `waitForConfirmation` waiters (see the permanent branch above).
|
|
2383
|
-
this.emit(`transaction:failed:${transaction.id}`, { error });
|
|
2384
|
-
}
|
|
2385
|
-
}
|
|
2386
|
-
/**
|
|
2387
|
-
* Resolves a conflict against server data using the configured strategy:
|
|
2388
|
-
* last-write-wins rolls the local change back, merge and reject re-enqueue it,
|
|
2389
|
-
* and custom applies the caller's resolver.
|
|
2390
|
-
*/
|
|
2391
|
-
async handleConflict(transaction, serverData) {
|
|
2392
|
-
const { strategy, resolver } = this.config.conflictResolution;
|
|
2393
|
-
switch (strategy) {
|
|
2394
|
-
case 'last-write-wins':
|
|
2395
|
-
// Server wins, cancel transaction
|
|
2396
|
-
this.store.updateStatus(transaction.id, 'rolled_back');
|
|
2397
|
-
await this.rollbackOptimistic(transaction, 'conflict_server_wins');
|
|
2398
|
-
break;
|
|
2399
|
-
case 'merge':
|
|
2400
|
-
// Merge changes
|
|
2401
|
-
const merged = this.mergeData(transaction.data, serverData);
|
|
2402
|
-
transaction.data = merged;
|
|
2403
|
-
this.enqueue(transaction);
|
|
2404
|
-
break;
|
|
2405
|
-
case 'reject':
|
|
2406
|
-
// Client wins, re-execute
|
|
2407
|
-
this.enqueue(transaction);
|
|
2408
|
-
break;
|
|
2409
|
-
case 'custom':
|
|
2410
|
-
if (resolver) {
|
|
2411
|
-
const resolved = resolver(transaction.data, serverData);
|
|
2412
|
-
transaction.data = resolved;
|
|
2413
|
-
this.enqueue(transaction);
|
|
2414
|
-
}
|
|
2415
|
-
break;
|
|
2416
|
-
}
|
|
2417
|
-
}
|
|
2418
|
-
/**
|
|
2419
|
-
* Optimistic updates. The apply and rollback rules live in `./optimisticApply.js`;
|
|
2420
|
-
* these methods bind them to the queue's own tracking map and event emitter.
|
|
2421
|
-
*/
|
|
2422
|
-
applyOptimisticCreate(model, transaction) {
|
|
2423
|
-
applyOptimisticCreate(this.optimisticUpdates, this, model, transaction);
|
|
2424
|
-
}
|
|
2425
|
-
applyOptimisticUpdate(model, transaction) {
|
|
2426
|
-
applyOptimisticUpdate(this.optimisticUpdates, this, model, transaction);
|
|
2427
|
-
}
|
|
2428
|
-
applyOptimisticDelete(model, transaction) {
|
|
2429
|
-
applyOptimisticDelete(this.optimisticUpdates, this, model, transaction);
|
|
2430
|
-
}
|
|
2431
|
-
async rollbackOptimistic(transaction, reason, error) {
|
|
2432
|
-
await rollbackOptimistic(this.optimisticUpdates, this, transaction, reason, error);
|
|
2433
|
-
}
|
|
2434
|
-
/**
|
|
2435
|
-
* Loads transactions persisted from a previous session and re-enqueues them,
|
|
2436
|
-
* so writes made while offline survive a restart. Does nothing when
|
|
2437
|
-
* persistence is disabled.
|
|
2438
|
-
*/
|
|
2439
|
-
async loadPersistedTransactions(database) {
|
|
2440
|
-
if (!this.config.enablePersistence)
|
|
2441
|
-
return;
|
|
2442
|
-
try {
|
|
2443
|
-
const persisted = await database.getPersistedTransactions();
|
|
2444
|
-
for (const data of persisted) {
|
|
2445
|
-
const transaction = this.deserializeTransaction(data);
|
|
2446
|
-
if (!transaction)
|
|
2447
|
-
continue;
|
|
2448
|
-
this.store.add(transaction);
|
|
2449
|
-
this.enqueue(transaction);
|
|
2450
|
-
}
|
|
2451
|
-
}
|
|
2452
|
-
catch (error) {
|
|
2453
|
-
this.runtime.observability.captureMutationFailure({
|
|
2454
|
-
context: 'load-persisted-transactions',
|
|
2455
|
-
error: error instanceof Error ? error : String(error),
|
|
2456
|
-
});
|
|
2457
|
-
}
|
|
2458
|
-
}
|
|
2459
|
-
/**
|
|
2460
|
-
* Restore exact sealed requests after the local database is open. Sealed
|
|
2461
|
-
* envelopes replay through the atomic commit lane and are never re-projected
|
|
2462
|
-
* through model/schema state from the new process.
|
|
2463
|
-
*/
|
|
2464
|
-
async restoreDurableCommits() {
|
|
2465
|
-
if (!this.config.enablePersistence)
|
|
2466
|
-
return new Set();
|
|
2467
|
-
const sourceMutationIds = new Set();
|
|
2468
|
-
try {
|
|
2469
|
-
if (!this.commitOutbox)
|
|
2470
|
-
return sourceMutationIds;
|
|
2471
|
-
const rows = await this.commitOutbox.list();
|
|
2472
|
-
const envelopes = [];
|
|
2473
|
-
for (const row of rows) {
|
|
2474
|
-
if (typeof row !== 'object' ||
|
|
2475
|
-
row === null ||
|
|
2476
|
-
row.type !== 'commit_envelope')
|
|
2477
|
-
continue;
|
|
2478
|
-
const parsed = durableCommitEnvelopeSchema.safeParse(row);
|
|
2479
|
-
if (parsed.success) {
|
|
2480
|
-
envelopes.push(parsed.data);
|
|
2481
|
-
}
|
|
2482
|
-
else {
|
|
2483
|
-
this.runtime.logger.warn('A saved local write is unreadable and was held for review.');
|
|
2484
|
-
this.runtime.observability.captureMutationFailure({
|
|
2485
|
-
context: 'restore-commit-envelope',
|
|
2486
|
-
error: parsed.error,
|
|
2487
|
-
});
|
|
2488
|
-
throw new AbloValidationError('A saved commit envelope is unreadable; replay stopped before newer writes were sent.', { code: 'write_options_invalid', cause: parsed.error });
|
|
2489
|
-
}
|
|
2490
|
-
}
|
|
2491
|
-
envelopes.sort((a, b) => (a.sequence ?? a.sealedAt * 1_000) -
|
|
2492
|
-
(b.sequence ?? b.sealedAt * 1_000) ||
|
|
2493
|
-
a.id.localeCompare(b.id));
|
|
2494
|
-
for (const envelope of envelopes) {
|
|
2495
|
-
for (const mutationId of envelope.sourceMutationIds) {
|
|
2496
|
-
sourceMutationIds.add(mutationId);
|
|
2497
|
-
}
|
|
2498
|
-
if (envelope.acceptedAt === undefined &&
|
|
2499
|
-
Date.now() - envelope.sealedAt >=
|
|
2500
|
-
MutationQueue.DURABLE_REPLAY_WINDOW_MS) {
|
|
2501
|
-
this.runtime.logger.warn('A saved local write is too old to retry safely and was held for review.');
|
|
2502
|
-
this.runtime.observability.captureMutationFailure({
|
|
2503
|
-
context: 'quarantine-expired-commit-envelope',
|
|
2504
|
-
error: `Envelope ${envelope.idempotencyKey} is too old to replay safely`,
|
|
2505
|
-
});
|
|
2506
|
-
throw new AbloIdempotencyError('A saved commit is older than the server idempotency window and cannot be replayed safely.', { code: 'idempotency_conflict' });
|
|
2507
|
-
}
|
|
2508
|
-
if (this.commitOutboxScope &&
|
|
2509
|
-
(!envelope.scope || // eslint-disable-line @typescript-eslint/prefer-optional-chain -- missing scope must quarantine
|
|
2510
|
-
envelope.scope.organizationId !== this.commitOutboxScope.organizationId ||
|
|
2511
|
-
envelope.scope.participantId !== this.commitOutboxScope.participantId ||
|
|
2512
|
-
envelope.scope.namespace !== this.commitOutboxScope.namespace)) {
|
|
2513
|
-
this.runtime.logger.warn('A saved local write belongs to a different account or server and was held for review.');
|
|
2514
|
-
continue;
|
|
2515
|
-
}
|
|
2516
|
-
if (this.commitStore.has(envelope.idempotencyKey))
|
|
2517
|
-
continue;
|
|
2518
|
-
const transaction = {
|
|
2519
|
-
id: envelope.idempotencyKey,
|
|
2520
|
-
kind: 'commit',
|
|
2521
|
-
operations: envelope.operations.map((operation) => ({ ...operation })),
|
|
2522
|
-
...(envelope.commitOptions.reads
|
|
2523
|
-
? { reads: [...envelope.commitOptions.reads] }
|
|
2524
|
-
: {}),
|
|
2525
|
-
...(envelope.commitOptions.track
|
|
2526
|
-
? { track: [...envelope.commitOptions.track] }
|
|
2527
|
-
: {}),
|
|
2528
|
-
status: 'pending',
|
|
2529
|
-
createdAt: envelope.createdAt,
|
|
2530
|
-
sealedAt: envelope.sealedAt,
|
|
2531
|
-
sequence: envelope.sequence ?? envelope.sealedAt * 1_000,
|
|
2532
|
-
attempts: 0,
|
|
2533
|
-
...(envelope.correlationId
|
|
2534
|
-
? { correlationId: envelope.correlationId }
|
|
2535
|
-
: {}),
|
|
2536
|
-
sourceMutationIds: [...envelope.sourceMutationIds],
|
|
2537
|
-
durableEnvelope: envelope,
|
|
2538
|
-
};
|
|
2539
|
-
this.commitStore.set(transaction.id, transaction);
|
|
2540
|
-
this.commitLane.push(transaction);
|
|
2541
|
-
}
|
|
2542
|
-
if (this.commitLane.length > 0)
|
|
2543
|
-
void this.processCommitLane();
|
|
2544
|
-
}
|
|
2545
|
-
catch (error) {
|
|
2546
|
-
this.runtime.logger.debug('[MutationQueue] Failed to restore durable writes', {
|
|
2547
|
-
error: error instanceof Error ? error.message : String(error),
|
|
2548
|
-
});
|
|
2549
|
-
this.runtime.observability.captureMutationFailure({
|
|
2550
|
-
context: 'restore-commit-envelopes',
|
|
2551
|
-
error: error instanceof Error ? error : String(error),
|
|
2552
|
-
});
|
|
2553
|
-
throw error;
|
|
2554
|
-
}
|
|
2555
|
-
return sourceMutationIds;
|
|
2556
|
-
}
|
|
2557
|
-
/**
|
|
2558
|
-
* Validates and rehydrates one persisted row. Rows written to the same store
|
|
2559
|
-
* by other subsystems are skipped, and rows that fail the persisted
|
|
2560
|
-
* transaction schema — from an older version or corruption — are dropped and
|
|
2561
|
-
* reported rather than replayed as commits.
|
|
2562
|
-
*/
|
|
2563
|
-
deserializeTransaction(data) {
|
|
2564
|
-
if (isNonReplayablePersistedRow(data))
|
|
2565
|
-
return null;
|
|
2566
|
-
const transaction = deserializePersistedTransaction(data, this.runtime);
|
|
2567
|
-
if (!transaction) {
|
|
2568
|
-
const rowId = typeof data === 'object' && data !== null && typeof data.id === 'string'
|
|
2569
|
-
? data.id
|
|
2570
|
-
: undefined;
|
|
2571
|
-
this.runtime.logger.debug('[MutationQueue] Dropping malformed persisted transaction', {
|
|
2572
|
-
rowId,
|
|
2573
|
-
});
|
|
2574
|
-
this.runtime.observability.captureMutationFailure({
|
|
2575
|
-
context: 'deserialize-persisted-transaction',
|
|
2576
|
-
error: `Persisted transaction failed schema validation${rowId ? ` (id: ${rowId})` : ''}`,
|
|
2577
|
-
});
|
|
2578
|
-
return null;
|
|
2579
|
-
}
|
|
2580
|
-
return transaction;
|
|
2581
|
-
}
|
|
2582
|
-
/**
|
|
2583
|
-
* Cancels every pending or executing transaction for a given model id,
|
|
2584
|
-
* optionally limited to one operation type, rolling back their optimistic
|
|
2585
|
-
* state. Returns the cancelled transactions.
|
|
2586
|
-
*/
|
|
2587
|
-
cancelTransactionsForModel(modelId, transactionType) {
|
|
2588
|
-
const cancelledTransactions = [];
|
|
2589
|
-
const allTransactions = [
|
|
2590
|
-
...this.store.getByStatus('pending'),
|
|
2591
|
-
...this.store.getByStatus('executing'),
|
|
2592
|
-
];
|
|
2593
|
-
for (const transaction of allTransactions) {
|
|
2594
|
-
if (transaction.modelId === modelId) {
|
|
2595
|
-
if (!transactionType || transaction.type === transactionType) {
|
|
2596
|
-
cancelledTransactions.push(transaction);
|
|
2597
|
-
this.store.updateStatus(transaction.id, 'rolled_back');
|
|
2598
|
-
// Sync caller: a rejected rollback (throwing optimistic:rollback
|
|
2599
|
-
// listener) must surface, not vanish — the status flip above is
|
|
2600
|
-
// already committed either way.
|
|
2601
|
-
void this.rollbackOptimistic(transaction, 'model_cancelled').catch((error) => {
|
|
2602
|
-
this.runtime.observability.captureMutationFailure({
|
|
2603
|
-
context: 'rollback-model-cancelled',
|
|
2604
|
-
error: error instanceof Error ? error : String(error),
|
|
2605
|
-
});
|
|
2606
|
-
});
|
|
2607
|
-
}
|
|
2608
|
-
}
|
|
2609
|
-
}
|
|
2610
|
-
return cancelledTransactions;
|
|
2611
|
-
}
|
|
2612
|
-
/**
|
|
2613
|
-
* Cancels pending transactions for child rows that reference a deleted parent,
|
|
2614
|
-
* used to cascade a parent deletion. The caller supplies the foreign-key
|
|
2615
|
-
* relationship; this method performs the cancellation.
|
|
2616
|
-
*
|
|
2617
|
-
* @param childModelName - The child model type (for example 'Block').
|
|
2618
|
-
* @param foreignKey - The foreign-key property name (for example 'sectionId').
|
|
2619
|
-
* @param parentId - The deleted parent's id.
|
|
2620
|
-
* @returns The number of transactions cancelled.
|
|
2621
|
-
*/
|
|
2622
|
-
cancelTransactionsByForeignKey(childModelName, foreignKey, parentId) {
|
|
2623
|
-
let cancelled = 0;
|
|
2624
|
-
const allTransactions = [
|
|
2625
|
-
...this.store.getByStatus('pending'),
|
|
2626
|
-
...this.store.getByStatus('executing'),
|
|
2627
|
-
...this.store.getByStatus('awaiting_delta'),
|
|
2628
|
-
];
|
|
2629
|
-
for (const transaction of allTransactions) {
|
|
2630
|
-
if (transaction.modelName === childModelName) {
|
|
2631
|
-
// Check if this transaction's data contains the parent FK
|
|
2632
|
-
const fkValue = transaction.data?.[foreignKey];
|
|
2633
|
-
if (fkValue === parentId) {
|
|
2634
|
-
this.store.updateStatus(transaction.id, 'rolled_back');
|
|
2635
|
-
void this.rollbackOptimistic(transaction, 'cascade_parent_deleted').catch((error) => {
|
|
2636
|
-
this.runtime.observability.captureMutationFailure({
|
|
2637
|
-
context: 'rollback-cascade-parent-deleted',
|
|
2638
|
-
error: error instanceof Error ? error : String(error),
|
|
2639
|
-
});
|
|
2640
|
-
});
|
|
2641
|
-
cancelled++;
|
|
2642
|
-
this.runtime.logger.debug('[MutationQueue] Cascade cancelled orphaned transaction', {
|
|
2643
|
-
txId: transaction.id.slice(0, 12),
|
|
2644
|
-
model: childModelName,
|
|
2645
|
-
foreignKey,
|
|
2646
|
-
parentId: parentId.slice(0, 12),
|
|
2647
|
-
});
|
|
2648
|
-
}
|
|
2649
|
-
}
|
|
2650
|
-
}
|
|
2651
|
-
return cancelled;
|
|
2652
|
-
}
|
|
2653
|
-
/**
|
|
2654
|
-
* Returns the number of transactions still pending or executing.
|
|
2655
|
-
*/
|
|
2656
|
-
getOutstandingTransactionCount() {
|
|
2657
|
-
return this.store.getByStatus('pending').length + this.store.getByStatus('executing').length;
|
|
2658
|
-
}
|
|
2659
|
-
/** Generates a unique local transaction id. */
|
|
2660
|
-
generateId() {
|
|
2661
|
-
return `tx_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
|
|
2662
|
-
}
|
|
2663
|
-
mergeData(local, remote) {
|
|
2664
|
-
return { ...(remote || {}), ...(local || {}) };
|
|
2665
|
-
}
|
|
2666
|
-
extractCreateData(model) {
|
|
2667
|
-
return projectCommitPayload(model.getModelName(), model.toJSON(), { dropUndefined: false }, this.runtime);
|
|
2668
|
-
}
|
|
2669
|
-
mapChangesToInput(modelName, changes) {
|
|
2670
|
-
return projectCommitPayload(modelName, changes, { dropUndefined: true }, this.runtime);
|
|
2671
|
-
}
|
|
2672
|
-
extractUpdateData(model) {
|
|
2673
|
-
return projectCommitPayload(model.getModelName(), model.getChanges(), { dropUndefined: true }, this.runtime);
|
|
2674
|
-
}
|
|
2675
|
-
// Derive previous values for changed fields to support accurate rollback.
|
|
2676
|
-
// Model-specific special cases do not belong here; a model that needs to
|
|
2677
|
-
// surface previous state beyond `modifiedProperties` should expose a typed
|
|
2678
|
-
// `getPreviousData()` accessor for this method to call.
|
|
2679
|
-
extractPreviousData(model, updateInput) {
|
|
2680
|
-
// When the update's written keys are known, capture a before-image for
|
|
2681
|
-
// exactly those keys, so the recorded undo inverse reverts them and nothing
|
|
2682
|
-
// else — a full-row inverse would clobber concurrent edits to unrelated
|
|
2683
|
-
// fields. `fallbackToLive: false` makes `Model.capturePreviousValues` omit
|
|
2684
|
-
// any key it cannot resolve, and `buildUndoOps` then drops an un-revertible
|
|
2685
|
-
// inverse rather than inventing one. With no `updateInput` (a full extract)
|
|
2686
|
-
// it falls back to every tracked field. `Model.capturePreviousValues` is the
|
|
2687
|
-
// single before-image source, shared with
|
|
2688
|
-
// `RecordingMutation.snapshotFields`.
|
|
2689
|
-
const keys = updateInput
|
|
2690
|
-
? Object.keys(updateInput)
|
|
2691
|
-
: [...(model.modifiedProperties instanceof Map ? model.modifiedProperties.keys() : [])];
|
|
2692
|
-
return { id: model.id, ...model.capturePreviousValues(keys, { fallbackToLive: false }) };
|
|
2693
|
-
}
|
|
2694
|
-
/** Returns a snapshot of queue counts and the current configuration. */
|
|
2695
|
-
getStats() {
|
|
2696
|
-
return {
|
|
2697
|
-
pending: this.store.getByStatus('pending').length,
|
|
2698
|
-
executing: this.store.getByStatus('executing').length,
|
|
2699
|
-
completed: this.store.getByStatus('completed').length,
|
|
2700
|
-
failed: this.store.getByStatus('failed').length,
|
|
2701
|
-
optimistic: this.optimisticUpdates.size,
|
|
2702
|
-
totalTransactions: this.store.getAll().length,
|
|
2703
|
-
batchIndex: this.batchIndex,
|
|
2704
|
-
config: { ...this.config },
|
|
2705
|
-
};
|
|
2706
|
-
}
|
|
2707
|
-
/**
|
|
2708
|
-
* Returns detailed internal state — pending, executing, and awaiting-delta
|
|
2709
|
-
* transactions — to help diagnose delta-confirmation issues.
|
|
2710
|
-
*/
|
|
2711
|
-
getDebugInfo() {
|
|
2712
|
-
const awaitingDelta = this.store.getByStatus('awaiting_delta');
|
|
2713
|
-
return {
|
|
2714
|
-
lastSeenSyncId: this.lastSeenSyncId,
|
|
2715
|
-
awaitingDeltaCount: awaitingDelta.length,
|
|
2716
|
-
awaitingDeltaTransactions: awaitingDelta.map((tx) => ({
|
|
2717
|
-
id: tx.id.slice(0, 8),
|
|
2718
|
-
type: tx.type,
|
|
2719
|
-
modelName: tx.modelName,
|
|
2720
|
-
modelId: tx.modelId.slice(0, 8),
|
|
2721
|
-
syncIdNeeded: tx.syncIdNeededForCompletion,
|
|
2722
|
-
createdAt: tx.createdAt,
|
|
2723
|
-
age: Date.now() - tx.createdAt,
|
|
2724
|
-
})),
|
|
2725
|
-
pendingTransactions: this.store.getByStatus('pending').map((tx) => ({
|
|
2726
|
-
id: tx.id.slice(0, 8),
|
|
2727
|
-
type: tx.type,
|
|
2728
|
-
modelName: tx.modelName,
|
|
2729
|
-
modelId: tx.modelId.slice(0, 8),
|
|
2730
|
-
})),
|
|
2731
|
-
executingTransactions: this.store.getByStatus('executing').map((tx) => ({
|
|
2732
|
-
id: tx.id.slice(0, 8),
|
|
2733
|
-
type: tx.type,
|
|
2734
|
-
modelName: tx.modelName,
|
|
2735
|
-
modelId: tx.modelId.slice(0, 8),
|
|
2736
|
-
})),
|
|
2737
|
-
};
|
|
2738
|
-
}
|
|
2739
|
-
/** Merges the given options into the queue's configuration. */
|
|
2740
|
-
setConfig(config) {
|
|
2741
|
-
this.config = { ...this.config, ...config };
|
|
2742
|
-
}
|
|
2743
|
-
/**
|
|
2744
|
-
* Re-emits an incoming sync delta on the `sync:delta` event for the store to
|
|
2745
|
-
* apply. Because rows use stable ids, no id reconciliation is needed here.
|
|
2746
|
-
*/
|
|
2747
|
-
handleSyncDelta(delta) {
|
|
2748
|
-
// Row ids are stable, so no reconciliation is needed; re-emit the delta for
|
|
2749
|
-
// the store to apply directly.
|
|
2750
|
-
this.emit('sync:delta', {
|
|
2751
|
-
id: delta.id,
|
|
2752
|
-
modelName: delta.modelName,
|
|
2753
|
-
action: delta.action,
|
|
2754
|
-
data: delta.data,
|
|
2755
|
-
});
|
|
2756
|
-
return true;
|
|
2757
|
-
}
|
|
2758
|
-
/**
|
|
2759
|
-
* Releases the queue's resources: rolls back outstanding optimistic updates,
|
|
2760
|
-
* clears all timers and stored transactions, and removes event listeners.
|
|
2761
|
-
*/
|
|
2762
|
-
dispose() {
|
|
2763
|
-
// Cancel all active optimistic updates
|
|
2764
|
-
for (const [, optimistic] of this.optimisticUpdates) {
|
|
2765
|
-
this.emit('optimistic:rollback', {
|
|
2766
|
-
model: optimistic.model,
|
|
2767
|
-
previousState: optimistic.previousState,
|
|
2768
|
-
transaction: optimistic.transaction,
|
|
2769
|
-
reason: 'dispose',
|
|
2770
|
-
});
|
|
2771
|
-
}
|
|
2772
|
-
// Clear processing
|
|
2773
|
-
if (this.processTimer) {
|
|
2774
|
-
clearTimeout(this.processTimer);
|
|
2775
|
-
}
|
|
2776
|
-
// Clear every armed delta-confirmation timer (one per in-flight tx,
|
|
2777
|
-
// 30–120s each) — a disposed queue must not keep the process alive or
|
|
2778
|
-
// fire confirmation callbacks against the cleared store below.
|
|
2779
|
-
this.deltaConfirmation.dispose();
|
|
2780
|
-
for (const timeout of this.replicationLagTimeouts.values()) {
|
|
2781
|
-
clearTimeout(timeout);
|
|
2782
|
-
}
|
|
2783
|
-
this.replicationLagTimeouts.clear();
|
|
2784
|
-
this.replicationLagErrors.clear();
|
|
2785
|
-
// Clear the offline-grace timer armed by setConnectionState('disconnected').
|
|
2786
|
-
if (this.commitOfflineGraceTimer !== null) {
|
|
2787
|
-
clearTimeout(this.commitOfflineGraceTimer);
|
|
2788
|
-
this.commitOfflineGraceTimer = null;
|
|
2789
|
-
}
|
|
2790
|
-
// Clear store
|
|
2791
|
-
this.store.clear();
|
|
2792
|
-
this.optimisticUpdates.clear();
|
|
2793
|
-
this.executionQueue = [];
|
|
2794
|
-
this.createdTransactions = [];
|
|
2795
|
-
this.deferredDeletesByCreate.clear();
|
|
2796
|
-
this.recentDeltaCorrelations.clear();
|
|
2797
|
-
this.commitLane = [];
|
|
2798
|
-
this.commitStore.clear();
|
|
2799
|
-
this.commitNotifications.clear();
|
|
2800
|
-
this.commitMissingIds.clear();
|
|
2801
|
-
// Clear event listeners
|
|
2802
|
-
this.removeAllListeners();
|
|
2803
|
-
// Reset state
|
|
2804
|
-
this.isProcessing = false;
|
|
2805
|
-
this.batchIndex = 0;
|
|
2806
|
-
}
|
|
2807
|
-
}
|