@abloatai/ablo 0.36.0 → 0.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +2 -2
- package/CHANGELOG.md +71 -2013
- package/NOTICE +2 -2
- package/README.md +25 -71
- 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,1388 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Private HTTP protocol client behind `Ablo({ schema, transport: 'http' })`.
|
|
3
|
-
* It carries no object pool, local database, or WebSocket and maps Model,
|
|
4
|
-
* Claim, and Commit protocol shapes directly to server routes. The typed
|
|
5
|
-
* facade in `httpClient.ts` is the application boundary; this module owns
|
|
6
|
-
* transport envelopes, watermarks, replay, and route details.
|
|
7
|
-
*/
|
|
8
|
-
import { AbloClaimedError, AbloAuthenticationError, AbloConnectionError, AbloIdempotencyError, AbloValidationError, AbloNotFoundError, claimedError, translateHttpError, } from '../errors.js';
|
|
9
|
-
import { v5 as uuidv5 } from 'uuid';
|
|
10
|
-
import { z } from 'zod';
|
|
11
|
-
import { reconcileFunctionalUpdate, } from '../resources/functionalUpdate.js';
|
|
12
|
-
import { assertBrowserSafety, readProcessEnv, resolveApiKey, resolveApiKeyValue, resolveAuthToken, resolveBaseURL, resolveBootstrapBaseUrl, rejectRemovedDatabaseUrlOption, warnIfCliKeyMismatch, } from '../auth/apiKey.js';
|
|
13
|
-
import { PROTOCOL_VERSION, PROTOCOL_VERSION_HEADER } from '../wire/protocolVersion.js';
|
|
14
|
-
import { commitReceiptSchema } from '../wire/commit.js';
|
|
15
|
-
import { claimAcquireResponseSchema, claimHeartbeatBatchReplySchema, claimHeartbeatReplySchema, claimListResponseSchema, claimStateSchema, } from '../wire/claims.js';
|
|
16
|
-
import { modelListResponseSchema, modelReadResponseSchema, } from '../wire/modelResponses.js';
|
|
17
|
-
import { toMs } from '../utils/duration.js';
|
|
18
|
-
import { heartbeatCadenceMs, resolveHeartbeatOptions, resolveHeartbeatPlan, startClaimHeartbeatLoop, } from '../coordination/claimHeartbeatLoop.js';
|
|
19
|
-
import { mintSession } from '../auth/sessionMint.js';
|
|
20
|
-
import { parseIdentityResolveResponse } from '../auth/schemas.js';
|
|
21
|
-
/**
|
|
22
|
-
* Interpret a heartbeat reply for a lease this handle HOLDS: anything other
|
|
23
|
-
* than `held` means the lease is no longer ours (a holder cannot be `queued`;
|
|
24
|
-
* `lost` rides a 409 that the wire error mapping already surfaces as
|
|
25
|
-
* AbloClaimedError before reaching here). The thrown loss is the definitive
|
|
26
|
-
* signal that stops the auto-heartbeat loop.
|
|
27
|
-
*/
|
|
28
|
-
function heldHeartbeatReply(reply, label) {
|
|
29
|
-
if (reply.status === 'held' && typeof reply.expiresAt === 'number') {
|
|
30
|
-
return {
|
|
31
|
-
expiresAt: reply.expiresAt,
|
|
32
|
-
...(reply.queueDepth !== undefined ? { queueDepth: reply.queueDepth } : {}),
|
|
33
|
-
};
|
|
34
|
-
}
|
|
35
|
-
throw new AbloClaimedError(`The lease behind ${label} is no longer held — it expired or was granted onward. Re-acquire the claim and retry; a write attempted under the old lease is rejected by its \`readAt\` guard.`, { code: 'claim_lost' });
|
|
36
|
-
}
|
|
37
|
-
import { claimDescription, partName } from '../coordination/schema.js';
|
|
38
|
-
import { subTarget, streamTarget, batchFence, fenceTokenFor, } from '../coordination/locator.js';
|
|
39
|
-
import { declaredMeta, wireMeta } from '../coordination/claimMeta.js';
|
|
40
|
-
import { assertWriteOptions } from '../resources/writeOptionsSchema.js';
|
|
41
|
-
import { createDurableHttpCommitEnvelope, canonicalHttpCommitBody, durableHttpCommitEnvelopeSchema, httpCommitEnvelopeRecordId, isHttpCommitReplayExpired, } from '../transactions/settlement/httpCommitEnvelope.js';
|
|
42
|
-
import { resolveDurableWrites } from '../durableWrites.js';
|
|
43
|
-
/** @internal Default per-request deadline for the private HTTP transport. */
|
|
44
|
-
export const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
|
|
45
|
-
const HTTP_CONFIRMATION_POLL_INTERVAL_MS = 250;
|
|
46
|
-
/**
|
|
47
|
-
* The server's acquire window, mirrored here as the client-side default for a
|
|
48
|
-
* claim that names no `ttl` — it sets the auto-heartbeat cadence.
|
|
49
|
-
*/
|
|
50
|
-
const DEFAULT_CLAIM_TTL_MS = 60_000;
|
|
51
|
-
function parseSuccessfulCommitResponse(value, idempotencyKey) {
|
|
52
|
-
const parsed = commitReceiptSchema.safeParse(value);
|
|
53
|
-
if (!parsed.success || parsed.data.clientTxId !== idempotencyKey) {
|
|
54
|
-
throw new AbloConnectionError('The commit endpoint returned an invalid success receipt; its outcome remains pending and is safe to retry.', {
|
|
55
|
-
code: 'commit_no_result',
|
|
56
|
-
cause: parsed.success
|
|
57
|
-
? new Error('Commit receipt clientTxId did not match its idempotency key')
|
|
58
|
-
: parsed.error,
|
|
59
|
-
});
|
|
60
|
-
}
|
|
61
|
-
return parsed.data;
|
|
62
|
-
}
|
|
63
|
-
/** Decode the HTTP claim DTO into the one public Claim shape. */
|
|
64
|
-
function claimFromModelClaim(claim) {
|
|
65
|
-
// The handle a caller reads back is a public claim, so its `meta` is the
|
|
66
|
-
// declared shape; the rest of the sub-entity locator crosses whole rather
|
|
67
|
-
// than member by member, which is how `fields` used to die on this hop.
|
|
68
|
-
const { meta, ...details } = subTarget(claim.target);
|
|
69
|
-
return {
|
|
70
|
-
object: 'claim',
|
|
71
|
-
id: claim.id,
|
|
72
|
-
...(claim.status ? { status: claim.status } : {}),
|
|
73
|
-
// The server always stamps a description; default only for total safety.
|
|
74
|
-
description: claim.description ?? 'editing',
|
|
75
|
-
heldBy: claim.actor,
|
|
76
|
-
participantKind: claim.participantKind,
|
|
77
|
-
expiresAt: claim.expiresAt,
|
|
78
|
-
...(claim.position !== undefined ? { position: claim.position } : {}),
|
|
79
|
-
target: {
|
|
80
|
-
...streamTarget(claim.target),
|
|
81
|
-
...details,
|
|
82
|
-
...(meta !== undefined ? { meta: declaredMeta(meta) } : {}),
|
|
83
|
-
},
|
|
84
|
-
};
|
|
85
|
-
}
|
|
86
|
-
/** @internal Constructed only by the typed HTTP facade. */
|
|
87
|
-
export function createHttpTransport(options) {
|
|
88
|
-
const env = readProcessEnv();
|
|
89
|
-
const authInput = { options, env };
|
|
90
|
-
const configuredApiKey = resolveApiKey(authInput);
|
|
91
|
-
const configuredAuthToken = resolveAuthToken(authInput);
|
|
92
|
-
void warnIfCliKeyMismatch(authInput);
|
|
93
|
-
rejectRemovedDatabaseUrlOption(options);
|
|
94
|
-
assertBrowserSafety({
|
|
95
|
-
apiKey: configuredApiKey,
|
|
96
|
-
dangerouslyAllowBrowser: options.dangerouslyAllowBrowser,
|
|
97
|
-
});
|
|
98
|
-
// Observability hook for the stateless HTTP transport. The WebSocket transport
|
|
99
|
-
// emits claim and conflict events; the HTTP path (server-side agents,
|
|
100
|
-
// `transport: 'http'`) emitted nothing, so a `ClaimLog` handed to a headless
|
|
101
|
-
// agent evaluation stayed empty. This mirrors the two WebSocket events here:
|
|
102
|
-
// claim acquired and coordination-conflict rejection. A no-op when no provider
|
|
103
|
-
// is configured.
|
|
104
|
-
const observability = options.observability;
|
|
105
|
-
// Shared by the two HTTP write doors (`commits.create` + per-model
|
|
106
|
-
// `mutateModel`): a rejected write whose code is a coordination conflict is
|
|
107
|
-
// the collision ClaimLog exists to surface. Prefer the server's `conflicts`
|
|
108
|
-
// detail (carried on the typed error / envelope); fall back to the rows the
|
|
109
|
-
// caller tried to write so the collision always names a target. Inert without
|
|
110
|
-
// a provider or for non-conflict errors. Never throws (capture is best-effort).
|
|
111
|
-
const recordCoordinationConflict = (error, clientTxId, fallbackRows) => {
|
|
112
|
-
if (!observability)
|
|
113
|
-
return;
|
|
114
|
-
const errorRecord = typeof error === 'object' && error !== null
|
|
115
|
-
? error
|
|
116
|
-
: undefined;
|
|
117
|
-
const code = errorRecord?.code;
|
|
118
|
-
const isConflict = code === 'stale_context' ||
|
|
119
|
-
code === 'claim_conflict' ||
|
|
120
|
-
code === 'entity_claimed' ||
|
|
121
|
-
(typeof code === 'string' && code.startsWith('policy:'));
|
|
122
|
-
if (!isConflict)
|
|
123
|
-
return;
|
|
124
|
-
const rawConflicts = errorRecord?.conflicts;
|
|
125
|
-
const rows = Array.isArray(rawConflicts) && rawConflicts.length > 0
|
|
126
|
-
? rawConflicts.map((r) => ({
|
|
127
|
-
model: typeof r.model === 'string' ? r.model : 'unknown',
|
|
128
|
-
id: typeof r.id === 'string' ? r.id : 'unknown',
|
|
129
|
-
fields: [],
|
|
130
|
-
}))
|
|
131
|
-
: fallbackRows.map((r) => ({ model: r.model, id: r.id, fields: [] }));
|
|
132
|
-
observability.captureConflict({ clientTxId, rows });
|
|
133
|
-
};
|
|
134
|
-
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
135
|
-
if (typeof fetchImpl !== 'function') {
|
|
136
|
-
throw new AbloConnectionError('Ablo API client requires a fetch implementation. Pass `fetch` in Ablo({ ... }) for this runtime.', { code: 'fetch_unavailable' });
|
|
137
|
-
}
|
|
138
|
-
const url = resolveBaseURL(authInput);
|
|
139
|
-
const apiBaseUrl = resolveBootstrapBaseUrl({
|
|
140
|
-
url,
|
|
141
|
-
bootstrapBaseUrl: options.bootstrapBaseUrl,
|
|
142
|
-
}).replace(/\/+$/, '');
|
|
143
|
-
const durableWrites = resolveDurableWrites(options);
|
|
144
|
-
// Internal replay code retains transactional-outbox terminology. The public
|
|
145
|
-
// constructor exposes the behavior as `durableWrites`.
|
|
146
|
-
const commitOutbox = durableWrites.store;
|
|
147
|
-
const durableWriteNamespace = durableWrites.namespace ?? 'http';
|
|
148
|
-
const legacyCommitOutboxScope = options
|
|
149
|
-
.commitOutboxScope;
|
|
150
|
-
const httpOutboxPlaneNamespace = canonicalHttpCommitBody({
|
|
151
|
-
apiBaseUrl,
|
|
152
|
-
defaultQuery: Object.entries(options.defaultQuery ?? {}).sort(([a], [b]) => a.localeCompare(b)),
|
|
153
|
-
});
|
|
154
|
-
let httpOutboxScopeNamespace = null;
|
|
155
|
-
let readyPromise = null;
|
|
156
|
-
let httpCommitLane = Promise.resolve();
|
|
157
|
-
function runInHttpCommitLane(work) {
|
|
158
|
-
const result = httpCommitLane.then(work);
|
|
159
|
-
httpCommitLane = result.then(() => undefined, () => undefined);
|
|
160
|
-
return result;
|
|
161
|
-
}
|
|
162
|
-
async function resolveHttpOutboxScope() {
|
|
163
|
-
if (!commitOutbox)
|
|
164
|
-
return null;
|
|
165
|
-
if (httpOutboxScopeNamespace)
|
|
166
|
-
return httpOutboxScopeNamespace;
|
|
167
|
-
let scope = legacyCommitOutboxScope
|
|
168
|
-
? {
|
|
169
|
-
...legacyCommitOutboxScope,
|
|
170
|
-
namespace: durableWriteNamespace,
|
|
171
|
-
}
|
|
172
|
-
: undefined;
|
|
173
|
-
if (!scope) {
|
|
174
|
-
const rawIdentity = await requestRaw('/auth/identity', { method: 'GET' }, true);
|
|
175
|
-
const identity = parseIdentityResolveResponse(rawIdentity);
|
|
176
|
-
scope = {
|
|
177
|
-
organizationId: identity.accountScope,
|
|
178
|
-
participantId: identity.participantId,
|
|
179
|
-
namespace: durableWriteNamespace,
|
|
180
|
-
};
|
|
181
|
-
}
|
|
182
|
-
httpOutboxScopeNamespace = canonicalHttpCommitBody({
|
|
183
|
-
...scope,
|
|
184
|
-
plane: httpOutboxPlaneNamespace,
|
|
185
|
-
});
|
|
186
|
-
return httpOutboxScopeNamespace;
|
|
187
|
-
}
|
|
188
|
-
async function ready() {
|
|
189
|
-
if (readyPromise)
|
|
190
|
-
return readyPromise;
|
|
191
|
-
readyPromise = (async () => {
|
|
192
|
-
await resolveHttpOutboxScope();
|
|
193
|
-
await replayHttpCommitOutbox();
|
|
194
|
-
})();
|
|
195
|
-
try {
|
|
196
|
-
await readyPromise;
|
|
197
|
-
}
|
|
198
|
-
catch (error) {
|
|
199
|
-
readyPromise = null;
|
|
200
|
-
throw error;
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
async function authHeaders(sealedProtocolVersion) {
|
|
204
|
-
const apiKey = await resolveApiKeyValue(configuredApiKey);
|
|
205
|
-
const token = apiKey ?? configuredAuthToken;
|
|
206
|
-
if (!token) {
|
|
207
|
-
throw new AbloAuthenticationError('The HTTP client requires an API key. Pass `apiKey` or set ABLO_API_KEY.', { code: 'api_key_required' });
|
|
208
|
-
}
|
|
209
|
-
const headers = {
|
|
210
|
-
'Content-Type': 'application/json',
|
|
211
|
-
Authorization: `Bearer ${token}`,
|
|
212
|
-
// Protocol handshake for the HTTP transport (wire/protocolVersion.ts):
|
|
213
|
-
// the server answers an out-of-range version with a typed 426.
|
|
214
|
-
[PROTOCOL_VERSION_HEADER]: String(PROTOCOL_VERSION),
|
|
215
|
-
};
|
|
216
|
-
for (const [key, value] of Object.entries(options.defaultHeaders ?? {})) {
|
|
217
|
-
if (value == null) {
|
|
218
|
-
delete headers[key];
|
|
219
|
-
}
|
|
220
|
-
else {
|
|
221
|
-
headers[key] = value;
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
// A durable write owns its wire version. Force the sealed value after
|
|
225
|
-
// caller defaults so a restarted (or rolled-back) SDK cannot rewrite the
|
|
226
|
-
// protocol identity of a request that may already have reached the server.
|
|
227
|
-
if (sealedProtocolVersion !== undefined) {
|
|
228
|
-
headers[PROTOCOL_VERSION_HEADER] = String(sealedProtocolVersion);
|
|
229
|
-
}
|
|
230
|
-
return headers;
|
|
231
|
-
}
|
|
232
|
-
function endpoint(path) {
|
|
233
|
-
const target = new URL(`${apiBaseUrl}${path.startsWith('/') ? path : `/${path}`}`);
|
|
234
|
-
for (const [key, value] of Object.entries(options.defaultQuery ?? {})) {
|
|
235
|
-
if (value !== undefined)
|
|
236
|
-
target.searchParams.set(key, value);
|
|
237
|
-
}
|
|
238
|
-
return target.toString();
|
|
239
|
-
}
|
|
240
|
-
const requestTimeoutMs = options.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
241
|
-
/**
|
|
242
|
-
* Issues one request and returns its decoded body without a contract.
|
|
243
|
-
*
|
|
244
|
-
* Use this only where the response has no shape worth checking — a release
|
|
245
|
-
* that answers `{}` — or where the caller runs a richer check of its own, as
|
|
246
|
-
* the commit paths do with their receipt schema. Everywhere else, go through
|
|
247
|
-
* {@link requestJson}, which will not let a response past unvalidated.
|
|
248
|
-
*/
|
|
249
|
-
async function requestRaw(path, init, skipReady = false) {
|
|
250
|
-
if (!skipReady)
|
|
251
|
-
await ready();
|
|
252
|
-
const { idempotencyKey, sealedProtocolVersion, ...requestInit } = init;
|
|
253
|
-
const headers = await authHeaders(sealedProtocolVersion);
|
|
254
|
-
if (idempotencyKey)
|
|
255
|
-
headers['Idempotency-Key'] = idempotencyKey;
|
|
256
|
-
// Deadline: abort the request after `timeoutMs` so a black-holed server
|
|
257
|
-
// can't hang the caller forever (fetch has NO default timeout in browsers,
|
|
258
|
-
// and only undici's generous defaults in Node). A caller-supplied signal
|
|
259
|
-
// is combined with the deadline via a shared controller — the portable
|
|
260
|
-
// equivalent of `AbortSignal.any([caller, AbortSignal.timeout(t)])`,
|
|
261
|
-
// which older runtimes (and the jsdom test env) don't implement. The
|
|
262
|
-
// same pattern already guards `query/client.ts` and `BootstrapFetcher`.
|
|
263
|
-
const callerSignal = requestInit.signal ?? undefined;
|
|
264
|
-
const controller = new AbortController();
|
|
265
|
-
const onCallerAbort = () => {
|
|
266
|
-
controller.abort(callerSignal?.reason);
|
|
267
|
-
};
|
|
268
|
-
if (callerSignal) {
|
|
269
|
-
if (callerSignal.aborted)
|
|
270
|
-
onCallerAbort();
|
|
271
|
-
else
|
|
272
|
-
callerSignal.addEventListener('abort', onCallerAbort, { once: true });
|
|
273
|
-
}
|
|
274
|
-
let timedOut = false;
|
|
275
|
-
const deadline = requestTimeoutMs > 0
|
|
276
|
-
? setTimeout(() => {
|
|
277
|
-
timedOut = true;
|
|
278
|
-
controller.abort();
|
|
279
|
-
}, requestTimeoutMs)
|
|
280
|
-
: null;
|
|
281
|
-
let res;
|
|
282
|
-
let bodyText;
|
|
283
|
-
try {
|
|
284
|
-
res = await fetchImpl(endpoint(path), {
|
|
285
|
-
...requestInit,
|
|
286
|
-
signal: controller.signal,
|
|
287
|
-
headers: {
|
|
288
|
-
...headers,
|
|
289
|
-
...requestInit.headers,
|
|
290
|
-
},
|
|
291
|
-
});
|
|
292
|
-
// Keep the deadline armed while the body streams — a server that sends
|
|
293
|
-
// headers then stalls the body is the same hang with better manners.
|
|
294
|
-
bodyText = await res.text();
|
|
295
|
-
}
|
|
296
|
-
catch (error) {
|
|
297
|
-
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- assigned asynchronously by the deadline callback
|
|
298
|
-
if (timedOut) {
|
|
299
|
-
// Retryable by contract: `wait_for_timeout` is a registered transient
|
|
300
|
-
// transport code, so `isRetryableCode` steers callers to retry.
|
|
301
|
-
throw new AbloConnectionError(`The Ablo API did not respond within ${requestTimeoutMs}ms ` +
|
|
302
|
-
`(${requestInit.method ?? 'GET'} ${path}). The request was aborted; ` +
|
|
303
|
-
'it is safe to retry.', { code: 'wait_for_timeout', cause: error });
|
|
304
|
-
}
|
|
305
|
-
throw error;
|
|
306
|
-
}
|
|
307
|
-
finally {
|
|
308
|
-
if (deadline)
|
|
309
|
-
clearTimeout(deadline);
|
|
310
|
-
callerSignal?.removeEventListener('abort', onCallerAbort);
|
|
311
|
-
}
|
|
312
|
-
const body = parseBody(bodyText);
|
|
313
|
-
if (!res.ok) {
|
|
314
|
-
throw translateHttpError(res.status, body ?? `Ablo API request failed: ${res.status} ${res.statusText}`, res.headers.get('x-request-id') ?? undefined);
|
|
315
|
-
}
|
|
316
|
-
return body;
|
|
317
|
-
}
|
|
318
|
-
/**
|
|
319
|
-
* Issues one request and validates its body against the route's schema.
|
|
320
|
-
*
|
|
321
|
-
* The schema is the route's response contract, declared once in `wire/` and
|
|
322
|
-
* shared with the server that produces it. A body that does not match is a
|
|
323
|
-
* version disagreement between the two, so it is refused whole rather than
|
|
324
|
-
* read field by field and half-trusted.
|
|
325
|
-
*/
|
|
326
|
-
async function requestJson(path, init, responseSchema, skipReady = false) {
|
|
327
|
-
const body = await requestRaw(path, init, skipReady);
|
|
328
|
-
const parsed = responseSchema.safeParse(body);
|
|
329
|
-
if (!parsed.success) {
|
|
330
|
-
throw new AbloConnectionError(`The Ablo API returned a response for ${init.method ?? 'GET'} ${path} that this client could not read; nothing was applied.`, { code: 'malformed_response', cause: parsed.error });
|
|
331
|
-
}
|
|
332
|
-
return parsed.data;
|
|
333
|
-
}
|
|
334
|
-
function isDefinitiveHttpRejection(error) {
|
|
335
|
-
if (typeof error !== 'object' || error === null)
|
|
336
|
-
return false;
|
|
337
|
-
const candidate = error;
|
|
338
|
-
const status = typeof candidate.httpStatus === 'number'
|
|
339
|
-
? candidate.httpStatus
|
|
340
|
-
: typeof candidate.status === 'number'
|
|
341
|
-
? candidate.status
|
|
342
|
-
: undefined;
|
|
343
|
-
return (status !== undefined &&
|
|
344
|
-
status >= 400 &&
|
|
345
|
-
status < 500 &&
|
|
346
|
-
status !== 408 &&
|
|
347
|
-
status !== 425 &&
|
|
348
|
-
status !== 429);
|
|
349
|
-
}
|
|
350
|
-
async function settleHttpEnvelope(recordId) {
|
|
351
|
-
if (!commitOutbox)
|
|
352
|
-
return;
|
|
353
|
-
try {
|
|
354
|
-
await commitOutbox.remove(recordId);
|
|
355
|
-
}
|
|
356
|
-
catch (cause) {
|
|
357
|
-
// Do not report the remote outcome until local settlement is durable.
|
|
358
|
-
// The retained record can still be replayed inside the safe window.
|
|
359
|
-
throw new AbloConnectionError('The server settled the commit, but its local outbox record could not be cleared.', { code: 'db_not_opened', cause });
|
|
360
|
-
}
|
|
361
|
-
}
|
|
362
|
-
/**
|
|
363
|
-
* Persist the first queued source receipt before exposing acceptance to the
|
|
364
|
-
* caller. This is a monotonic upgrade of the same sealed request: connected
|
|
365
|
-
* source keys are permanent, so the envelope may safely remain replayable
|
|
366
|
-
* after the hosted 24-hour idempotency window while it awaits its WAL echo.
|
|
367
|
-
*/
|
|
368
|
-
async function persistHttpAcceptance(envelope, response) {
|
|
369
|
-
if (!commitOutbox || response.status !== 'queued')
|
|
370
|
-
return envelope;
|
|
371
|
-
const correlationId = response.correlationId;
|
|
372
|
-
if (!correlationId) {
|
|
373
|
-
throw new AbloConnectionError('The source accepted the commit without durable correlation evidence.', { code: 'commit_no_result' });
|
|
374
|
-
}
|
|
375
|
-
if (envelope.correlationId !== undefined && envelope.correlationId !== correlationId) {
|
|
376
|
-
throw new AbloIdempotencyError('The same HTTP commit replay returned a different source correlation.', { code: 'idempotency_conflict' });
|
|
377
|
-
}
|
|
378
|
-
if (envelope.acceptedAt !== undefined)
|
|
379
|
-
return envelope;
|
|
380
|
-
const accepted = durableHttpCommitEnvelopeSchema.parse({
|
|
381
|
-
...envelope,
|
|
382
|
-
acceptedAt: Date.now(),
|
|
383
|
-
correlationId,
|
|
384
|
-
});
|
|
385
|
-
try {
|
|
386
|
-
await commitOutbox.seal(accepted, []);
|
|
387
|
-
}
|
|
388
|
-
catch (cause) {
|
|
389
|
-
throw new AbloConnectionError('The source accepted the commit, but that acceptance could not be persisted locally.', { code: 'db_not_opened', cause });
|
|
390
|
-
}
|
|
391
|
-
return accepted;
|
|
392
|
-
}
|
|
393
|
-
function replicationLagTimeout(request, response) {
|
|
394
|
-
return new AbloConnectionError(`The source accepted commit ${request.idempotencyKey}, but its replication echo did not arrive within ${requestTimeoutMs}ms.`, {
|
|
395
|
-
code: 'replication_lag_timeout',
|
|
396
|
-
httpStatus: 504,
|
|
397
|
-
details: {
|
|
398
|
-
clientTxId: request.idempotencyKey,
|
|
399
|
-
...(response.correlationId ? { correlationId: response.correlationId } : {}),
|
|
400
|
-
timeoutMs: requestTimeoutMs,
|
|
401
|
-
accepted: true,
|
|
402
|
-
},
|
|
403
|
-
});
|
|
404
|
-
}
|
|
405
|
-
/**
|
|
406
|
-
* Replays one byte-identical, idempotent HTTP commit until mutation-log
|
|
407
|
-
* replay reports the source echo as confirmed. `queued` is acceptance only:
|
|
408
|
-
* this loop never clears the durable envelope and never converts it into a
|
|
409
|
-
* successful `wait: 'confirmed'` result.
|
|
410
|
-
*/
|
|
411
|
-
async function pollHttpCommitConfirmation(request, initial) {
|
|
412
|
-
let current = initial;
|
|
413
|
-
const correlationId = initial.correlationId;
|
|
414
|
-
const deadlineAt = requestTimeoutMs > 0 ? Date.now() + requestTimeoutMs : null;
|
|
415
|
-
while (current.status === 'queued') {
|
|
416
|
-
const remaining = deadlineAt === null ? null : deadlineAt - Date.now();
|
|
417
|
-
if (remaining !== null && remaining <= 0) {
|
|
418
|
-
throw replicationLagTimeout(request, current);
|
|
419
|
-
}
|
|
420
|
-
const confirmationController = new AbortController();
|
|
421
|
-
const confirmationDeadline = remaining !== null
|
|
422
|
-
? setTimeout(() => {
|
|
423
|
-
confirmationController.abort();
|
|
424
|
-
}, remaining)
|
|
425
|
-
: null;
|
|
426
|
-
try {
|
|
427
|
-
const raw = await requestRaw(request.path, {
|
|
428
|
-
method: request.method,
|
|
429
|
-
idempotencyKey: request.idempotencyKey,
|
|
430
|
-
...(request.sealedProtocolVersion !== undefined
|
|
431
|
-
? { sealedProtocolVersion: request.sealedProtocolVersion }
|
|
432
|
-
: {}),
|
|
433
|
-
body: request.body,
|
|
434
|
-
signal: confirmationController.signal,
|
|
435
|
-
}, true);
|
|
436
|
-
const next = parseSuccessfulCommitResponse(raw, request.idempotencyKey);
|
|
437
|
-
if (next.correlationId !== correlationId) {
|
|
438
|
-
throw new AbloIdempotencyError('The same HTTP commit replay returned different source correlation evidence.', { code: 'idempotency_conflict' });
|
|
439
|
-
}
|
|
440
|
-
current = next;
|
|
441
|
-
}
|
|
442
|
-
catch (error) {
|
|
443
|
-
if (confirmationController.signal.aborted ||
|
|
444
|
-
(deadlineAt !== null && Date.now() >= deadlineAt)) {
|
|
445
|
-
throw replicationLagTimeout(request, current);
|
|
446
|
-
}
|
|
447
|
-
throw error;
|
|
448
|
-
}
|
|
449
|
-
finally {
|
|
450
|
-
if (confirmationDeadline)
|
|
451
|
-
clearTimeout(confirmationDeadline);
|
|
452
|
-
}
|
|
453
|
-
if (current.status === 'confirmed')
|
|
454
|
-
return current;
|
|
455
|
-
const delayMs = deadlineAt === null
|
|
456
|
-
? HTTP_CONFIRMATION_POLL_INTERVAL_MS
|
|
457
|
-
: Math.min(HTTP_CONFIRMATION_POLL_INTERVAL_MS, Math.max(0, deadlineAt - Date.now()));
|
|
458
|
-
if (delayMs > 0) {
|
|
459
|
-
await new Promise((resolve) => {
|
|
460
|
-
setTimeout(resolve, delayMs);
|
|
461
|
-
});
|
|
462
|
-
}
|
|
463
|
-
}
|
|
464
|
-
return current;
|
|
465
|
-
}
|
|
466
|
-
async function replayHttpCommitOutbox() {
|
|
467
|
-
const replayed = new Map();
|
|
468
|
-
if (!commitOutbox)
|
|
469
|
-
return replayed;
|
|
470
|
-
const scopeNamespace = await resolveHttpOutboxScope();
|
|
471
|
-
if (!scopeNamespace)
|
|
472
|
-
return replayed;
|
|
473
|
-
const rows = await commitOutbox.list();
|
|
474
|
-
const envelopes = [];
|
|
475
|
-
for (const row of rows) {
|
|
476
|
-
if (typeof row !== 'object' ||
|
|
477
|
-
row === null ||
|
|
478
|
-
row.type !== 'http_commit_envelope')
|
|
479
|
-
continue;
|
|
480
|
-
const parsed = durableHttpCommitEnvelopeSchema.safeParse(row);
|
|
481
|
-
if (!parsed.success) {
|
|
482
|
-
throw new AbloValidationError('A saved HTTP write is unreadable; replay stopped before any newer write was sent.', { code: 'write_options_invalid', cause: parsed.error });
|
|
483
|
-
}
|
|
484
|
-
if (parsed.data.scopeNamespace !== scopeNamespace)
|
|
485
|
-
continue;
|
|
486
|
-
if (isHttpCommitReplayExpired(parsed.data)) {
|
|
487
|
-
throw new AbloIdempotencyError('A saved HTTP write is older than the server idempotency window and cannot be replayed safely.', { code: 'idempotency_conflict' });
|
|
488
|
-
}
|
|
489
|
-
envelopes.push(parsed.data);
|
|
490
|
-
}
|
|
491
|
-
envelopes.sort((a, b) => (a.sequence ?? a.sealedAt * 1_000) - (b.sequence ?? b.sealedAt * 1_000) ||
|
|
492
|
-
a.id.localeCompare(b.id));
|
|
493
|
-
for (const envelope of envelopes) {
|
|
494
|
-
try {
|
|
495
|
-
const raw = await requestRaw(envelope.request.path, {
|
|
496
|
-
method: envelope.request.method,
|
|
497
|
-
idempotencyKey: envelope.idempotencyKey,
|
|
498
|
-
sealedProtocolVersion: envelope.protocolVersion,
|
|
499
|
-
body: envelope.request.body,
|
|
500
|
-
}, true);
|
|
501
|
-
const response = parseSuccessfulCommitResponse(raw, envelope.idempotencyKey);
|
|
502
|
-
if (envelope.correlationId !== undefined &&
|
|
503
|
-
response.correlationId !== envelope.correlationId) {
|
|
504
|
-
throw new AbloIdempotencyError('The saved HTTP commit replay returned different source correlation evidence.', { code: 'idempotency_conflict' });
|
|
505
|
-
}
|
|
506
|
-
const replayEnvelope = await persistHttpAcceptance(envelope, response);
|
|
507
|
-
// A queued source receipt is only acceptance. Keep the exact request
|
|
508
|
-
// durable so startup/retry can ask mutation-log replay whether its WAL
|
|
509
|
-
// echo has materialized; only confirmed is a definitive success.
|
|
510
|
-
if (response.status === 'confirmed') {
|
|
511
|
-
await settleHttpEnvelope(envelope.id);
|
|
512
|
-
}
|
|
513
|
-
replayed.set(envelope.idempotencyKey, {
|
|
514
|
-
envelope: replayEnvelope,
|
|
515
|
-
response,
|
|
516
|
-
});
|
|
517
|
-
}
|
|
518
|
-
catch (error) {
|
|
519
|
-
if (isDefinitiveHttpRejection(error)) {
|
|
520
|
-
await settleHttpEnvelope(envelope.id);
|
|
521
|
-
}
|
|
522
|
-
throw error;
|
|
523
|
-
}
|
|
524
|
-
}
|
|
525
|
-
return replayed;
|
|
526
|
-
}
|
|
527
|
-
/**
|
|
528
|
-
* A flush is stronger than startup replay: it promises that every retained
|
|
529
|
-
* envelope has reached a definitive outcome, not merely that the server
|
|
530
|
-
* accepted it for forwarding. Poll queued receipts through mutation-log
|
|
531
|
-
* replay and leave their envelopes intact if the confirmation deadline
|
|
532
|
-
* expires.
|
|
533
|
-
*/
|
|
534
|
-
async function confirmReplayedHttpCommits(replayed) {
|
|
535
|
-
for (const { envelope, response } of replayed.values()) {
|
|
536
|
-
if (response.status !== 'queued')
|
|
537
|
-
continue;
|
|
538
|
-
try {
|
|
539
|
-
const confirmed = await pollHttpCommitConfirmation({
|
|
540
|
-
idempotencyKey: envelope.idempotencyKey,
|
|
541
|
-
method: envelope.request.method,
|
|
542
|
-
path: envelope.request.path,
|
|
543
|
-
body: envelope.request.body,
|
|
544
|
-
sealedProtocolVersion: envelope.protocolVersion,
|
|
545
|
-
}, response);
|
|
546
|
-
if (confirmed.status === 'confirmed') {
|
|
547
|
-
await settleHttpEnvelope(envelope.id);
|
|
548
|
-
}
|
|
549
|
-
}
|
|
550
|
-
catch (error) {
|
|
551
|
-
if (isDefinitiveHttpRejection(error)) {
|
|
552
|
-
await settleHttpEnvelope(envelope.id);
|
|
553
|
-
}
|
|
554
|
-
throw error;
|
|
555
|
-
}
|
|
556
|
-
}
|
|
557
|
-
}
|
|
558
|
-
let lastHttpCommitSequence = 0;
|
|
559
|
-
function nextHttpCommitSequence() {
|
|
560
|
-
const wallSequence = Date.now() * 1_000;
|
|
561
|
-
lastHttpCommitSequence = Math.max(wallSequence, lastHttpCommitSequence + 1);
|
|
562
|
-
return lastHttpCommitSequence;
|
|
563
|
-
}
|
|
564
|
-
async function sealHttpCommit(input) {
|
|
565
|
-
if (!commitOutbox)
|
|
566
|
-
return null;
|
|
567
|
-
const scopeNamespace = await resolveHttpOutboxScope();
|
|
568
|
-
if (!scopeNamespace) {
|
|
569
|
-
throw new AbloValidationError('HTTP durable-write scope was not resolved', {
|
|
570
|
-
code: 'write_options_invalid',
|
|
571
|
-
});
|
|
572
|
-
}
|
|
573
|
-
const recordId = httpCommitEnvelopeRecordId(input.idempotencyKey, scopeNamespace);
|
|
574
|
-
const legacyRecordId = httpCommitEnvelopeRecordId(input.idempotencyKey);
|
|
575
|
-
const existingRows = await commitOutbox.list();
|
|
576
|
-
const existingRaw = existingRows.find((row) => typeof row === 'object' &&
|
|
577
|
-
row !== null &&
|
|
578
|
-
(row.id === recordId ||
|
|
579
|
-
row.id === legacyRecordId));
|
|
580
|
-
const serializedBody = canonicalHttpCommitBody(input.body);
|
|
581
|
-
if (existingRaw !== undefined) {
|
|
582
|
-
const existing = durableHttpCommitEnvelopeSchema.parse(existingRaw);
|
|
583
|
-
if (isHttpCommitReplayExpired(existing)) {
|
|
584
|
-
throw new AbloIdempotencyError('This saved HTTP write is older than the server idempotency window and cannot be retried safely.', { code: 'idempotency_conflict' });
|
|
585
|
-
}
|
|
586
|
-
if (existing.scopeNamespace !== scopeNamespace ||
|
|
587
|
-
existing.request.method !== input.method ||
|
|
588
|
-
existing.request.path !== input.path ||
|
|
589
|
-
existing.request.body !== serializedBody) {
|
|
590
|
-
throw new AbloIdempotencyError('Idempotency key reused with a different HTTP commit request', { code: 'idempotency_conflict' });
|
|
591
|
-
}
|
|
592
|
-
return existing;
|
|
593
|
-
}
|
|
594
|
-
const envelope = createDurableHttpCommitEnvelope({
|
|
595
|
-
idempotencyKey: input.idempotencyKey,
|
|
596
|
-
request: { method: input.method, path: input.path, body: input.body },
|
|
597
|
-
scopeNamespace,
|
|
598
|
-
sequence: nextHttpCommitSequence(),
|
|
599
|
-
});
|
|
600
|
-
await commitOutbox.seal(envelope, []);
|
|
601
|
-
return envelope;
|
|
602
|
-
}
|
|
603
|
-
async function dispatchHttpCommit(input, beforeSettlement) {
|
|
604
|
-
return runInHttpCommitLane(async () => {
|
|
605
|
-
await ready();
|
|
606
|
-
// `ready()` covers startup. Re-draining here makes every later write wait
|
|
607
|
-
// behind an ambiguous predecessor from this same process.
|
|
608
|
-
const replayed = await replayHttpCommitOutbox();
|
|
609
|
-
const prior = replayed.get(input.idempotencyKey);
|
|
610
|
-
if (prior) {
|
|
611
|
-
const serializedBody = canonicalHttpCommitBody(input.body);
|
|
612
|
-
if (prior.envelope.request.method !== input.method ||
|
|
613
|
-
prior.envelope.request.path !== input.path ||
|
|
614
|
-
prior.envelope.request.body !== serializedBody) {
|
|
615
|
-
throw new AbloIdempotencyError('Idempotency key reused with a different HTTP commit request', { code: 'idempotency_conflict' });
|
|
616
|
-
}
|
|
617
|
-
let priorResponse = prior.response;
|
|
618
|
-
if (priorResponse.status === 'queued' && input.wait === 'confirmed') {
|
|
619
|
-
try {
|
|
620
|
-
priorResponse = await pollHttpCommitConfirmation({
|
|
621
|
-
idempotencyKey: prior.envelope.idempotencyKey,
|
|
622
|
-
method: prior.envelope.request.method,
|
|
623
|
-
path: prior.envelope.request.path,
|
|
624
|
-
body: prior.envelope.request.body,
|
|
625
|
-
sealedProtocolVersion: prior.envelope.protocolVersion,
|
|
626
|
-
}, priorResponse);
|
|
627
|
-
}
|
|
628
|
-
catch (error) {
|
|
629
|
-
if (isDefinitiveHttpRejection(error)) {
|
|
630
|
-
await settleHttpEnvelope(prior.envelope.id);
|
|
631
|
-
}
|
|
632
|
-
throw error;
|
|
633
|
-
}
|
|
634
|
-
}
|
|
635
|
-
if (priorResponse.status === 'confirmed') {
|
|
636
|
-
await beforeSettlement?.(priorResponse);
|
|
637
|
-
await settleHttpEnvelope(prior.envelope.id);
|
|
638
|
-
}
|
|
639
|
-
return priorResponse;
|
|
640
|
-
}
|
|
641
|
-
const durableEnvelope = await sealHttpCommit(input);
|
|
642
|
-
const requestBody = durableEnvelope?.request.body ?? canonicalHttpCommitBody(input.body);
|
|
643
|
-
const exactRequest = {
|
|
644
|
-
idempotencyKey: input.idempotencyKey,
|
|
645
|
-
method: input.method,
|
|
646
|
-
path: input.path,
|
|
647
|
-
body: requestBody,
|
|
648
|
-
...(durableEnvelope ? { sealedProtocolVersion: durableEnvelope.protocolVersion } : {}),
|
|
649
|
-
};
|
|
650
|
-
let response;
|
|
651
|
-
try {
|
|
652
|
-
const raw = await requestRaw(exactRequest.path, {
|
|
653
|
-
method: exactRequest.method,
|
|
654
|
-
idempotencyKey: exactRequest.idempotencyKey,
|
|
655
|
-
...(exactRequest.sealedProtocolVersion !== undefined
|
|
656
|
-
? { sealedProtocolVersion: exactRequest.sealedProtocolVersion }
|
|
657
|
-
: {}),
|
|
658
|
-
body: exactRequest.body,
|
|
659
|
-
}, true);
|
|
660
|
-
response = parseSuccessfulCommitResponse(raw, input.idempotencyKey);
|
|
661
|
-
if (durableEnvelope && response.status === 'queued') {
|
|
662
|
-
await persistHttpAcceptance(durableEnvelope, response);
|
|
663
|
-
}
|
|
664
|
-
if (response.status === 'queued' && input.wait === 'confirmed') {
|
|
665
|
-
response = await pollHttpCommitConfirmation(exactRequest, response);
|
|
666
|
-
}
|
|
667
|
-
}
|
|
668
|
-
catch (error) {
|
|
669
|
-
if (durableEnvelope && isDefinitiveHttpRejection(error)) {
|
|
670
|
-
await settleHttpEnvelope(durableEnvelope.id);
|
|
671
|
-
}
|
|
672
|
-
throw error;
|
|
673
|
-
}
|
|
674
|
-
// A model-create readback can participate in settlement: if it fails,
|
|
675
|
-
// retain the exact write so a same-key retry recovers the generated id.
|
|
676
|
-
// A queued source receipt cannot be read back from the log yet and stays
|
|
677
|
-
// durable until a later confirmed replay.
|
|
678
|
-
if (response.status === 'confirmed') {
|
|
679
|
-
await beforeSettlement?.(response);
|
|
680
|
-
if (durableEnvelope)
|
|
681
|
-
await settleHttpEnvelope(durableEnvelope.id);
|
|
682
|
-
}
|
|
683
|
-
return response;
|
|
684
|
-
});
|
|
685
|
-
}
|
|
686
|
-
function createClientTxId(idempotencyKey) {
|
|
687
|
-
if (idempotencyKey && idempotencyKey.length > 0)
|
|
688
|
-
return idempotencyKey;
|
|
689
|
-
return typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
|
|
690
|
-
? crypto.randomUUID()
|
|
691
|
-
: `tx_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
|
692
|
-
}
|
|
693
|
-
function createModelId(modelName, idempotencyKey) {
|
|
694
|
-
if (idempotencyKey) {
|
|
695
|
-
return uuidv5(`${modelName}:${idempotencyKey}`, 'aa4ba6d4-bf0b-5b38-9c45-116f79a6e548');
|
|
696
|
-
}
|
|
697
|
-
return typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
|
|
698
|
-
? crypto.randomUUID()
|
|
699
|
-
: `id_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
|
700
|
-
}
|
|
701
|
-
function normalizeCommitOperation(op, defaults, fence) {
|
|
702
|
-
return {
|
|
703
|
-
action: op.action,
|
|
704
|
-
model: op.model,
|
|
705
|
-
id: op.id ?? null,
|
|
706
|
-
data: op.data ?? null,
|
|
707
|
-
transactionId: op.transactionId ?? null,
|
|
708
|
-
readAt: op.readAt ?? defaults.readAt ?? null,
|
|
709
|
-
onStale: op.onStale ?? defaults.onStale ?? null,
|
|
710
|
-
fenceToken: op.fenceToken ?? fenceTokenFor(fence, op.model, op.id ?? null),
|
|
711
|
-
};
|
|
712
|
-
}
|
|
713
|
-
function normalizeCommitOperations(commitOptions, fence) {
|
|
714
|
-
if (commitOptions.operations.length === 0) {
|
|
715
|
-
throw new AbloValidationError('Commit requires a non-empty `operations` array.', {
|
|
716
|
-
code: 'commit_operation_required',
|
|
717
|
-
});
|
|
718
|
-
}
|
|
719
|
-
return commitOptions.operations.map((op) => normalizeCommitOperation(op, commitOptions, fence));
|
|
720
|
-
}
|
|
721
|
-
async function listClaimState(target) {
|
|
722
|
-
const params = new URLSearchParams();
|
|
723
|
-
if (target?.model)
|
|
724
|
-
params.set('model', target.model);
|
|
725
|
-
if (target?.id)
|
|
726
|
-
params.set('id', target.id);
|
|
727
|
-
if (target?.field)
|
|
728
|
-
params.set('field', target.field);
|
|
729
|
-
const suffix = params.toString();
|
|
730
|
-
const body = await requestJson(`/v1/claims${suffix ? `?${suffix}` : ''}`, { method: 'GET' }, claimListResponseSchema);
|
|
731
|
-
// One list, one resource in two states: holders and waiters are told
|
|
732
|
-
// apart by each entry's `status`, not by bespoke envelope members.
|
|
733
|
-
return {
|
|
734
|
-
active: body.data.filter((row) => row.status !== 'queued'),
|
|
735
|
-
queue: body.data.filter((row) => row.status === 'queued'),
|
|
736
|
-
};
|
|
737
|
-
}
|
|
738
|
-
// The claim-ticket surface: everything a caller does holding only a
|
|
739
|
-
// `claimId` — which is all a queued acquire leaves in its hand. Each method
|
|
740
|
-
// is a thin cast of its route; the reply schemas are the wire's own, so the
|
|
741
|
-
// surface cannot describe a response the server does not send.
|
|
742
|
-
const claims = {
|
|
743
|
-
retrieve({ claimId }) {
|
|
744
|
-
return requestJson(`/v1/claims/${encodeURIComponent(claimId)}`, { method: 'GET' }, claimStateSchema);
|
|
745
|
-
},
|
|
746
|
-
heartbeat({ claimId, ttl }) {
|
|
747
|
-
return requestJson(`/v1/claims/${encodeURIComponent(claimId)}/heartbeat`, {
|
|
748
|
-
method: 'POST',
|
|
749
|
-
body: JSON.stringify(ttl !== undefined ? { ttl } : {}),
|
|
750
|
-
}, claimHeartbeatReplySchema);
|
|
751
|
-
},
|
|
752
|
-
async heartbeatAll(options) {
|
|
753
|
-
const reply = await requestJson('/v1/claims/heartbeat', {
|
|
754
|
-
method: 'POST',
|
|
755
|
-
body: JSON.stringify(options?.ttl !== undefined ? { ttl: options.ttl } : {}),
|
|
756
|
-
}, claimHeartbeatBatchReplySchema);
|
|
757
|
-
return reply.results;
|
|
758
|
-
},
|
|
759
|
-
async release({ claimId }) {
|
|
760
|
-
await requestRaw(`/v1/claims/${encodeURIComponent(claimId)}`, {
|
|
761
|
-
method: 'DELETE',
|
|
762
|
-
});
|
|
763
|
-
},
|
|
764
|
-
};
|
|
765
|
-
// How the stateless client waits its turn. The queued slot is real server
|
|
766
|
-
// state, so one heartbeat per tick does both jobs: it refreshes this
|
|
767
|
-
// waiter's slot and reports the line's answer — `queued` (still waiting) or
|
|
768
|
-
// `held` (granted). The first check comes quickly because most holds are a
|
|
769
|
-
// short claim→write→release; after that the cadence relaxes, with jitter so
|
|
770
|
-
// a fleet of waiters doesn't beat in step.
|
|
771
|
-
const GRANT_POLL_FIRST_MS = 250;
|
|
772
|
-
const GRANT_POLL_INTERVAL_MS = 1_000;
|
|
773
|
-
// An abort cuts the sleep short so the wait ends within a tick of the
|
|
774
|
-
// signal, not at the next scheduled beat.
|
|
775
|
-
const sleep = (ms, signal) => new Promise((resolve) => {
|
|
776
|
-
const done = () => {
|
|
777
|
-
clearTimeout(timer);
|
|
778
|
-
signal?.removeEventListener('abort', done);
|
|
779
|
-
resolve();
|
|
780
|
-
};
|
|
781
|
-
const timer = setTimeout(done, ms);
|
|
782
|
-
signal?.addEventListener('abort', done, { once: true });
|
|
783
|
-
});
|
|
784
|
-
async function awaitGrantOverHttp(targetLabel, queued, options) {
|
|
785
|
-
// The queued reply is a claim resource in its waiting state, so the
|
|
786
|
-
// handle is its `id` — same rule as the 201 and the poll.
|
|
787
|
-
const claimId = queued.id;
|
|
788
|
-
const { signal } = options;
|
|
789
|
-
// Leave the line before rejecting: an abandoned slot would otherwise sit
|
|
790
|
-
// in the queue until its TTL lapses, stalling every waiter behind it.
|
|
791
|
-
const rejectAndLeave = async (error) => {
|
|
792
|
-
await claims.release({ claimId }).catch(() => { });
|
|
793
|
-
throw error;
|
|
794
|
-
};
|
|
795
|
-
if (options.maxQueueDepth !== undefined && queued.position >= options.maxQueueDepth) {
|
|
796
|
-
return rejectAndLeave(new AbloClaimedError(`Claim queue for ${targetLabel} is ${queued.position} deep (max ${options.maxQueueDepth}).`, { code: 'queue_too_deep' }));
|
|
797
|
-
}
|
|
798
|
-
const deadline = options.waitTimeoutMs !== undefined ? Date.now() + options.waitTimeoutMs : undefined;
|
|
799
|
-
let delay = GRANT_POLL_FIRST_MS;
|
|
800
|
-
for (;;) {
|
|
801
|
-
if (signal?.aborted) {
|
|
802
|
-
return rejectAndLeave(new AbloClaimedError(`The wait for the claim on ${targetLabel} was aborted before the grant arrived.`, { code: 'claim_wait_aborted' }));
|
|
803
|
-
}
|
|
804
|
-
if (deadline !== undefined && Date.now() >= deadline) {
|
|
805
|
-
return rejectAndLeave(new AbloClaimedError(`Timed out after ${options.waitTimeoutMs}ms waiting for the queue grant on ${targetLabel}.`, { code: 'grant_timeout' }));
|
|
806
|
-
}
|
|
807
|
-
await sleep(deadline !== undefined ? Math.min(delay, Math.max(0, deadline - Date.now())) : delay, signal);
|
|
808
|
-
if (signal?.aborted) {
|
|
809
|
-
return rejectAndLeave(new AbloClaimedError(`The wait for the claim on ${targetLabel} was aborted before the grant arrived.`, { code: 'claim_wait_aborted' }));
|
|
810
|
-
}
|
|
811
|
-
delay = GRANT_POLL_INTERVAL_MS * (0.85 + Math.random() * 0.3);
|
|
812
|
-
// A lease that ended answers the beat with 409 `claim_lost`, which the
|
|
813
|
-
// wire error mapping raises as AbloClaimedError before this reads
|
|
814
|
-
// anything — the wait fails with the loss, as the socket wait does.
|
|
815
|
-
const beat = await claims.heartbeat({ claimId });
|
|
816
|
-
if (beat.status !== 'held')
|
|
817
|
-
continue;
|
|
818
|
-
// Granted. The heartbeat ack does not carry the fence token — the claim
|
|
819
|
-
// state does, server-stamped at grant.
|
|
820
|
-
const state = await claims.retrieve({ claimId });
|
|
821
|
-
if (state.status !== 'active') {
|
|
822
|
-
return rejectAndLeave(new AbloClaimedError(`Claim lost while queued for ${targetLabel}.`, {
|
|
823
|
-
code: 'claim_lost',
|
|
824
|
-
}));
|
|
825
|
-
}
|
|
826
|
-
return state.fenceToken !== undefined
|
|
827
|
-
? { id: claimId, fenceToken: state.fenceToken }
|
|
828
|
-
: { id: claimId };
|
|
829
|
-
}
|
|
830
|
-
}
|
|
831
|
-
async function applyClaimedPolicy(target, options, defaultPolicy = 'return') {
|
|
832
|
-
const policy = options?.ifClaimed ?? defaultPolicy;
|
|
833
|
-
if (policy === 'return')
|
|
834
|
-
return;
|
|
835
|
-
// policy === 'fail' — gate the read only when the caller opts in.
|
|
836
|
-
const state = await listClaimState(target);
|
|
837
|
-
if (state.active.length === 0)
|
|
838
|
-
return;
|
|
839
|
-
throw claimedError(target, state.active, 'model_claimed');
|
|
840
|
-
}
|
|
841
|
-
const commits = {
|
|
842
|
-
async create(commitOptions) {
|
|
843
|
-
// Same runtime contract as every other write door — one schema.
|
|
844
|
-
assertWriteOptions({
|
|
845
|
-
idempotencyKey: commitOptions.idempotencyKey,
|
|
846
|
-
readAt: commitOptions.readAt,
|
|
847
|
-
onStale: commitOptions.onStale,
|
|
848
|
-
wait: commitOptions.wait,
|
|
849
|
-
claim: commitOptions.claim,
|
|
850
|
-
}, 'commits.create');
|
|
851
|
-
const clientTxId = createClientTxId(commitOptions.idempotencyKey);
|
|
852
|
-
// Same claim vocabulary as the WS client's `commits.create`: a handle
|
|
853
|
-
// supplies the batch stale-guard defaults; explicit options win.
|
|
854
|
-
const claim = commitOptions.claim ?? null;
|
|
855
|
-
const operations = normalizeCommitOperations({
|
|
856
|
-
...commitOptions,
|
|
857
|
-
readAt: commitOptions.readAt ?? claim?.readAt ?? null,
|
|
858
|
-
onStale: commitOptions.onStale ?? (claim?.readAt !== undefined ? 'reject' : null),
|
|
859
|
-
}, batchFence(claim?.target, claim?.fenceToken));
|
|
860
|
-
const requestBody = {
|
|
861
|
-
operations,
|
|
862
|
-
reads: commitOptions.reads,
|
|
863
|
-
track: commitOptions.track,
|
|
864
|
-
};
|
|
865
|
-
const wait = commitOptions.wait ?? 'confirmed';
|
|
866
|
-
let body;
|
|
867
|
-
try {
|
|
868
|
-
body = await dispatchHttpCommit({
|
|
869
|
-
path: '/v1/commits',
|
|
870
|
-
method: 'POST',
|
|
871
|
-
idempotencyKey: clientTxId,
|
|
872
|
-
body: requestBody,
|
|
873
|
-
wait,
|
|
874
|
-
});
|
|
875
|
-
}
|
|
876
|
-
catch (error) {
|
|
877
|
-
// Coordination collision over HTTP — surface it to observability on the
|
|
878
|
-
// same footing as the WS transport, then rethrow unchanged. Fall back to
|
|
879
|
-
// the ops we tried to write so the collision always names a row.
|
|
880
|
-
recordCoordinationConflict(error, clientTxId, operations.map((o) => ({
|
|
881
|
-
model: typeof o.model === 'string' ? o.model : 'unknown',
|
|
882
|
-
id: typeof o.id === 'string' ? o.id : 'unknown',
|
|
883
|
-
})));
|
|
884
|
-
throw error;
|
|
885
|
-
}
|
|
886
|
-
// `requestJson` throws via `translateHttpError` on any non-2xx, so
|
|
887
|
-
// reaching here implies success and `body` is already the success-only
|
|
888
|
-
// receipt union — a rejection is a separate type that never arrives here.
|
|
889
|
-
// The settlement status therefore passes through verbatim: no branch may
|
|
890
|
-
// collapse a state the server reported into a different one.
|
|
891
|
-
return {
|
|
892
|
-
id: body.id ?? body.clientTxId,
|
|
893
|
-
status: body.status,
|
|
894
|
-
lastSyncId: body.lastSyncId,
|
|
895
|
-
...(body.notifications && body.notifications.length > 0
|
|
896
|
-
? { notifications: body.notifications }
|
|
897
|
-
: {}),
|
|
898
|
-
...(body.missingIds && body.missingIds.length > 0 ? { missingIds: body.missingIds } : {}),
|
|
899
|
-
};
|
|
900
|
-
},
|
|
901
|
-
};
|
|
902
|
-
async function listModel(modelName, options) {
|
|
903
|
-
const params = new URLSearchParams();
|
|
904
|
-
if (options?.limit !== undefined)
|
|
905
|
-
params.set('limit', String(options.limit));
|
|
906
|
-
if (options?.orderBy) {
|
|
907
|
-
const [col, dir] = Object.entries(options.orderBy)[0] ?? [];
|
|
908
|
-
if (col) {
|
|
909
|
-
params.set('order_by', col);
|
|
910
|
-
if (dir === 'desc')
|
|
911
|
-
params.set('order', 'desc');
|
|
912
|
-
}
|
|
913
|
-
}
|
|
914
|
-
// The collection route turns any non-reserved query param into an equality
|
|
915
|
-
// filter (`?status=todo`). The wire is AND-only equality — matches what a
|
|
916
|
-
// stateless reactor needs; richer predicates stay on the stateful path.
|
|
917
|
-
if (options?.where && typeof options.where === 'object') {
|
|
918
|
-
for (const [k, v] of Object.entries(options.where)) {
|
|
919
|
-
if (v !== undefined && v !== null && typeof v !== 'object')
|
|
920
|
-
params.set(k, String(v));
|
|
921
|
-
}
|
|
922
|
-
}
|
|
923
|
-
const qs = params.toString();
|
|
924
|
-
const res = await requestJson(`/v1/models/${encodeURIComponent(modelName)}${qs ? `?${qs}` : ''}`, { method: 'GET' }, modelListResponseSchema);
|
|
925
|
-
// The envelope is checked; the rows are not, and cannot be here. This
|
|
926
|
-
// transport is schema-agnostic — it moves rows for whatever schema the
|
|
927
|
-
// caller declared, and `T` is that declaration. Row validation belongs to
|
|
928
|
-
// the typed facade above, which holds the model's schema.
|
|
929
|
-
return res.data;
|
|
930
|
-
}
|
|
931
|
-
async function retrieveModel(modelName, params) {
|
|
932
|
-
await applyClaimedPolicy({ model: modelName, id: params.id }, params);
|
|
933
|
-
const query = await requestJson(`/v1/models/${encodeURIComponent(modelName)}/${encodeURIComponent(params.id)}`, { method: 'GET' }, modelReadResponseSchema);
|
|
934
|
-
// A miss is `data: undefined`, not a thrown error. The WebSocket client's
|
|
935
|
-
// `retrieve` returns `T | undefined` for a missing row; throwing only here
|
|
936
|
-
// made the obvious read ("does this row exist?") a hard edge that an agent
|
|
937
|
-
// had to wrap in try/catch. Both transports agree: an absent row means absent
|
|
938
|
-
// data. Callers branch on `.data` (the documented `.data?.x` usage).
|
|
939
|
-
// Normalize a miss to `undefined` (the server may send `null` or omit it).
|
|
940
|
-
// The row itself is the caller's declared type — see the note in `listModel`
|
|
941
|
-
// on why this transport validates the envelope and not the row.
|
|
942
|
-
const data = (query.data ?? undefined);
|
|
943
|
-
return { data, stamp: query.stamp, claims: query.claims };
|
|
944
|
-
}
|
|
945
|
-
/**
|
|
946
|
-
* A single-operation mutation over the model-scoped routes — the canonical
|
|
947
|
-
* surface that mirrors `ablo.<model>.create/update/delete`:
|
|
948
|
-
*
|
|
949
|
-
* POST /v1/models/:model create
|
|
950
|
-
* PATCH /v1/models/:model/:id update
|
|
951
|
-
* DELETE /v1/models/:model/:id delete
|
|
952
|
-
*
|
|
953
|
-
* The `commits.create(...)` resource remains the path for atomic
|
|
954
|
-
* multi-operation envelopes; this helper handles the one-operation,
|
|
955
|
-
* one-record case.
|
|
956
|
-
*/
|
|
957
|
-
async function mutateModel(action, modelName, id, data, options, beforeSettlement) {
|
|
958
|
-
assertWriteOptions(options && {
|
|
959
|
-
idempotencyKey: options.idempotencyKey,
|
|
960
|
-
readAt: options.readAt,
|
|
961
|
-
onStale: options.onStale,
|
|
962
|
-
wait: options.wait,
|
|
963
|
-
claim: options.claim,
|
|
964
|
-
}, `${modelName} ${action}`);
|
|
965
|
-
const clientTxId = createClientTxId(options?.idempotencyKey);
|
|
966
|
-
const encModel = encodeURIComponent(modelName);
|
|
967
|
-
const path = action === 'create'
|
|
968
|
-
? `/v1/models/${encModel}`
|
|
969
|
-
: `/v1/models/${encModel}/${encodeURIComponent(id)}`;
|
|
970
|
-
const method = action === 'create' ? 'POST' : action === 'update' ? 'PATCH' : 'DELETE';
|
|
971
|
-
// A carried claim handle supplies the stale-guard defaults — one claim
|
|
972
|
-
// vocabulary across the WS proxy, `commits.create`, and these routes.
|
|
973
|
-
const rawClaim = options?.claim;
|
|
974
|
-
const claimHandle = typeof rawClaim === 'object' &&
|
|
975
|
-
rawClaim !== null &&
|
|
976
|
-
rawClaim.object === 'claim' &&
|
|
977
|
-
typeof rawClaim.id === 'string'
|
|
978
|
-
? rawClaim
|
|
979
|
-
: undefined;
|
|
980
|
-
const readAt = options?.readAt ?? claimHandle?.readAt;
|
|
981
|
-
const requestBody = {
|
|
982
|
-
claim: normalizeClaimId(options?.claimRef) ?? claimHandle?.id,
|
|
983
|
-
onStale: options?.onStale ?? (claimHandle?.readAt !== undefined ? 'reject' : undefined),
|
|
984
|
-
readAt,
|
|
985
|
-
// The claim's fencing token (Option B), so the per-model HTTP write door
|
|
986
|
-
// fences the same as the WS proxy and `commits.create`.
|
|
987
|
-
fenceToken: options?.fenceToken ?? claimHandle?.fenceToken,
|
|
988
|
-
};
|
|
989
|
-
if (action === 'create')
|
|
990
|
-
requestBody.id = id;
|
|
991
|
-
if (data !== undefined)
|
|
992
|
-
requestBody.data = data;
|
|
993
|
-
let body;
|
|
994
|
-
try {
|
|
995
|
-
body = await dispatchHttpCommit({
|
|
996
|
-
path,
|
|
997
|
-
method,
|
|
998
|
-
idempotencyKey: clientTxId,
|
|
999
|
-
body: requestBody,
|
|
1000
|
-
wait: options?.wait ?? 'confirmed',
|
|
1001
|
-
}, beforeSettlement);
|
|
1002
|
-
}
|
|
1003
|
-
catch (error) {
|
|
1004
|
-
// The per-model write door (`ablo.<model>.update/create/delete`). Capture
|
|
1005
|
-
// coordination collisions here too; this single row is the fallback target.
|
|
1006
|
-
recordCoordinationConflict(error, clientTxId, [{ model: modelName, id }]);
|
|
1007
|
-
throw error;
|
|
1008
|
-
}
|
|
1009
|
-
// Same contract as `commits.create` above: a non-2xx already threw, so
|
|
1010
|
-
// `body` is the success-only receipt union and its settlement status passes
|
|
1011
|
-
// through verbatim rather than through a catch-all branch.
|
|
1012
|
-
return {
|
|
1013
|
-
id: body.serverTxId,
|
|
1014
|
-
status: body.status,
|
|
1015
|
-
lastSyncId: body.lastSyncId,
|
|
1016
|
-
};
|
|
1017
|
-
}
|
|
1018
|
-
function model(name) {
|
|
1019
|
-
// Durable lease + FIFO wait-line over HTTP (the existing claim routes). A
|
|
1020
|
-
// claim is server state, not a subscription — acquire/hold/release are plain
|
|
1021
|
-
// request/response, so a stateless agent participates in coordination too.
|
|
1022
|
-
const claimPath = (id) => `/v1/models/${encodeURIComponent(name)}/${encodeURIComponent(id)}/claim`;
|
|
1023
|
-
const isClaimHandle = (value) => typeof value === 'object' &&
|
|
1024
|
-
value !== null &&
|
|
1025
|
-
value.object === 'claim' &&
|
|
1026
|
-
typeof value.id === 'string' &&
|
|
1027
|
-
typeof value.release === 'function';
|
|
1028
|
-
const acquireClaim = async (params) => {
|
|
1029
|
-
// The row is named by the URL, so `target` carries only the narrowing a
|
|
1030
|
-
// claim adds below it. Sending it is what makes a field-scoped claim
|
|
1031
|
-
// actually field-scoped: the server's conflict rule reads `path`,
|
|
1032
|
-
// `range`, and `field`, so a claim that keeps them client-side takes a
|
|
1033
|
-
// lease on the whole row while its handle says otherwise.
|
|
1034
|
-
// Projected in one move rather than member by member. The member-by-member
|
|
1035
|
-
// version is how `field` came to be sent while `fields` was not, which
|
|
1036
|
-
// left a set-scoped claim silently holding the whole row.
|
|
1037
|
-
const narrowing = subTarget(params);
|
|
1038
|
-
// Typed as the request contract rather than a bare literal — the omission
|
|
1039
|
-
// above was invisible for exactly as long as this was an untyped object.
|
|
1040
|
-
const request = {
|
|
1041
|
-
description: claimDescription(params),
|
|
1042
|
-
...(params.ttl !== undefined ? { ttl: params.ttl } : {}),
|
|
1043
|
-
// The caller's `meta` is the declared shape; the body is wire-shaped,
|
|
1044
|
-
// so it crosses through the same conversion `subTarget` used above.
|
|
1045
|
-
...(params.meta !== undefined ? { meta: wireMeta(params.meta) } : {}),
|
|
1046
|
-
...(Object.keys(narrowing).length > 0 ? { target: narrowing } : {}),
|
|
1047
|
-
// `queue` (default true) → queue behind the holder; false → fail-fast
|
|
1048
|
-
// with AbloClaimedError (work-distribution dedup).
|
|
1049
|
-
queue: params.queue ?? true,
|
|
1050
|
-
};
|
|
1051
|
-
const body = await requestJson(claimPath(params.id), { method: 'POST', body: JSON.stringify(request) }, claimAcquireResponseSchema);
|
|
1052
|
-
// One resource, two states, discriminated by `status`. The queued arm
|
|
1053
|
-
// WAITS, exactly as the socket client does: `claim({ id })` means
|
|
1054
|
-
// "serialize me behind the holder" on every transport, and the grant
|
|
1055
|
-
// machinery is the SDK's to own, not a loop each caller re-derives.
|
|
1056
|
-
// (Being queued is still not an error — ADR 0018 — which is precisely
|
|
1057
|
-
// why it no longer surfaces as one here. The `claims` namespace remains
|
|
1058
|
-
// the manual ticket surface.)
|
|
1059
|
-
if (body.status === 'queued') {
|
|
1060
|
-
return awaitGrantOverHttp(`${name}/${params.id}`, body, params);
|
|
1061
|
-
}
|
|
1062
|
-
// The lease's own fields are mirrored at the top level, the same place
|
|
1063
|
-
// the poll puts them — one reader for both answers.
|
|
1064
|
-
return body.fenceToken !== undefined
|
|
1065
|
-
? { id: body.id, fenceToken: body.fenceToken }
|
|
1066
|
-
: { id: body.id };
|
|
1067
|
-
};
|
|
1068
|
-
const releaseClaim = (params) => requestRaw(claimPath(isClaimHandle(params) ? params.target.id : params.id), {
|
|
1069
|
-
method: 'DELETE',
|
|
1070
|
-
}).then(() => undefined);
|
|
1071
|
-
// One beat on the held lease. A lapsed lease answers `claim_lost`
|
|
1072
|
-
// (409), which the wire error mapping surfaces as AbloClaimedError —
|
|
1073
|
-
// the definitive signal that stops the auto-heartbeat loop.
|
|
1074
|
-
const heartbeatClaim = async (id, claimId, options) => {
|
|
1075
|
-
const reply = await requestJson(`${claimPath(id)}/heartbeat`, {
|
|
1076
|
-
method: 'POST',
|
|
1077
|
-
body: JSON.stringify({
|
|
1078
|
-
claimId,
|
|
1079
|
-
...(options.ttl !== undefined ? { ttl: options.ttl } : {}),
|
|
1080
|
-
...(options.details !== undefined ? { details: options.details } : {}),
|
|
1081
|
-
}),
|
|
1082
|
-
}, claimHeartbeatReplySchema);
|
|
1083
|
-
return heldHeartbeatReply(reply, `claim ${claimId} on ${name}/${id}`);
|
|
1084
|
-
};
|
|
1085
|
-
async function claimImpl(params) {
|
|
1086
|
-
let acquired;
|
|
1087
|
-
try {
|
|
1088
|
-
acquired = await acquireClaim(params);
|
|
1089
|
-
}
|
|
1090
|
-
catch (error) {
|
|
1091
|
-
// The try-claim: a held target is an expected outcome of `queue:
|
|
1092
|
-
// false`, not an error — resolve `null` and let the caller move on.
|
|
1093
|
-
// Every other failure (auth, validation, network) stays a rejection,
|
|
1094
|
-
// and the write-site claim path calls `acquireClaim` directly, so a
|
|
1095
|
-
// write that could not claim still fails loudly.
|
|
1096
|
-
if (params.queue === false &&
|
|
1097
|
-
error instanceof AbloClaimedError &&
|
|
1098
|
-
(error.code === 'entity_claimed' || error.code === 'claim_conflict')) {
|
|
1099
|
-
return null;
|
|
1100
|
-
}
|
|
1101
|
-
throw error;
|
|
1102
|
-
}
|
|
1103
|
-
const { id: claimId, fenceToken } = acquired;
|
|
1104
|
-
observability?.captureClaim({
|
|
1105
|
-
phase: 'acquired',
|
|
1106
|
-
claimId,
|
|
1107
|
-
model: name,
|
|
1108
|
-
id: params.id,
|
|
1109
|
-
...(params.field ? { field: partName(params.field) } : {}),
|
|
1110
|
-
description: claimDescription(params),
|
|
1111
|
-
});
|
|
1112
|
-
const { data, stamp } = await retrieveModel(name, { id: params.id });
|
|
1113
|
-
// A held claim hands back a snapshot; the typed `HeldClaim.data` is `T`.
|
|
1114
|
-
// `retrieve` now reports a miss as `undefined` rather than throwing, but a
|
|
1115
|
-
// claim on a row that doesn't exist has nothing to hold — surface it.
|
|
1116
|
-
if (data === undefined) {
|
|
1117
|
-
throw new AbloNotFoundError(`Cannot claim ${name}/${params.id}: it does not exist (or is outside this credential's scope).`, [params.id]);
|
|
1118
|
-
}
|
|
1119
|
-
// One reading of the heartbeat options — cadence and callbacks from
|
|
1120
|
-
// whichever spelling the caller used (plan object, shorthand, or the
|
|
1121
|
-
// deprecated flat callbacks).
|
|
1122
|
-
const plan = resolveHeartbeatPlan(params);
|
|
1123
|
-
const heartbeat = async (beatOptions) => {
|
|
1124
|
-
const resolved = resolveHeartbeatOptions(beatOptions);
|
|
1125
|
-
const beat = await heartbeatClaim(params.id, claimId, {
|
|
1126
|
-
ttl: resolved.ttl ?? params.ttl,
|
|
1127
|
-
...(resolved.details !== undefined ? { details: resolved.details } : {}),
|
|
1128
|
-
});
|
|
1129
|
-
plan.onBeat?.(beat);
|
|
1130
|
-
return beat;
|
|
1131
|
-
};
|
|
1132
|
-
// Opt-in auto-heartbeat — the background-worker cadence. The stateless
|
|
1133
|
-
// HTTP claim defaults to the server's acquire window when no TTL
|
|
1134
|
-
// was requested, so the default cadence lands at 20s beats.
|
|
1135
|
-
const stopHeartbeatLoop = plan.loop
|
|
1136
|
-
? startClaimHeartbeatLoop({
|
|
1137
|
-
beat: () => heartbeat(),
|
|
1138
|
-
intervalMs: heartbeatCadenceMs(params.ttl !== undefined ? toMs(params.ttl) : DEFAULT_CLAIM_TTL_MS, plan.cadence),
|
|
1139
|
-
...(plan.onLost ? { onLost: plan.onLost } : {}),
|
|
1140
|
-
})
|
|
1141
|
-
: undefined;
|
|
1142
|
-
const release = () => {
|
|
1143
|
-
stopHeartbeatLoop?.();
|
|
1144
|
-
return releaseClaim(params);
|
|
1145
|
-
};
|
|
1146
|
-
// The handle handed back is a public claim, so its `meta` is the declared
|
|
1147
|
-
// shape — the same crossing the two decodes above make, spelled the same
|
|
1148
|
-
// way. `subTarget` is wire-shaped by contract, including here, where the
|
|
1149
|
-
// value happens to have started out declared.
|
|
1150
|
-
const { meta, ...narrowed } = subTarget(params);
|
|
1151
|
-
return {
|
|
1152
|
-
object: 'claim',
|
|
1153
|
-
id: claimId,
|
|
1154
|
-
readAt: stamp,
|
|
1155
|
-
...(fenceToken !== undefined ? { fenceToken } : {}),
|
|
1156
|
-
target: {
|
|
1157
|
-
...streamTarget({ model: name, id: params.id }),
|
|
1158
|
-
...narrowed,
|
|
1159
|
-
...(meta !== undefined ? { meta: declaredMeta(meta) } : {}),
|
|
1160
|
-
},
|
|
1161
|
-
description: claimDescription(params),
|
|
1162
|
-
data,
|
|
1163
|
-
release,
|
|
1164
|
-
revoke: () => {
|
|
1165
|
-
void release().catch(() => { });
|
|
1166
|
-
},
|
|
1167
|
-
heartbeat,
|
|
1168
|
-
[Symbol.asyncDispose]: release,
|
|
1169
|
-
};
|
|
1170
|
-
}
|
|
1171
|
-
const claimsForEntity = (params) => requestJson(`/v1/claims?model=${encodeURIComponent(name)}&id=${encodeURIComponent(params.id)}${params.field ? `&field=${encodeURIComponent(partName(params.field))}` : ''}`, { method: 'GET' }, claimListResponseSchema);
|
|
1172
|
-
const claim = Object.assign(claimImpl, {
|
|
1173
|
-
release: releaseClaim,
|
|
1174
|
-
state: async (params) => {
|
|
1175
|
-
const res = await claimsForEntity(params);
|
|
1176
|
-
// Holders come first in the one list; a `queued` entry is a waiter.
|
|
1177
|
-
const first = res.data.find((row) => row.status !== 'queued');
|
|
1178
|
-
return first ? claimFromModelClaim(first) : null;
|
|
1179
|
-
},
|
|
1180
|
-
queue: async (params) => {
|
|
1181
|
-
const res = await claimsForEntity(params);
|
|
1182
|
-
return {
|
|
1183
|
-
object: 'list',
|
|
1184
|
-
data: res.data
|
|
1185
|
-
.filter((row) => row.status === 'queued')
|
|
1186
|
-
.map(claimFromModelClaim),
|
|
1187
|
-
};
|
|
1188
|
-
},
|
|
1189
|
-
reorder: async (params) => {
|
|
1190
|
-
await requestRaw(`${claimPath(params.id)}/reorder`, {
|
|
1191
|
-
method: 'POST',
|
|
1192
|
-
// The reorder route's payload is `{ heldBy, claimId }[]` — a Claim's id
|
|
1193
|
-
// is the claimId.
|
|
1194
|
-
body: JSON.stringify({
|
|
1195
|
-
order: params.order.map((i) => ({ heldBy: i.heldBy, claimId: i.id })),
|
|
1196
|
-
}),
|
|
1197
|
-
});
|
|
1198
|
-
},
|
|
1199
|
-
});
|
|
1200
|
-
const withMutationClaim = async (id, input, run) => {
|
|
1201
|
-
const claimInput = input?.claim;
|
|
1202
|
-
if (!claimInput)
|
|
1203
|
-
return run(input);
|
|
1204
|
-
if (isClaimHandle(claimInput)) {
|
|
1205
|
-
return run({ ...input, claimRef: { id: claimInput.id }, claim: undefined });
|
|
1206
|
-
}
|
|
1207
|
-
// `isClaimHandle` ruled out the handle form above; the generic mismatch
|
|
1208
|
-
// (the union carries `Claim`, the guard narrows `Claim<T>`) keeps the
|
|
1209
|
-
// compiler from subtracting it, so narrow to the inline-options form.
|
|
1210
|
-
const { id: claimId, fenceToken } = await acquireClaim({
|
|
1211
|
-
id,
|
|
1212
|
-
...claimInput,
|
|
1213
|
-
});
|
|
1214
|
-
try {
|
|
1215
|
-
return await run({
|
|
1216
|
-
...input,
|
|
1217
|
-
claimRef: { id: claimId },
|
|
1218
|
-
...(fenceToken !== undefined ? { fenceToken } : {}),
|
|
1219
|
-
claim: undefined,
|
|
1220
|
-
});
|
|
1221
|
-
}
|
|
1222
|
-
finally {
|
|
1223
|
-
await releaseClaim({ id }).catch(() => { });
|
|
1224
|
-
}
|
|
1225
|
-
};
|
|
1226
|
-
function updateModel(arg, updater, contention) {
|
|
1227
|
-
// Functional form: update(id, current => next). The SDK owns the
|
|
1228
|
-
// read-fresh → compute → compare-and-swap → reconcile loop; correctness
|
|
1229
|
-
// rides on the row's watermark (readAt + onStale:'reject'), so no claim
|
|
1230
|
-
// or per-participant identity is needed and contention never clobbers.
|
|
1231
|
-
if (typeof arg === 'string') {
|
|
1232
|
-
const id = arg;
|
|
1233
|
-
if (typeof updater !== 'function') {
|
|
1234
|
-
throw new AbloValidationError(`${name}.update('${id}', updater): the second argument must be an updater ` +
|
|
1235
|
-
`function (current) => next. To write a fixed value, use update({ id, data }).`, { code: 'write_options_invalid' });
|
|
1236
|
-
}
|
|
1237
|
-
return reconcileFunctionalUpdate(updater, contention, {
|
|
1238
|
-
model: name,
|
|
1239
|
-
id,
|
|
1240
|
-
readFresh: async () => {
|
|
1241
|
-
const read = await retrieveModel(name, { id });
|
|
1242
|
-
return { data: read.data, stamp: read.stamp };
|
|
1243
|
-
},
|
|
1244
|
-
writeNext: (patch, readAt) => mutateModel('update', name, id, patch, {
|
|
1245
|
-
readAt,
|
|
1246
|
-
onStale: 'reject',
|
|
1247
|
-
wait: 'confirmed',
|
|
1248
|
-
}),
|
|
1249
|
-
});
|
|
1250
|
-
}
|
|
1251
|
-
const params = arg;
|
|
1252
|
-
return withMutationClaim(params.id, params, async (options) => {
|
|
1253
|
-
await applyClaimedPolicy({ model: name, id: params.id }, options);
|
|
1254
|
-
return mutateModel('update', name, params.id, params.data, options);
|
|
1255
|
-
});
|
|
1256
|
-
}
|
|
1257
|
-
return {
|
|
1258
|
-
claim,
|
|
1259
|
-
retrieve(params) {
|
|
1260
|
-
return retrieveModel(name, params);
|
|
1261
|
-
},
|
|
1262
|
-
list(options) {
|
|
1263
|
-
return listModel(name, options);
|
|
1264
|
-
},
|
|
1265
|
-
async create(params) {
|
|
1266
|
-
const id = params.id ?? createModelId(name, params.idempotencyKey);
|
|
1267
|
-
return withMutationClaim(id, params, async (options) => {
|
|
1268
|
-
await applyClaimedPolicy({ model: name, id }, options);
|
|
1269
|
-
// Confirm the write, then return the row — the obvious expectation of
|
|
1270
|
-
// "create" (the WebSocket client already returns the row). The read-
|
|
1271
|
-
// back is the authoritative server row, so it carries the framework
|
|
1272
|
-
// defaults (createdAt, createdBy, …) and, for an idempotent re-create of
|
|
1273
|
-
// an existing id, the existing row rather than the caller's input.
|
|
1274
|
-
let created;
|
|
1275
|
-
await mutateModel('create', name, id, params.data, {
|
|
1276
|
-
...options,
|
|
1277
|
-
// This method returns the authoritative row, not a receipt. A
|
|
1278
|
-
// queued source acceptance cannot satisfy that return contract,
|
|
1279
|
-
// even when the caller supplied `wait: 'queued'`.
|
|
1280
|
-
wait: 'confirmed',
|
|
1281
|
-
}, async () => {
|
|
1282
|
-
const read = await retrieveModel(name, { id });
|
|
1283
|
-
if (read.data === undefined) {
|
|
1284
|
-
throw new AbloNotFoundError(`create ${name}/${id} did not yield a readable row (the write did not confirm).`, [id]);
|
|
1285
|
-
}
|
|
1286
|
-
created = read.data;
|
|
1287
|
-
});
|
|
1288
|
-
if (created === undefined) {
|
|
1289
|
-
throw new AbloConnectionError('Create settlement did not return its row.', {
|
|
1290
|
-
code: 'commit_no_result',
|
|
1291
|
-
});
|
|
1292
|
-
}
|
|
1293
|
-
return created;
|
|
1294
|
-
});
|
|
1295
|
-
},
|
|
1296
|
-
update: updateModel,
|
|
1297
|
-
async delete(params) {
|
|
1298
|
-
return withMutationClaim(params.id, params, async (options) => {
|
|
1299
|
-
await applyClaimedPolicy({ model: name, id: params.id }, options);
|
|
1300
|
-
return mutateModel('delete', name, params.id, undefined, options);
|
|
1301
|
-
});
|
|
1302
|
-
},
|
|
1303
|
-
async track(params) {
|
|
1304
|
-
const dependency = {
|
|
1305
|
-
model: name.toLowerCase(),
|
|
1306
|
-
id: params.id,
|
|
1307
|
-
...(params.readAt !== undefined ? { readAt: params.readAt } : {}),
|
|
1308
|
-
};
|
|
1309
|
-
// A track carries no write, so it rides the commit lane as a
|
|
1310
|
-
// zero-operation body — the shape `/v1/commits` accepts for registering
|
|
1311
|
-
// a premise without one. Going through the same durable lane as every
|
|
1312
|
-
// other commit means a disconnect replays the registration rather than
|
|
1313
|
-
// dropping it, and a notification that had already fired is not lost to
|
|
1314
|
-
// a retry.
|
|
1315
|
-
const body = await dispatchHttpCommit({
|
|
1316
|
-
path: '/v1/commits',
|
|
1317
|
-
method: 'POST',
|
|
1318
|
-
idempotencyKey: createClientTxId(),
|
|
1319
|
-
body: { track: [dependency] },
|
|
1320
|
-
wait: 'confirmed',
|
|
1321
|
-
});
|
|
1322
|
-
return body.notifications && body.notifications.length > 0
|
|
1323
|
-
? { notifications: body.notifications }
|
|
1324
|
-
: {};
|
|
1325
|
-
},
|
|
1326
|
-
};
|
|
1327
|
-
}
|
|
1328
|
-
return {
|
|
1329
|
-
ready,
|
|
1330
|
-
waitForFlush: () => runInHttpCommitLane(async () => {
|
|
1331
|
-
await ready();
|
|
1332
|
-
const replayed = await replayHttpCommitOutbox();
|
|
1333
|
-
await confirmReplayedHttpCommits(replayed);
|
|
1334
|
-
}),
|
|
1335
|
-
async dispose() { },
|
|
1336
|
-
async purge() { },
|
|
1337
|
-
commits,
|
|
1338
|
-
claims,
|
|
1339
|
-
model,
|
|
1340
|
-
sessions: {
|
|
1341
|
-
async create(params) {
|
|
1342
|
-
// Stateless mint: the configured key is the control-plane credential here
|
|
1343
|
-
// (no startup `rk_` exchange runs on this client). It reuses the resolved
|
|
1344
|
-
// base URL and fetch; the shared `mintSession` handles the two server routes.
|
|
1345
|
-
const apiKey = await resolveApiKeyValue(configuredApiKey);
|
|
1346
|
-
if (!apiKey) {
|
|
1347
|
-
throw new AbloAuthenticationError('sessions.create requires a secret (sk_) API key — call it from your backend, not the browser.', { code: 'apikey_missing' });
|
|
1348
|
-
}
|
|
1349
|
-
// A transport built without a schema has no way to translate `can`'s
|
|
1350
|
-
// schema keys into the type names the server gates on. Minting anyway
|
|
1351
|
-
// would spell every override wrong and surface as
|
|
1352
|
-
// `capability_scope_denied` on the agent's first write, so refuse here
|
|
1353
|
-
// instead of guessing.
|
|
1354
|
-
if (!options.modelTypenames) {
|
|
1355
|
-
throw new AbloValidationError('sessions.create needs the schema this client is bound to. Construct it ' +
|
|
1356
|
-
"through Ablo({ schema, apiKey, transport: 'http' }) rather than the " +
|
|
1357
|
-
'bare transport.', { code: 'invalid_options', param: 'schema' });
|
|
1358
|
-
}
|
|
1359
|
-
return mintSession(params, {
|
|
1360
|
-
apiKey,
|
|
1361
|
-
baseUrl: apiBaseUrl,
|
|
1362
|
-
modelTypenames: options.modelTypenames,
|
|
1363
|
-
...(options.fetch ? { fetch: options.fetch } : {}),
|
|
1364
|
-
});
|
|
1365
|
-
},
|
|
1366
|
-
},
|
|
1367
|
-
async getAuthToken() {
|
|
1368
|
-
// Mirror `authHeaders()`: a configured API key wins, else the
|
|
1369
|
-
// construction-time auth token. Resolve the (possibly async) key setter.
|
|
1370
|
-
return (await resolveApiKeyValue(configuredApiKey)) ?? configuredAuthToken ?? null;
|
|
1371
|
-
},
|
|
1372
|
-
};
|
|
1373
|
-
}
|
|
1374
|
-
function normalizeClaimId(claim) {
|
|
1375
|
-
if (typeof claim === 'string')
|
|
1376
|
-
return claim;
|
|
1377
|
-
return claim?.id;
|
|
1378
|
-
}
|
|
1379
|
-
function parseBody(bodyText) {
|
|
1380
|
-
if (bodyText.length === 0)
|
|
1381
|
-
return null;
|
|
1382
|
-
try {
|
|
1383
|
-
return JSON.parse(bodyText);
|
|
1384
|
-
}
|
|
1385
|
-
catch {
|
|
1386
|
-
return bodyText;
|
|
1387
|
-
}
|
|
1388
|
-
}
|