@abloatai/transaction 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/CONVENTIONS.md +83 -0
- package/README.md +48 -0
- package/dist/ablo.d.ts +90 -0
- package/dist/ablo.d.ts.map +1 -0
- package/dist/ablo.js +34 -0
- package/dist/ablo.js.map +1 -0
- package/dist/ai-sdk/coordinatedTool.d.ts +123 -0
- package/dist/ai-sdk/coordinatedTool.d.ts.map +1 -0
- package/dist/ai-sdk/coordinatedTool.js +135 -0
- package/dist/ai-sdk/coordinatedTool.js.map +1 -0
- package/dist/ai-sdk/index.d.ts +2 -0
- package/dist/ai-sdk/index.d.ts.map +1 -0
- package/dist/ai-sdk/index.js +2 -0
- package/dist/ai-sdk/index.js.map +1 -0
- package/dist/auth/apiKey.d.ts +154 -0
- package/dist/auth/apiKey.d.ts.map +1 -0
- package/dist/auth/apiKey.js +408 -0
- package/dist/auth/apiKey.js.map +1 -0
- package/dist/auth/bootstrapScope.d.ts +16 -0
- package/dist/auth/bootstrapScope.d.ts.map +1 -0
- package/dist/auth/bootstrapScope.js +2 -0
- package/dist/auth/bootstrapScope.js.map +1 -0
- package/dist/auth/browserCredentialSafety.d.ts +7 -0
- package/dist/auth/browserCredentialSafety.d.ts.map +1 -0
- package/dist/auth/browserCredentialSafety.js +29 -0
- package/dist/auth/browserCredentialSafety.js.map +1 -0
- package/dist/auth/capability.d.ts +241 -0
- package/dist/auth/capability.d.ts.map +1 -0
- package/dist/auth/capability.js +253 -0
- package/dist/auth/capability.js.map +1 -0
- package/dist/auth/capabilityLifecycle.d.ts +47 -0
- package/dist/auth/capabilityLifecycle.d.ts.map +1 -0
- package/dist/auth/capabilityLifecycle.js +108 -0
- package/dist/auth/capabilityLifecycle.js.map +1 -0
- package/dist/auth/credentialEndpoint.d.ts +10 -0
- package/dist/auth/credentialEndpoint.d.ts.map +1 -0
- package/dist/auth/credentialEndpoint.js +58 -0
- package/dist/auth/credentialEndpoint.js.map +1 -0
- package/dist/auth/credentialEndpointProtocol.d.ts +24 -0
- package/dist/auth/credentialEndpointProtocol.d.ts.map +1 -0
- package/dist/auth/credentialEndpointProtocol.js +34 -0
- package/dist/auth/credentialEndpointProtocol.js.map +1 -0
- package/dist/auth/credentialKind.d.ts +4 -0
- package/dist/auth/credentialKind.d.ts.map +1 -0
- package/dist/auth/credentialKind.js +15 -0
- package/dist/auth/credentialKind.js.map +1 -0
- package/dist/auth/credentialPolicy.d.ts +141 -0
- package/dist/auth/credentialPolicy.d.ts.map +1 -0
- package/dist/auth/credentialPolicy.js +113 -0
- package/dist/auth/credentialPolicy.js.map +1 -0
- package/dist/auth/credentialResult.d.ts +7 -0
- package/dist/auth/credentialResult.d.ts.map +1 -0
- package/dist/auth/credentialResult.js +9 -0
- package/dist/auth/credentialResult.js.map +1 -0
- package/dist/auth/credentialSource.d.ts +31 -0
- package/dist/auth/credentialSource.d.ts.map +1 -0
- package/dist/auth/credentialSource.js +56 -0
- package/dist/auth/credentialSource.js.map +1 -0
- package/dist/auth/hostedEndpoints.d.ts +22 -0
- package/dist/auth/hostedEndpoints.d.ts.map +1 -0
- package/dist/auth/hostedEndpoints.js +22 -0
- package/dist/auth/hostedEndpoints.js.map +1 -0
- package/dist/auth/identity.d.ts +62 -0
- package/dist/auth/identity.d.ts.map +1 -0
- package/dist/auth/identity.js +224 -0
- package/dist/auth/identity.js.map +1 -0
- package/dist/auth/index.d.ts +189 -0
- package/dist/auth/index.d.ts.map +1 -0
- package/dist/auth/index.js +312 -0
- package/dist/auth/index.js.map +1 -0
- package/dist/auth/schemas.d.ts +41 -0
- package/dist/auth/schemas.d.ts.map +1 -0
- package/dist/auth/schemas.js +69 -0
- package/dist/auth/schemas.js.map +1 -0
- package/dist/auth/sessionMint.d.ts +29 -0
- package/dist/auth/sessionMint.d.ts.map +1 -0
- package/dist/auth/sessionMint.js +92 -0
- package/dist/auth/sessionMint.js.map +1 -0
- package/dist/auth/token.d.ts +4 -0
- package/dist/auth/token.d.ts.map +1 -0
- package/dist/auth/token.js +4 -0
- package/dist/auth/token.js.map +1 -0
- package/dist/batching/index.d.ts +56 -0
- package/dist/batching/index.d.ts.map +1 -0
- package/dist/batching/index.js +148 -0
- package/dist/batching/index.js.map +1 -0
- package/dist/coordination/awaitClaimGrant.d.ts +57 -0
- package/dist/coordination/awaitClaimGrant.d.ts.map +1 -0
- package/dist/coordination/awaitClaimGrant.js +138 -0
- package/dist/coordination/awaitClaimGrant.js.map +1 -0
- package/dist/coordination/claimHeartbeatLoop.d.ts +85 -0
- package/dist/coordination/claimHeartbeatLoop.d.ts.map +1 -0
- package/dist/coordination/claimHeartbeatLoop.js +109 -0
- package/dist/coordination/claimHeartbeatLoop.js.map +1 -0
- package/dist/coordination/claimMeta.d.ts +50 -0
- package/dist/coordination/claimMeta.d.ts.map +1 -0
- package/dist/coordination/claimMeta.js +53 -0
- package/dist/coordination/claimMeta.js.map +1 -0
- package/dist/coordination/events.d.ts +75 -0
- package/dist/coordination/events.d.ts.map +1 -0
- package/dist/coordination/events.js +8 -0
- package/dist/coordination/events.js.map +1 -0
- package/dist/coordination/index.d.ts +20 -0
- package/dist/coordination/index.d.ts.map +1 -0
- package/dist/coordination/index.js +46 -0
- package/dist/coordination/index.js.map +1 -0
- package/dist/coordination/locator.d.ts +106 -0
- package/dist/coordination/locator.d.ts.map +1 -0
- package/dist/coordination/locator.js +110 -0
- package/dist/coordination/locator.js.map +1 -0
- package/dist/coordination/schema.d.ts +1332 -0
- package/dist/coordination/schema.d.ts.map +1 -0
- package/dist/coordination/schema.js +1137 -0
- package/dist/coordination/schema.js.map +1 -0
- package/dist/coordination/targetConflict.d.ts +3 -0
- package/dist/coordination/targetConflict.d.ts.map +1 -0
- package/dist/coordination/targetConflict.js +74 -0
- package/dist/coordination/targetConflict.js.map +1 -0
- package/dist/coordination/trace.d.ts +79 -0
- package/dist/coordination/trace.d.ts.map +1 -0
- package/dist/coordination/trace.js +139 -0
- package/dist/coordination/trace.js.map +1 -0
- package/dist/docs/catalog.d.ts +73 -0
- package/dist/docs/catalog.d.ts.map +1 -0
- package/dist/docs/catalog.js +231 -0
- package/dist/docs/catalog.js.map +1 -0
- package/dist/docs/index.d.ts +11 -0
- package/dist/docs/index.d.ts.map +1 -0
- package/dist/docs/index.js +11 -0
- package/dist/docs/index.js.map +1 -0
- package/dist/durableWrites.d.ts +63 -0
- package/dist/durableWrites.d.ts.map +1 -0
- package/dist/durableWrites.js +72 -0
- package/dist/durableWrites.js.map +1 -0
- package/dist/environment.d.ts +106 -0
- package/dist/environment.d.ts.map +1 -0
- package/dist/environment.js +109 -0
- package/dist/environment.js.map +1 -0
- package/dist/errorCodes.d.ts +411 -0
- package/dist/errorCodes.d.ts.map +1 -0
- package/dist/errorCodes.js +500 -0
- package/dist/errorCodes.js.map +1 -0
- package/dist/errors.d.ts +429 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +687 -0
- package/dist/errors.js.map +1 -0
- package/dist/footprint.d.ts +112 -0
- package/dist/footprint.d.ts.map +1 -0
- package/dist/footprint.js +0 -0
- package/dist/footprint.js.map +1 -0
- package/dist/headlessClient.d.ts +10 -0
- package/dist/headlessClient.d.ts.map +1 -0
- package/dist/headlessClient.js +114 -0
- package/dist/headlessClient.js.map +1 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +21 -0
- package/dist/index.js.map +1 -0
- package/dist/keys/index.d.ts +88 -0
- package/dist/keys/index.d.ts.map +1 -0
- package/dist/keys/index.js +208 -0
- package/dist/keys/index.js.map +1 -0
- package/dist/log/syncDeltaRow.d.ts +159 -0
- package/dist/log/syncDeltaRow.d.ts.map +1 -0
- package/dist/log/syncDeltaRow.js +96 -0
- package/dist/log/syncDeltaRow.js.map +1 -0
- package/dist/logger.d.ts +17 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +8 -0
- package/dist/logger.js.map +1 -0
- package/dist/observability.d.ts +54 -0
- package/dist/observability.d.ts.map +1 -0
- package/dist/observability.js +20 -0
- package/dist/observability.js.map +1 -0
- package/dist/policy/types.d.ts +218 -0
- package/dist/policy/types.d.ts.map +1 -0
- package/dist/policy/types.js +127 -0
- package/dist/policy/types.js.map +1 -0
- package/dist/resources/functionalUpdate.d.ts +80 -0
- package/dist/resources/functionalUpdate.d.ts.map +1 -0
- package/dist/resources/functionalUpdate.js +88 -0
- package/dist/resources/functionalUpdate.js.map +1 -0
- package/dist/resources/httpResources.d.ts +449 -0
- package/dist/resources/httpResources.d.ts.map +1 -0
- package/dist/resources/httpResources.js +8 -0
- package/dist/resources/httpResources.js.map +1 -0
- package/dist/resources/modelOperations.d.ts +380 -0
- package/dist/resources/modelOperations.d.ts.map +1 -0
- package/dist/resources/modelOperations.js +13 -0
- package/dist/resources/modelOperations.js.map +1 -0
- package/dist/resources/mutationOptions.d.ts +67 -0
- package/dist/resources/mutationOptions.d.ts.map +1 -0
- package/dist/resources/mutationOptions.js +10 -0
- package/dist/resources/mutationOptions.js.map +1 -0
- package/dist/resources/where.d.ts +102 -0
- package/dist/resources/where.d.ts.map +1 -0
- package/dist/resources/where.js +116 -0
- package/dist/resources/where.js.map +1 -0
- package/dist/resources/writeOptionsSchema.d.ts +48 -0
- package/dist/resources/writeOptionsSchema.d.ts.map +1 -0
- package/dist/resources/writeOptionsSchema.js +74 -0
- package/dist/resources/writeOptionsSchema.js.map +1 -0
- package/dist/schema/coordination.d.ts +113 -0
- package/dist/schema/coordination.d.ts.map +1 -0
- package/dist/schema/coordination.js +134 -0
- package/dist/schema/coordination.js.map +1 -0
- package/dist/schema/ddl.d.ts +98 -0
- package/dist/schema/ddl.d.ts.map +1 -0
- package/dist/schema/ddl.js +492 -0
- package/dist/schema/ddl.js.map +1 -0
- package/dist/schema/ddlLock.d.ts +36 -0
- package/dist/schema/ddlLock.d.ts.map +1 -0
- package/dist/schema/ddlLock.js +47 -0
- package/dist/schema/ddlLock.js.map +1 -0
- package/dist/schema/diff.d.ts +226 -0
- package/dist/schema/diff.d.ts.map +1 -0
- package/dist/schema/diff.js +290 -0
- package/dist/schema/diff.js.map +1 -0
- package/dist/schema/field.d.ts +121 -0
- package/dist/schema/field.d.ts.map +1 -0
- package/dist/schema/field.js +266 -0
- package/dist/schema/field.js.map +1 -0
- package/dist/schema/fieldRef.d.ts +58 -0
- package/dist/schema/fieldRef.d.ts.map +1 -0
- package/dist/schema/fieldRef.js +26 -0
- package/dist/schema/fieldRef.js.map +1 -0
- package/dist/schema/generate.d.ts +20 -0
- package/dist/schema/generate.d.ts.map +1 -0
- package/dist/schema/generate.js +87 -0
- package/dist/schema/generate.js.map +1 -0
- package/dist/schema/index.d.ts +43 -0
- package/dist/schema/index.d.ts.map +1 -0
- package/dist/schema/index.js +81 -0
- package/dist/schema/index.js.map +1 -0
- package/dist/schema/loadStrategy.d.ts +46 -0
- package/dist/schema/loadStrategy.d.ts.map +1 -0
- package/dist/schema/loadStrategy.js +47 -0
- package/dist/schema/loadStrategy.js.map +1 -0
- package/dist/schema/model.d.ts +380 -0
- package/dist/schema/model.d.ts.map +1 -0
- package/dist/schema/model.js +124 -0
- package/dist/schema/model.js.map +1 -0
- package/dist/schema/openapi.d.ts +59 -0
- package/dist/schema/openapi.d.ts.map +1 -0
- package/dist/schema/openapi.js +508 -0
- package/dist/schema/openapi.js.map +1 -0
- package/dist/schema/queries.d.ts +202 -0
- package/dist/schema/queries.d.ts.map +1 -0
- package/dist/schema/queries.js +144 -0
- package/dist/schema/queries.js.map +1 -0
- package/dist/schema/relation.d.ts +205 -0
- package/dist/schema/relation.d.ts.map +1 -0
- package/dist/schema/relation.js +105 -0
- package/dist/schema/relation.js.map +1 -0
- package/dist/schema/residency.d.ts +30 -0
- package/dist/schema/residency.d.ts.map +1 -0
- package/dist/schema/residency.js +26 -0
- package/dist/schema/residency.js.map +1 -0
- package/dist/schema/roles.d.ts +250 -0
- package/dist/schema/roles.d.ts.map +1 -0
- package/dist/schema/roles.js +231 -0
- package/dist/schema/roles.js.map +1 -0
- package/dist/schema/schema.d.ts +352 -0
- package/dist/schema/schema.d.ts.map +1 -0
- package/dist/schema/schema.js +326 -0
- package/dist/schema/schema.js.map +1 -0
- package/dist/schema/select.d.ts +41 -0
- package/dist/schema/select.d.ts.map +1 -0
- package/dist/schema/select.js +91 -0
- package/dist/schema/select.js.map +1 -0
- package/dist/schema/serialize.d.ts +116 -0
- package/dist/schema/serialize.d.ts.map +1 -0
- package/dist/schema/serialize.js +278 -0
- package/dist/schema/serialize.js.map +1 -0
- package/dist/schema/sugar.d.ts +110 -0
- package/dist/schema/sugar.d.ts.map +1 -0
- package/dist/schema/sugar.js +84 -0
- package/dist/schema/sugar.js.map +1 -0
- package/dist/schema/tenancy.d.ts +140 -0
- package/dist/schema/tenancy.d.ts.map +1 -0
- package/dist/schema/tenancy.js +191 -0
- package/dist/schema/tenancy.js.map +1 -0
- package/dist/server/adapter.d.ts +174 -0
- package/dist/server/adapter.d.ts.map +1 -0
- package/dist/server/adapter.js +19 -0
- package/dist/server/adapter.js.map +1 -0
- package/dist/server/commit.d.ts +108 -0
- package/dist/server/commit.d.ts.map +1 -0
- package/dist/server/commit.js +2 -0
- package/dist/server/commit.js.map +1 -0
- package/dist/server/index.d.ts +15 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +3 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/readConfig.d.ts +81 -0
- package/dist/server/readConfig.d.ts.map +1 -0
- package/dist/server/readConfig.js +9 -0
- package/dist/server/readConfig.js.map +1 -0
- package/dist/server/storageMode.d.ts +24 -0
- package/dist/server/storageMode.d.ts.map +1 -0
- package/dist/server/storageMode.js +18 -0
- package/dist/server/storageMode.js.map +1 -0
- package/dist/source/adapter.d.ts +84 -0
- package/dist/source/adapter.d.ts.map +1 -0
- package/dist/source/adapter.js +25 -0
- package/dist/source/adapter.js.map +1 -0
- package/dist/source/adapters/drizzle.d.ts +49 -0
- package/dist/source/adapters/drizzle.d.ts.map +1 -0
- package/dist/source/adapters/drizzle.js +220 -0
- package/dist/source/adapters/drizzle.js.map +1 -0
- package/dist/source/adapters/kysely.d.ts +43 -0
- package/dist/source/adapters/kysely.d.ts.map +1 -0
- package/dist/source/adapters/kysely.js +206 -0
- package/dist/source/adapters/kysely.js.map +1 -0
- package/dist/source/adapters/kyselyMutationCore.d.ts +77 -0
- package/dist/source/adapters/kyselyMutationCore.d.ts.map +1 -0
- package/dist/source/adapters/kyselyMutationCore.js +126 -0
- package/dist/source/adapters/kyselyMutationCore.js.map +1 -0
- package/dist/source/adapters/memory.d.ts +14 -0
- package/dist/source/adapters/memory.d.ts.map +1 -0
- package/dist/source/adapters/memory.js +131 -0
- package/dist/source/adapters/memory.js.map +1 -0
- package/dist/source/adapters/prisma.d.ts +64 -0
- package/dist/source/adapters/prisma.d.ts.map +1 -0
- package/dist/source/adapters/prisma.js +203 -0
- package/dist/source/adapters/prisma.js.map +1 -0
- package/dist/source/conformance.d.ts +38 -0
- package/dist/source/conformance.d.ts.map +1 -0
- package/dist/source/conformance.js +216 -0
- package/dist/source/conformance.js.map +1 -0
- package/dist/source/connector.d.ts +96 -0
- package/dist/source/connector.d.ts.map +1 -0
- package/dist/source/connector.js +267 -0
- package/dist/source/connector.js.map +1 -0
- package/dist/source/connectorProtocol.d.ts +155 -0
- package/dist/source/connectorProtocol.d.ts.map +1 -0
- package/dist/source/connectorProtocol.js +164 -0
- package/dist/source/connectorProtocol.js.map +1 -0
- package/dist/source/contract.d.ts +196 -0
- package/dist/source/contract.d.ts.map +1 -0
- package/dist/source/contract.js +165 -0
- package/dist/source/contract.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/factory.d.ts +93 -0
- package/dist/source/factory.d.ts.map +1 -0
- package/dist/source/factory.js +287 -0
- package/dist/source/factory.js.map +1 -0
- package/dist/source/idempotency.d.ts +62 -0
- package/dist/source/idempotency.d.ts.map +1 -0
- package/dist/source/idempotency.js +145 -0
- package/dist/source/idempotency.js.map +1 -0
- package/dist/source/index.d.ts +24 -0
- package/dist/source/index.d.ts.map +1 -0
- package/dist/source/index.js +29 -0
- package/dist/source/index.js.map +1 -0
- package/dist/source/kysely.d.ts +3 -0
- package/dist/source/kysely.d.ts.map +1 -0
- package/dist/source/kysely.js +3 -0
- package/dist/source/kysely.js.map +1 -0
- package/dist/source/migrations.d.ts +22 -0
- package/dist/source/migrations.d.ts.map +1 -0
- package/dist/source/migrations.js +104 -0
- package/dist/source/migrations.js.map +1 -0
- package/dist/source/next.d.ts +33 -0
- package/dist/source/next.d.ts.map +1 -0
- package/dist/source/next.js +26 -0
- package/dist/source/next.js.map +1 -0
- package/dist/source/pushQueue.d.ts +135 -0
- package/dist/source/pushQueue.d.ts.map +1 -0
- package/dist/source/pushQueue.js +257 -0
- package/dist/source/pushQueue.js.map +1 -0
- package/dist/source/signing.d.ts +93 -0
- package/dist/source/signing.d.ts.map +1 -0
- package/dist/source/signing.js +163 -0
- package/dist/source/signing.js.map +1 -0
- package/dist/source/types.d.ts +402 -0
- package/dist/source/types.d.ts.map +1 -0
- package/dist/source/types.js +60 -0
- package/dist/source/types.js.map +1 -0
- package/dist/syncLog/contract.d.ts +21 -0
- package/dist/syncLog/contract.d.ts.map +1 -0
- package/dist/syncLog/contract.js +20 -0
- package/dist/syncLog/contract.js.map +1 -0
- package/dist/syncLog/index.d.ts +2 -0
- package/dist/syncLog/index.d.ts.map +1 -0
- package/dist/syncLog/index.js +2 -0
- package/dist/syncLog/index.js.map +1 -0
- package/dist/testing/fixtures/httpResponses.d.ts +74 -0
- package/dist/testing/fixtures/httpResponses.d.ts.map +1 -0
- package/dist/testing/fixtures/httpResponses.js +102 -0
- package/dist/testing/fixtures/httpResponses.js.map +1 -0
- package/dist/transactionLayer.d.ts +110 -0
- package/dist/transactionLayer.d.ts.map +1 -0
- package/dist/transactionLayer.js +25 -0
- package/dist/transactionLayer.js.map +1 -0
- package/dist/transactions/settlement/commitEnvelope.d.ts +144 -0
- package/dist/transactions/settlement/commitEnvelope.d.ts.map +1 -0
- package/dist/transactions/settlement/commitEnvelope.js +162 -0
- package/dist/transactions/settlement/commitEnvelope.js.map +1 -0
- package/dist/transactions/settlement/httpCommitEnvelope.d.ts +54 -0
- package/dist/transactions/settlement/httpCommitEnvelope.d.ts.map +1 -0
- package/dist/transactions/settlement/httpCommitEnvelope.js +208 -0
- package/dist/transactions/settlement/httpCommitEnvelope.js.map +1 -0
- package/dist/transactions/settlement/idempotencyKey.d.ts +11 -0
- package/dist/transactions/settlement/idempotencyKey.d.ts.map +1 -0
- package/dist/transactions/settlement/idempotencyKey.js +10 -0
- package/dist/transactions/settlement/idempotencyKey.js.map +1 -0
- package/dist/transactions/settlement/pendingWrite.d.ts +113 -0
- package/dist/transactions/settlement/pendingWrite.d.ts.map +1 -0
- package/dist/transactions/settlement/pendingWrite.js +21 -0
- package/dist/transactions/settlement/pendingWrite.js.map +1 -0
- package/dist/transport/commitFrames.d.ts +91 -0
- package/dist/transport/commitFrames.d.ts.map +1 -0
- package/dist/transport/commitFrames.js +135 -0
- package/dist/transport/commitFrames.js.map +1 -0
- package/dist/transport/connectionManager.d.ts +216 -0
- package/dist/transport/connectionManager.d.ts.map +1 -0
- package/dist/transport/connectionManager.js +674 -0
- package/dist/transport/connectionManager.js.map +1 -0
- package/dist/transport/credentialLifecycle.d.ts +178 -0
- package/dist/transport/credentialLifecycle.d.ts.map +1 -0
- package/dist/transport/credentialLifecycle.js +324 -0
- package/dist/transport/credentialLifecycle.js.map +1 -0
- package/dist/transport/heartbeat.d.ts +66 -0
- package/dist/transport/heartbeat.d.ts.map +1 -0
- package/dist/transport/heartbeat.js +94 -0
- package/dist/transport/heartbeat.js.map +1 -0
- package/dist/transport/httpClient.d.ts +143 -0
- package/dist/transport/httpClient.d.ts.map +1 -0
- package/dist/transport/httpClient.js +150 -0
- package/dist/transport/httpClient.js.map +1 -0
- package/dist/transport/httpFeed.d.ts +4 -0
- package/dist/transport/httpFeed.d.ts.map +1 -0
- package/dist/transport/httpFeed.js +94 -0
- package/dist/transport/httpFeed.js.map +1 -0
- package/dist/transport/httpOptions.d.ts +34 -0
- package/dist/transport/httpOptions.d.ts.map +1 -0
- package/dist/transport/httpOptions.js +13 -0
- package/dist/transport/httpOptions.js.map +1 -0
- package/dist/transport/httpTransport.d.ts +68 -0
- package/dist/transport/httpTransport.d.ts.map +1 -0
- package/dist/transport/httpTransport.js +1433 -0
- package/dist/transport/httpTransport.js.map +1 -0
- package/dist/transport/networkProbe.d.ts +85 -0
- package/dist/transport/networkProbe.d.ts.map +1 -0
- package/dist/transport/networkProbe.js +208 -0
- package/dist/transport/networkProbe.js.map +1 -0
- package/dist/transport/wsFrameHandlers.d.ts +129 -0
- package/dist/transport/wsFrameHandlers.d.ts.map +1 -0
- package/dist/transport/wsFrameHandlers.js +429 -0
- package/dist/transport/wsFrameHandlers.js.map +1 -0
- package/dist/transport/wsTransport.d.ts +575 -0
- package/dist/transport/wsTransport.d.ts.map +1 -0
- package/dist/transport/wsTransport.js +1024 -0
- package/dist/transport/wsTransport.js.map +1 -0
- package/dist/types/assertExact.d.ts +18 -0
- package/dist/types/assertExact.d.ts.map +1 -0
- package/dist/types/assertExact.js +2 -0
- package/dist/types/assertExact.js.map +1 -0
- package/dist/types/global.d.ts +108 -0
- package/dist/types/global.d.ts.map +1 -0
- package/dist/types/global.js +41 -0
- package/dist/types/global.js.map +1 -0
- package/dist/types/index.d.ts +206 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +57 -0
- package/dist/types/index.js.map +1 -0
- package/dist/types/modelData.d.ts +11 -0
- package/dist/types/modelData.d.ts.map +1 -0
- package/dist/types/modelData.js +10 -0
- package/dist/types/modelData.js.map +1 -0
- package/dist/types/participant.d.ts +21 -0
- package/dist/types/participant.d.ts.map +1 -0
- package/dist/types/participant.js +11 -0
- package/dist/types/participant.js.map +1 -0
- package/dist/types/streams.d.ts +545 -0
- package/dist/types/streams.d.ts.map +1 -0
- package/dist/types/streams.js +12 -0
- package/dist/types/streams.js.map +1 -0
- package/dist/utils/asyncIterator.d.ts +35 -0
- package/dist/utils/asyncIterator.d.ts.map +1 -0
- package/dist/utils/asyncIterator.js +136 -0
- package/dist/utils/asyncIterator.js.map +1 -0
- package/dist/utils/duration.d.ts +51 -0
- package/dist/utils/duration.d.ts.map +1 -0
- package/dist/utils/duration.js +78 -0
- package/dist/utils/duration.js.map +1 -0
- package/dist/utils/json.d.ts +58 -0
- package/dist/utils/json.d.ts.map +1 -0
- package/dist/utils/json.js +277 -0
- package/dist/utils/json.js.map +1 -0
- package/dist/webhooks/events.d.ts +44 -0
- package/dist/webhooks/events.d.ts.map +1 -0
- package/dist/webhooks/events.js +43 -0
- package/dist/webhooks/events.js.map +1 -0
- package/dist/webhooks/index.d.ts +9 -0
- package/dist/webhooks/index.d.ts.map +1 -0
- package/dist/webhooks/index.js +9 -0
- package/dist/webhooks/index.js.map +1 -0
- package/dist/wire/accountResponses.d.ts +463 -0
- package/dist/wire/accountResponses.d.ts.map +1 -0
- package/dist/wire/accountResponses.js +294 -0
- package/dist/wire/accountResponses.js.map +1 -0
- package/dist/wire/auth.d.ts +57 -0
- package/dist/wire/auth.d.ts.map +1 -0
- package/dist/wire/auth.js +71 -0
- package/dist/wire/auth.js.map +1 -0
- package/dist/wire/bootstrapReason.d.ts +10 -0
- package/dist/wire/bootstrapReason.d.ts.map +1 -0
- package/dist/wire/bootstrapReason.js +9 -0
- package/dist/wire/bootstrapReason.js.map +1 -0
- package/dist/wire/claimEvent.d.ts +70 -0
- package/dist/wire/claimEvent.d.ts.map +1 -0
- package/dist/wire/claimEvent.js +74 -0
- package/dist/wire/claimEvent.js.map +1 -0
- package/dist/wire/claims.d.ts +475 -0
- package/dist/wire/claims.d.ts.map +1 -0
- package/dist/wire/claims.js +326 -0
- package/dist/wire/claims.js.map +1 -0
- package/dist/wire/commit.d.ts +604 -0
- package/dist/wire/commit.d.ts.map +1 -0
- package/dist/wire/commit.js +322 -0
- package/dist/wire/commit.js.map +1 -0
- package/dist/wire/delta.d.ts +251 -0
- package/dist/wire/delta.d.ts.map +1 -0
- package/dist/wire/delta.js +148 -0
- package/dist/wire/delta.js.map +1 -0
- package/dist/wire/errorEnvelope.d.ts +73 -0
- package/dist/wire/errorEnvelope.d.ts.map +1 -0
- package/dist/wire/errorEnvelope.js +124 -0
- package/dist/wire/errorEnvelope.js.map +1 -0
- package/dist/wire/feedCursor.d.ts +61 -0
- package/dist/wire/feedCursor.d.ts.map +1 -0
- package/dist/wire/feedCursor.js +83 -0
- package/dist/wire/feedCursor.js.map +1 -0
- package/dist/wire/feedEvent.d.ts +264 -0
- package/dist/wire/feedEvent.d.ts.map +1 -0
- package/dist/wire/feedEvent.js +66 -0
- package/dist/wire/feedEvent.js.map +1 -0
- package/dist/wire/frames.d.ts +195 -0
- package/dist/wire/frames.d.ts.map +1 -0
- package/dist/wire/frames.js +51 -0
- package/dist/wire/frames.js.map +1 -0
- package/dist/wire/inboundFrames.d.ts +490 -0
- package/dist/wire/inboundFrames.d.ts.map +1 -0
- package/dist/wire/inboundFrames.js +117 -0
- package/dist/wire/inboundFrames.js.map +1 -0
- package/dist/wire/index.d.ts +55 -0
- package/dist/wire/index.d.ts.map +1 -0
- package/dist/wire/index.js +84 -0
- package/dist/wire/index.js.map +1 -0
- package/dist/wire/listEnvelope.d.ts +38 -0
- package/dist/wire/listEnvelope.d.ts.map +1 -0
- package/dist/wire/listEnvelope.js +43 -0
- package/dist/wire/listEnvelope.js.map +1 -0
- package/dist/wire/modelMutations.d.ts +32 -0
- package/dist/wire/modelMutations.d.ts.map +1 -0
- package/dist/wire/modelMutations.js +53 -0
- package/dist/wire/modelMutations.js.map +1 -0
- package/dist/wire/modelResponses.d.ts +79 -0
- package/dist/wire/modelResponses.d.ts.map +1 -0
- package/dist/wire/modelResponses.js +44 -0
- package/dist/wire/modelResponses.js.map +1 -0
- package/dist/wire/modelShape.d.ts +79 -0
- package/dist/wire/modelShape.d.ts.map +1 -0
- package/dist/wire/modelShape.js +75 -0
- package/dist/wire/modelShape.js.map +1 -0
- package/dist/wire/protocol.d.ts +39 -0
- package/dist/wire/protocol.d.ts.map +1 -0
- package/dist/wire/protocol.js +39 -0
- package/dist/wire/protocol.js.map +1 -0
- package/dist/wire/protocolVersion.d.ts +74 -0
- package/dist/wire/protocolVersion.d.ts.map +1 -0
- package/dist/wire/protocolVersion.js +84 -0
- package/dist/wire/protocolVersion.js.map +1 -0
- package/package.json +187 -0
- package/src/ablo.ts +139 -0
- package/src/ai-sdk/coordinatedTool.ts +214 -0
- package/src/ai-sdk/index.ts +7 -0
- package/src/auth/apiKey.ts +540 -0
- package/src/auth/bootstrapScope.ts +15 -0
- package/src/auth/browserCredentialSafety.ts +48 -0
- package/src/auth/capability.ts +326 -0
- package/src/auth/capabilityLifecycle.ts +174 -0
- package/src/auth/credentialEndpoint.ts +79 -0
- package/src/auth/credentialEndpointProtocol.ts +51 -0
- package/src/auth/credentialKind.ts +20 -0
- package/src/auth/credentialPolicy.ts +244 -0
- package/src/auth/credentialResult.ts +23 -0
- package/src/auth/credentialSource.ts +99 -0
- package/src/auth/hostedEndpoints.ts +24 -0
- package/src/auth/identity.ts +330 -0
- package/src/auth/index.ts +592 -0
- package/src/auth/schemas.ts +94 -0
- package/src/auth/sessionMint.ts +126 -0
- package/src/auth/token.ts +4 -0
- package/src/batching/index.ts +200 -0
- package/src/coordination/awaitClaimGrant.ts +243 -0
- package/src/coordination/claimHeartbeatLoop.ts +165 -0
- package/src/coordination/claimMeta.ts +56 -0
- package/src/coordination/events.ts +86 -0
- package/src/coordination/index.ts +181 -0
- package/src/coordination/locator.ts +200 -0
- package/src/coordination/schema.ts +1346 -0
- package/src/coordination/targetConflict.ts +85 -0
- package/src/coordination/trace.ts +173 -0
- package/src/docs/catalog.ts +274 -0
- package/src/docs/index.ts +18 -0
- package/src/durableWrites.ts +134 -0
- package/src/environment.ts +132 -0
- package/src/errorCodes.ts +1591 -0
- package/src/errors.ts +918 -0
- package/src/footprint.ts +0 -0
- package/src/headlessClient.ts +161 -0
- package/src/index.ts +52 -0
- package/src/keys/index.ts +254 -0
- package/src/log/syncDeltaRow.ts +119 -0
- package/src/logger.ts +22 -0
- package/src/observability.ts +85 -0
- package/src/policy/types.ts +292 -0
- package/src/resources/functionalUpdate.ts +151 -0
- package/src/resources/httpResources.ts +520 -0
- package/src/resources/modelOperations.ts +444 -0
- package/src/resources/mutationOptions.ts +66 -0
- package/src/resources/where.ts +160 -0
- package/src/resources/writeOptionsSchema.ts +91 -0
- package/src/schema/coordination.ts +161 -0
- package/src/schema/ddl.ts +593 -0
- package/src/schema/ddlLock.ts +53 -0
- package/src/schema/diff.ts +489 -0
- package/src/schema/field.ts +321 -0
- package/src/schema/fieldRef.ts +88 -0
- package/src/schema/generate.ts +95 -0
- package/src/schema/index.ts +278 -0
- package/src/schema/loadStrategy.ts +52 -0
- package/src/schema/model.ts +476 -0
- package/src/schema/openapi.ts +658 -0
- package/src/schema/queries.ts +273 -0
- package/src/schema/relation.ts +291 -0
- package/src/schema/residency.ts +30 -0
- package/src/schema/roles.ts +325 -0
- package/src/schema/schema.ts +825 -0
- package/src/schema/select.ts +115 -0
- package/src/schema/serialize.ts +373 -0
- package/src/schema/sugar.ts +194 -0
- package/src/schema/tenancy.ts +221 -0
- package/src/server/adapter.ts +203 -0
- package/src/server/commit.ts +109 -0
- package/src/server/index.ts +26 -0
- package/src/server/readConfig.ts +82 -0
- package/src/server/storageMode.ts +20 -0
- package/src/source/adapter.ts +85 -0
- package/src/source/adapters/drizzle.ts +291 -0
- package/src/source/adapters/kysely.ts +346 -0
- package/src/source/adapters/kyselyMutationCore.ts +220 -0
- package/src/source/adapters/memory.ts +154 -0
- package/src/source/adapters/prisma.ts +282 -0
- package/src/source/conformance.ts +287 -0
- package/src/source/connector.ts +394 -0
- package/src/source/connectorProtocol.ts +189 -0
- package/src/source/contract.ts +221 -0
- package/src/source/drizzle.ts +1 -0
- package/src/source/factory.ts +444 -0
- package/src/source/idempotency.ts +189 -0
- package/src/source/index.ts +182 -0
- package/src/source/kysely.ts +2 -0
- package/src/source/migrations.ts +109 -0
- package/src/source/next.ts +38 -0
- package/src/source/pushQueue.ts +368 -0
- package/src/source/signing.ts +279 -0
- package/src/source/types.ts +502 -0
- package/src/syncLog/contract.ts +32 -0
- package/src/syncLog/index.ts +1 -0
- package/src/testing/fixtures/httpResponses.ts +155 -0
- package/src/transactionLayer.ts +122 -0
- package/src/transactions/settlement/commitEnvelope.ts +192 -0
- package/src/transactions/settlement/httpCommitEnvelope.ts +250 -0
- package/src/transactions/settlement/idempotencyKey.ts +11 -0
- package/src/transactions/settlement/pendingWrite.ts +24 -0
- package/src/transport/commitFrames.ts +225 -0
- package/src/transport/connectionManager.ts +856 -0
- package/src/transport/credentialLifecycle.ts +389 -0
- package/src/transport/heartbeat.ts +118 -0
- package/src/transport/httpClient.ts +354 -0
- package/src/transport/httpFeed.ts +111 -0
- package/src/transport/httpOptions.ts +41 -0
- package/src/transport/httpTransport.ts +1976 -0
- package/src/transport/networkProbe.ts +249 -0
- package/src/transport/wsFrameHandlers.ts +619 -0
- package/src/transport/wsTransport.ts +1491 -0
- package/src/types/assertExact.ts +17 -0
- package/src/types/global.ts +123 -0
- package/src/types/index.ts +270 -0
- package/src/types/modelData.ts +11 -0
- package/src/types/participant.ts +22 -0
- package/src/types/streams.ts +702 -0
- package/src/utils/asyncIterator.ts +141 -0
- package/src/utils/duration.ts +88 -0
- package/src/utils/json.ts +281 -0
- package/src/webhooks/events.ts +96 -0
- package/src/webhooks/index.ts +12 -0
- package/src/wire/accountResponses.ts +328 -0
- package/src/wire/auth.ts +86 -0
- package/src/wire/bootstrapReason.ts +10 -0
- package/src/wire/claimEvent.ts +85 -0
- package/src/wire/claims.ts +370 -0
- package/src/wire/commit.ts +379 -0
- package/src/wire/delta.ts +173 -0
- package/src/wire/errorEnvelope.ts +133 -0
- package/src/wire/feedCursor.ts +91 -0
- package/src/wire/feedEvent.ts +74 -0
- package/src/wire/frames.ts +77 -0
- package/src/wire/inboundFrames.ts +182 -0
- package/src/wire/index.ts +328 -0
- package/src/wire/listEnvelope.ts +51 -0
- package/src/wire/modelMutations.ts +55 -0
- package/src/wire/modelResponses.ts +48 -0
- package/src/wire/modelShape.ts +83 -0
- package/src/wire/protocol.ts +39 -0
- package/src/wire/protocolVersion.ts +97 -0
|
@@ -0,0 +1,1491 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The duplex transport: a WebSocket connection to the sync server, extracted
|
|
3
|
+
* out of the reactive engine's `SyncWebSocket` (ADR 0016). It owns the socket
|
|
4
|
+
* lifecycle (connect, reconnect with exponential backoff, disconnect, the
|
|
5
|
+
* application-level heartbeat), sends commits, claims, releases, and
|
|
6
|
+
* subscription updates over the one connection, correlates their
|
|
7
|
+
* acknowledgement frames back to awaiting callers, and dispatches every other
|
|
8
|
+
* inbound frame through {@link dispatchWsFrame}.
|
|
9
|
+
*
|
|
10
|
+
* What it deliberately does not do is materialise: deltas, sync responses,
|
|
11
|
+
* and bootstrap payloads are surfaced through protected frame hooks
|
|
12
|
+
* ({@link handleDelta} and its siblings) whose defaults just emit, so a
|
|
13
|
+
* server-side caller gets the push feed — claim grants, losses, deltas —
|
|
14
|
+
* with no store, no cursor, and no renderer. The reactive engine subclasses
|
|
15
|
+
* this and overrides the hooks with validation, cursor advancement, and
|
|
16
|
+
* bootstrap handling. The membership test (ADR 0016): a caller with no socket
|
|
17
|
+
* loses only push — it polls instead; a caller with no reactive layer loses
|
|
18
|
+
* the local copy it never wanted.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { EventEmitter } from 'events';
|
|
22
|
+
import type { ParticipantKind } from '../types/participant.js';
|
|
23
|
+
import {
|
|
24
|
+
AbloConnectionError,
|
|
25
|
+
AbloError,
|
|
26
|
+
AbloSessionError,
|
|
27
|
+
AbloValidationError,
|
|
28
|
+
toAbloError,
|
|
29
|
+
} from '../errors.js';
|
|
30
|
+
import {
|
|
31
|
+
participantClaimPayloadSchema,
|
|
32
|
+
updateSubscriptionPayloadSchema,
|
|
33
|
+
} from '../coordination/schema.js';
|
|
34
|
+
import type { BootstrapReason } from '../wire/bootstrapReason.js';
|
|
35
|
+
import type { ClientSyncDelta } from '../wire/delta.js';
|
|
36
|
+
import type {
|
|
37
|
+
ClaimAcquired,
|
|
38
|
+
PresenceUpdate,
|
|
39
|
+
ClaimExpired,
|
|
40
|
+
ClaimGranted,
|
|
41
|
+
ClaimHeartbeatAckPayload,
|
|
42
|
+
ClaimLost,
|
|
43
|
+
ClaimQueue,
|
|
44
|
+
ClaimQueued,
|
|
45
|
+
ClaimRejection,
|
|
46
|
+
ParticipantClaimPayload,
|
|
47
|
+
StaleNotification,
|
|
48
|
+
ReadDependency,
|
|
49
|
+
TrackDependency,
|
|
50
|
+
WireClaim,
|
|
51
|
+
} from '../coordination/schema.js';
|
|
52
|
+
import { PROTOCOL_VERSION, WS_CLOSE_PROTOCOL_VERSION } from '../wire/protocolVersion.js';
|
|
53
|
+
import {
|
|
54
|
+
WS_BEARER_SUBPROTOCOL_PREFIX,
|
|
55
|
+
WS_SYNC_SUBPROTOCOL,
|
|
56
|
+
type AuthTokenGetter,
|
|
57
|
+
} from '../auth/credentialSource.js';
|
|
58
|
+
import { buildCommitFrame, type CommitAck, type CommitFrameOperation } from './commitFrames.js';
|
|
59
|
+
import {
|
|
60
|
+
dispatchWsFrame,
|
|
61
|
+
readWsInboundFrame,
|
|
62
|
+
type WsSession,
|
|
63
|
+
type PendingCommit,
|
|
64
|
+
type PendingClaim,
|
|
65
|
+
type PendingSubscription,
|
|
66
|
+
} from './wsFrameHandlers.js';
|
|
67
|
+
import { HeartbeatController } from './heartbeat.js';
|
|
68
|
+
import { noopLogger, type Logger } from '../logger.js';
|
|
69
|
+
import {
|
|
70
|
+
noopSocketObservability,
|
|
71
|
+
type SocketObservability,
|
|
72
|
+
} from '../observability.js';
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Ceiling for the exponential reconnect backoff (`reconnectDelay * 2^n`,
|
|
76
|
+
* ±15% jitter). A client-side setting, not part of the wire contract.
|
|
77
|
+
*/
|
|
78
|
+
const MAX_RECONNECT_DELAY_MS = 30_000;
|
|
79
|
+
|
|
80
|
+
export interface SyncCapabilities {
|
|
81
|
+
partialBootstrap?: boolean;
|
|
82
|
+
compressedDeltas?: boolean;
|
|
83
|
+
streamingBootstrap?: boolean;
|
|
84
|
+
batchedDeltas?: boolean;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface WsTransportOptions {
|
|
88
|
+
/** Base HTTP URL of the sync server */
|
|
89
|
+
baseUrl?: string;
|
|
90
|
+
url?: string;
|
|
91
|
+
/**
|
|
92
|
+
* Engine bookkeeping only. The server is bearer-only — it resolves identity
|
|
93
|
+
* from the verified credential and never reads these — and the transport
|
|
94
|
+
* itself never reads them either. A bare connection omits them.
|
|
95
|
+
*/
|
|
96
|
+
userId?: string;
|
|
97
|
+
organizationId?: string;
|
|
98
|
+
lastSyncId?: number;
|
|
99
|
+
syncGroups?: string[];
|
|
100
|
+
capabilities?: SyncCapabilities;
|
|
101
|
+
reconnectDelay?: number;
|
|
102
|
+
maxReconnectDelay?: number;
|
|
103
|
+
/**
|
|
104
|
+
* Collaboration event type keys to listen for (e.g., ['document:selection',
|
|
105
|
+
* 'document:cursor']). Wire messages with matching types (underscore format)
|
|
106
|
+
* are emitted as events.
|
|
107
|
+
*
|
|
108
|
+
* Defaults to none. The vocabulary is the application's, not the SDK's, so
|
|
109
|
+
* an application names the events it broadcasts rather than inheriting a
|
|
110
|
+
* built-in list that would only ever fit one consumer.
|
|
111
|
+
*/
|
|
112
|
+
collaborationEvents?: string[];
|
|
113
|
+
/**
|
|
114
|
+
* The participant kind declared on the WebSocket upgrade. Defaults to
|
|
115
|
+
* `'user'` (session auth, the web app). Agent runtimes pass `'agent'` so the
|
|
116
|
+
* server verifies them by capability token instead of session auth. The
|
|
117
|
+
* server reads this as the `kind` query parameter.
|
|
118
|
+
*/
|
|
119
|
+
kind?: ParticipantKind;
|
|
120
|
+
/**
|
|
121
|
+
* The agent's bearer credential — a restricted (`rk_`) API key. When set, it
|
|
122
|
+
* is sent in the `ablo.bearer.<token>` WebSocket subprotocol so the credential
|
|
123
|
+
* stays out of URLs and proxy logs. Required for `kind: 'agent'` and ignored
|
|
124
|
+
* for `kind: 'user'`.
|
|
125
|
+
*/
|
|
126
|
+
capabilityToken?: string;
|
|
127
|
+
/**
|
|
128
|
+
* Getter for the current credential. When provided, the WebSocket upgrade
|
|
129
|
+
* reads it instead of a copied `capabilityToken`, so reconnects always use
|
|
130
|
+
* the freshest token from the SDK's single credential source. Preferred over
|
|
131
|
+
* `getCapabilityToken`.
|
|
132
|
+
*/
|
|
133
|
+
getAuthToken?: AuthTokenGetter;
|
|
134
|
+
/** @deprecated Use `getAuthToken`. Kept for direct low-level callers. */
|
|
135
|
+
getCapabilityToken?: AuthTokenGetter;
|
|
136
|
+
/**
|
|
137
|
+
* Hold the first connection until the owner releases it. While held,
|
|
138
|
+
* `connect()` is ignored (debug-logged). A host that builds the socket
|
|
139
|
+
* before identity is resolved sets this, seeds the late values (`setKind`,
|
|
140
|
+
* `setSyncGroups`, the resume cursor), and calls {@link WsTransport.allowConnect}
|
|
141
|
+
* followed by `connect()` — so no caller can open an unscoped connection in
|
|
142
|
+
* the window between construction and identity resolution.
|
|
143
|
+
*/
|
|
144
|
+
deferConnect?: boolean;
|
|
145
|
+
/** Where the transport logs. Defaults to silent. */
|
|
146
|
+
logger?: Logger;
|
|
147
|
+
/** Where lifecycle breadcrumbs, socket errors, and coordination outcomes
|
|
148
|
+
* are reported. Defaults to silent. */
|
|
149
|
+
observability?: SocketObservability;
|
|
150
|
+
/** The connectivity signal consulted before connecting, sending, and
|
|
151
|
+
* scheduling reconnects. Defaults to always-online, which is correct for a
|
|
152
|
+
* server-side host; the browser engine passes its `navigator.onLine`-backed
|
|
153
|
+
* provider. */
|
|
154
|
+
onlineStatus?: { isOnline(): boolean };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Bootstrap hint from server indicating full or partial bootstrap is needed.
|
|
159
|
+
* Properties are optional since server payload structure may vary.
|
|
160
|
+
*/
|
|
161
|
+
export interface BootstrapHint {
|
|
162
|
+
tables?: string[];
|
|
163
|
+
reason?: BootstrapReason;
|
|
164
|
+
staleTables?: string[];
|
|
165
|
+
totalDeltaCount?: number;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Bootstrap data event payload */
|
|
169
|
+
export interface BootstrapDataEvent {
|
|
170
|
+
entityType: string;
|
|
171
|
+
data: unknown;
|
|
172
|
+
isComplete: boolean;
|
|
173
|
+
cursor?: string;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* The presence frame, re-exported at the path consumers already reach it
|
|
178
|
+
* through. The declaration lives with the rest of the coordination vocabulary;
|
|
179
|
+
* this keeps `import { PresenceUpdate } from '…/wsTransport'` working for
|
|
180
|
+
* everything that used to import the hand-written type from here. The local
|
|
181
|
+
* `import type` above is what makes this a re-export of a bound name rather
|
|
182
|
+
* than a pass-through that leaves the name unusable in this file.
|
|
183
|
+
*/
|
|
184
|
+
export type { PresenceUpdate };
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Core event map — transport-level events that every connection emits.
|
|
188
|
+
* SDK consumers extend this with app-specific collaboration events.
|
|
189
|
+
*/
|
|
190
|
+
export interface CoreSyncEventMap {
|
|
191
|
+
connected: [];
|
|
192
|
+
disconnected: [CloseEvent];
|
|
193
|
+
reconnecting: [{ attempt: number; delay: number }];
|
|
194
|
+
delta: [ClientSyncDelta];
|
|
195
|
+
delta_batch: [ClientSyncDelta[]];
|
|
196
|
+
bootstrap_required: [BootstrapHint];
|
|
197
|
+
bootstrap_data: [BootstrapDataEvent];
|
|
198
|
+
presence_update: [PresenceUpdate];
|
|
199
|
+
error: [Error];
|
|
200
|
+
session_error: [Error];
|
|
201
|
+
/**
|
|
202
|
+
* The WebSocket `onclose` fired before `onopen` — the handshake itself
|
|
203
|
+
* failed. The browser cannot expose the HTTP status (it shows as code
|
|
204
|
+
* 1006 with no reason), so the consumer should run an authenticated
|
|
205
|
+
* HTTP probe to distinguish auth failure (session expired) from a
|
|
206
|
+
* generic network issue.
|
|
207
|
+
*/
|
|
208
|
+
handshake_failed: [CloseEvent];
|
|
209
|
+
reconnect_failed: [{ attempts: number }];
|
|
210
|
+
/**
|
|
211
|
+
* Server-initiated notification that a previously-active claim's
|
|
212
|
+
* TTL has expired. Consumers (e.g., the participant SDK) re-mint
|
|
213
|
+
* a fresh capability and re-claim, OR accept the drop. The claim
|
|
214
|
+
* is already inactive on the server side by the time this fires —
|
|
215
|
+
* no client-side action needed unless re-claiming.
|
|
216
|
+
*/
|
|
217
|
+
claim_expired: [ClaimExpired];
|
|
218
|
+
/**
|
|
219
|
+
* Server rejected an `claim_begin` because another participant
|
|
220
|
+
* already holds an open claim on the same target (cooperative
|
|
221
|
+
* mutex enforced server-side). Surfaces to the participant-level
|
|
222
|
+
* ClaimStream so the caller knows their announce was denied.
|
|
223
|
+
* Payload mirrors the wire frame's `payload`.
|
|
224
|
+
*/
|
|
225
|
+
claim_rejected: [ClaimRejection];
|
|
226
|
+
/**
|
|
227
|
+
* Fair-queue frames (opt-in `queue: true` on `claim_begin`). `claim_acquired`
|
|
228
|
+
* means the target was free and the lease is ours immediately; `claim_queued`
|
|
229
|
+
* means the claim is waiting in line (carries `position`); `claim_granted`
|
|
230
|
+
* means it reached the head and the lease is now ours; `claim_lost` means a
|
|
231
|
+
* held/granted claim was taken away (TTL lapse on disconnect, revoke).
|
|
232
|
+
*/
|
|
233
|
+
/**
|
|
234
|
+
* Per-entity wait-queue snapshot: `{ target, queue: Claim[] }` with each
|
|
235
|
+
* entry `status: 'queued'` + `position`. Broadcast to entity peers on every
|
|
236
|
+
* queue mutation — powers the reactive `ablo.<model>.claim.queue({ id })` read.
|
|
237
|
+
*/
|
|
238
|
+
claim_queue: [ClaimQueue];
|
|
239
|
+
claim_acquired: [ClaimAcquired];
|
|
240
|
+
claim_queued: [ClaimQueued];
|
|
241
|
+
claim_granted: [ClaimGranted];
|
|
242
|
+
claim_lost: [ClaimLost];
|
|
243
|
+
/**
|
|
244
|
+
* Reply to an outbound `claim_heartbeat` — the lease's fate: `held` with
|
|
245
|
+
* the extended `expiresAt`, `queued` with the current `position`, or
|
|
246
|
+
* `lost`. Correlated back to the awaiting caller by `claimId` in the
|
|
247
|
+
* claim stream.
|
|
248
|
+
*/
|
|
249
|
+
claim_heartbeat_ack: [ClaimHeartbeatAckPayload];
|
|
250
|
+
/**
|
|
251
|
+
* A committed write guarded with `onStale: 'notify'` collided with a
|
|
252
|
+
* concurrent change. Rather than forcing an outcome, the engine returns the
|
|
253
|
+
* conflicting field's current value so the actor — an agent reasoning over the
|
|
254
|
+
* change, or a person watching the row — can reconcile it. The commit itself
|
|
255
|
+
* succeeded; the held operations were not written, and the actor re-issues
|
|
256
|
+
* them once it has reconciled.
|
|
257
|
+
*/
|
|
258
|
+
'conflict:notified': [{ clientTxId: string; notifications: StaleNotification[] }];
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Collaboration event — app-specific real-time events (selection, cursors, etc.)
|
|
263
|
+
* Each event is a [payload] tuple matching the EventEmitter convention.
|
|
264
|
+
*/
|
|
265
|
+
// Empty default — consumers extend with their own events
|
|
266
|
+
export type DefaultCollaborationEvents = Record<string, never>;
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Constraint for event maps: every value must be a tuple of handler args.
|
|
270
|
+
*
|
|
271
|
+
* Why a mapped type and not `Record<string, unknown[]>`?
|
|
272
|
+
* `Record<string, ...>` requires an implicit string index signature, which
|
|
273
|
+
* TypeScript interfaces don't have. So a closed interface like Ablo's
|
|
274
|
+
* `AbloCollaborationEvents` would fail to satisfy `Record<string, unknown[]>`,
|
|
275
|
+
* even though every one of its values is a tuple. This mapped form iterates
|
|
276
|
+
* over `keyof T` instead of demanding a string index, so it accepts both
|
|
277
|
+
* closed interfaces and open Record types — while still enforcing
|
|
278
|
+
* "every value is an array."
|
|
279
|
+
*/
|
|
280
|
+
export type EventMap<T> = { [K in keyof T]: unknown[] };
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Full event map = core + collaboration events.
|
|
284
|
+
* Pass your own TCollaboration to add app-specific events.
|
|
285
|
+
*/
|
|
286
|
+
export type SyncWebSocketEventMap<
|
|
287
|
+
TCollaboration extends EventMap<TCollaboration> = DefaultCollaborationEvents
|
|
288
|
+
> = CoreSyncEventMap & TCollaboration;
|
|
289
|
+
|
|
290
|
+
// ---------------------------------------------------------------------------
|
|
291
|
+
// Consumers pass their own event types as the TCollaboration generic parameter.
|
|
292
|
+
|
|
293
|
+
export class WsTransport<
|
|
294
|
+
TCollaboration extends EventMap<TCollaboration> = DefaultCollaborationEvents
|
|
295
|
+
> extends EventEmitter {
|
|
296
|
+
/**
|
|
297
|
+
* Subscribe to events with automatic cleanup.
|
|
298
|
+
* Returns unsubscribe function for clean disposal.
|
|
299
|
+
*/
|
|
300
|
+
subscribe<K extends keyof SyncWebSocketEventMap<TCollaboration>>(
|
|
301
|
+
event: K,
|
|
302
|
+
handler: (...args: SyncWebSocketEventMap<TCollaboration>[K]) => void
|
|
303
|
+
): () => void {
|
|
304
|
+
this.on(event as string, handler as (...args: unknown[]) => void);
|
|
305
|
+
return () => this.off(event as string, handler as (...args: unknown[]) => void);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Send a collaboration event (app-specific real-time message).
|
|
310
|
+
* The wire format is `{ type: messageType, payload: { ...payload, timestamp } }`.
|
|
311
|
+
*/
|
|
312
|
+
sendCollaborationEvent<K extends string & keyof TCollaboration>(
|
|
313
|
+
messageType: K,
|
|
314
|
+
payload: TCollaboration[K] extends [infer P] ? Omit<P & Record<string, unknown>, 'timestamp'> : never
|
|
315
|
+
): void {
|
|
316
|
+
if (this.ws?.readyState !== WebSocket.OPEN) return;
|
|
317
|
+
this.send({
|
|
318
|
+
type: messageType.replace(/:/g, '_'), // 'document:selection' → 'document_selection' wire format
|
|
319
|
+
payload: { ...payload, timestamp: Date.now() },
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
private ws: WebSocket | null = null;
|
|
323
|
+
protected options: Required<
|
|
324
|
+
Omit<
|
|
325
|
+
WsTransportOptions,
|
|
326
|
+
| 'baseUrl'
|
|
327
|
+
| 'kind'
|
|
328
|
+
| 'capabilityToken'
|
|
329
|
+
| 'getAuthToken'
|
|
330
|
+
| 'getCapabilityToken'
|
|
331
|
+
| 'deferConnect'
|
|
332
|
+
| 'logger'
|
|
333
|
+
| 'observability'
|
|
334
|
+
| 'onlineStatus'
|
|
335
|
+
>
|
|
336
|
+
> & {
|
|
337
|
+
baseUrl?: string;
|
|
338
|
+
// `kind`, `capabilityToken`, `getAuthToken`, and `getCapabilityToken` are
|
|
339
|
+
// genuinely optional: session connections pass no token field and agents
|
|
340
|
+
// pass one. They are excluded from the Required<> wrap so callers do not
|
|
341
|
+
// have to supply placeholders.
|
|
342
|
+
kind?: WsTransportOptions['kind'];
|
|
343
|
+
capabilityToken?: WsTransportOptions['capabilityToken'];
|
|
344
|
+
getAuthToken?: WsTransportOptions['getAuthToken'];
|
|
345
|
+
getCapabilityToken?: WsTransportOptions['getCapabilityToken'];
|
|
346
|
+
};
|
|
347
|
+
/** The transport's reporting ports, shared with the subclassing engine. */
|
|
348
|
+
protected readonly logger: Logger;
|
|
349
|
+
protected readonly observability: SocketObservability;
|
|
350
|
+
protected readonly onlineStatus: { isOnline(): boolean };
|
|
351
|
+
private reconnectAttempts = 0;
|
|
352
|
+
/** Stop retrying after this many consecutive failures (backoff caps at 30s, so ~7.5 min total) */
|
|
353
|
+
private static readonly MAX_RECONNECT_ATTEMPTS = 15;
|
|
354
|
+
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
355
|
+
/**
|
|
356
|
+
* Application-level heartbeat: ping every 30 seconds and force-close after a
|
|
357
|
+
* 10-second silence. The {@link HeartbeatController} holds the timing and the
|
|
358
|
+
* zombie-socket rationale; the closures below are the only socket access it
|
|
359
|
+
* gets.
|
|
360
|
+
*/
|
|
361
|
+
private readonly heartbeat: HeartbeatController;
|
|
362
|
+
private isConnecting = false;
|
|
363
|
+
private isManualClose = false;
|
|
364
|
+
/** True while the owner still holds the first connection (`deferConnect`).
|
|
365
|
+
* `connect()` is ignored until {@link allowConnect} lifts the hold. */
|
|
366
|
+
private connectHeld: boolean;
|
|
367
|
+
/** When true, a session error has been detected (from any path — WS close or HTTP bootstrap).
|
|
368
|
+
* Suppresses reconnection and Sentry error capture to avoid cascading noise. */
|
|
369
|
+
private _sessionErrorDetected = false;
|
|
370
|
+
/** True once `onopen` has fired at least once on the current socket. Reset each
|
|
371
|
+
* time a new socket is created in `connect()`. Used by `onclose` to detect
|
|
372
|
+
* handshake failures (close before open) — the one signal we have for "the
|
|
373
|
+
* server rejected the upgrade" since browsers hide the HTTP status (e.g.
|
|
374
|
+
* 401) behind the opaque 1006 close code. */
|
|
375
|
+
private _everOpened = false;
|
|
376
|
+
/**
|
|
377
|
+
* Diagnostic snapshot of the last connection lifecycle. Persisted across
|
|
378
|
+
* the lifetime of the transport so that any subsequent "not connected"
|
|
379
|
+
* rejection can quote the actual root cause (close code + reason + when)
|
|
380
|
+
* instead of bottoming out at a generic error string. Browser WS code 1006
|
|
381
|
+
* hides the real reason, so we layer on our own signals: `forceCloseReason`
|
|
382
|
+
* captures heartbeat trips / send failures, `everOpened` distinguishes
|
|
383
|
+
* handshake reject from mid-session drop, and `sessionErrorAt` tells us
|
|
384
|
+
* whether reconnect is suppressed.
|
|
385
|
+
*/
|
|
386
|
+
private lastOpenAt: number | null = null;
|
|
387
|
+
private lastCloseAt: number | null = null;
|
|
388
|
+
private lastCloseCode: number | null = null;
|
|
389
|
+
private lastCloseReason: string | null = null;
|
|
390
|
+
private lastForceCloseReason: string | null = null;
|
|
391
|
+
private sessionErrorAt: number | null = null;
|
|
392
|
+
/** Registered collaboration event keys (colon format) for dispatch in onmessage */
|
|
393
|
+
private collaborationEventTypes: Set<string>;
|
|
394
|
+
/**
|
|
395
|
+
* A minimal session adapter handed to the inbound frame dispatch table
|
|
396
|
+
* ({@link dispatchWsFrame}). It exposes only the members the handlers touch;
|
|
397
|
+
* the closure members read live state so a reassignment here (for example the
|
|
398
|
+
* `pendingSubscriptions` reset on close) cannot strand a handler on a stale
|
|
399
|
+
* reference. Built in the constructor, after the state it captures exists.
|
|
400
|
+
*/
|
|
401
|
+
private readonly frameSession: WsSession;
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* In-flight `commit` mutation requests keyed by clientTxId. Resolved when
|
|
405
|
+
* a matching `mutation_result` frame arrives from the server, or rejected on
|
|
406
|
+
* timeout / disconnect. Lets consumers await a server ack for mutations
|
|
407
|
+
* sent over the same socket that streams deltas.
|
|
408
|
+
*/
|
|
409
|
+
private pendingMutations = new Map<string, PendingCommit>();
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* In-flight `claim` requests keyed by claimId. Resolved when the matching
|
|
413
|
+
* `claim_ack` arrives, or rejected on timeout or disconnect — the same
|
|
414
|
+
* request/response pattern as `pendingMutations`, multiplexed over the one
|
|
415
|
+
* connection.
|
|
416
|
+
*/
|
|
417
|
+
private pendingClaims = new Map<string, PendingClaim>();
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* In-flight `update_subscription` frames awaiting `subscription_ack`.
|
|
421
|
+
* A FIFO queue rather than a keyed Map because the wire ack carries no
|
|
422
|
+
* correlation id — the server applies subscription updates in receive
|
|
423
|
+
* order and acks in the same order, so `shift()` on ack matches the
|
|
424
|
+
* oldest pending request. (Read-interest changes are infrequent and
|
|
425
|
+
* usually settle before the next one, so depth is ~1 in practice.)
|
|
426
|
+
*/
|
|
427
|
+
private pendingSubscriptions: PendingSubscription[] = [];
|
|
428
|
+
|
|
429
|
+
constructor(options: WsTransportOptions) {
|
|
430
|
+
super();
|
|
431
|
+
|
|
432
|
+
// Construct the WebSocket URL from the base server URL.
|
|
433
|
+
const baseUrl = options.baseUrl || options.url || "http://localhost:8080";
|
|
434
|
+
const wsProtocol = baseUrl.startsWith('https') ? 'wss' : 'ws';
|
|
435
|
+
const wsUrl = baseUrl.replace(/^https?/, wsProtocol) + '/api/sync/ws';
|
|
436
|
+
|
|
437
|
+
const { logger, observability, onlineStatus, deferConnect, ...connectionOptions } =
|
|
438
|
+
options;
|
|
439
|
+
this.logger = logger ?? noopLogger;
|
|
440
|
+
this.observability = observability ?? noopSocketObservability;
|
|
441
|
+
this.onlineStatus = onlineStatus ?? { isOnline: () => true };
|
|
442
|
+
this.connectHeld = deferConnect === true;
|
|
443
|
+
|
|
444
|
+
this.options = {
|
|
445
|
+
url: wsUrl,
|
|
446
|
+
reconnectDelay: 1000,
|
|
447
|
+
maxReconnectDelay: MAX_RECONNECT_DELAY_MS,
|
|
448
|
+
collaborationEvents: [],
|
|
449
|
+
syncGroups: [],
|
|
450
|
+
lastSyncId: 0,
|
|
451
|
+
userId: '',
|
|
452
|
+
organizationId: '',
|
|
453
|
+
capabilities: {
|
|
454
|
+
partialBootstrap: true,
|
|
455
|
+
compressedDeltas: true,
|
|
456
|
+
streamingBootstrap: true,
|
|
457
|
+
batchedDeltas: true,
|
|
458
|
+
},
|
|
459
|
+
...connectionOptions,
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
this.heartbeat = new HeartbeatController(
|
|
463
|
+
{
|
|
464
|
+
isSocketOpen: () => this.ws?.readyState === WebSocket.OPEN,
|
|
465
|
+
// Optional-chained rather than asserted: the controller only calls
|
|
466
|
+
// this synchronously after `isSocketOpen()`, so `ws` is present.
|
|
467
|
+
sendPing: () => {
|
|
468
|
+
this.ws?.send(JSON.stringify({ type: 'ping' }));
|
|
469
|
+
},
|
|
470
|
+
forceClose: (reason) => { this.forceClose(reason); },
|
|
471
|
+
},
|
|
472
|
+
this.observability,
|
|
473
|
+
);
|
|
474
|
+
|
|
475
|
+
this.collaborationEventTypes = new Set(
|
|
476
|
+
options.collaborationEvents ?? []
|
|
477
|
+
);
|
|
478
|
+
|
|
479
|
+
// Session slice for the inbound frame dispatch table — see the field doc on
|
|
480
|
+
// `frameSession` for why reassigned members are exposed through closures
|
|
481
|
+
// instead of captured references. The four materialisation handlers route
|
|
482
|
+
// through the protected hooks, so a subclass's overrides win.
|
|
483
|
+
this.frameSession = {
|
|
484
|
+
emit: (event, ...args) => this.emit(event, ...args),
|
|
485
|
+
logger: this.logger,
|
|
486
|
+
observability: this.observability,
|
|
487
|
+
pendingMutations: this.pendingMutations,
|
|
488
|
+
pendingClaims: this.pendingClaims,
|
|
489
|
+
shiftPendingSubscription: () => this.pendingSubscriptions.shift(),
|
|
490
|
+
options: this.options,
|
|
491
|
+
collaborationEventTypes: this.collaborationEventTypes,
|
|
492
|
+
handleDelta: (delta) => { this.handleDelta(delta); },
|
|
493
|
+
handleSyncResponse: (payload) => { this.handleSyncResponse(payload); },
|
|
494
|
+
handleBootstrapResponse: (payload) => { this.handleBootstrapResponse(payload); },
|
|
495
|
+
handlePresenceUpdate: (message) => {
|
|
496
|
+
this.handlePresenceUpdate(
|
|
497
|
+
message as { payload?: PresenceUpdate; [k: string]: unknown },
|
|
498
|
+
);
|
|
499
|
+
},
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// ── Materialisation hooks ─────────────────────────────────────────────
|
|
504
|
+
//
|
|
505
|
+
// The frame dispatch routes deltas, sync responses, bootstrap payloads, and
|
|
506
|
+
// presence frames through these protected hooks. The defaults surface the
|
|
507
|
+
// raw push feed as events — enough for a socketed agent that wants claim
|
|
508
|
+
// push and change notifications without a local copy. The reactive engine
|
|
509
|
+
// overrides them with wire validation, cursor advancement, and bootstrap
|
|
510
|
+
// handling; anything it does not override keeps the transport default.
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* One inbound delta, straight off the wire and unvalidated. The default
|
|
514
|
+
* emits it as-is; the reactive engine's override validates against the
|
|
515
|
+
* canonical delta schema and drops anything malformed before emitting.
|
|
516
|
+
*/
|
|
517
|
+
protected handleDelta(rawDelta: unknown): void {
|
|
518
|
+
this.emit('delta', rawDelta);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/** A `sync_response` frame. Meaningless without a resume cursor to advance,
|
|
522
|
+
* so the transport default does nothing. */
|
|
523
|
+
protected handleSyncResponse(_payload: unknown): void {}
|
|
524
|
+
|
|
525
|
+
/** A `bootstrap_response` frame. Bootstrap is materialisation, so the
|
|
526
|
+
* transport default does nothing. */
|
|
527
|
+
protected handleBootstrapResponse(_payload: unknown): void {}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Handles a presence update from the server. The wire frame's payload is
|
|
531
|
+
* forwarded as-is, so every consumer reads the same shape; stripping fields
|
|
532
|
+
* here would drop `kind`, `activity`, `syncGroups`, and `isAgent` for
|
|
533
|
+
* consumers that need them.
|
|
534
|
+
*
|
|
535
|
+
* The wire frame is:
|
|
536
|
+
* { type: 'presence_update', payload: { kind, userId, status,
|
|
537
|
+
* syncGroups, activity, isAgent, timestamp, activeClaims } }
|
|
538
|
+
*/
|
|
539
|
+
protected handlePresenceUpdate(
|
|
540
|
+
// Typed as a partial presence event as well as an envelope, because both
|
|
541
|
+
// shapes genuinely arrive: the server's canonical `{ payload: {...} }`,
|
|
542
|
+
// and legacy pathways (test fixtures) that put the fields at the top
|
|
543
|
+
// level. Declaring the union up front is what lets the fallback below
|
|
544
|
+
// stay a plain read instead of a checked-off cast.
|
|
545
|
+
message: Partial<PresenceUpdate> & {
|
|
546
|
+
payload?: PresenceUpdate;
|
|
547
|
+
[k: string]: unknown;
|
|
548
|
+
},
|
|
549
|
+
): void {
|
|
550
|
+
const event: PresenceUpdate =
|
|
551
|
+
// Server canonical path: `{ payload: {...} }`. Some legacy
|
|
552
|
+
// pathways emit fields at the top level (test fixtures) — fall
|
|
553
|
+
// back to reading from the message itself.
|
|
554
|
+
message.payload ?? (message as PresenceUpdate);
|
|
555
|
+
this.emit('presence_update', event);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Runs after the socket opens and `connected` is emitted, before the
|
|
560
|
+
* heartbeat starts. The default does nothing; the reactive engine's
|
|
561
|
+
* override runs its open ritual — presence, ack, incremental sync, and the
|
|
562
|
+
* catch-up poll.
|
|
563
|
+
*/
|
|
564
|
+
protected onOpened(): void {}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* Runs inside the close handler, after the socket reference is cleared and
|
|
568
|
+
* before in-flight requests are rejected. The default does nothing; the
|
|
569
|
+
* reactive engine's override stops its catch-up poll.
|
|
570
|
+
*/
|
|
571
|
+
protected onClosed(): void {}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* The resume position sent as the `cursor` query parameter on the upgrade.
|
|
575
|
+
* The transport itself holds no cursor — a bare connection resumes from
|
|
576
|
+
* nothing — so the default is empty; the reactive engine's override supplies
|
|
577
|
+
* its persisted sync cursor.
|
|
578
|
+
*/
|
|
579
|
+
protected resumeCursor(): string {
|
|
580
|
+
return '';
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Mark that a session error has been detected (e.g. 401 from HTTP bootstrap).
|
|
585
|
+
* Suppresses further reconnection attempts and Sentry error capture.
|
|
586
|
+
*/
|
|
587
|
+
setSessionErrorDetected(): void {
|
|
588
|
+
this._sessionErrorDetected = true;
|
|
589
|
+
this.sessionErrorAt = Date.now();
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Clear the session-error latch so `connect()` / `scheduleReconnect()`
|
|
594
|
+
* work again. Called by the store's access-credential recovery path when
|
|
595
|
+
* the close was a re-mintable `ek_`/`rk_` expiry (`4001 credential_expired`),
|
|
596
|
+
* not a login loss — see `isAccessCredentialExpiryCloseReason`. Genuine
|
|
597
|
+
* session losses never clear the latch; re-auth builds a fresh client.
|
|
598
|
+
*/
|
|
599
|
+
clearSessionError(): void {
|
|
600
|
+
this._sessionErrorDetected = false;
|
|
601
|
+
this.sessionErrorAt = null;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Lift the `deferConnect` hold. The owner calls this once the connection's
|
|
606
|
+
* identity and read scope are seeded; from then on `connect()` works
|
|
607
|
+
* normally, including every reconnect path.
|
|
608
|
+
*/
|
|
609
|
+
allowConnect(): void {
|
|
610
|
+
this.connectHeld = false;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* Connect to the sync engine WebSocket
|
|
615
|
+
*/
|
|
616
|
+
connect(): void {
|
|
617
|
+
if (this.connectHeld) {
|
|
618
|
+
// Deliberately ignored, not queued: the decided answer to "connect()
|
|
619
|
+
// before the host has seeded identity" is a no-op, so an early caller
|
|
620
|
+
// can never open an unscoped connection (see deferConnect).
|
|
621
|
+
this.logger.debug('WebSocket connect ignored — the connection is still held by its host');
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
if (this._sessionErrorDetected) {
|
|
625
|
+
this.logger.debug('WebSocket connect suppressed — session error detected');
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// CLOSING counts as busy: the socket's close teardown is still in
|
|
630
|
+
// flight and its `onclose` (which runs `scheduleReconnect`) hasn't
|
|
631
|
+
// fired yet. Overwriting `this.ws` mid-teardown is what produced the
|
|
632
|
+
// orphaned-socket race — see the stale-socket guards in
|
|
633
|
+
// `setupEventHandlers`.
|
|
634
|
+
if (
|
|
635
|
+
this.ws?.readyState === WebSocket.OPEN ||
|
|
636
|
+
this.ws?.readyState === WebSocket.CLOSING ||
|
|
637
|
+
this.isConnecting
|
|
638
|
+
) {
|
|
639
|
+
this.logger.debug('WebSocket already connected, connecting, or closing');
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// Note: onlineStatus is advisory — we'll try to connect and let the WebSocket
|
|
644
|
+
// handle failures. The default browser implementation reads navigator.onLine,
|
|
645
|
+
// which is unreliable but the only signal available; in Node it returns true
|
|
646
|
+
// (assume online) so the sidecar/agent path doesn't short-circuit here.
|
|
647
|
+
if (!this.onlineStatus.isOnline()) {
|
|
648
|
+
this.logger.debug('onlineStatus reports offline, but attempting connection anyway');
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
this.isConnecting = true;
|
|
652
|
+
this.isManualClose = false;
|
|
653
|
+
|
|
654
|
+
// One credential, server-resolved identity. The bearer travels in a
|
|
655
|
+
// `Sec-WebSocket-Protocol` value (built below), not the URL. The server is
|
|
656
|
+
// bearer-only and resolves identity from the verified token; userId and
|
|
657
|
+
// organizationId are never read from URL parameters.
|
|
658
|
+
const params = new URLSearchParams({
|
|
659
|
+
// Intentionally omit lastSyncId, capabilities from URL; these are sent in sync_request
|
|
660
|
+
// and ack messages to avoid stale baselines on reconnect.
|
|
661
|
+
cursor: this.resumeCursor(),
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
// Participant kind — defaults to `user` for session connections. Agent
|
|
665
|
+
// runtimes pass `'agent'` so the server's capability-token path activates
|
|
666
|
+
// instead of session auth.
|
|
667
|
+
if (this.options.kind && this.options.kind !== 'user') {
|
|
668
|
+
params.set('kind', this.options.kind);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// Add sync groups if provided
|
|
672
|
+
this.options.syncGroups.forEach((group) => {
|
|
673
|
+
params.append('syncGroups', group);
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
const wsUrl = `${this.options.url}?${params.toString()}`;
|
|
677
|
+
|
|
678
|
+
// Carry the bearer in a `Sec-WebSocket-Protocol` value, not the URL. A
|
|
679
|
+
// browser cannot set an Authorization header on a WebSocket, but it can
|
|
680
|
+
// offer subprotocols — and unlike the query string, those do not land in
|
|
681
|
+
// load-balancer access logs, proxies, or browser history. The server reads
|
|
682
|
+
// `ablo.bearer.<token>` and selects the real `ablo.sync.v1` protocol, never
|
|
683
|
+
// echoing the token-bearing value back. The token is the raw `ek_`/`rk_`,
|
|
684
|
+
// which is safe as a subprotocol value (alphanumerics and `_`).
|
|
685
|
+
const authToken = this.resolveAuthToken();
|
|
686
|
+
const protocols = authToken
|
|
687
|
+
? [`${WS_BEARER_SUBPROTOCOL_PREFIX}${authToken}`, WS_SYNC_SUBPROTOCOL]
|
|
688
|
+
: [WS_SYNC_SUBPROTOCOL];
|
|
689
|
+
|
|
690
|
+
try {
|
|
691
|
+
// Reset the handshake flag before wiring the new socket. Each connect()
|
|
692
|
+
// gets its own lifecycle — a prior successful open on a previous socket
|
|
693
|
+
// must not mask a handshake failure on the new one.
|
|
694
|
+
this._everOpened = false;
|
|
695
|
+
this.ws = new WebSocket(wsUrl, protocols);
|
|
696
|
+
this.setupEventHandlers();
|
|
697
|
+
} catch (error) {
|
|
698
|
+
// WebSocket constructor can throw if URL is invalid
|
|
699
|
+
const errorMessage = error instanceof Error ? error.message : 'Failed to create WebSocket';
|
|
700
|
+
this.observability.captureWebSocketError({ context: 'create-websocket', error: errorMessage });
|
|
701
|
+
this.isConnecting = false;
|
|
702
|
+
this.emit('error', new AbloConnectionError(errorMessage, { cause: error }));
|
|
703
|
+
this.scheduleReconnect();
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* Setup WebSocket event handlers
|
|
709
|
+
*/
|
|
710
|
+
private setupEventHandlers(): void {
|
|
711
|
+
// Capture the socket this call wires. Every handler below guards on
|
|
712
|
+
// `this.ws === socket` (onclose additionally tolerates a nulled field —
|
|
713
|
+
// see there), so a handler firing late, after `connect()` has replaced the
|
|
714
|
+
// socket, can never clobber the new connection's shared state. Without this
|
|
715
|
+
// guard, an old socket's `onclose` would unconditionally run `this.ws =
|
|
716
|
+
// null; onClosed(); stopHeartbeat()` — a reconnect during close
|
|
717
|
+
// teardown then orphaned the fresh socket (a zombie receiving deltas with
|
|
718
|
+
// no timers and broken send paths).
|
|
719
|
+
const socket = this.ws;
|
|
720
|
+
if (!socket) return;
|
|
721
|
+
|
|
722
|
+
socket.onopen = () => {
|
|
723
|
+
if (this.ws !== socket) return; // stale socket — a newer connect() owns the state
|
|
724
|
+
this.observability.breadcrumb('WebSocket connected', 'sync.websocket', 'info', {
|
|
725
|
+
reconnectAttempts: this.reconnectAttempts,
|
|
726
|
+
});
|
|
727
|
+
this.isConnecting = false;
|
|
728
|
+
this.reconnectAttempts = 0;
|
|
729
|
+
this._everOpened = true;
|
|
730
|
+
this.lastOpenAt = Date.now();
|
|
731
|
+
this.emit('connected');
|
|
732
|
+
|
|
733
|
+
// The subclass's open ritual (presence, ack, incremental sync,
|
|
734
|
+
// catch-up poll) runs here, between the `connected` emit and the
|
|
735
|
+
// heartbeat start — the exact position the inline code held before
|
|
736
|
+
// the split.
|
|
737
|
+
this.onOpened();
|
|
738
|
+
|
|
739
|
+
// Start the application-level heartbeat (see HeartbeatController).
|
|
740
|
+
this.heartbeat.start();
|
|
741
|
+
};
|
|
742
|
+
|
|
743
|
+
socket.onmessage = (event) => {
|
|
744
|
+
if (this.ws !== socket) return; // stale socket — drop, don't feed shared state
|
|
745
|
+
try {
|
|
746
|
+
// Untrusted wire input: parse to `unknown`, then narrow through
|
|
747
|
+
// the frame-envelope guard before dispatch. Payload-level
|
|
748
|
+
// validation (deltas etc.) happens per-frame downstream.
|
|
749
|
+
const message: unknown = JSON.parse(event.data);
|
|
750
|
+
|
|
751
|
+
// Any inbound frame proves the socket is alive — clear the
|
|
752
|
+
// heartbeat-timeout timer so we don't false-trip force-close
|
|
753
|
+
// during normal traffic.
|
|
754
|
+
this.heartbeat.clearHeartbeatTimeout();
|
|
755
|
+
|
|
756
|
+
const frame = readWsInboundFrame(message);
|
|
757
|
+
if (!frame) {
|
|
758
|
+
this.logger.debug('[WsTransport] dropped malformed wire frame', {
|
|
759
|
+
received: Array.isArray(message) ? 'array' : typeof message,
|
|
760
|
+
});
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// Dispatch by frame type (see dispatchWsFrame). The session adapter
|
|
765
|
+
// exposes only the members the handlers touch; keepalives, the older
|
|
766
|
+
// bare-delta form, and collaboration events are all routed there too.
|
|
767
|
+
dispatchWsFrame(this.frameSession, frame);
|
|
768
|
+
} catch (error) {
|
|
769
|
+
this.observability.captureWebSocketError({
|
|
770
|
+
context: 'parse-message',
|
|
771
|
+
error: error instanceof Error ? error.message : String(error),
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
};
|
|
775
|
+
|
|
776
|
+
socket.onerror = (_event) => {
|
|
777
|
+
if (this.ws !== socket) return; // stale socket — its errors are no longer ours
|
|
778
|
+
// WebSocket errors are DOM Events, not Error objects
|
|
779
|
+
// Check if we're offline first
|
|
780
|
+
if (!this.onlineStatus.isOnline()) {
|
|
781
|
+
this.observability.breadcrumb(
|
|
782
|
+
'WebSocket error: Network is offline',
|
|
783
|
+
'sync.websocket',
|
|
784
|
+
'warning'
|
|
785
|
+
);
|
|
786
|
+
this.emit('error', new AbloConnectionError('Network is offline', { code: 'bootstrap_offline' }));
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// After a session error, suppress error capture — the root cause is
|
|
791
|
+
// already reported. Still emit so the store can update UI state.
|
|
792
|
+
const error = new AbloConnectionError(`WebSocket connection failed`);
|
|
793
|
+
if (!this._sessionErrorDetected) {
|
|
794
|
+
this.observability.captureWebSocketError({
|
|
795
|
+
context: 'connection-error',
|
|
796
|
+
error: error.message,
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
this.emit('error', error);
|
|
800
|
+
};
|
|
801
|
+
|
|
802
|
+
socket.onclose = (event) => {
|
|
803
|
+
// Stale-socket close: a newer socket already owns the connection
|
|
804
|
+
// state — don't null it, stop its timers, or schedule a duplicate
|
|
805
|
+
// reconnect (the orphaning race this guard exists for). The one
|
|
806
|
+
// deliberate asymmetry vs the other handlers: `this.ws === null`
|
|
807
|
+
// (manual `disconnect()` nulls the field before the close event
|
|
808
|
+
// lands) still runs the full body, so in-flight work is rejected
|
|
809
|
+
// promptly and 'disconnected' reaches consumers — the pre-guard
|
|
810
|
+
// behavior manual close always had.
|
|
811
|
+
if (this.ws !== null && this.ws !== socket) return;
|
|
812
|
+
const everOpened = this._everOpened;
|
|
813
|
+
this.lastCloseAt = Date.now();
|
|
814
|
+
this.lastCloseCode = event.code;
|
|
815
|
+
this.lastCloseReason = event.reason || null;
|
|
816
|
+
this.logger.info('WebSocket closed', {
|
|
817
|
+
code: event.code,
|
|
818
|
+
reason: event.reason,
|
|
819
|
+
everOpened,
|
|
820
|
+
reconnectAttempts: this.reconnectAttempts,
|
|
821
|
+
forceCloseReason: this.lastForceCloseReason,
|
|
822
|
+
msSinceOpen:
|
|
823
|
+
this.lastOpenAt != null ? Date.now() - this.lastOpenAt : null,
|
|
824
|
+
isManualClose: this.isManualClose,
|
|
825
|
+
});
|
|
826
|
+
this.isConnecting = false;
|
|
827
|
+
this.ws = null;
|
|
828
|
+
this.onClosed();
|
|
829
|
+
this.heartbeat.stop();
|
|
830
|
+
|
|
831
|
+
// Cancel in-flight mutations — the socket that was carrying them is
|
|
832
|
+
// gone, and the server-side state may or may not have accepted each
|
|
833
|
+
// one. Rejecting promptly is better than hanging the caller forever;
|
|
834
|
+
// higher-level retry belongs to MutationQueue, not here.
|
|
835
|
+
if (this.pendingMutations.size > 0) {
|
|
836
|
+
for (const pending of this.pendingMutations.values()) {
|
|
837
|
+
clearTimeout(pending.timeout);
|
|
838
|
+
// AbloConnectionError → `isPermanentError` treats it as transient,
|
|
839
|
+
// so MutationQueue retries the commit on reconnect rather than
|
|
840
|
+
// rolling it back. `diagnostics` is preserved as a property (the
|
|
841
|
+
// queue's failure log walks the cause chain for it).
|
|
842
|
+
pending.reject(
|
|
843
|
+
Object.assign(
|
|
844
|
+
new AbloConnectionError(
|
|
845
|
+
`WebSocket closed while commit was in flight (code=${event.code}` +
|
|
846
|
+
(event.reason ? ` reason=${event.reason}` : '') +
|
|
847
|
+
(this.lastForceCloseReason
|
|
848
|
+
? ` forceCloseReason=${this.lastForceCloseReason}`
|
|
849
|
+
: '') +
|
|
850
|
+
')',
|
|
851
|
+
{ code: 'commit_no_result' },
|
|
852
|
+
),
|
|
853
|
+
{ diagnostics: this.getConnectionDiagnostics() },
|
|
854
|
+
),
|
|
855
|
+
);
|
|
856
|
+
}
|
|
857
|
+
this.pendingMutations.clear();
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// Cancel in-flight claims — same rationale. Server-side
|
|
861
|
+
// claims are bound to the connection; a reconnect will need
|
|
862
|
+
// to re-claim. Higher-level retry belongs to whoever holds
|
|
863
|
+
// the participant handle (typically the SDK's claim manager).
|
|
864
|
+
if (this.pendingClaims.size > 0) {
|
|
865
|
+
for (const pending of this.pendingClaims.values()) {
|
|
866
|
+
clearTimeout(pending.timeout);
|
|
867
|
+
pending.reject(
|
|
868
|
+
new AbloConnectionError(
|
|
869
|
+
`WebSocket closed while claim was in flight (code=${event.code})`,
|
|
870
|
+
),
|
|
871
|
+
);
|
|
872
|
+
}
|
|
873
|
+
this.pendingClaims.clear();
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
// Cancel in-flight subscription updates — the reconnect handshake
|
|
877
|
+
// re-sends `options.syncGroups` (the last acked interest) in the
|
|
878
|
+
// upgrade URL, so a pending change that never acked is simply
|
|
879
|
+
// retried by the caller against the fresh connection.
|
|
880
|
+
if (this.pendingSubscriptions.length > 0) {
|
|
881
|
+
for (const pending of this.pendingSubscriptions) {
|
|
882
|
+
clearTimeout(pending.timeout);
|
|
883
|
+
pending.reject(
|
|
884
|
+
new AbloConnectionError(
|
|
885
|
+
`WebSocket closed while update_subscription was in flight (code=${event.code})`,
|
|
886
|
+
),
|
|
887
|
+
);
|
|
888
|
+
}
|
|
889
|
+
this.pendingSubscriptions = [];
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// Protocol-version rejection (4010): terminal. Reconnecting cannot heal a
|
|
893
|
+
// version mismatch — only upgrading the SDK, or rolling the server
|
|
894
|
+
// forward, can — so a blind retry here would loop forever against the
|
|
895
|
+
// same typed close. Surface it and stop.
|
|
896
|
+
if (event.code === WS_CLOSE_PROTOCOL_VERSION) {
|
|
897
|
+
this.observability.captureWebSocketError({
|
|
898
|
+
context: 'protocol-version-close',
|
|
899
|
+
code: event.code,
|
|
900
|
+
reason: event.reason,
|
|
901
|
+
});
|
|
902
|
+
this.emit('protocol_mismatch', event);
|
|
903
|
+
this.emit('disconnected', event);
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
// Check for session-related close codes
|
|
908
|
+
// 1008 = Policy Violation (often auth)
|
|
909
|
+
// 4001 = Unauthorized (custom)
|
|
910
|
+
// 4003 = Forbidden (custom)
|
|
911
|
+
const isSessionClose =
|
|
912
|
+
event.code === 1008 ||
|
|
913
|
+
event.code === 4001 ||
|
|
914
|
+
event.code === 4003 ||
|
|
915
|
+
AbloSessionError.isSessionError(event.reason || '');
|
|
916
|
+
|
|
917
|
+
if (isSessionClose) {
|
|
918
|
+
this._sessionErrorDetected = true;
|
|
919
|
+
this.sessionErrorAt = Date.now();
|
|
920
|
+
this.observability.captureWebSocketError({
|
|
921
|
+
context: 'session-error-close',
|
|
922
|
+
code: event.code,
|
|
923
|
+
reason: event.reason,
|
|
924
|
+
});
|
|
925
|
+
this.emit('session_error', new AbloSessionError(event.reason || 'Session expired', event.code));
|
|
926
|
+
// Don't reconnect from here. For a genuine session loss the user must
|
|
927
|
+
// re-authenticate; for an expired access credential (`credential_expired`)
|
|
928
|
+
// the store's session-error handler re-mints, clears the latch, and
|
|
929
|
+
// drives the reconnect itself.
|
|
930
|
+
this.emit('disconnected', event);
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
// Handshake failure: `onclose` fired before `onopen` ever did, so the
|
|
935
|
+
// server rejected the upgrade (typically 401/403 on a bad cookie, but it
|
|
936
|
+
// could also be a CORS/origin reject or a load-balancer 5xx). The browser
|
|
937
|
+
// hides the HTTP status behind code 1006, so we cannot tell which from
|
|
938
|
+
// here.
|
|
939
|
+
//
|
|
940
|
+
// Emit a dedicated event and skip the internal reconnect — the owner
|
|
941
|
+
// should run an auth-validating HTTP probe to distinguish session expiry
|
|
942
|
+
// from a transient network issue and transition the UI accordingly.
|
|
943
|
+
// Reconnecting blindly is what produced the infinite
|
|
944
|
+
// "offline → reconnecting → offline" loop on stale cookies.
|
|
945
|
+
if (!everOpened && !this.isManualClose) {
|
|
946
|
+
this.observability.captureWebSocketError({
|
|
947
|
+
context: 'handshake-failed-close',
|
|
948
|
+
code: event.code,
|
|
949
|
+
reason: event.reason,
|
|
950
|
+
});
|
|
951
|
+
this.emit('handshake_failed', event);
|
|
952
|
+
this.emit('disconnected', event);
|
|
953
|
+
return;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
this.emit('disconnected', event);
|
|
957
|
+
|
|
958
|
+
// Reconnect if not manually closed
|
|
959
|
+
if (!this.isManualClose) {
|
|
960
|
+
this.scheduleReconnect();
|
|
961
|
+
}
|
|
962
|
+
};
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
/**
|
|
966
|
+
* Send message to server
|
|
967
|
+
*/
|
|
968
|
+
send(message: any): void {
|
|
969
|
+
if (this.ws?.readyState !== WebSocket.OPEN) {
|
|
970
|
+
// Only log at debug level when offline - this is expected behavior, not an error
|
|
971
|
+
if (this.onlineStatus.isOnline()) {
|
|
972
|
+
this.observability.breadcrumb(
|
|
973
|
+
'WebSocket not connected, cannot send message',
|
|
974
|
+
'sync.websocket',
|
|
975
|
+
'warning'
|
|
976
|
+
);
|
|
977
|
+
} else {
|
|
978
|
+
this.logger.debug('WebSocket send skipped - offline');
|
|
979
|
+
}
|
|
980
|
+
return;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
try {
|
|
984
|
+
this.ws.send(JSON.stringify(message));
|
|
985
|
+
} catch (error) {
|
|
986
|
+
// Only log as error if we're online - offline send failures are expected
|
|
987
|
+
if (this.onlineStatus.isOnline()) {
|
|
988
|
+
this.observability.captureWebSocketError({
|
|
989
|
+
context: 'send-message',
|
|
990
|
+
error: error instanceof Error ? error.message : String(error),
|
|
991
|
+
});
|
|
992
|
+
} else {
|
|
993
|
+
this.logger.debug('WebSocket send failed - offline');
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
/**
|
|
999
|
+
* Sends a `commit` mutation request over the existing WebSocket and resolves
|
|
1000
|
+
* when the server's `mutation_result` frame comes back with the same
|
|
1001
|
+
* `clientTxId`. The wire frame is `{ type: 'commit', payload: { operations,
|
|
1002
|
+
* clientTxId } }`.
|
|
1003
|
+
*
|
|
1004
|
+
* Times out after 15 seconds of silence from the server. The socket may close
|
|
1005
|
+
* during an in-flight mutation (a network flap, a server restart); this does
|
|
1006
|
+
* not auto-retry — the caller's transaction queue owns retry and offline
|
|
1007
|
+
* replay, and the SDK does not duplicate that logic.
|
|
1008
|
+
*/
|
|
1009
|
+
sendCommit(
|
|
1010
|
+
operations: readonly CommitFrameOperation[],
|
|
1011
|
+
clientTxId: string,
|
|
1012
|
+
timeoutMs = 15_000,
|
|
1013
|
+
reads?: readonly ReadDependency[] | null,
|
|
1014
|
+
track?: readonly TrackDependency[] | null,
|
|
1015
|
+
): Promise<CommitAck> {
|
|
1016
|
+
if (this.ws?.readyState !== WebSocket.OPEN) {
|
|
1017
|
+
return Promise.reject(this.notConnectedError('commit'));
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
return new Promise<CommitAck>((resolve, reject) => {
|
|
1021
|
+
const timeout = setTimeout(() => {
|
|
1022
|
+
this.pendingMutations.delete(clientTxId);
|
|
1023
|
+
reject(
|
|
1024
|
+
new AbloConnectionError(
|
|
1025
|
+
`commit timed out after ${timeoutMs}ms (clientTxId=${clientTxId})`,
|
|
1026
|
+
{ code: 'commit_no_result' },
|
|
1027
|
+
),
|
|
1028
|
+
);
|
|
1029
|
+
}, timeoutMs);
|
|
1030
|
+
this.pendingMutations.set(clientTxId, { resolve, reject, timeout });
|
|
1031
|
+
try {
|
|
1032
|
+
const frame = buildCommitFrame(operations, clientTxId, reads, track);
|
|
1033
|
+
this.ws!.send(JSON.stringify(frame));
|
|
1034
|
+
} catch (error) {
|
|
1035
|
+
clearTimeout(timeout);
|
|
1036
|
+
this.pendingMutations.delete(clientTxId);
|
|
1037
|
+
reject(toAbloError(error));
|
|
1038
|
+
}
|
|
1039
|
+
});
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
/**
|
|
1043
|
+
* Send a commit frame without waiting for `mutation_result`.
|
|
1044
|
+
*
|
|
1045
|
+
* This backs the public `wait: 'queued'` API: the socket accepted the
|
|
1046
|
+
* frame for delivery, but the server has not confirmed it yet. The
|
|
1047
|
+
* eventual `mutation_result` frame is intentionally ignored by this
|
|
1048
|
+
* instance because no pending resolver is registered.
|
|
1049
|
+
*/
|
|
1050
|
+
sendCommitQueued(
|
|
1051
|
+
operations: readonly CommitFrameOperation[],
|
|
1052
|
+
clientTxId: string,
|
|
1053
|
+
reads?: readonly ReadDependency[] | null,
|
|
1054
|
+
track?: readonly TrackDependency[] | null,
|
|
1055
|
+
): void {
|
|
1056
|
+
if (this.ws?.readyState !== WebSocket.OPEN) {
|
|
1057
|
+
throw this.notConnectedError('commit');
|
|
1058
|
+
}
|
|
1059
|
+
const frame = buildCommitFrame(operations, clientTxId, reads, track);
|
|
1060
|
+
this.ws.send(JSON.stringify(frame));
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
/**
|
|
1064
|
+
* Activates a participant claim on this connection. One connection can hold
|
|
1065
|
+
* several concurrent claims at once, each scoped to a different set of sync
|
|
1066
|
+
* groups, so the SDK reuses the existing connection instead of opening a
|
|
1067
|
+
* separate socket per scope.
|
|
1068
|
+
*
|
|
1069
|
+
* Returns a promise that resolves with the server-canonicalized `syncGroups`
|
|
1070
|
+
* and effective `ttlSeconds` once `claim_ack` arrives, or rejects with a typed
|
|
1071
|
+
* error on a failed ack, a timeout, or a disconnect.
|
|
1072
|
+
*/
|
|
1073
|
+
sendClaim(
|
|
1074
|
+
claimId: string,
|
|
1075
|
+
syncGroups: readonly string[],
|
|
1076
|
+
options?: Pick<ParticipantClaimPayload, 'capabilityToken' | 'ttlSeconds'> & {
|
|
1077
|
+
timeoutMs?: number;
|
|
1078
|
+
},
|
|
1079
|
+
): Promise<{ syncGroups: string[]; ttlSeconds?: number }> {
|
|
1080
|
+
if (this.ws?.readyState !== WebSocket.OPEN) {
|
|
1081
|
+
return Promise.reject(this.notConnectedError('claim'));
|
|
1082
|
+
}
|
|
1083
|
+
// Checked against the schema the server ingests it with, for the same
|
|
1084
|
+
// reason `updateSubscription` below is: the two frames name their scopes
|
|
1085
|
+
// identically, so a group that would be refused there is refused here, at
|
|
1086
|
+
// the call that asked for it, rather than coming back as a failed ack a
|
|
1087
|
+
// round trip later with nothing to point at.
|
|
1088
|
+
const payload = participantClaimPayloadSchema.safeParse({
|
|
1089
|
+
claimId,
|
|
1090
|
+
syncGroups: [...syncGroups],
|
|
1091
|
+
capabilityToken: options?.capabilityToken,
|
|
1092
|
+
ttlSeconds: options?.ttlSeconds,
|
|
1093
|
+
});
|
|
1094
|
+
if (!payload.success) {
|
|
1095
|
+
return Promise.reject(
|
|
1096
|
+
new AbloValidationError(
|
|
1097
|
+
`join was given a sync group the protocol does not accept: ${payload.error.issues[0]?.message ?? 'unreadable'}. A group is 'default' or 'kind:id'.`,
|
|
1098
|
+
{ code: 'malformed_claim' },
|
|
1099
|
+
),
|
|
1100
|
+
);
|
|
1101
|
+
}
|
|
1102
|
+
const timeoutMs = options?.timeoutMs ?? 15_000;
|
|
1103
|
+
return new Promise((resolve, reject) => {
|
|
1104
|
+
const timeout = setTimeout(() => {
|
|
1105
|
+
this.pendingClaims.delete(claimId);
|
|
1106
|
+
reject(
|
|
1107
|
+
new AbloConnectionError(`claim timed out after ${timeoutMs}ms (claimId=${claimId})`, {
|
|
1108
|
+
code: 'wait_for_timeout',
|
|
1109
|
+
}),
|
|
1110
|
+
);
|
|
1111
|
+
}, timeoutMs);
|
|
1112
|
+
this.pendingClaims.set(claimId, { resolve, reject, timeout });
|
|
1113
|
+
try {
|
|
1114
|
+
this.ws!.send(JSON.stringify({ type: 'claim', payload: payload.data }));
|
|
1115
|
+
} catch (error) {
|
|
1116
|
+
clearTimeout(timeout);
|
|
1117
|
+
this.pendingClaims.delete(claimId);
|
|
1118
|
+
reject(toAbloError(error));
|
|
1119
|
+
}
|
|
1120
|
+
});
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
/**
|
|
1124
|
+
* Drop a previously-active claim. Idempotent — `release` is
|
|
1125
|
+
* fire-and-forget per the wire contract; the server accepts
|
|
1126
|
+
* unknown claimIds silently so disconnect-time release storms
|
|
1127
|
+
* never error. No ack is expected.
|
|
1128
|
+
*
|
|
1129
|
+
* If a claim's send promise is still pending (no claim_ack yet),
|
|
1130
|
+
* we reject it locally — the user explicitly chose to release.
|
|
1131
|
+
*/
|
|
1132
|
+
sendRelease(claimId: string): void {
|
|
1133
|
+
// Cancel any in-flight claim that hadn't acked yet — the user
|
|
1134
|
+
// changed their mind. Without this the timer would eventually
|
|
1135
|
+
// reject; doing it now matches the user's claim immediately.
|
|
1136
|
+
const pending = this.pendingClaims.get(claimId);
|
|
1137
|
+
if (pending) {
|
|
1138
|
+
clearTimeout(pending.timeout);
|
|
1139
|
+
this.pendingClaims.delete(claimId);
|
|
1140
|
+
pending.reject(
|
|
1141
|
+
new AbloError(`claim ${claimId} released before ack`, {
|
|
1142
|
+
code: 'claim_wait_aborted',
|
|
1143
|
+
httpStatus: 409,
|
|
1144
|
+
}),
|
|
1145
|
+
);
|
|
1146
|
+
}
|
|
1147
|
+
if (this.ws?.readyState !== WebSocket.OPEN) return;
|
|
1148
|
+
try {
|
|
1149
|
+
this.ws.send(
|
|
1150
|
+
JSON.stringify({ type: 'release', payload: { claimId } }),
|
|
1151
|
+
);
|
|
1152
|
+
} catch {
|
|
1153
|
+
// Idempotent contract — silent failure is acceptable here.
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
/**
|
|
1158
|
+
* Moves this connection's read interest — replaces the connection-level sync
|
|
1159
|
+
* groups mid-session as the user opens and closes entities. This is the
|
|
1160
|
+
* area-of-interest navigation primitive: the server fans out deltas only for
|
|
1161
|
+
* the groups currently in view, rather than the fixed set chosen at connect.
|
|
1162
|
+
*
|
|
1163
|
+
* This is a full-set replace: pass the complete new group list, not a delta.
|
|
1164
|
+
* Resolves with the server's effective set once `subscription_ack` arrives;
|
|
1165
|
+
* rejects (with a typed error) on a scope denial (a restricted `rk_` key
|
|
1166
|
+
* requesting a group outside its allowlist), a timeout, or a disconnect. On
|
|
1167
|
+
* success the new set is recorded as `options.syncGroups`, so a later reconnect
|
|
1168
|
+
* re-subscribes to the current interest rather than the connect-time set.
|
|
1169
|
+
*
|
|
1170
|
+
* Distinct from {@link sendClaim} (a write claim, per operation, with a TTL):
|
|
1171
|
+
* this is the read side, carries no capability token of its own, and is
|
|
1172
|
+
* bounded by the connection credential's grant.
|
|
1173
|
+
*/
|
|
1174
|
+
updateSubscription(
|
|
1175
|
+
syncGroups: readonly string[],
|
|
1176
|
+
options?: { timeoutMs?: number },
|
|
1177
|
+
): Promise<{ syncGroups: string[] }> {
|
|
1178
|
+
if (this.ws?.readyState !== WebSocket.OPEN) {
|
|
1179
|
+
return Promise.reject(this.notConnectedError('update_subscription'));
|
|
1180
|
+
}
|
|
1181
|
+
const timeoutMs = options?.timeoutMs ?? 15_000;
|
|
1182
|
+
return new Promise<{ syncGroups: string[] }>((resolve, reject) => {
|
|
1183
|
+
const entry: PendingSubscription = {
|
|
1184
|
+
resolve,
|
|
1185
|
+
reject,
|
|
1186
|
+
timeout: setTimeout(() => {
|
|
1187
|
+
const idx = this.pendingSubscriptions.indexOf(entry);
|
|
1188
|
+
if (idx !== -1) this.pendingSubscriptions.splice(idx, 1);
|
|
1189
|
+
reject(
|
|
1190
|
+
new AbloConnectionError(
|
|
1191
|
+
`update_subscription timed out after ${timeoutMs}ms`,
|
|
1192
|
+
{ code: 'wait_for_timeout' },
|
|
1193
|
+
),
|
|
1194
|
+
);
|
|
1195
|
+
}, timeoutMs),
|
|
1196
|
+
};
|
|
1197
|
+
// Check the payload against the schema the server ingests it with, so a
|
|
1198
|
+
// malformed sync group fails here — naming the group and the call that
|
|
1199
|
+
// asked for it — instead of coming back as a rejection ack a round trip
|
|
1200
|
+
// later, detached from the code that caused it.
|
|
1201
|
+
const payload = updateSubscriptionPayloadSchema.safeParse({
|
|
1202
|
+
syncGroups: [...syncGroups],
|
|
1203
|
+
});
|
|
1204
|
+
if (!payload.success) {
|
|
1205
|
+
clearTimeout(entry.timeout);
|
|
1206
|
+
reject(
|
|
1207
|
+
new AbloValidationError(
|
|
1208
|
+
`update_subscription was given a sync group the protocol does not accept: ${payload.error.issues[0]?.message ?? 'unreadable'}. A group is 'default' or 'kind:id'.`,
|
|
1209
|
+
{ code: 'malformed_subscription' },
|
|
1210
|
+
),
|
|
1211
|
+
);
|
|
1212
|
+
return;
|
|
1213
|
+
}
|
|
1214
|
+
this.pendingSubscriptions.push(entry);
|
|
1215
|
+
try {
|
|
1216
|
+
this.ws!.send(
|
|
1217
|
+
JSON.stringify({ type: 'update_subscription', payload: payload.data }),
|
|
1218
|
+
);
|
|
1219
|
+
} catch (error) {
|
|
1220
|
+
clearTimeout(entry.timeout);
|
|
1221
|
+
const idx = this.pendingSubscriptions.indexOf(entry);
|
|
1222
|
+
if (idx !== -1) this.pendingSubscriptions.splice(idx, 1);
|
|
1223
|
+
reject(toAbloError(error));
|
|
1224
|
+
}
|
|
1225
|
+
});
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
/**
|
|
1229
|
+
* Sets a fixed credential for callers that construct the socket directly. The
|
|
1230
|
+
* SDK instead supplies `getAuthToken`, so reconnects read the shared
|
|
1231
|
+
* credential source rather than this copied value.
|
|
1232
|
+
*/
|
|
1233
|
+
setCapabilityToken(token: string): void {
|
|
1234
|
+
this.options.capabilityToken = token;
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
/**
|
|
1238
|
+
* Seeds the participant kind after identity resolution. The kind rides the
|
|
1239
|
+
* upgrade URL and selects the server's auth path, and on the hosted path it
|
|
1240
|
+
* is derived from the credential's scope — known only once identity
|
|
1241
|
+
* resolves, after the socket is built. Call before `connect()`.
|
|
1242
|
+
*/
|
|
1243
|
+
setKind(kind: ParticipantKind): void {
|
|
1244
|
+
this.options.kind = kind;
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
getAuthToken(): string | undefined {
|
|
1248
|
+
return this.resolveAuthToken();
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
/**
|
|
1252
|
+
* Return the credential that will be used by the next WebSocket upgrade.
|
|
1253
|
+
* ConnectionManager reads this for HTTP auth probes so visibility/network
|
|
1254
|
+
* checks authenticate the same way reconnects do.
|
|
1255
|
+
*/
|
|
1256
|
+
getCapabilityToken(): string | undefined {
|
|
1257
|
+
return this.resolveAuthToken();
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
private resolveAuthToken = (): string | undefined => {
|
|
1261
|
+
return this.options.getAuthToken?.()
|
|
1262
|
+
?? this.options.getCapabilityToken?.()
|
|
1263
|
+
?? this.options.capabilityToken;
|
|
1264
|
+
};
|
|
1265
|
+
|
|
1266
|
+
/**
|
|
1267
|
+
* Schedule reconnection with exponential backoff
|
|
1268
|
+
*/
|
|
1269
|
+
private scheduleReconnect(): void {
|
|
1270
|
+
if (this.reconnectTimer) {
|
|
1271
|
+
clearTimeout(this.reconnectTimer);
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
// Session error means the user needs to re-authenticate — don't reconnect.
|
|
1275
|
+
if (this._sessionErrorDetected) {
|
|
1276
|
+
return;
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
// Don't attempt reconnection while offline. The owning store manages the
|
|
1280
|
+
// offline→online transition: it bootstraps first, then calls `connect()`
|
|
1281
|
+
// explicitly. Self-reconnecting here would bypass that bootstrap gate and
|
|
1282
|
+
// surface stale data.
|
|
1283
|
+
if (!this.onlineStatus.isOnline()) {
|
|
1284
|
+
this.emit('reconnecting', { attempt: this.reconnectAttempts + 1, delay: 0 });
|
|
1285
|
+
return;
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
// Give up after MAX_RECONNECT_ATTEMPTS consecutive failures. The user can
|
|
1289
|
+
// recover by refreshing, or the store resets the attempt count and
|
|
1290
|
+
// reconnects when the network returns.
|
|
1291
|
+
if (this.reconnectAttempts >= WsTransport.MAX_RECONNECT_ATTEMPTS) {
|
|
1292
|
+
this.emit('reconnect_failed', { attempts: this.reconnectAttempts });
|
|
1293
|
+
return;
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
// Exponential backoff with ±15% jitter to prevent thundering herd
|
|
1297
|
+
const baseDelay = Math.min(
|
|
1298
|
+
this.options.reconnectDelay * Math.pow(2, this.reconnectAttempts),
|
|
1299
|
+
this.options.maxReconnectDelay
|
|
1300
|
+
);
|
|
1301
|
+
const jitter = baseDelay * (0.85 + Math.random() * 0.3);
|
|
1302
|
+
const delay = Math.round(jitter);
|
|
1303
|
+
|
|
1304
|
+
// Emit reconnecting event so UI can show reconnection status
|
|
1305
|
+
this.emit('reconnecting', { attempt: this.reconnectAttempts + 1, delay });
|
|
1306
|
+
|
|
1307
|
+
this.reconnectTimer = setTimeout(() => {
|
|
1308
|
+
this.reconnectAttempts++;
|
|
1309
|
+
this.connect();
|
|
1310
|
+
}, delay);
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
/**
|
|
1314
|
+
* Reset reconnect attempt counter. Called when network comes back online
|
|
1315
|
+
* to allow a fresh reconnect cycle after the max was previously reached.
|
|
1316
|
+
*/
|
|
1317
|
+
resetReconnectAttempts(): void {
|
|
1318
|
+
this.reconnectAttempts = 0;
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
/**
|
|
1322
|
+
* Disconnect from WebSocket
|
|
1323
|
+
*/
|
|
1324
|
+
disconnect(): void {
|
|
1325
|
+
this.isManualClose = true;
|
|
1326
|
+
this.heartbeat.stop();
|
|
1327
|
+
|
|
1328
|
+
if (this.reconnectTimer) {
|
|
1329
|
+
clearTimeout(this.reconnectTimer);
|
|
1330
|
+
this.reconnectTimer = null;
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
if (this.ws) {
|
|
1334
|
+
this.ws.close(1000, 'Manual disconnect');
|
|
1335
|
+
this.ws = null;
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
/**
|
|
1340
|
+
* Force-close the socket from the client side using a private 4xxx
|
|
1341
|
+
* code. Callers expect `onclose` to fire; that handler runs the
|
|
1342
|
+
* existing reconnect / handshake-failed dispatch. Wrapped in
|
|
1343
|
+
* try/catch because `close()` on a CLOSING/CLOSED socket throws on
|
|
1344
|
+
* some browsers.
|
|
1345
|
+
*/
|
|
1346
|
+
protected forceClose(reason: string): void {
|
|
1347
|
+
if (!this.ws) return;
|
|
1348
|
+
this.lastForceCloseReason = reason;
|
|
1349
|
+
this.logger.debug('[WsTransport] forceClose', {
|
|
1350
|
+
reason,
|
|
1351
|
+
readyState: this.ws.readyState,
|
|
1352
|
+
msSinceOpen:
|
|
1353
|
+
this.lastOpenAt != null ? Date.now() - this.lastOpenAt : null,
|
|
1354
|
+
});
|
|
1355
|
+
try {
|
|
1356
|
+
this.ws.close(4000, reason);
|
|
1357
|
+
} catch {
|
|
1358
|
+
// Already closing / closed — onclose will still fire.
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
/**
|
|
1363
|
+
* Get connection state
|
|
1364
|
+
*/
|
|
1365
|
+
isConnected(): boolean {
|
|
1366
|
+
return this.ws?.readyState === WebSocket.OPEN;
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
/**
|
|
1370
|
+
* Snapshot of recent connection lifecycle state, for diagnostic logs
|
|
1371
|
+
* and error messages. Cheap to call (no I/O); safe to log every time
|
|
1372
|
+
* a send is rejected so we can attribute "not connected" rejections
|
|
1373
|
+
* to the actual root cause (handshake reject vs heartbeat zombie vs
|
|
1374
|
+
* session expiry vs explicit close).
|
|
1375
|
+
*/
|
|
1376
|
+
getConnectionDiagnostics(): {
|
|
1377
|
+
readyState: number | null;
|
|
1378
|
+
isConnecting: boolean;
|
|
1379
|
+
isManualClose: boolean;
|
|
1380
|
+
sessionErrorDetected: boolean;
|
|
1381
|
+
everOpened: boolean;
|
|
1382
|
+
reconnectAttempts: number;
|
|
1383
|
+
maxReconnectAttempts: number;
|
|
1384
|
+
lastOpenAt: number | null;
|
|
1385
|
+
lastCloseAt: number | null;
|
|
1386
|
+
lastCloseCode: number | null;
|
|
1387
|
+
lastCloseReason: string | null;
|
|
1388
|
+
lastForceCloseReason: string | null;
|
|
1389
|
+
sessionErrorAt: number | null;
|
|
1390
|
+
msSinceLastOpen: number | null;
|
|
1391
|
+
msSinceLastClose: number | null;
|
|
1392
|
+
} {
|
|
1393
|
+
const now = Date.now();
|
|
1394
|
+
return {
|
|
1395
|
+
readyState: this.ws?.readyState ?? null,
|
|
1396
|
+
isConnecting: this.isConnecting,
|
|
1397
|
+
isManualClose: this.isManualClose,
|
|
1398
|
+
sessionErrorDetected: this._sessionErrorDetected,
|
|
1399
|
+
everOpened: this._everOpened,
|
|
1400
|
+
reconnectAttempts: this.reconnectAttempts,
|
|
1401
|
+
maxReconnectAttempts: WsTransport.MAX_RECONNECT_ATTEMPTS,
|
|
1402
|
+
lastOpenAt: this.lastOpenAt,
|
|
1403
|
+
lastCloseAt: this.lastCloseAt,
|
|
1404
|
+
lastCloseCode: this.lastCloseCode,
|
|
1405
|
+
lastCloseReason: this.lastCloseReason,
|
|
1406
|
+
lastForceCloseReason: this.lastForceCloseReason,
|
|
1407
|
+
sessionErrorAt: this.sessionErrorAt,
|
|
1408
|
+
msSinceLastOpen: this.lastOpenAt != null ? now - this.lastOpenAt : null,
|
|
1409
|
+
msSinceLastClose:
|
|
1410
|
+
this.lastCloseAt != null ? now - this.lastCloseAt : null,
|
|
1411
|
+
};
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
/**
|
|
1415
|
+
* Build a richly-diagnosed "not connected" error so callers (and the
|
|
1416
|
+
* logs they emit) can attribute the rejection. The message embeds the
|
|
1417
|
+
* dominant signal in human-readable form; the structured detail is
|
|
1418
|
+
* also attached as `error.diagnostics` for log scrapers.
|
|
1419
|
+
*/
|
|
1420
|
+
protected notConnectedError(action: string): Error & {
|
|
1421
|
+
diagnostics: ReturnType<WsTransport['getConnectionDiagnostics']>;
|
|
1422
|
+
} {
|
|
1423
|
+
const d = this.getConnectionDiagnostics();
|
|
1424
|
+
|
|
1425
|
+
// A session-latched socket is not a transient transport hiccup: reconnection
|
|
1426
|
+
// is suppressed until re-auth (or the store's credential re-mint clears the
|
|
1427
|
+
// latch), so retrying can never succeed. Reject with the permanent session
|
|
1428
|
+
// error type — `isPermanentError` surfaces it to the caller as
|
|
1429
|
+
// "re-authenticate" instead of parking the write for a reconnect that will
|
|
1430
|
+
// never happen.
|
|
1431
|
+
if (d.sessionErrorDetected) {
|
|
1432
|
+
return Object.assign(
|
|
1433
|
+
new AbloSessionError(
|
|
1434
|
+
`SyncWebSocket not connected — cannot send ${action}: session expired` +
|
|
1435
|
+
(d.lastCloseReason ? ` (${d.lastCloseReason})` : '') +
|
|
1436
|
+
'; re-authenticate',
|
|
1437
|
+
),
|
|
1438
|
+
{ diagnostics: d },
|
|
1439
|
+
);
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
let detail: string;
|
|
1443
|
+
if (d.isManualClose) {
|
|
1444
|
+
detail = 'manual_close';
|
|
1445
|
+
} else if (d.isConnecting) {
|
|
1446
|
+
detail = 'still_connecting';
|
|
1447
|
+
} else if (!d.everOpened && d.lastCloseAt != null) {
|
|
1448
|
+
detail = `handshake_failed code=${d.lastCloseCode}`;
|
|
1449
|
+
} else if (d.lastForceCloseReason) {
|
|
1450
|
+
detail = `force_closed reason=${d.lastForceCloseReason}`;
|
|
1451
|
+
} else if (d.lastCloseAt != null) {
|
|
1452
|
+
detail =
|
|
1453
|
+
`closed code=${d.lastCloseCode}` +
|
|
1454
|
+
(d.lastCloseReason ? ` reason=${d.lastCloseReason}` : '') +
|
|
1455
|
+
(d.msSinceLastClose != null ? ` ${d.msSinceLastClose}ms ago` : '') +
|
|
1456
|
+
(d.reconnectAttempts > 0
|
|
1457
|
+
? ` reconnectAttempts=${d.reconnectAttempts}/${d.maxReconnectAttempts}`
|
|
1458
|
+
: '');
|
|
1459
|
+
} else {
|
|
1460
|
+
detail = 'never_connected';
|
|
1461
|
+
}
|
|
1462
|
+
// Typed so it lands in the AbloError hierarchy and `isPermanentError` sees a
|
|
1463
|
+
// transient transport failure (retry on reconnect, don't roll back).
|
|
1464
|
+
// `diagnostics` stays a property — the queue's failure log walks the cause
|
|
1465
|
+
// chain for it.
|
|
1466
|
+
const err = Object.assign(
|
|
1467
|
+
new AbloConnectionError(
|
|
1468
|
+
`SyncWebSocket not connected — cannot send ${action} (${detail})`,
|
|
1469
|
+
{ code: 'ws_not_ready' },
|
|
1470
|
+
),
|
|
1471
|
+
{ diagnostics: d },
|
|
1472
|
+
);
|
|
1473
|
+
return err;
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
/** Returns the sync groups this connection is subscribed to. */
|
|
1477
|
+
getSyncGroups(): string[] {
|
|
1478
|
+
return this.options.syncGroups;
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
/**
|
|
1482
|
+
* Seeds the connection's read interest — the sync groups the next upgrade
|
|
1483
|
+
* URL carries. The set is already mutable state (`subscription_ack` writes
|
|
1484
|
+
* the acked set back so a reconnect resubscribes to current interest);
|
|
1485
|
+
* this setter is the host's way to seed it once identity resolves, before
|
|
1486
|
+
* the first `connect()`.
|
|
1487
|
+
*/
|
|
1488
|
+
setSyncGroups(syncGroups: readonly string[]): void {
|
|
1489
|
+
this.options.syncGroups = [...syncGroups];
|
|
1490
|
+
}
|
|
1491
|
+
}
|