@abloatai/ablo 0.34.1 → 0.36.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +2 -1
- package/CHANGELOG.md +758 -5
- package/README.md +56 -502
- package/bin/ablo.cjs +39 -0
- package/dist/BaseSyncedStore.d.ts +176 -48
- package/dist/BaseSyncedStore.js +346 -214
- package/dist/Database.d.ts +17 -44
- package/dist/Database.js +96 -79
- package/dist/InstanceCache.d.ts +31 -6
- package/dist/InstanceCache.js +65 -30
- package/dist/LazyReferenceCollection.d.ts +3 -3
- package/dist/LazyReferenceCollection.js +4 -4
- package/dist/Model.d.ts +23 -13
- package/dist/Model.js +27 -17
- package/dist/ModelRegistry.d.ts +8 -4
- package/dist/ModelRegistry.js +20 -18
- package/dist/NetworkMonitor.d.ts +3 -1
- package/dist/NetworkMonitor.js +7 -5
- package/dist/{SyncEngineContext.d.ts → RuntimeContext.d.ts} +20 -13
- package/dist/{SyncEngineContext.js → RuntimeContext.js} +11 -12
- package/dist/SyncClient.d.ts +47 -47
- package/dist/SyncClient.js +215 -156
- package/dist/ai-sdk/coordinatedTool.d.ts +2 -2
- package/dist/ai-sdk/coordinatedTool.js +1 -1
- package/dist/ai-sdk/coordinationContext.d.ts +2 -2
- package/dist/ai-sdk/coordinationContext.js +1 -1
- package/dist/ai-sdk/wrap.d.ts +3 -3
- package/dist/ai-sdk/wrap.js +2 -2
- package/dist/auth/index.d.ts +1 -156
- package/dist/auth/index.js +8 -301
- package/dist/client/Ablo.d.ts +42 -287
- package/dist/client/Ablo.js +129 -963
- package/dist/client/abloClient.d.ts +309 -0
- package/dist/client/abloClient.js +13 -0
- package/dist/client/clientPrelude.d.ts +52 -0
- package/dist/client/clientPrelude.js +60 -0
- package/dist/client/consoleLogger.d.ts +2 -2
- package/dist/client/coreClient.d.ts +60 -0
- package/dist/client/coreClient.js +118 -0
- package/dist/client/createInternalComponents.d.ts +8 -4
- package/dist/client/createInternalComponents.js +17 -10
- package/dist/client/createModelProxy.d.ts +98 -373
- package/dist/client/createModelProxy.js +233 -139
- package/dist/client/humans.d.ts +69 -0
- package/dist/client/humans.js +78 -0
- package/dist/client/modelRegistration.d.ts +1 -1
- package/dist/client/modelRegistration.js +9 -9
- package/dist/client/options.d.ts +73 -17
- package/dist/client/reactiveEngine.d.ts +53 -0
- package/dist/client/reactiveEngine.js +688 -0
- package/dist/client/resourceTypes.d.ts +9 -250
- package/dist/client/resourceTypes.js +8 -5
- package/dist/client/schemaConfig.d.ts +4 -4
- package/dist/client/schemaConfig.js +6 -2
- package/dist/client/storeCluster.d.ts +47 -0
- package/dist/client/storeCluster.js +118 -0
- package/dist/client/storeLifecycle.d.ts +61 -0
- package/dist/client/storeLifecycle.js +231 -0
- package/dist/client/validateAbloOptions.d.ts +3 -2
- package/dist/client/validateAbloOptions.js +1 -1
- package/dist/client/wsMutationExecutor.d.ts +3 -3
- package/dist/client/wsMutationExecutor.js +3 -3
- package/dist/context.d.ts +22 -9
- package/dist/context.js +33 -9
- package/dist/coordination/ClaimLog.d.ts +26 -0
- package/dist/coordination/ClaimLog.js +32 -0
- package/dist/coordination/index.d.ts +1 -15
- package/dist/coordination/index.js +8 -31
- package/dist/core/index.d.ts +3 -3
- package/dist/core/index.js +2 -2
- package/dist/docs/catalog.d.ts +72 -0
- package/dist/docs/catalog.js +230 -0
- package/dist/docs/index.d.ts +10 -0
- package/dist/docs/index.js +10 -0
- package/dist/environment.d.ts +1 -40
- package/dist/environment.js +8 -37
- package/dist/index.d.ts +44 -36
- package/dist/index.js +30 -22
- package/dist/interfaces/index.d.ts +44 -134
- package/dist/keys/index.d.ts +1 -77
- package/dist/keys/index.js +8 -190
- package/dist/mutators/{RecordingTransaction.d.ts → RecordingMutation.d.ts} +4 -4
- package/dist/mutators/{RecordingTransaction.js → RecordingMutation.js} +2 -2
- package/dist/mutators/Transaction.d.ts +1 -1
- package/dist/mutators/Transaction.js +1 -1
- package/dist/mutators/UndoManager.d.ts +6 -6
- package/dist/mutators/UndoManager.js +5 -5
- package/dist/mutators/defineMutators.d.ts +3 -3
- package/dist/mutators/defineMutators.js +1 -1
- package/dist/mutators/inverseOp.js +2 -2
- package/dist/mutators/mutateActions.d.ts +3 -3
- package/dist/mutators/mutateActions.js +1 -1
- package/dist/mutators/readerActions.d.ts +1 -1
- package/dist/mutators/undoApply.d.ts +1 -1
- package/dist/mutators/undoApply.js +1 -1
- package/dist/policy/index.d.ts +2 -2
- package/dist/policy/index.js +1 -1
- package/dist/query/client.d.ts +5 -2
- package/dist/query/client.js +10 -9
- package/dist/query/types.d.ts +6 -41
- package/dist/query/types.js +2 -2
- package/dist/react/AbloProvider.d.ts +18 -8
- package/dist/react/AbloProvider.js +10 -9
- package/dist/react/context.d.ts +3 -3
- package/dist/react/context.js +1 -1
- package/dist/react/createAbloReact.d.ts +56 -0
- package/dist/react/createAbloReact.js +51 -0
- package/dist/react/index.d.ts +6 -5
- package/dist/react/index.js +6 -3
- package/dist/react/internalContext.d.ts +1 -1
- package/dist/react/useAblo.d.ts +12 -5
- package/dist/react/useAblo.js +26 -8
- package/dist/react/useCurrentUserId.js +1 -1
- package/dist/react/useErrorListener.js +1 -1
- package/dist/react/useMutationFailureListener.d.ts +2 -2
- package/dist/react/useMutationFailureListener.js +1 -1
- package/dist/react/useMutators.d.ts +3 -3
- package/dist/react/useMutators.js +3 -3
- package/dist/react/useUndoScope.d.ts +5 -5
- package/dist/react/useUndoScope.js +1 -1
- package/dist/schema/coordination.d.ts +69 -10
- package/dist/schema/coordination.js +90 -9
- package/dist/schema/ddl.js +2 -2
- package/dist/schema/diff.d.ts +1 -1
- package/dist/schema/generate.js +1 -1
- package/dist/schema/index.d.ts +11 -10
- package/dist/schema/index.js +22 -18
- package/dist/schema/queries.d.ts +27 -27
- package/dist/schema/queries.js +23 -23
- package/dist/schema/select.d.ts +3 -3
- package/dist/schema/select.js +6 -3
- package/dist/schema/serialize.d.ts +15 -6
- package/dist/schema/serialize.js +20 -3
- package/dist/schema/sugar.d.ts +6 -7
- package/dist/schema/sugar.js +9 -12
- package/dist/schema/syncDeltaRow.d.ts +4 -152
- package/dist/schema/syncDeltaRow.js +4 -105
- package/dist/server/adapter.d.ts +18 -1
- package/dist/server/commit.d.ts +10 -16
- package/dist/server/index.d.ts +1 -1
- package/dist/server/index.js +1 -1
- package/dist/server/readConfig.d.ts +1 -1
- package/dist/source/adapter.d.ts +7 -5
- package/dist/source/adapter.js +7 -5
- package/dist/source/adapters/drizzle.d.ts +1 -1
- package/dist/source/adapters/drizzle.js +2 -2
- package/dist/source/adapters/kysely.d.ts +1 -1
- package/dist/source/adapters/kysely.js +1 -1
- package/dist/source/adapters/kyselyMutationCore.d.ts +1 -1
- package/dist/source/adapters/kyselyMutationCore.js +2 -2
- package/dist/source/adapters/memory.js +1 -1
- package/dist/source/adapters/prisma.d.ts +8 -3
- package/dist/source/adapters/prisma.js +1 -1
- package/dist/source/connector.js +1 -1
- package/dist/source/connectorProtocol.d.ts +2 -8
- package/dist/source/connectorProtocol.js +3 -2
- package/dist/source/contract.d.ts +29 -17
- package/dist/source/contract.js +27 -22
- package/dist/source/factory.d.ts +1 -1
- package/dist/source/idempotency.js +2 -2
- package/dist/source/index.d.ts +1 -0
- package/dist/source/index.js +3 -0
- package/dist/source/next.d.ts +1 -1
- package/dist/source/signing.d.ts +9 -2
- package/dist/source/signing.js +4 -1
- package/dist/source/types.d.ts +6 -4
- package/dist/source/types.js +1 -1
- package/dist/{core/storeContract.d.ts → storeContract.d.ts} +6 -6
- package/dist/{core → stores}/DatabaseManager.d.ts +3 -1
- package/dist/{core → stores}/DatabaseManager.js +14 -13
- package/dist/stores/ObjectStore.d.ts +1 -1
- package/dist/{core → stores}/StoreManager.d.ts +9 -26
- package/dist/{core → stores}/StoreManager.js +29 -77
- package/dist/stores/SyncActionStore.d.ts +4 -2
- package/dist/stores/SyncActionStore.js +11 -17
- package/dist/stores/syncAction.d.ts +26 -0
- package/dist/stores/syncAction.js +16 -0
- package/dist/surface.d.ts +3 -3
- package/dist/surface.js +6 -4
- package/dist/sync/BootstrapFetcher.d.ts +127 -6
- package/dist/sync/BootstrapFetcher.js +511 -83
- package/dist/sync/ConnectionManager.d.ts +6 -198
- package/dist/sync/ConnectionManager.js +6 -677
- package/dist/sync/OnDemandLoader.d.ts +5 -2
- package/dist/sync/OnDemandLoader.js +61 -21
- package/dist/sync/SubscriptionManager.d.ts +13 -2
- package/dist/sync/SubscriptionManager.js +23 -5
- package/dist/sync/SyncWebSocket.d.ts +27 -510
- package/dist/sync/SyncWebSocket.js +76 -954
- package/dist/sync/awaitClaimGrant.d.ts +4 -44
- package/dist/sync/awaitClaimGrant.js +4 -109
- package/dist/sync/bootstrapApply.d.ts +3 -0
- package/dist/sync/bootstrapApply.js +2 -2
- package/dist/sync/commitFrames.d.ts +6 -40
- package/dist/sync/commitFrames.js +6 -97
- package/dist/sync/contextPorts.d.ts +18 -0
- package/dist/sync/contextPorts.js +31 -0
- package/dist/sync/createClaimStream.d.ts +5 -49
- package/dist/sync/createClaimStream.js +5 -469
- package/dist/sync/createPresenceStream.d.ts +26 -4
- package/dist/sync/createPresenceStream.js +28 -20
- package/dist/sync/createSnapshot.d.ts +2 -2
- package/dist/sync/createSnapshot.js +1 -1
- package/dist/sync/credentialLifecycle.d.ts +5 -173
- package/dist/sync/credentialLifecycle.js +5 -320
- package/dist/sync/deltaPipeline.d.ts +13 -12
- package/dist/sync/deltaPipeline.js +21 -4
- package/dist/sync/groupChange.d.ts +3 -0
- package/dist/sync/groupChange.js +16 -14
- package/dist/sync/participants.d.ts +24 -6
- package/dist/sync/participants.js +32 -23
- package/dist/sync/schemaDrift.d.ts +55 -0
- package/dist/sync/schemaDrift.js +53 -0
- package/dist/sync/schemas.d.ts +23 -33
- package/dist/sync/schemas.js +29 -20
- package/dist/sync/syncPlan.d.ts +3 -3
- package/dist/sync/wsFrameHandlers.d.ts +6 -114
- package/dist/sync/wsFrameHandlers.js +6 -392
- package/dist/syncLog/contract.d.ts +20 -0
- package/dist/syncLog/contract.js +19 -0
- package/dist/syncLog/index.d.ts +1 -0
- package/dist/syncLog/index.js +1 -0
- package/dist/transaction/ablo.d.ts +88 -0
- package/dist/transaction/ablo.js +33 -0
- package/dist/{client/auth.d.ts → transaction/auth/apiKey.d.ts} +43 -8
- package/dist/{client/auth.js → transaction/auth/apiKey.js} +19 -0
- package/dist/transaction/auth/bootstrapScope.d.ts +15 -0
- package/dist/transaction/auth/bootstrapScope.js +1 -0
- package/dist/transaction/auth/capability.d.ts +212 -0
- package/dist/transaction/auth/capability.js +224 -0
- package/dist/{auth → transaction/auth}/credentialSource.d.ts +8 -1
- package/dist/{client → transaction/auth}/identity.d.ts +8 -7
- package/dist/{client → transaction/auth}/identity.js +1 -1
- package/dist/transaction/auth/index.d.ts +162 -0
- package/dist/transaction/auth/index.js +304 -0
- package/dist/{auth → transaction/auth}/schemas.d.ts +1 -1
- package/dist/{auth → transaction/auth}/schemas.js +13 -13
- package/dist/{client → transaction/auth}/sessionMint.d.ts +8 -8
- package/dist/{client → transaction/auth}/sessionMint.js +4 -7
- package/dist/transaction/coordination/awaitClaimGrant.d.ts +56 -0
- package/dist/transaction/coordination/awaitClaimGrant.js +124 -0
- package/dist/{client → transaction/coordination}/claimHeartbeatLoop.d.ts +34 -0
- package/dist/{client → transaction/coordination}/claimHeartbeatLoop.js +20 -0
- package/dist/transaction/coordination/claimMeta.d.ts +49 -0
- package/dist/transaction/coordination/claimMeta.js +52 -0
- package/dist/transaction/coordination/createClaimStream.d.ts +64 -0
- package/dist/transaction/coordination/createClaimStream.js +475 -0
- package/dist/transaction/coordination/events.d.ts +74 -0
- package/dist/transaction/coordination/events.js +7 -0
- package/dist/transaction/coordination/index.d.ts +19 -0
- package/dist/transaction/coordination/index.js +45 -0
- package/dist/transaction/coordination/locator.d.ts +104 -0
- package/dist/transaction/coordination/locator.js +102 -0
- package/dist/transaction/coordination/schema.d.ts +1536 -0
- package/dist/transaction/coordination/schema.js +1177 -0
- package/dist/transaction/coordination/targetConflict.d.ts +2 -0
- package/dist/transaction/coordination/targetConflict.js +107 -0
- package/dist/{coordination → transaction/coordination}/trace.d.ts +7 -18
- package/dist/{coordination → transaction/coordination}/trace.js +18 -25
- package/dist/transaction/durableWrites.d.ts +62 -0
- package/dist/{client → transaction}/durableWrites.js +28 -3
- package/dist/transaction/environment.d.ts +105 -0
- package/dist/transaction/environment.js +108 -0
- package/dist/{errorCodes.d.ts → transaction/errorCodes.d.ts} +12 -12
- package/dist/{errorCodes.js → transaction/errorCodes.js} +45 -18
- package/dist/{errors.d.ts → transaction/errors.d.ts} +37 -13
- package/dist/{errors.js → transaction/errors.js} +85 -16
- package/dist/transaction/footprint.d.ts +111 -0
- package/dist/transaction/footprint.js +0 -0
- package/dist/transaction/index.d.ts +20 -0
- package/dist/transaction/index.js +20 -0
- package/dist/transaction/keys/index.d.ts +87 -0
- package/dist/transaction/keys/index.js +207 -0
- package/dist/transaction/log/syncDeltaRow.d.ts +158 -0
- package/dist/transaction/log/syncDeltaRow.js +95 -0
- package/dist/{sync/syncPosition.d.ts → transaction/logPosition.d.ts} +22 -8
- package/dist/{sync/syncPosition.js → transaction/logPosition.js} +15 -6
- package/dist/transaction/logger.d.ts +16 -0
- package/dist/transaction/logger.js +7 -0
- package/dist/transaction/observability.d.ts +53 -0
- package/dist/transaction/observability.js +19 -0
- package/dist/transaction/plugin.d.ts +285 -0
- package/dist/transaction/plugin.js +106 -0
- package/dist/{policy → transaction/policy}/types.d.ts +3 -3
- package/dist/{policy → transaction/policy}/types.js +2 -0
- package/dist/transaction/resources/httpResources.d.ts +321 -0
- package/dist/transaction/resources/httpResources.js +7 -0
- package/dist/transaction/resources/modelOperations.d.ts +427 -0
- package/dist/transaction/resources/modelOperations.js +12 -0
- package/dist/transaction/resources/mutationOptions.d.ts +66 -0
- package/dist/transaction/resources/mutationOptions.js +9 -0
- package/dist/transaction/resources/where.d.ts +101 -0
- package/dist/transaction/resources/where.js +115 -0
- package/dist/{client → transaction/resources}/writeOptionsSchema.d.ts +2 -6
- package/dist/{client → transaction/resources}/writeOptionsSchema.js +9 -5
- package/dist/{schema → transaction/schema}/field.d.ts +17 -23
- package/dist/{schema → transaction/schema}/field.js +5 -5
- package/dist/transaction/schema/fieldRef.d.ts +38 -0
- package/dist/transaction/schema/fieldRef.js +11 -0
- package/dist/transaction/schema/loadStrategy.d.ts +45 -0
- package/dist/transaction/schema/loadStrategy.js +46 -0
- package/dist/{schema → transaction/schema}/model.d.ts +50 -35
- package/dist/{schema → transaction/schema}/model.js +30 -20
- package/dist/transaction/schema/openapi.d.ts +58 -0
- package/dist/transaction/schema/openapi.js +501 -0
- package/dist/{schema → transaction/schema}/relation.d.ts +21 -16
- package/dist/{schema → transaction/schema}/relation.js +7 -7
- package/dist/{schema → transaction/schema}/residency.d.ts +0 -9
- package/dist/{schema → transaction/schema}/residency.js +0 -5
- package/dist/{schema → transaction/schema}/roles.d.ts +5 -5
- package/dist/{schema → transaction/schema}/roles.js +5 -5
- package/dist/{schema → transaction/schema}/schema.d.ts +39 -10
- package/dist/{schema → transaction/schema}/schema.js +24 -3
- package/dist/{schema → transaction/schema}/tenancy.d.ts +1 -1
- package/dist/{schema → transaction/schema}/tenancy.js +7 -4
- package/dist/transaction/transactionLayer.d.ts +82 -0
- package/dist/transaction/transactionLayer.js +24 -0
- package/dist/{transactions → transaction/transactions/settlement}/commitEnvelope.d.ts +5 -6
- package/dist/{transactions → transaction/transactions/settlement}/commitEnvelope.js +9 -13
- package/dist/{transactions → transaction/transactions/settlement}/httpCommitEnvelope.d.ts +9 -2
- package/dist/{transactions → transaction/transactions/settlement}/httpCommitEnvelope.js +5 -9
- package/dist/{transactions/durableWriteStore.d.ts → transaction/transactions/settlement/pendingWrite.d.ts} +11 -37
- package/dist/transaction/transactions/settlement/pendingWrite.js +20 -0
- package/dist/transaction/transport/commitFrames.d.ts +90 -0
- package/dist/transaction/transport/commitFrames.js +134 -0
- package/dist/transaction/transport/connectionManager.d.ts +215 -0
- package/dist/transaction/transport/connectionManager.js +673 -0
- package/dist/transaction/transport/credentialLifecycle.d.ts +177 -0
- package/dist/transaction/transport/credentialLifecycle.js +324 -0
- package/dist/{sync → transaction/transport}/heartbeat.d.ts +3 -1
- package/dist/{sync → transaction/transport}/heartbeat.js +6 -4
- package/dist/transaction/transport/httpClient.d.ts +131 -0
- package/dist/{client → transaction/transport}/httpClient.js +6 -5
- package/dist/transaction/transport/httpOptions.d.ts +33 -0
- package/dist/transaction/transport/httpOptions.js +12 -0
- package/dist/{client → transaction/transport}/httpTransport.js +295 -97
- package/dist/{sync/NetworkProbe.d.ts → transaction/transport/networkProbe.d.ts} +7 -4
- package/dist/{sync/NetworkProbe.js → transaction/transport/networkProbe.js} +14 -13
- package/dist/transaction/transport/wsFrameHandlers.d.ts +128 -0
- package/dist/transaction/transport/wsFrameHandlers.js +429 -0
- package/dist/transaction/transport/wsTransport.d.ts +574 -0
- package/dist/transaction/transport/wsTransport.js +1023 -0
- package/dist/transaction/types/assertExact.d.ts +17 -0
- package/dist/transaction/types/assertExact.js +1 -0
- package/dist/{types → transaction/types}/global.d.ts +17 -2
- package/dist/{types → transaction/types}/global.js +2 -1
- package/dist/{types → transaction/types}/index.d.ts +14 -46
- package/dist/{types → transaction/types}/index.js +7 -16
- package/dist/{types → transaction/types}/streams.d.ts +73 -45
- package/dist/transaction/utils/duration.d.ts +50 -0
- package/dist/{utils → transaction/utils}/duration.js +32 -0
- package/dist/{utils → transaction/utils}/json.d.ts +18 -0
- package/dist/transaction/utils/json.js +276 -0
- package/dist/transaction/wire/accountResponses.d.ts +420 -0
- package/dist/transaction/wire/accountResponses.js +290 -0
- package/dist/transaction/wire/auth.d.ts +56 -0
- package/dist/transaction/wire/auth.js +63 -0
- package/dist/transaction/wire/claimEvent.d.ts +76 -0
- package/dist/transaction/wire/claimEvent.js +73 -0
- package/dist/transaction/wire/claims.d.ts +530 -0
- package/dist/transaction/wire/claims.js +327 -0
- package/dist/{wire → transaction/wire}/commit.d.ts +125 -193
- package/dist/{wire → transaction/wire}/commit.js +68 -47
- package/dist/{wire → transaction/wire}/delta.d.ts +66 -17
- package/dist/{wire → transaction/wire}/delta.js +37 -13
- package/dist/transaction/wire/errorEnvelope.d.ts +72 -0
- package/dist/{wire → transaction/wire}/errorEnvelope.js +36 -5
- package/dist/transaction/wire/feedCursor.d.ts +60 -0
- package/dist/transaction/wire/feedCursor.js +82 -0
- package/dist/transaction/wire/feedEvent.d.ts +204 -0
- package/dist/transaction/wire/feedEvent.js +65 -0
- package/dist/transaction/wire/frames.d.ts +194 -0
- package/dist/transaction/wire/frames.js +50 -0
- package/dist/transaction/wire/inboundFrames.d.ts +562 -0
- package/dist/transaction/wire/inboundFrames.js +116 -0
- package/dist/transaction/wire/index.d.ts +54 -0
- package/dist/transaction/wire/index.js +83 -0
- package/dist/{wire → transaction/wire}/listEnvelope.d.ts +13 -14
- package/dist/transaction/wire/listEnvelope.js +42 -0
- package/dist/transaction/wire/modelMutations.d.ts +31 -0
- package/dist/transaction/wire/modelMutations.js +52 -0
- package/dist/transaction/wire/modelResponses.d.ts +85 -0
- package/dist/transaction/wire/modelResponses.js +43 -0
- package/dist/transaction/wire/modelShape.d.ts +78 -0
- package/dist/transaction/wire/modelShape.js +74 -0
- package/dist/transactions/{TransactionQueue.d.ts → mutations/MutationQueue.d.ts} +85 -38
- package/dist/transactions/{TransactionQueue.js → mutations/MutationQueue.js} +141 -80
- package/dist/transactions/{TransactionStore.d.ts → mutations/MutationStore.d.ts} +8 -8
- package/dist/transactions/{TransactionStore.js → mutations/MutationStore.js} +2 -2
- package/dist/transactions/{coalesceRules.d.ts → mutations/coalesceRules.d.ts} +10 -10
- package/dist/transactions/{coalesceRules.js → mutations/coalesceRules.js} +1 -1
- package/dist/transactions/mutations/commitLatency.d.ts +52 -0
- package/dist/transactions/mutations/commitLatency.js +130 -0
- package/dist/transactions/{commitOutboxStore.d.ts → mutations/commitOutboxStore.d.ts} +1 -5
- package/dist/transactions/{commitOutboxStore.js → mutations/commitOutboxStore.js} +1 -1
- package/dist/transactions/{commitPayload.d.ts → mutations/commitPayload.d.ts} +18 -16
- package/dist/transactions/{commitPayload.js → mutations/commitPayload.js} +15 -15
- package/dist/transactions/{deltaConfirmation.d.ts → mutations/deltaConfirmation.d.ts} +15 -11
- package/dist/transactions/{deltaConfirmation.js → mutations/deltaConfirmation.js} +14 -12
- package/dist/transactions/mutations/durableWriteStore.d.ts +14 -0
- package/dist/transactions/mutations/durableWriteStore.js +12 -0
- package/dist/transactions/{optimisticApply.d.ts → mutations/optimisticApply.d.ts} +7 -7
- package/dist/transactions/{replayValidation.d.ts → mutations/replayValidation.d.ts} +4 -3
- package/dist/transactions/{replayValidation.js → mutations/replayValidation.js} +7 -5
- package/dist/utils/mobxSetup.d.ts +1 -1
- package/dist/utils/mobxSetup.js +5 -2
- package/dist/{core → views}/QueryView.d.ts +2 -2
- package/dist/{core → views}/QueryView.js +2 -2
- package/dist/{core → views}/ViewRegistry.d.ts +1 -1
- package/dist/{core/queryUtils.d.ts → views/incrementalView.d.ts} +6 -6
- package/dist/{core/queryUtils.js → views/incrementalView.js} +6 -6
- package/dist/webhooks/events.d.ts +2 -2
- package/dist/wire/index.d.ts +1 -34
- package/dist/wire/index.js +8 -49
- package/docs/agent-messaging.md +3 -3
- package/docs/agents.md +20 -13
- package/docs/api-keys.md +14 -10
- package/docs/api.md +27 -61
- package/docs/audit.md +6 -3
- package/docs/cli.md +41 -13
- package/docs/client-behavior.md +11 -9
- package/docs/concurrency-convention.md +49 -57
- package/docs/coordination.md +283 -121
- package/docs/data-sources.md +7 -5
- package/docs/debugging.md +39 -15
- package/docs/deployment.md +267 -0
- package/docs/examples/agent-human.md +49 -42
- package/docs/examples/ai-sdk-tool.md +69 -44
- package/docs/examples/existing-python-backend.md +8 -6
- package/docs/examples/nextjs.md +129 -47
- package/docs/examples/scoped-agent.md +46 -45
- package/docs/examples/server-agent.md +46 -26
- package/docs/groups.md +87 -30
- package/docs/guarantees.md +41 -12
- package/docs/how-it-works.md +38 -12
- package/docs/idempotency.md +126 -0
- package/docs/identity.md +77 -74
- package/docs/index.md +172 -86
- package/docs/integration-guide.md +31 -19
- package/docs/mcp.md +46 -21
- package/docs/migration.md +95 -18
- package/docs/operating-on-your-database.md +3 -1
- package/docs/projects.md +3 -1
- package/docs/quickstart.md +22 -5
- package/docs/react.md +31 -18
- package/docs/schema-contract.md +5 -3
- package/docs/session-settings.md +108 -0
- package/docs/sessions.md +4 -2
- package/docs/webhooks.md +12 -10
- package/llms.txt +48 -18
- package/package.json +21 -26
- package/dist/agent/Agent.d.ts +0 -366
- package/dist/agent/Agent.js +0 -514
- package/dist/agent/index.d.ts +0 -115
- package/dist/agent/index.js +0 -128
- package/dist/agent/session.d.ts +0 -93
- package/dist/agent/session.js +0 -149
- package/dist/agent/types.d.ts +0 -68
- package/dist/agent/types.js +0 -9
- package/dist/cli.cjs +0 -286329
- package/dist/client/durableWrites.d.ts +0 -21
- package/dist/client/httpClient.d.ts +0 -80
- package/dist/coordination/schema.d.ts +0 -722
- package/dist/coordination/schema.js +0 -578
- package/dist/schema/openapi.d.ts +0 -29
- package/dist/schema/openapi.js +0 -124
- package/dist/testing/fixtures/bootstrap.d.ts +0 -49
- package/dist/testing/fixtures/bootstrap.js +0 -59
- package/dist/testing/fixtures/deltas.d.ts +0 -83
- package/dist/testing/fixtures/deltas.js +0 -136
- package/dist/testing/fixtures/models.d.ts +0 -83
- package/dist/testing/fixtures/models.js +0 -272
- package/dist/testing/helpers/reactWrapper.d.ts +0 -69
- package/dist/testing/helpers/reactWrapper.js +0 -67
- package/dist/testing/helpers/syncEngineHarness.d.ts +0 -54
- package/dist/testing/helpers/syncEngineHarness.js +0 -73
- package/dist/testing/helpers/wait.d.ts +0 -30
- package/dist/testing/helpers/wait.js +0 -49
- package/dist/testing/index.d.ts +0 -23
- package/dist/testing/index.js +0 -33
- package/dist/testing/mocks/FakeDatabase.d.ts +0 -18
- package/dist/testing/mocks/FakeDatabase.js +0 -10
- package/dist/testing/mocks/MockMutationExecutor.d.ts +0 -87
- package/dist/testing/mocks/MockMutationExecutor.js +0 -192
- package/dist/testing/mocks/MockNetworkMonitor.d.ts +0 -20
- package/dist/testing/mocks/MockNetworkMonitor.js +0 -46
- package/dist/testing/mocks/MockSyncContext.d.ts +0 -51
- package/dist/testing/mocks/MockSyncContext.js +0 -71
- package/dist/testing/mocks/MockSyncStore.d.ts +0 -88
- package/dist/testing/mocks/MockSyncStore.js +0 -171
- package/dist/testing/mocks/MockWebSocket.d.ts +0 -71
- package/dist/testing/mocks/MockWebSocket.js +0 -118
- package/dist/transactions/durableWriteStore.js +0 -30
- package/dist/utils/duration.d.ts +0 -25
- package/dist/utils/json.js +0 -88
- package/dist/wire/errorEnvelope.d.ts +0 -55
- package/dist/wire/frames.d.ts +0 -197
- package/dist/wire/frames.js +0 -49
- package/dist/wire/listEnvelope.js +0 -18
- package/docs/interaction-model.md +0 -97
- /package/dist/{core → query}/QueryProcessor.d.ts +0 -0
- /package/dist/{core → query}/QueryProcessor.js +0 -0
- /package/dist/{core/storeContract.js → storeContract.js} +0 -0
- /package/dist/{core → stores}/openIDBWithTimeout.d.ts +0 -0
- /package/dist/{core → stores}/openIDBWithTimeout.js +0 -0
- /package/dist/{client → transaction/auth}/credentialEndpoint.d.ts +0 -0
- /package/dist/{client → transaction/auth}/credentialEndpoint.js +0 -0
- /package/dist/{auth → transaction/auth}/credentialPolicy.d.ts +0 -0
- /package/dist/{auth → transaction/auth}/credentialPolicy.js +0 -0
- /package/dist/{auth → transaction/auth}/credentialSource.js +0 -0
- /package/dist/{client → transaction/auth}/hostedEndpoints.d.ts +0 -0
- /package/dist/{client → transaction/auth}/hostedEndpoints.js +0 -0
- /package/dist/{client → transaction}/persistence.d.ts +0 -0
- /package/dist/{client → transaction}/persistence.js +0 -0
- /package/dist/{client → transaction/resources}/functionalUpdate.d.ts +0 -0
- /package/dist/{client → transaction/resources}/functionalUpdate.js +0 -0
- /package/dist/{transactions → transaction/transactions/settlement}/idempotencyKey.d.ts +0 -0
- /package/dist/{transactions → transaction/transactions/settlement}/idempotencyKey.js +0 -0
- /package/dist/{client → transaction/transport}/httpTransport.d.ts +0 -0
- /package/dist/{types → transaction/types}/modelData.d.ts +0 -0
- /package/dist/{types → transaction/types}/modelData.js +0 -0
- /package/dist/{types → transaction/types}/participant.d.ts +0 -0
- /package/dist/{types → transaction/types}/participant.js +0 -0
- /package/dist/{types → transaction/types}/streams.js +0 -0
- /package/dist/{utils → transaction/utils}/asyncIterator.d.ts +0 -0
- /package/dist/{utils → transaction/utils}/asyncIterator.js +0 -0
- /package/dist/{wire → transaction/wire}/bootstrapReason.d.ts +0 -0
- /package/dist/{wire → transaction/wire}/bootstrapReason.js +0 -0
- /package/dist/{wire → transaction/wire}/protocol.d.ts +0 -0
- /package/dist/{wire → transaction/wire}/protocol.js +0 -0
- /package/dist/{wire → transaction/wire}/protocolVersion.d.ts +0 -0
- /package/dist/{wire → transaction/wire}/protocolVersion.js +0 -0
- /package/dist/transactions/{UnconfirmedWrites.d.ts → mutations/UnconfirmedWrites.d.ts} +0 -0
- /package/dist/transactions/{UnconfirmedWrites.js → mutations/UnconfirmedWrites.js} +0 -0
- /package/dist/transactions/{optimisticApply.js → mutations/optimisticApply.js} +0 -0
- /package/dist/{core → views}/ViewRegistry.js +0 -0
|
@@ -1,27 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
2
|
+
* The reactive engine's connection to the sync server: the settlement core's
|
|
3
|
+
* duplex transport ({@link WsTransport}, ADR 0016) plus everything that turns
|
|
4
|
+
* the stream into a local, watchable copy — wire-delta validation, the resume
|
|
5
|
+
* cursor and its persistence-gated ack discipline, incremental sync and
|
|
6
|
+
* bootstrap requests, the catch-up poll, and presence. The socket lifecycle,
|
|
7
|
+
* reconnect backoff, heartbeat, and the commit/claim/release/subscription
|
|
8
|
+
* frames all live in the transport; this subclass overrides its frame hooks
|
|
9
|
+
* and open/close hooks with the materialisation the transport deliberately
|
|
10
|
+
* does not do.
|
|
8
11
|
*/
|
|
9
|
-
import { EventEmitter } from 'events';
|
|
10
12
|
import { getContext } from '../context.js';
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
// `CommitAck` below so importers keep this module as their path.
|
|
15
|
-
import { buildCommitFrame } from './commitFrames.js';
|
|
16
|
-
// Inbound frame dispatch table + the minimal session slice it operates on.
|
|
17
|
-
import { dispatchWsFrame, isRecord, isWsInboundFrame, } from './wsFrameHandlers.js';
|
|
13
|
+
import { clientSyncDeltaSchema } from '../transaction/wire/delta.js';
|
|
14
|
+
import { WsTransport, } from '../transaction/transport/wsTransport.js';
|
|
15
|
+
import { isRecord } from './wsFrameHandlers.js';
|
|
18
16
|
// Sync-position state (lastSyncId watermark, version vector, server cursor).
|
|
19
17
|
import { SyncCursor } from './syncCursor.js';
|
|
20
|
-
import { PROTOCOL_VERSION
|
|
21
|
-
//
|
|
22
|
-
import {
|
|
23
|
-
import { WS_BEARER_SUBPROTOCOL_PREFIX, WS_SYNC_SUBPROTOCOL, } from '../auth/credentialSource.js';
|
|
24
|
-
// SyncObservability replaced by getContext().observability
|
|
18
|
+
import { PROTOCOL_VERSION } from '../transaction/wire/protocolVersion.js';
|
|
19
|
+
// Context-backed ports in the shape the core transport takes.
|
|
20
|
+
import { contextLogger, contextSocketObservability, contextOnlineStatus, } from './contextPorts.js';
|
|
25
21
|
/**
|
|
26
22
|
* How often, while connected, the client polls for any deltas whose best-effort
|
|
27
23
|
* broadcast was lost in transit. This is a client-side eventual-consistency
|
|
@@ -30,516 +26,81 @@ import { WS_BEARER_SUBPROTOCOL_PREFIX, WS_SYNC_SUBPROTOCOL, } from '../auth/cred
|
|
|
30
26
|
* guarantee.
|
|
31
27
|
*/
|
|
32
28
|
const CATCHUP_POLL_INTERVAL_MS = 30_000;
|
|
33
|
-
/**
|
|
34
|
-
* Ceiling for the exponential reconnect backoff (`reconnectDelay * 2^n`,
|
|
35
|
-
* ±15% jitter). Local for the same reason as the catch-up poll.
|
|
36
|
-
*/
|
|
37
|
-
const MAX_RECONNECT_DELAY_MS = 30_000;
|
|
38
29
|
// ---------------------------------------------------------------------------
|
|
39
30
|
// Consumers pass their own event types as the TCollaboration generic parameter.
|
|
40
|
-
export class SyncWebSocket extends
|
|
41
|
-
/**
|
|
42
|
-
* Subscribe to events with automatic cleanup.
|
|
43
|
-
* Returns unsubscribe function for clean disposal.
|
|
44
|
-
*/
|
|
45
|
-
subscribe(event, handler) {
|
|
46
|
-
this.on(event, handler);
|
|
47
|
-
return () => this.off(event, handler);
|
|
48
|
-
}
|
|
49
|
-
/**
|
|
50
|
-
* Send a collaboration event (app-specific real-time message).
|
|
51
|
-
* The wire format is `{ type: messageType, payload: { ...payload, timestamp } }`.
|
|
52
|
-
*/
|
|
53
|
-
sendCollaborationEvent(messageType, payload) {
|
|
54
|
-
if (this.ws?.readyState !== WebSocket.OPEN)
|
|
55
|
-
return;
|
|
56
|
-
this.send({
|
|
57
|
-
type: messageType.replace(/:/g, '_'), // 'sheet:selection' → 'sheet_selection' wire format
|
|
58
|
-
payload: { ...payload, timestamp: Date.now() },
|
|
59
|
-
});
|
|
60
|
-
}
|
|
61
|
-
ws = null;
|
|
62
|
-
options;
|
|
63
|
-
reconnectAttempts = 0;
|
|
64
|
-
/** Stop retrying after this many consecutive failures (backoff caps at 30s, so ~7.5 min total) */
|
|
65
|
-
static MAX_RECONNECT_ATTEMPTS = 15;
|
|
66
|
-
reconnectTimer = null;
|
|
31
|
+
export class SyncWebSocket extends WsTransport {
|
|
67
32
|
/** Periodic catchup interval — polls for missed deltas every 30s while connected */
|
|
68
33
|
catchupInterval = null;
|
|
69
|
-
/**
|
|
70
|
-
* Application-level heartbeat: ping every 30 seconds and force-close after a
|
|
71
|
-
* 10-second silence. The {@link HeartbeatController} holds the timing and the
|
|
72
|
-
* zombie-socket rationale; the closures below are the only socket access it
|
|
73
|
-
* gets.
|
|
74
|
-
*/
|
|
75
|
-
heartbeat = new HeartbeatController({
|
|
76
|
-
isSocketOpen: () => this.ws?.readyState === WebSocket.OPEN,
|
|
77
|
-
// Optional-chained rather than asserted: the controller only calls
|
|
78
|
-
// this synchronously after `isSocketOpen()`, so `ws` is present.
|
|
79
|
-
sendPing: () => {
|
|
80
|
-
this.ws?.send(JSON.stringify({ type: 'ping' }));
|
|
81
|
-
},
|
|
82
|
-
forceClose: (reason) => { this.forceClose(reason); },
|
|
83
|
-
});
|
|
84
|
-
isConnecting = false;
|
|
85
|
-
isManualClose = false;
|
|
86
|
-
/** When true, a session error has been detected (from any path — WS close or HTTP bootstrap).
|
|
87
|
-
* Suppresses reconnection and Sentry error capture to avoid cascading noise. */
|
|
88
|
-
_sessionErrorDetected = false;
|
|
89
|
-
/** True once `onopen` has fired at least once on the current socket. Reset each
|
|
90
|
-
* time a new socket is created in `connect()`. Used by `onclose` to detect
|
|
91
|
-
* handshake failures (close before open) — the one signal we have for "the
|
|
92
|
-
* server rejected the upgrade" since browsers hide the HTTP status (e.g.
|
|
93
|
-
* 401) behind the opaque 1006 close code. */
|
|
94
|
-
_everOpened = false;
|
|
95
|
-
/**
|
|
96
|
-
* Diagnostic snapshot of the last connection lifecycle. Persisted across
|
|
97
|
-
* the lifetime of the SyncWebSocket so that any subsequent "not connected"
|
|
98
|
-
* rejection can quote the actual root cause (close code + reason + when)
|
|
99
|
-
* instead of bottoming out at a generic error string. Browser WS code 1006
|
|
100
|
-
* hides the real reason, so we layer on our own signals: `forceCloseReason`
|
|
101
|
-
* captures heartbeat trips / send failures, `everOpened` distinguishes
|
|
102
|
-
* handshake reject from mid-session drop, and `sessionErrorAt` tells us
|
|
103
|
-
* whether reconnect is suppressed.
|
|
104
|
-
*/
|
|
105
|
-
lastOpenAt = null;
|
|
106
|
-
lastCloseAt = null;
|
|
107
|
-
lastCloseCode = null;
|
|
108
|
-
lastCloseReason = null;
|
|
109
|
-
lastForceCloseReason = null;
|
|
110
|
-
sessionErrorAt = null;
|
|
111
34
|
/**
|
|
112
35
|
* Sync-position state: the lastSyncId watermark, version vector, and server
|
|
113
36
|
* cursor. The advance discipline is documented at `sendAck` and `handleDelta`;
|
|
114
37
|
* the state itself lives in {@link SyncCursor}.
|
|
115
38
|
*/
|
|
116
39
|
cursor;
|
|
117
|
-
/** Registered collaboration event keys (colon format) for dispatch in onmessage */
|
|
118
|
-
collaborationEventTypes;
|
|
119
|
-
/**
|
|
120
|
-
* A minimal session adapter handed to the inbound frame dispatch table
|
|
121
|
-
* ({@link dispatchWsFrame}). It exposes only the members the handlers touch;
|
|
122
|
-
* the closure members read live state so a reassignment here (for example the
|
|
123
|
-
* `pendingSubscriptions` reset on close) cannot strand a handler on a stale
|
|
124
|
-
* reference. Built in the constructor, after the state it captures exists.
|
|
125
|
-
*/
|
|
126
|
-
frameSession;
|
|
127
|
-
/**
|
|
128
|
-
* In-flight `commit` mutation requests keyed by clientTxId. Resolved when
|
|
129
|
-
* a matching `mutation_result` frame arrives from the server, or rejected on
|
|
130
|
-
* timeout / disconnect. Lets consumers await a server ack for mutations
|
|
131
|
-
* sent over the same socket that streams deltas.
|
|
132
|
-
*/
|
|
133
|
-
pendingMutations = new Map();
|
|
134
|
-
/**
|
|
135
|
-
* In-flight `claim` requests keyed by claimId. Resolved when the matching
|
|
136
|
-
* `claim_ack` arrives, or rejected on timeout or disconnect — the same
|
|
137
|
-
* request/response pattern as `pendingMutations`, multiplexed over the one
|
|
138
|
-
* connection.
|
|
139
|
-
*/
|
|
140
|
-
pendingClaims = new Map();
|
|
141
|
-
/**
|
|
142
|
-
* In-flight `update_subscription` frames awaiting `subscription_ack`.
|
|
143
|
-
* A FIFO queue rather than a keyed Map because the wire ack carries no
|
|
144
|
-
* correlation id — the server applies subscription updates in receive
|
|
145
|
-
* order and acks in the same order, so `shift()` on ack matches the
|
|
146
|
-
* oldest pending request. (Read-interest changes are infrequent and
|
|
147
|
-
* usually settle before the next one, so depth is ~1 in practice.)
|
|
148
|
-
*/
|
|
149
|
-
pendingSubscriptions = [];
|
|
150
40
|
constructor(options) {
|
|
151
|
-
super(
|
|
152
|
-
// Construct the WebSocket URL from the base server URL.
|
|
153
|
-
const baseUrl = options.baseUrl || options.url || "http://localhost:8080";
|
|
154
|
-
const wsProtocol = baseUrl.startsWith('https') ? 'wss' : 'ws';
|
|
155
|
-
const wsUrl = baseUrl.replace(/^https?/, wsProtocol) + '/api/sync/ws';
|
|
156
|
-
this.options = {
|
|
157
|
-
url: wsUrl,
|
|
158
|
-
reconnectDelay: 1000,
|
|
159
|
-
maxReconnectDelay: MAX_RECONNECT_DELAY_MS,
|
|
160
|
-
collaborationEvents: ['sheet:selection', 'slide:selection', 'slide:cursor'],
|
|
161
|
-
syncGroups: [],
|
|
162
|
-
lastSyncId: 0,
|
|
163
|
-
capabilities: {
|
|
164
|
-
partialBootstrap: true,
|
|
165
|
-
compressedDeltas: true,
|
|
166
|
-
streamingBootstrap: true,
|
|
167
|
-
batchedDeltas: true,
|
|
168
|
-
},
|
|
41
|
+
super({
|
|
169
42
|
...options,
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
// Session slice for the inbound frame dispatch table — see the field doc on
|
|
174
|
-
// `frameSession` for why reassigned members are exposed through closures
|
|
175
|
-
// instead of captured references.
|
|
176
|
-
this.frameSession = {
|
|
177
|
-
emit: (event, ...args) => this.emit(event, ...args),
|
|
178
|
-
pendingMutations: this.pendingMutations,
|
|
179
|
-
pendingClaims: this.pendingClaims,
|
|
180
|
-
shiftPendingSubscription: () => this.pendingSubscriptions.shift(),
|
|
181
|
-
options: this.options,
|
|
182
|
-
collaborationEventTypes: this.collaborationEventTypes,
|
|
183
|
-
handleDelta: (delta) => { this.handleDelta(delta); },
|
|
184
|
-
handleSyncResponse: (payload) => { this.handleSyncResponse(payload); },
|
|
185
|
-
handleBootstrapResponse: (payload) => { this.handleBootstrapResponse(payload); },
|
|
186
|
-
handlePresenceUpdate: (message) => {
|
|
187
|
-
this.handlePresenceUpdate(message);
|
|
188
|
-
},
|
|
189
|
-
};
|
|
190
|
-
}
|
|
191
|
-
/**
|
|
192
|
-
* Mark that a session error has been detected (e.g. 401 from HTTP bootstrap).
|
|
193
|
-
* Suppresses further reconnection attempts and Sentry error capture.
|
|
194
|
-
*/
|
|
195
|
-
setSessionErrorDetected() {
|
|
196
|
-
this._sessionErrorDetected = true;
|
|
197
|
-
this.sessionErrorAt = Date.now();
|
|
198
|
-
}
|
|
199
|
-
/**
|
|
200
|
-
* Clear the session-error latch so `connect()` / `scheduleReconnect()`
|
|
201
|
-
* work again. Called by the store's access-credential recovery path when
|
|
202
|
-
* the close was a re-mintable `ek_`/`rk_` expiry (`4001 credential_expired`),
|
|
203
|
-
* not a login loss — see `isAccessCredentialExpiryCloseReason`. Genuine
|
|
204
|
-
* session losses never clear the latch; re-auth builds a fresh client.
|
|
205
|
-
*/
|
|
206
|
-
clearSessionError() {
|
|
207
|
-
this._sessionErrorDetected = false;
|
|
208
|
-
this.sessionErrorAt = null;
|
|
209
|
-
}
|
|
210
|
-
/**
|
|
211
|
-
* Connect to the sync engine WebSocket
|
|
212
|
-
*/
|
|
213
|
-
connect() {
|
|
214
|
-
if (this._sessionErrorDetected) {
|
|
215
|
-
getContext().logger.debug('WebSocket connect suppressed — session error detected');
|
|
216
|
-
return;
|
|
217
|
-
}
|
|
218
|
-
// CLOSING counts as busy: the socket's close teardown is still in
|
|
219
|
-
// flight and its `onclose` (which runs `scheduleReconnect`) hasn't
|
|
220
|
-
// fired yet. Overwriting `this.ws` mid-teardown is what produced the
|
|
221
|
-
// orphaned-socket race — see the stale-socket guards in
|
|
222
|
-
// `setupEventHandlers`.
|
|
223
|
-
if (this.ws?.readyState === WebSocket.OPEN ||
|
|
224
|
-
this.ws?.readyState === WebSocket.CLOSING ||
|
|
225
|
-
this.isConnecting) {
|
|
226
|
-
getContext().logger.debug('WebSocket already connected, connecting, or closing');
|
|
227
|
-
return;
|
|
228
|
-
}
|
|
229
|
-
// Note: onlineStatus is advisory — we'll try to connect and let the WebSocket
|
|
230
|
-
// handle failures. The default browser implementation reads navigator.onLine,
|
|
231
|
-
// which is unreliable but the only signal available; in Node it returns true
|
|
232
|
-
// (assume online) so the sidecar/agent path doesn't short-circuit here.
|
|
233
|
-
if (!getContext().onlineStatus.isOnline()) {
|
|
234
|
-
getContext().logger.debug('onlineStatus reports offline, but attempting connection anyway');
|
|
235
|
-
}
|
|
236
|
-
this.isConnecting = true;
|
|
237
|
-
this.isManualClose = false;
|
|
238
|
-
// One credential, server-resolved identity. The bearer travels in a
|
|
239
|
-
// `Sec-WebSocket-Protocol` value (built below), not the URL. The server is
|
|
240
|
-
// bearer-only and resolves identity from the verified token; userId and
|
|
241
|
-
// organizationId are never read from URL parameters.
|
|
242
|
-
const params = new URLSearchParams({
|
|
243
|
-
// Intentionally omit lastSyncId, capabilities from URL; these are sent in sync_request
|
|
244
|
-
// and ack messages to avoid stale baselines on reconnect.
|
|
245
|
-
cursor: this.cursor.syncCursor || '',
|
|
246
|
-
});
|
|
247
|
-
// Participant kind — defaults to `user` for session connections. Agent
|
|
248
|
-
// runtimes pass `'agent'` so the server's capability-token path activates
|
|
249
|
-
// instead of session auth.
|
|
250
|
-
if (this.options.kind && this.options.kind !== 'user') {
|
|
251
|
-
params.set('kind', this.options.kind);
|
|
252
|
-
}
|
|
253
|
-
// Add sync groups if provided
|
|
254
|
-
this.options.syncGroups.forEach((group) => {
|
|
255
|
-
params.append('syncGroups', group);
|
|
43
|
+
logger: contextLogger,
|
|
44
|
+
observability: contextSocketObservability,
|
|
45
|
+
onlineStatus: contextOnlineStatus,
|
|
256
46
|
});
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
47
|
+
this.cursor = new SyncCursor(options.lastSyncId ?? 0);
|
|
48
|
+
}
|
|
49
|
+
/** The persisted resume position, sent on the upgrade URL. */
|
|
50
|
+
resumeCursor() {
|
|
51
|
+
return this.cursor.syncCursor || '';
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* The open ritual, run by the transport between its `connected` emit and the
|
|
55
|
+
* heartbeat start: announce presence, tell the server where we left off,
|
|
56
|
+
* request the deltas we missed, and start the catch-up poll.
|
|
57
|
+
*/
|
|
58
|
+
onOpened() {
|
|
59
|
+
// Send presence update with timezone (server sets presence to "online" on connect,
|
|
60
|
+
// this improves localTime accuracy by providing the user's actual timezone)
|
|
61
|
+
this.sendPresenceUpdate('online');
|
|
62
|
+
// Immediately request incremental sync based on our stored cursor.
|
|
63
|
+
// `requestIncrementalSync` is async — a bare call inside try/catch is a
|
|
64
|
+
// rejection hole (the catch never sees it); route failures through
|
|
65
|
+
// `.catch` so they land in the same breadcrumb instead of an
|
|
66
|
+
// unhandled rejection.
|
|
67
|
+
const reportSyncRequestFailure = (e) => {
|
|
68
|
+
getContext().observability.breadcrumb('Failed to request incremental sync on open', 'sync.websocket', 'warning', {
|
|
69
|
+
error: e instanceof Error ? e.message : String(e),
|
|
70
|
+
});
|
|
71
|
+
};
|
|
269
72
|
try {
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
this.ws = new WebSocket(wsUrl, protocols);
|
|
275
|
-
this.setupEventHandlers();
|
|
73
|
+
if (this.cursor.lastSyncId && this.cursor.lastSyncId > 0) {
|
|
74
|
+
// Let server know where we left off before requesting deltas
|
|
75
|
+
this.sendAck(this.cursor.lastSyncId);
|
|
76
|
+
}
|
|
276
77
|
}
|
|
277
|
-
catch (
|
|
278
|
-
|
|
279
|
-
const errorMessage = error instanceof Error ? error.message : 'Failed to create WebSocket';
|
|
280
|
-
getContext().observability.captureWebSocketError({ context: 'create-websocket', error: errorMessage });
|
|
281
|
-
this.isConnecting = false;
|
|
282
|
-
this.emit('error', new AbloConnectionError(errorMessage, { cause: error }));
|
|
283
|
-
this.scheduleReconnect();
|
|
78
|
+
catch (e) {
|
|
79
|
+
reportSyncRequestFailure(e);
|
|
284
80
|
}
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
//
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
return;
|
|
301
|
-
socket.onopen = () => {
|
|
302
|
-
if (this.ws !== socket)
|
|
303
|
-
return; // stale socket — a newer connect() owns the state
|
|
304
|
-
getContext().observability.breadcrumb('WebSocket connected', 'sync.websocket', 'info', {
|
|
305
|
-
lastSyncId: this.cursor.lastSyncId,
|
|
306
|
-
reconnectAttempts: this.reconnectAttempts,
|
|
307
|
-
});
|
|
308
|
-
this.isConnecting = false;
|
|
309
|
-
this.reconnectAttempts = 0;
|
|
310
|
-
this._everOpened = true;
|
|
311
|
-
this.lastOpenAt = Date.now();
|
|
312
|
-
this.emit('connected');
|
|
313
|
-
// Send presence update with timezone (server sets presence to "online" on connect,
|
|
314
|
-
// this improves localTime accuracy by providing the user's actual timezone)
|
|
315
|
-
this.sendPresenceUpdate('online');
|
|
316
|
-
// Immediately request incremental sync based on our stored cursor.
|
|
317
|
-
// `requestIncrementalSync` is async — a bare call inside try/catch is a
|
|
318
|
-
// rejection hole (the catch never sees it); route failures through
|
|
319
|
-
// `.catch` so they land in the same breadcrumb instead of an
|
|
320
|
-
// unhandled rejection.
|
|
321
|
-
const reportSyncRequestFailure = (e) => {
|
|
322
|
-
getContext().observability.breadcrumb('Failed to request incremental sync on open', 'sync.websocket', 'warning', {
|
|
323
|
-
error: e instanceof Error ? e.message : String(e),
|
|
324
|
-
});
|
|
325
|
-
};
|
|
326
|
-
try {
|
|
327
|
-
if (this.cursor.lastSyncId && this.cursor.lastSyncId > 0) {
|
|
328
|
-
// Let server know where we left off before requesting deltas
|
|
329
|
-
this.sendAck(this.cursor.lastSyncId);
|
|
330
|
-
}
|
|
331
|
-
}
|
|
332
|
-
catch (e) {
|
|
333
|
-
reportSyncRequestFailure(e);
|
|
334
|
-
}
|
|
335
|
-
this.requestIncrementalSync().catch(reportSyncRequestFailure);
|
|
336
|
-
// Start periodic catchup — polls for missed deltas every
|
|
337
|
-
// CATCHUP_POLL_INTERVAL_MS while connected. Real-time WebSocket delivery
|
|
338
|
-
// is best-effort, so this interval guarantees eventual consistency by
|
|
339
|
-
// fetching any deltas that were committed but whose broadcast was lost in
|
|
340
|
-
// transit.
|
|
341
|
-
this.stopCatchupInterval();
|
|
342
|
-
this.catchupInterval = setInterval(() => {
|
|
343
|
-
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
344
|
-
// A rejected sync request must never surface as an unhandled
|
|
345
|
-
// rejection from a background interval — log and let the next
|
|
346
|
-
// poll retry.
|
|
347
|
-
this.requestIncrementalSync().catch((e) => {
|
|
348
|
-
getContext().observability.breadcrumb('Periodic catchup sync request failed', 'sync.websocket', 'warning', {
|
|
349
|
-
error: e instanceof Error ? e.message : String(e),
|
|
350
|
-
});
|
|
351
|
-
});
|
|
352
|
-
}
|
|
353
|
-
}, CATCHUP_POLL_INTERVAL_MS);
|
|
354
|
-
// Start the application-level heartbeat (see HeartbeatController).
|
|
355
|
-
this.heartbeat.start();
|
|
356
|
-
};
|
|
357
|
-
socket.onmessage = (event) => {
|
|
358
|
-
if (this.ws !== socket)
|
|
359
|
-
return; // stale socket — drop, don't feed shared state
|
|
360
|
-
try {
|
|
361
|
-
// Untrusted wire input: parse to `unknown`, then narrow through
|
|
362
|
-
// the frame-envelope guard before dispatch. Payload-level
|
|
363
|
-
// validation (deltas etc.) happens per-frame downstream.
|
|
364
|
-
const message = JSON.parse(event.data);
|
|
365
|
-
// Any inbound frame proves the socket is alive — clear the
|
|
366
|
-
// heartbeat-timeout timer so we don't false-trip force-close
|
|
367
|
-
// during normal traffic.
|
|
368
|
-
this.heartbeat.clearHeartbeatTimeout();
|
|
369
|
-
if (!isWsInboundFrame(message)) {
|
|
370
|
-
getContext().logger.debug('[SyncWebSocket] dropped malformed wire frame', {
|
|
371
|
-
received: Array.isArray(message) ? 'array' : typeof message,
|
|
81
|
+
this.requestIncrementalSync().catch(reportSyncRequestFailure);
|
|
82
|
+
// Start periodic catchup — polls for missed deltas every
|
|
83
|
+
// CATCHUP_POLL_INTERVAL_MS while connected. Real-time WebSocket delivery
|
|
84
|
+
// is best-effort, so this interval guarantees eventual consistency by
|
|
85
|
+
// fetching any deltas that were committed but whose broadcast was lost in
|
|
86
|
+
// transit.
|
|
87
|
+
this.stopCatchupInterval();
|
|
88
|
+
this.catchupInterval = setInterval(() => {
|
|
89
|
+
if (this.isConnected()) {
|
|
90
|
+
// A rejected sync request must never surface as an unhandled
|
|
91
|
+
// rejection from a background interval — log and let the next
|
|
92
|
+
// poll retry.
|
|
93
|
+
this.requestIncrementalSync().catch((e) => {
|
|
94
|
+
getContext().observability.breadcrumb('Periodic catchup sync request failed', 'sync.websocket', 'warning', {
|
|
95
|
+
error: e instanceof Error ? e.message : String(e),
|
|
372
96
|
});
|
|
373
|
-
return;
|
|
374
|
-
}
|
|
375
|
-
// Dispatch by frame type (see dispatchWsFrame). The session adapter
|
|
376
|
-
// exposes only the members the handlers touch; keepalives, the older
|
|
377
|
-
// bare-delta form, and collaboration events are all routed there too.
|
|
378
|
-
dispatchWsFrame(this.frameSession, message);
|
|
379
|
-
}
|
|
380
|
-
catch (error) {
|
|
381
|
-
getContext().observability.captureWebSocketError({
|
|
382
|
-
context: 'parse-message',
|
|
383
|
-
error: error instanceof Error ? error.message : String(error),
|
|
384
|
-
});
|
|
385
|
-
}
|
|
386
|
-
};
|
|
387
|
-
socket.onerror = (_event) => {
|
|
388
|
-
if (this.ws !== socket)
|
|
389
|
-
return; // stale socket — its errors are no longer ours
|
|
390
|
-
// WebSocket errors are DOM Events, not Error objects
|
|
391
|
-
// Check if we're offline first
|
|
392
|
-
if (!getContext().onlineStatus.isOnline()) {
|
|
393
|
-
getContext().observability.breadcrumb('WebSocket error: Network is offline', 'sync.websocket', 'warning');
|
|
394
|
-
this.emit('error', new AbloConnectionError('Network is offline', { code: 'bootstrap_offline' }));
|
|
395
|
-
return;
|
|
396
|
-
}
|
|
397
|
-
// After a session error, suppress error capture — the root cause is
|
|
398
|
-
// already reported. Still emit so the store can update UI state.
|
|
399
|
-
const error = new AbloConnectionError(`WebSocket connection failed`);
|
|
400
|
-
if (!this._sessionErrorDetected) {
|
|
401
|
-
getContext().observability.captureWebSocketError({
|
|
402
|
-
context: 'connection-error',
|
|
403
|
-
error: error.message,
|
|
404
97
|
});
|
|
405
98
|
}
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
// reconnect (the orphaning race this guard exists for). The one
|
|
412
|
-
// deliberate asymmetry vs the other handlers: `this.ws === null`
|
|
413
|
-
// (manual `disconnect()` nulls the field before the close event
|
|
414
|
-
// lands) still runs the full body, so in-flight work is rejected
|
|
415
|
-
// promptly and 'disconnected' reaches consumers — the pre-guard
|
|
416
|
-
// behavior manual close always had.
|
|
417
|
-
if (this.ws !== null && this.ws !== socket)
|
|
418
|
-
return;
|
|
419
|
-
const everOpened = this._everOpened;
|
|
420
|
-
this.lastCloseAt = Date.now();
|
|
421
|
-
this.lastCloseCode = event.code;
|
|
422
|
-
this.lastCloseReason = event.reason || null;
|
|
423
|
-
getContext().logger.info('WebSocket closed', {
|
|
424
|
-
code: event.code,
|
|
425
|
-
reason: event.reason,
|
|
426
|
-
everOpened,
|
|
427
|
-
reconnectAttempts: this.reconnectAttempts,
|
|
428
|
-
forceCloseReason: this.lastForceCloseReason,
|
|
429
|
-
msSinceOpen: this.lastOpenAt != null ? Date.now() - this.lastOpenAt : null,
|
|
430
|
-
isManualClose: this.isManualClose,
|
|
431
|
-
});
|
|
432
|
-
this.isConnecting = false;
|
|
433
|
-
this.ws = null;
|
|
434
|
-
this.stopCatchupInterval();
|
|
435
|
-
this.heartbeat.stop();
|
|
436
|
-
// Cancel in-flight mutations — the socket that was carrying them is
|
|
437
|
-
// gone, and the server-side state may or may not have accepted each
|
|
438
|
-
// one. Rejecting promptly is better than hanging the caller forever;
|
|
439
|
-
// higher-level retry belongs to TransactionQueue, not here.
|
|
440
|
-
if (this.pendingMutations.size > 0) {
|
|
441
|
-
for (const pending of this.pendingMutations.values()) {
|
|
442
|
-
clearTimeout(pending.timeout);
|
|
443
|
-
// AbloConnectionError → `isPermanentError` treats it as transient,
|
|
444
|
-
// so TransactionQueue retries the commit on reconnect rather than
|
|
445
|
-
// rolling it back. `diagnostics` is preserved as a property (the
|
|
446
|
-
// queue's failure log walks the cause chain for it).
|
|
447
|
-
pending.reject(Object.assign(new AbloConnectionError(`WebSocket closed while commit was in flight (code=${event.code}` +
|
|
448
|
-
(event.reason ? ` reason=${event.reason}` : '') +
|
|
449
|
-
(this.lastForceCloseReason
|
|
450
|
-
? ` forceCloseReason=${this.lastForceCloseReason}`
|
|
451
|
-
: '') +
|
|
452
|
-
')', { code: 'commit_no_result' }), { diagnostics: this.getConnectionDiagnostics() }));
|
|
453
|
-
}
|
|
454
|
-
this.pendingMutations.clear();
|
|
455
|
-
}
|
|
456
|
-
// Cancel in-flight claims — same rationale. Server-side
|
|
457
|
-
// claims are bound to the connection; a reconnect will need
|
|
458
|
-
// to re-claim. Higher-level retry belongs to whoever holds
|
|
459
|
-
// the participant handle (typically the SDK's claim manager).
|
|
460
|
-
if (this.pendingClaims.size > 0) {
|
|
461
|
-
for (const pending of this.pendingClaims.values()) {
|
|
462
|
-
clearTimeout(pending.timeout);
|
|
463
|
-
pending.reject(new AbloConnectionError(`WebSocket closed while claim was in flight (code=${event.code})`));
|
|
464
|
-
}
|
|
465
|
-
this.pendingClaims.clear();
|
|
466
|
-
}
|
|
467
|
-
// Cancel in-flight subscription updates — the reconnect handshake
|
|
468
|
-
// re-sends `options.syncGroups` (the last acked interest) in the
|
|
469
|
-
// upgrade URL, so a pending change that never acked is simply
|
|
470
|
-
// retried by the caller against the fresh connection.
|
|
471
|
-
if (this.pendingSubscriptions.length > 0) {
|
|
472
|
-
for (const pending of this.pendingSubscriptions) {
|
|
473
|
-
clearTimeout(pending.timeout);
|
|
474
|
-
pending.reject(new AbloConnectionError(`WebSocket closed while update_subscription was in flight (code=${event.code})`));
|
|
475
|
-
}
|
|
476
|
-
this.pendingSubscriptions = [];
|
|
477
|
-
}
|
|
478
|
-
// Protocol-version rejection (4010): terminal. Reconnecting cannot heal a
|
|
479
|
-
// version mismatch — only upgrading the SDK, or rolling the server
|
|
480
|
-
// forward, can — so a blind retry here would loop forever against the
|
|
481
|
-
// same typed close. Surface it and stop.
|
|
482
|
-
if (event.code === WS_CLOSE_PROTOCOL_VERSION) {
|
|
483
|
-
getContext().observability.captureWebSocketError({
|
|
484
|
-
context: 'protocol-version-close',
|
|
485
|
-
code: event.code,
|
|
486
|
-
reason: event.reason,
|
|
487
|
-
});
|
|
488
|
-
this.emit('protocol_mismatch', event);
|
|
489
|
-
this.emit('disconnected', event);
|
|
490
|
-
return;
|
|
491
|
-
}
|
|
492
|
-
// Check for session-related close codes
|
|
493
|
-
// 1008 = Policy Violation (often auth)
|
|
494
|
-
// 4001 = Unauthorized (custom)
|
|
495
|
-
// 4003 = Forbidden (custom)
|
|
496
|
-
const isSessionClose = event.code === 1008 ||
|
|
497
|
-
event.code === 4001 ||
|
|
498
|
-
event.code === 4003 ||
|
|
499
|
-
SyncSessionError.isSessionError(event.reason || '');
|
|
500
|
-
if (isSessionClose) {
|
|
501
|
-
this._sessionErrorDetected = true;
|
|
502
|
-
this.sessionErrorAt = Date.now();
|
|
503
|
-
getContext().observability.captureWebSocketError({
|
|
504
|
-
context: 'session-error-close',
|
|
505
|
-
code: event.code,
|
|
506
|
-
reason: event.reason,
|
|
507
|
-
});
|
|
508
|
-
this.emit('session_error', new SyncSessionError(event.reason || 'Session expired', event.code));
|
|
509
|
-
// Don't reconnect from here. For a genuine session loss the user must
|
|
510
|
-
// re-authenticate; for an expired access credential (`credential_expired`)
|
|
511
|
-
// the store's session-error handler re-mints, clears the latch, and
|
|
512
|
-
// drives the reconnect itself.
|
|
513
|
-
this.emit('disconnected', event);
|
|
514
|
-
return;
|
|
515
|
-
}
|
|
516
|
-
// Handshake failure: `onclose` fired before `onopen` ever did, so the
|
|
517
|
-
// server rejected the upgrade (typically 401/403 on a bad cookie, but it
|
|
518
|
-
// could also be a CORS/origin reject or a load-balancer 5xx). The browser
|
|
519
|
-
// hides the HTTP status behind code 1006, so we cannot tell which from
|
|
520
|
-
// here.
|
|
521
|
-
//
|
|
522
|
-
// Emit a dedicated event and skip the internal reconnect — the owner
|
|
523
|
-
// should run an auth-validating HTTP probe to distinguish session expiry
|
|
524
|
-
// from a transient network issue and transition the UI accordingly.
|
|
525
|
-
// Reconnecting blindly is what produced the infinite
|
|
526
|
-
// "offline → reconnecting → offline" loop on stale cookies.
|
|
527
|
-
if (!everOpened && !this.isManualClose) {
|
|
528
|
-
getContext().observability.captureWebSocketError({
|
|
529
|
-
context: 'handshake-failed-close',
|
|
530
|
-
code: event.code,
|
|
531
|
-
reason: event.reason,
|
|
532
|
-
});
|
|
533
|
-
this.emit('handshake_failed', event);
|
|
534
|
-
this.emit('disconnected', event);
|
|
535
|
-
return;
|
|
536
|
-
}
|
|
537
|
-
this.emit('disconnected', event);
|
|
538
|
-
// Reconnect if not manually closed
|
|
539
|
-
if (!this.isManualClose) {
|
|
540
|
-
this.scheduleReconnect();
|
|
541
|
-
}
|
|
542
|
-
};
|
|
99
|
+
}, CATCHUP_POLL_INTERVAL_MS);
|
|
100
|
+
}
|
|
101
|
+
/** The transport clears its socket reference before this runs. */
|
|
102
|
+
onClosed() {
|
|
103
|
+
this.stopCatchupInterval();
|
|
543
104
|
}
|
|
544
105
|
/**
|
|
545
106
|
* Validates and normalizes a wire delta at the receive boundary — the single
|
|
@@ -633,7 +194,7 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
633
194
|
// will send next, and what `getLastSyncId()` reports for clean-
|
|
634
195
|
// shutdown persistence.
|
|
635
196
|
this.cursor.ackAdvance(syncId);
|
|
636
|
-
if (this.
|
|
197
|
+
if (!this.isConnected())
|
|
637
198
|
return;
|
|
638
199
|
this.send({
|
|
639
200
|
type: 'ack',
|
|
@@ -648,263 +209,6 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
648
209
|
acknowledge(syncId) {
|
|
649
210
|
this.sendAck(syncId);
|
|
650
211
|
}
|
|
651
|
-
/**
|
|
652
|
-
* Send message to server
|
|
653
|
-
*/
|
|
654
|
-
send(message) {
|
|
655
|
-
if (this.ws?.readyState !== WebSocket.OPEN) {
|
|
656
|
-
// Only log at debug level when offline - this is expected behavior, not an error
|
|
657
|
-
if (getContext().onlineStatus.isOnline()) {
|
|
658
|
-
getContext().observability.breadcrumb('WebSocket not connected, cannot send message', 'sync.websocket', 'warning');
|
|
659
|
-
}
|
|
660
|
-
else {
|
|
661
|
-
getContext().logger.debug('WebSocket send skipped - offline');
|
|
662
|
-
}
|
|
663
|
-
return;
|
|
664
|
-
}
|
|
665
|
-
try {
|
|
666
|
-
this.ws.send(JSON.stringify(message));
|
|
667
|
-
}
|
|
668
|
-
catch (error) {
|
|
669
|
-
// Only log as error if we're online - offline send failures are expected
|
|
670
|
-
if (getContext().onlineStatus.isOnline()) {
|
|
671
|
-
getContext().observability.captureWebSocketError({
|
|
672
|
-
context: 'send-message',
|
|
673
|
-
error: error instanceof Error ? error.message : String(error),
|
|
674
|
-
});
|
|
675
|
-
}
|
|
676
|
-
else {
|
|
677
|
-
getContext().logger.debug('WebSocket send failed - offline');
|
|
678
|
-
}
|
|
679
|
-
}
|
|
680
|
-
}
|
|
681
|
-
/**
|
|
682
|
-
* Sends a `commit` mutation request over the existing WebSocket and resolves
|
|
683
|
-
* when the server's `mutation_result` frame comes back with the same
|
|
684
|
-
* `clientTxId`. The wire frame is `{ type: 'commit', payload: { operations,
|
|
685
|
-
* clientTxId } }`.
|
|
686
|
-
*
|
|
687
|
-
* Times out after 15 seconds of silence from the server. The socket may close
|
|
688
|
-
* during an in-flight mutation (a network flap, a server restart); this does
|
|
689
|
-
* not auto-retry — the caller's transaction queue owns retry and offline
|
|
690
|
-
* replay, and the SDK does not duplicate that logic.
|
|
691
|
-
*/
|
|
692
|
-
sendCommit(operations, clientTxId, timeoutMs = 15_000, causedByTaskId, reads, track) {
|
|
693
|
-
if (this.ws?.readyState !== WebSocket.OPEN) {
|
|
694
|
-
return Promise.reject(this.notConnectedError('commit'));
|
|
695
|
-
}
|
|
696
|
-
return new Promise((resolve, reject) => {
|
|
697
|
-
const timeout = setTimeout(() => {
|
|
698
|
-
this.pendingMutations.delete(clientTxId);
|
|
699
|
-
reject(new AbloConnectionError(`commit timed out after ${timeoutMs}ms (clientTxId=${clientTxId})`, { code: 'commit_no_result' }));
|
|
700
|
-
}, timeoutMs);
|
|
701
|
-
this.pendingMutations.set(clientTxId, { resolve, reject, timeout });
|
|
702
|
-
try {
|
|
703
|
-
// `causedByTaskId` is included only when the agent SDK has
|
|
704
|
-
// an open turn — keeps the wire shape stable for sessions
|
|
705
|
-
// that don't use turns. Servers that don't know the field
|
|
706
|
-
// ignore it; newer servers stamp it onto every delta.
|
|
707
|
-
const frame = buildCommitFrame(operations, clientTxId, causedByTaskId, reads, track);
|
|
708
|
-
this.ws.send(JSON.stringify(frame));
|
|
709
|
-
}
|
|
710
|
-
catch (error) {
|
|
711
|
-
clearTimeout(timeout);
|
|
712
|
-
this.pendingMutations.delete(clientTxId);
|
|
713
|
-
reject(toAbloError(error));
|
|
714
|
-
}
|
|
715
|
-
});
|
|
716
|
-
}
|
|
717
|
-
/**
|
|
718
|
-
* Send a commit frame without waiting for `mutation_result`.
|
|
719
|
-
*
|
|
720
|
-
* This backs the public `wait: 'queued'` API: the socket accepted the
|
|
721
|
-
* frame for delivery, but the server has not confirmed it yet. The
|
|
722
|
-
* eventual `mutation_result` frame is intentionally ignored by this
|
|
723
|
-
* instance because no pending resolver is registered.
|
|
724
|
-
*/
|
|
725
|
-
sendCommitQueued(operations, clientTxId, causedByTaskId, reads, track) {
|
|
726
|
-
if (this.ws?.readyState !== WebSocket.OPEN) {
|
|
727
|
-
throw this.notConnectedError('commit');
|
|
728
|
-
}
|
|
729
|
-
const frame = buildCommitFrame(operations, clientTxId, causedByTaskId, reads, track);
|
|
730
|
-
this.ws.send(JSON.stringify(frame));
|
|
731
|
-
}
|
|
732
|
-
/**
|
|
733
|
-
* Activates a participant claim on this connection. One connection can hold
|
|
734
|
-
* several concurrent claims at once, each scoped to a different set of sync
|
|
735
|
-
* groups, so the SDK reuses the existing connection instead of opening a
|
|
736
|
-
* separate socket per scope.
|
|
737
|
-
*
|
|
738
|
-
* Returns a promise that resolves with the server-canonicalized `syncGroups`
|
|
739
|
-
* and effective `ttlSeconds` once `claim_ack` arrives, or rejects with a typed
|
|
740
|
-
* error on a failed ack, a timeout, or a disconnect.
|
|
741
|
-
*/
|
|
742
|
-
sendClaim(claimId, syncGroups, options) {
|
|
743
|
-
if (this.ws?.readyState !== WebSocket.OPEN) {
|
|
744
|
-
return Promise.reject(this.notConnectedError('claim'));
|
|
745
|
-
}
|
|
746
|
-
const timeoutMs = options?.timeoutMs ?? 15_000;
|
|
747
|
-
return new Promise((resolve, reject) => {
|
|
748
|
-
const timeout = setTimeout(() => {
|
|
749
|
-
this.pendingClaims.delete(claimId);
|
|
750
|
-
reject(new AbloConnectionError(`claim timed out after ${timeoutMs}ms (claimId=${claimId})`, {
|
|
751
|
-
code: 'wait_for_timeout',
|
|
752
|
-
}));
|
|
753
|
-
}, timeoutMs);
|
|
754
|
-
this.pendingClaims.set(claimId, { resolve, reject, timeout });
|
|
755
|
-
try {
|
|
756
|
-
this.ws.send(JSON.stringify({
|
|
757
|
-
type: 'claim',
|
|
758
|
-
payload: {
|
|
759
|
-
claimId,
|
|
760
|
-
syncGroups: [...syncGroups],
|
|
761
|
-
capabilityToken: options?.capabilityToken,
|
|
762
|
-
ttlSeconds: options?.ttlSeconds,
|
|
763
|
-
},
|
|
764
|
-
}));
|
|
765
|
-
}
|
|
766
|
-
catch (error) {
|
|
767
|
-
clearTimeout(timeout);
|
|
768
|
-
this.pendingClaims.delete(claimId);
|
|
769
|
-
reject(toAbloError(error));
|
|
770
|
-
}
|
|
771
|
-
});
|
|
772
|
-
}
|
|
773
|
-
/**
|
|
774
|
-
* Drop a previously-active claim. Idempotent — `release` is
|
|
775
|
-
* fire-and-forget per the wire contract; the server accepts
|
|
776
|
-
* unknown claimIds silently so disconnect-time release storms
|
|
777
|
-
* never error. No ack is expected.
|
|
778
|
-
*
|
|
779
|
-
* If a claim's send promise is still pending (no claim_ack yet),
|
|
780
|
-
* we reject it locally — the user explicitly chose to release.
|
|
781
|
-
*/
|
|
782
|
-
sendRelease(claimId) {
|
|
783
|
-
// Cancel any in-flight claim that hadn't acked yet — the user
|
|
784
|
-
// changed their mind. Without this the timer would eventually
|
|
785
|
-
// reject; doing it now matches the user's claim immediately.
|
|
786
|
-
const pending = this.pendingClaims.get(claimId);
|
|
787
|
-
if (pending) {
|
|
788
|
-
clearTimeout(pending.timeout);
|
|
789
|
-
this.pendingClaims.delete(claimId);
|
|
790
|
-
pending.reject(new AbloError(`claim ${claimId} released before ack`, {
|
|
791
|
-
code: 'claim_wait_aborted',
|
|
792
|
-
httpStatus: 409,
|
|
793
|
-
}));
|
|
794
|
-
}
|
|
795
|
-
if (this.ws?.readyState !== WebSocket.OPEN)
|
|
796
|
-
return;
|
|
797
|
-
try {
|
|
798
|
-
this.ws.send(JSON.stringify({ type: 'release', payload: { claimId } }));
|
|
799
|
-
}
|
|
800
|
-
catch {
|
|
801
|
-
// Idempotent contract — silent failure is acceptable here.
|
|
802
|
-
}
|
|
803
|
-
}
|
|
804
|
-
/**
|
|
805
|
-
* Moves this connection's read interest — replaces the connection-level sync
|
|
806
|
-
* groups mid-session as the user opens and closes entities. This is the
|
|
807
|
-
* area-of-interest navigation primitive: the server fans out deltas only for
|
|
808
|
-
* the groups currently in view, rather than the fixed set chosen at connect.
|
|
809
|
-
*
|
|
810
|
-
* This is a full-set replace: pass the complete new group list, not a delta.
|
|
811
|
-
* Resolves with the server's effective set once `subscription_ack` arrives;
|
|
812
|
-
* rejects (with a typed error) on a scope denial (a restricted `rk_` key
|
|
813
|
-
* requesting a group outside its allowlist), a timeout, or a disconnect. On
|
|
814
|
-
* success the new set is recorded as `options.syncGroups`, so a later reconnect
|
|
815
|
-
* re-subscribes to the current interest rather than the connect-time set.
|
|
816
|
-
*
|
|
817
|
-
* Distinct from {@link sendClaim} (a write claim, per operation, with a TTL):
|
|
818
|
-
* this is the read side, carries no capability token of its own, and is
|
|
819
|
-
* bounded by the connection credential's grant.
|
|
820
|
-
*/
|
|
821
|
-
updateSubscription(syncGroups, options) {
|
|
822
|
-
if (this.ws?.readyState !== WebSocket.OPEN) {
|
|
823
|
-
return Promise.reject(this.notConnectedError('update_subscription'));
|
|
824
|
-
}
|
|
825
|
-
const timeoutMs = options?.timeoutMs ?? 15_000;
|
|
826
|
-
return new Promise((resolve, reject) => {
|
|
827
|
-
const entry = {
|
|
828
|
-
resolve,
|
|
829
|
-
reject,
|
|
830
|
-
timeout: setTimeout(() => {
|
|
831
|
-
const idx = this.pendingSubscriptions.indexOf(entry);
|
|
832
|
-
if (idx !== -1)
|
|
833
|
-
this.pendingSubscriptions.splice(idx, 1);
|
|
834
|
-
reject(new AbloConnectionError(`update_subscription timed out after ${timeoutMs}ms`, { code: 'wait_for_timeout' }));
|
|
835
|
-
}, timeoutMs),
|
|
836
|
-
};
|
|
837
|
-
this.pendingSubscriptions.push(entry);
|
|
838
|
-
try {
|
|
839
|
-
this.ws.send(JSON.stringify({
|
|
840
|
-
type: 'update_subscription',
|
|
841
|
-
payload: { syncGroups: [...syncGroups] },
|
|
842
|
-
}));
|
|
843
|
-
}
|
|
844
|
-
catch (error) {
|
|
845
|
-
clearTimeout(entry.timeout);
|
|
846
|
-
const idx = this.pendingSubscriptions.indexOf(entry);
|
|
847
|
-
if (idx !== -1)
|
|
848
|
-
this.pendingSubscriptions.splice(idx, 1);
|
|
849
|
-
reject(toAbloError(error));
|
|
850
|
-
}
|
|
851
|
-
});
|
|
852
|
-
}
|
|
853
|
-
/**
|
|
854
|
-
* Sets a fixed credential for callers that construct the socket directly. The
|
|
855
|
-
* SDK instead supplies `getAuthToken`, so reconnects read the shared
|
|
856
|
-
* credential source rather than this copied value.
|
|
857
|
-
*/
|
|
858
|
-
setCapabilityToken(token) {
|
|
859
|
-
this.options.capabilityToken = token;
|
|
860
|
-
}
|
|
861
|
-
getAuthToken() {
|
|
862
|
-
return this.resolveAuthToken();
|
|
863
|
-
}
|
|
864
|
-
/**
|
|
865
|
-
* Return the credential that will be used by the next WebSocket upgrade.
|
|
866
|
-
* ConnectionManager reads this for HTTP auth probes so visibility/network
|
|
867
|
-
* checks authenticate the same way reconnects do.
|
|
868
|
-
*/
|
|
869
|
-
getCapabilityToken() {
|
|
870
|
-
return this.resolveAuthToken();
|
|
871
|
-
}
|
|
872
|
-
resolveAuthToken = () => {
|
|
873
|
-
return this.options.getAuthToken?.()
|
|
874
|
-
?? this.options.getCapabilityToken?.()
|
|
875
|
-
?? this.options.capabilityToken;
|
|
876
|
-
};
|
|
877
|
-
/**
|
|
878
|
-
* Send spreadsheet selection presence
|
|
879
|
-
*/
|
|
880
|
-
sendSheetSelection(sheetId, selectedCells) {
|
|
881
|
-
this.sendCollaborationEvent('sheet:selection', {
|
|
882
|
-
sheetId,
|
|
883
|
-
selectedCells,
|
|
884
|
-
});
|
|
885
|
-
}
|
|
886
|
-
/**
|
|
887
|
-
* Send slide layer selection presence
|
|
888
|
-
*/
|
|
889
|
-
sendSlideSelection(deckId, slideId, selectedLayers) {
|
|
890
|
-
this.sendCollaborationEvent('slide:selection', {
|
|
891
|
-
deckId,
|
|
892
|
-
slideId,
|
|
893
|
-
selectedLayers,
|
|
894
|
-
});
|
|
895
|
-
}
|
|
896
|
-
/**
|
|
897
|
-
* Send slide cursor position for real-time collaboration
|
|
898
|
-
* Note: Throttling should be handled by the caller (e.g., useSlideCursorBroadcast hook)
|
|
899
|
-
*/
|
|
900
|
-
sendSlideCursor(deckId, slideId, x, y) {
|
|
901
|
-
this.sendCollaborationEvent('slide:cursor', {
|
|
902
|
-
deckId,
|
|
903
|
-
slideId,
|
|
904
|
-
x,
|
|
905
|
-
y,
|
|
906
|
-
});
|
|
907
|
-
}
|
|
908
212
|
/**
|
|
909
213
|
* Send presence update to server.
|
|
910
214
|
* Use this for:
|
|
@@ -918,7 +222,7 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
918
222
|
* @param customStatus - Optional custom status message
|
|
919
223
|
*/
|
|
920
224
|
sendPresenceUpdate(status = 'online', customStatus) {
|
|
921
|
-
if (this.
|
|
225
|
+
if (!this.isConnected())
|
|
922
226
|
return;
|
|
923
227
|
const timezone = (() => {
|
|
924
228
|
try {
|
|
@@ -937,50 +241,6 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
937
241
|
},
|
|
938
242
|
});
|
|
939
243
|
}
|
|
940
|
-
/**
|
|
941
|
-
* Schedule reconnection with exponential backoff
|
|
942
|
-
*/
|
|
943
|
-
scheduleReconnect() {
|
|
944
|
-
if (this.reconnectTimer) {
|
|
945
|
-
clearTimeout(this.reconnectTimer);
|
|
946
|
-
}
|
|
947
|
-
// Session error means the user needs to re-authenticate — don't reconnect.
|
|
948
|
-
if (this._sessionErrorDetected) {
|
|
949
|
-
return;
|
|
950
|
-
}
|
|
951
|
-
// Don't attempt reconnection while offline. The owning store manages the
|
|
952
|
-
// offline→online transition: it bootstraps first, then calls `connect()`
|
|
953
|
-
// explicitly. Self-reconnecting here would bypass that bootstrap gate and
|
|
954
|
-
// surface stale data.
|
|
955
|
-
if (!getContext().onlineStatus.isOnline()) {
|
|
956
|
-
this.emit('reconnecting', { attempt: this.reconnectAttempts + 1, delay: 0 });
|
|
957
|
-
return;
|
|
958
|
-
}
|
|
959
|
-
// Give up after MAX_RECONNECT_ATTEMPTS consecutive failures. The user can
|
|
960
|
-
// recover by refreshing, or the store resets the attempt count and
|
|
961
|
-
// reconnects when the network returns.
|
|
962
|
-
if (this.reconnectAttempts >= SyncWebSocket.MAX_RECONNECT_ATTEMPTS) {
|
|
963
|
-
this.emit('reconnect_failed', { attempts: this.reconnectAttempts });
|
|
964
|
-
return;
|
|
965
|
-
}
|
|
966
|
-
// Exponential backoff with ±15% jitter to prevent thundering herd
|
|
967
|
-
const baseDelay = Math.min(this.options.reconnectDelay * Math.pow(2, this.reconnectAttempts), this.options.maxReconnectDelay);
|
|
968
|
-
const jitter = baseDelay * (0.85 + Math.random() * 0.3);
|
|
969
|
-
const delay = Math.round(jitter);
|
|
970
|
-
// Emit reconnecting event so UI can show reconnection status
|
|
971
|
-
this.emit('reconnecting', { attempt: this.reconnectAttempts + 1, delay });
|
|
972
|
-
this.reconnectTimer = setTimeout(() => {
|
|
973
|
-
this.reconnectAttempts++;
|
|
974
|
-
this.connect();
|
|
975
|
-
}, delay);
|
|
976
|
-
}
|
|
977
|
-
/**
|
|
978
|
-
* Reset reconnect attempt counter. Called when network comes back online
|
|
979
|
-
* to allow a fresh reconnect cycle after the max was previously reached.
|
|
980
|
-
*/
|
|
981
|
-
resetReconnectAttempts() {
|
|
982
|
-
this.reconnectAttempts = 0;
|
|
983
|
-
}
|
|
984
244
|
/**
|
|
985
245
|
* Stop the periodic catchup interval
|
|
986
246
|
*/
|
|
@@ -994,128 +254,8 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
994
254
|
* Disconnect from WebSocket
|
|
995
255
|
*/
|
|
996
256
|
disconnect() {
|
|
997
|
-
this.isManualClose = true;
|
|
998
257
|
this.stopCatchupInterval();
|
|
999
|
-
|
|
1000
|
-
if (this.reconnectTimer) {
|
|
1001
|
-
clearTimeout(this.reconnectTimer);
|
|
1002
|
-
this.reconnectTimer = null;
|
|
1003
|
-
}
|
|
1004
|
-
if (this.ws) {
|
|
1005
|
-
this.ws.close(1000, 'Manual disconnect');
|
|
1006
|
-
this.ws = null;
|
|
1007
|
-
}
|
|
1008
|
-
}
|
|
1009
|
-
/**
|
|
1010
|
-
* Force-close the socket from the client side using a private 4xxx
|
|
1011
|
-
* code. Callers expect `onclose` to fire; that handler runs the
|
|
1012
|
-
* existing reconnect / handshake-failed dispatch. Wrapped in
|
|
1013
|
-
* try/catch because `close()` on a CLOSING/CLOSED socket throws on
|
|
1014
|
-
* some browsers.
|
|
1015
|
-
*/
|
|
1016
|
-
forceClose(reason) {
|
|
1017
|
-
if (!this.ws)
|
|
1018
|
-
return;
|
|
1019
|
-
this.lastForceCloseReason = reason;
|
|
1020
|
-
getContext().logger.debug('[SyncWebSocket] forceClose', {
|
|
1021
|
-
reason,
|
|
1022
|
-
readyState: this.ws.readyState,
|
|
1023
|
-
msSinceOpen: this.lastOpenAt != null ? Date.now() - this.lastOpenAt : null,
|
|
1024
|
-
});
|
|
1025
|
-
try {
|
|
1026
|
-
this.ws.close(4000, reason);
|
|
1027
|
-
}
|
|
1028
|
-
catch {
|
|
1029
|
-
// Already closing / closed — onclose will still fire.
|
|
1030
|
-
}
|
|
1031
|
-
}
|
|
1032
|
-
/**
|
|
1033
|
-
* Get connection state
|
|
1034
|
-
*/
|
|
1035
|
-
isConnected() {
|
|
1036
|
-
return this.ws?.readyState === WebSocket.OPEN;
|
|
1037
|
-
}
|
|
1038
|
-
/**
|
|
1039
|
-
* Snapshot of recent connection lifecycle state, for diagnostic logs
|
|
1040
|
-
* and error messages. Cheap to call (no I/O); safe to log every time
|
|
1041
|
-
* a send is rejected so we can attribute "not connected" rejections
|
|
1042
|
-
* to the actual root cause (handshake reject vs heartbeat zombie vs
|
|
1043
|
-
* session expiry vs explicit close).
|
|
1044
|
-
*/
|
|
1045
|
-
getConnectionDiagnostics() {
|
|
1046
|
-
const now = Date.now();
|
|
1047
|
-
return {
|
|
1048
|
-
readyState: this.ws?.readyState ?? null,
|
|
1049
|
-
isConnecting: this.isConnecting,
|
|
1050
|
-
isManualClose: this.isManualClose,
|
|
1051
|
-
sessionErrorDetected: this._sessionErrorDetected,
|
|
1052
|
-
everOpened: this._everOpened,
|
|
1053
|
-
reconnectAttempts: this.reconnectAttempts,
|
|
1054
|
-
maxReconnectAttempts: SyncWebSocket.MAX_RECONNECT_ATTEMPTS,
|
|
1055
|
-
lastOpenAt: this.lastOpenAt,
|
|
1056
|
-
lastCloseAt: this.lastCloseAt,
|
|
1057
|
-
lastCloseCode: this.lastCloseCode,
|
|
1058
|
-
lastCloseReason: this.lastCloseReason,
|
|
1059
|
-
lastForceCloseReason: this.lastForceCloseReason,
|
|
1060
|
-
sessionErrorAt: this.sessionErrorAt,
|
|
1061
|
-
msSinceLastOpen: this.lastOpenAt != null ? now - this.lastOpenAt : null,
|
|
1062
|
-
msSinceLastClose: this.lastCloseAt != null ? now - this.lastCloseAt : null,
|
|
1063
|
-
};
|
|
1064
|
-
}
|
|
1065
|
-
/**
|
|
1066
|
-
* Build a richly-diagnosed "not connected" error so callers (and the
|
|
1067
|
-
* logs they emit) can attribute the rejection. The message embeds the
|
|
1068
|
-
* dominant signal in human-readable form; the structured detail is
|
|
1069
|
-
* also attached as `error.diagnostics` for log scrapers.
|
|
1070
|
-
*/
|
|
1071
|
-
notConnectedError(action) {
|
|
1072
|
-
const d = this.getConnectionDiagnostics();
|
|
1073
|
-
// A session-latched socket is not a transient transport hiccup: reconnection
|
|
1074
|
-
// is suppressed until re-auth (or the store's credential re-mint clears the
|
|
1075
|
-
// latch), so retrying can never succeed. Reject with the permanent session
|
|
1076
|
-
// error type — `isPermanentError` surfaces it to the caller as
|
|
1077
|
-
// "re-authenticate" instead of parking the write for a reconnect that will
|
|
1078
|
-
// never happen.
|
|
1079
|
-
if (d.sessionErrorDetected) {
|
|
1080
|
-
return Object.assign(new SyncSessionError(`SyncWebSocket not connected — cannot send ${action}: session expired` +
|
|
1081
|
-
(d.lastCloseReason ? ` (${d.lastCloseReason})` : '') +
|
|
1082
|
-
'; re-authenticate'), { diagnostics: d });
|
|
1083
|
-
}
|
|
1084
|
-
let detail;
|
|
1085
|
-
if (d.isManualClose) {
|
|
1086
|
-
detail = 'manual_close';
|
|
1087
|
-
}
|
|
1088
|
-
else if (d.isConnecting) {
|
|
1089
|
-
detail = 'still_connecting';
|
|
1090
|
-
}
|
|
1091
|
-
else if (!d.everOpened && d.lastCloseAt != null) {
|
|
1092
|
-
detail = `handshake_failed code=${d.lastCloseCode}`;
|
|
1093
|
-
}
|
|
1094
|
-
else if (d.lastForceCloseReason) {
|
|
1095
|
-
detail = `force_closed reason=${d.lastForceCloseReason}`;
|
|
1096
|
-
}
|
|
1097
|
-
else if (d.lastCloseAt != null) {
|
|
1098
|
-
detail =
|
|
1099
|
-
`closed code=${d.lastCloseCode}` +
|
|
1100
|
-
(d.lastCloseReason ? ` reason=${d.lastCloseReason}` : '') +
|
|
1101
|
-
(d.msSinceLastClose != null ? ` ${d.msSinceLastClose}ms ago` : '') +
|
|
1102
|
-
(d.reconnectAttempts > 0
|
|
1103
|
-
? ` reconnectAttempts=${d.reconnectAttempts}/${d.maxReconnectAttempts}`
|
|
1104
|
-
: '');
|
|
1105
|
-
}
|
|
1106
|
-
else {
|
|
1107
|
-
detail = 'never_connected';
|
|
1108
|
-
}
|
|
1109
|
-
// Typed so it lands in the AbloError hierarchy and `isPermanentError` sees a
|
|
1110
|
-
// transient transport failure (retry on reconnect, don't roll back).
|
|
1111
|
-
// `diagnostics` stays a property — the queue's failure log walks the cause
|
|
1112
|
-
// chain for it.
|
|
1113
|
-
const err = Object.assign(new AbloConnectionError(`SyncWebSocket not connected — cannot send ${action} (${detail})`, { code: 'ws_not_ready' }), { diagnostics: d });
|
|
1114
|
-
return err;
|
|
1115
|
-
}
|
|
1116
|
-
/** Returns the sync groups this connection is subscribed to. */
|
|
1117
|
-
getSyncGroups() {
|
|
1118
|
-
return this.options.syncGroups;
|
|
258
|
+
super.disconnect();
|
|
1119
259
|
}
|
|
1120
260
|
// Cursor accessors — thin delegates; the state and semantics live in
|
|
1121
261
|
// SyncCursor.
|
|
@@ -1147,7 +287,7 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
1147
287
|
* Requests an incremental sync from the server, starting at the current cursor.
|
|
1148
288
|
*/
|
|
1149
289
|
async requestIncrementalSync() {
|
|
1150
|
-
if (this.
|
|
290
|
+
if (!this.isConnected()) {
|
|
1151
291
|
// Silent when offline - not an error condition
|
|
1152
292
|
if (getContext().onlineStatus.isOnline()) {
|
|
1153
293
|
getContext().observability.breadcrumb('WebSocket not connected, cannot request sync', 'sync.websocket', 'warning');
|
|
@@ -1175,7 +315,7 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
1175
315
|
* Request bootstrap for specific entities
|
|
1176
316
|
*/
|
|
1177
317
|
async requestBootstrap(entities) {
|
|
1178
|
-
if (this.
|
|
318
|
+
if (!this.isConnected()) {
|
|
1179
319
|
// Silent when offline - not an error condition
|
|
1180
320
|
if (getContext().onlineStatus.isOnline()) {
|
|
1181
321
|
getContext().observability.breadcrumb('WebSocket not connected, cannot request bootstrap', 'sync.websocket', 'warning');
|
|
@@ -1295,22 +435,4 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
1295
435
|
cursor: typeof p.cursor === 'string' ? p.cursor : undefined,
|
|
1296
436
|
});
|
|
1297
437
|
}
|
|
1298
|
-
/**
|
|
1299
|
-
* Handles a presence update from the server. The wire frame's payload is
|
|
1300
|
-
* forwarded as-is, so every consumer reads the same shape; stripping fields
|
|
1301
|
-
* here would drop `kind`, `activity`, `syncGroups`, and `isAgent` for
|
|
1302
|
-
* consumers that need them.
|
|
1303
|
-
*
|
|
1304
|
-
* The wire frame is:
|
|
1305
|
-
* { type: 'presence_update', payload: { kind, userId, status,
|
|
1306
|
-
* syncGroups, activity, isAgent, timestamp, activeClaims } }
|
|
1307
|
-
*/
|
|
1308
|
-
handlePresenceUpdate(message) {
|
|
1309
|
-
const event =
|
|
1310
|
-
// Server canonical path: `{ payload: {...} }`. Some legacy
|
|
1311
|
-
// pathways emit fields at the top level (test fixtures) — fall
|
|
1312
|
-
// back to reading from the message itself.
|
|
1313
|
-
message.payload ?? message;
|
|
1314
|
-
this.emit('presence_update', event);
|
|
1315
|
-
}
|
|
1316
438
|
}
|