@abloatai/ablo 0.25.0 → 0.27.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 +5 -3
- package/CHANGELOG.md +34 -0
- package/README.md +104 -88
- package/dist/BaseSyncedStore.d.ts +140 -266
- package/dist/BaseSyncedStore.js +338 -739
- package/dist/Database.d.ts +62 -77
- package/dist/Database.js +106 -127
- package/dist/{ObjectPool.d.ts → InstanceCache.d.ts} +10 -13
- package/dist/{ObjectPool.js → InstanceCache.js} +91 -83
- package/dist/LazyReferenceCollection.d.ts +11 -15
- package/dist/LazyReferenceCollection.js +16 -15
- package/dist/Model.d.ts +37 -52
- package/dist/Model.js +52 -69
- package/dist/ModelRegistry.d.ts +46 -25
- package/dist/ModelRegistry.js +32 -30
- package/dist/NetworkMonitor.d.ts +5 -6
- package/dist/NetworkMonitor.js +6 -7
- package/dist/SyncClient.d.ts +119 -109
- package/dist/SyncClient.js +303 -224
- package/dist/SyncEngineContext.d.ts +1 -3
- package/dist/SyncEngineContext.js +1 -2
- package/dist/adapters/alwaysOnline.d.ts +6 -8
- package/dist/adapters/alwaysOnline.js +6 -8
- package/dist/adapters/inMemoryStorage.d.ts +9 -9
- package/dist/adapters/inMemoryStorage.js +9 -9
- package/dist/agent/Agent.d.ts +39 -31
- package/dist/agent/Agent.js +35 -23
- package/dist/agent/index.d.ts +4 -4
- package/dist/agent/index.js +5 -5
- package/dist/agent/session.d.ts +47 -44
- package/dist/agent/session.js +37 -48
- package/dist/agent/types.d.ts +26 -31
- package/dist/agent/types.js +6 -7
- package/dist/ai-sdk/coordinatedTool.d.ts +108 -0
- package/dist/ai-sdk/{coordinated-tool.js → coordinatedTool.js} +44 -38
- package/dist/ai-sdk/coordinationContext.d.ts +46 -0
- package/dist/ai-sdk/{coordination-context.js → coordinationContext.js} +30 -31
- package/dist/ai-sdk/index.d.ts +25 -22
- package/dist/ai-sdk/index.js +25 -22
- package/dist/ai-sdk/wrap.d.ts +7 -8
- package/dist/ai-sdk/wrap.js +2 -2
- package/dist/auth/credentialPolicy.d.ts +74 -71
- package/dist/auth/credentialPolicy.js +51 -56
- package/dist/auth/credentialSource.d.ts +7 -18
- package/dist/auth/credentialSource.js +10 -18
- package/dist/auth/index.d.ts +59 -58
- package/dist/auth/index.js +34 -40
- package/dist/auth/schemas.d.ts +5 -4
- package/dist/auth/schemas.js +5 -4
- package/dist/batching/index.d.ts +19 -21
- package/dist/batching/index.js +14 -17
- package/dist/cli.cjs +483 -369
- package/dist/client/Ablo.d.ts +107 -836
- package/dist/client/Ablo.js +174 -833
- package/dist/client/ApiClient.d.ts +44 -20
- package/dist/client/ApiClient.js +193 -44
- package/dist/client/auth.d.ts +51 -60
- package/dist/client/auth.js +137 -110
- package/dist/client/claimHeartbeatLoop.d.ts +50 -0
- package/dist/client/claimHeartbeatLoop.js +88 -0
- package/dist/client/consoleLogger.d.ts +35 -0
- package/dist/client/consoleLogger.js +44 -0
- package/dist/client/createInternalComponents.d.ts +14 -17
- package/dist/client/createInternalComponents.js +26 -31
- package/dist/client/createModelProxy.d.ts +130 -120
- package/dist/client/createModelProxy.js +158 -124
- package/dist/client/credentialEndpoint.d.ts +61 -0
- package/dist/client/credentialEndpoint.js +86 -0
- package/dist/client/functionalUpdate.d.ts +29 -27
- package/dist/client/functionalUpdate.js +21 -21
- package/dist/client/hostedEndpoints.d.ts +21 -0
- package/dist/client/hostedEndpoints.js +21 -0
- package/dist/client/httpClient.d.ts +58 -54
- package/dist/client/httpClient.js +29 -31
- package/dist/client/identity.d.ts +15 -20
- package/dist/client/identity.js +49 -59
- package/dist/client/modelRegistration.d.ts +10 -0
- package/dist/client/modelRegistration.js +301 -0
- package/dist/client/options.d.ts +373 -0
- package/dist/client/options.js +6 -0
- package/dist/client/registerDataSource.d.ts +9 -9
- package/dist/client/registerDataSource.js +15 -16
- package/dist/client/resourceTypes.d.ts +333 -0
- package/dist/client/resourceTypes.js +7 -0
- package/dist/client/schemaConfig.d.ts +44 -0
- package/dist/client/schemaConfig.js +176 -0
- package/dist/client/sessionMint.d.ts +17 -13
- package/dist/client/sessionMint.js +26 -31
- package/dist/client/validateAbloOptions.d.ts +12 -14
- package/dist/client/validateAbloOptions.js +9 -10
- package/dist/client/writeOptionsSchema.d.ts +18 -16
- package/dist/client/writeOptionsSchema.js +23 -20
- package/dist/client/wsMutationExecutor.d.ts +28 -0
- package/dist/client/wsMutationExecutor.js +71 -0
- package/dist/context.d.ts +6 -4
- package/dist/context.js +6 -7
- package/dist/coordination/index.d.ts +13 -4
- package/dist/coordination/index.js +29 -4
- package/dist/coordination/schema.d.ts +176 -128
- package/dist/coordination/schema.js +197 -133
- package/dist/coordination/trace.d.ts +9 -11
- package/dist/coordination/trace.js +13 -15
- package/dist/core/DatabaseManager.d.ts +5 -8
- package/dist/core/DatabaseManager.js +38 -40
- package/dist/core/QueryProcessor.d.ts +7 -9
- package/dist/core/QueryProcessor.js +27 -34
- package/dist/core/QueryView.d.ts +17 -5
- package/dist/core/QueryView.js +6 -7
- package/dist/core/StoreManager.d.ts +14 -16
- package/dist/core/StoreManager.js +26 -25
- package/dist/core/ViewRegistry.d.ts +5 -5
- package/dist/core/ViewRegistry.js +4 -4
- package/dist/core/index.d.ts +18 -13
- package/dist/core/index.js +32 -26
- package/dist/core/openIDBWithTimeout.d.ts +38 -36
- package/dist/core/openIDBWithTimeout.js +57 -54
- package/dist/core/queryUtils.d.ts +45 -0
- package/dist/core/queryUtils.js +69 -0
- package/dist/core/storeContract.d.ts +145 -0
- package/dist/core/storeContract.js +12 -0
- package/dist/environment.d.ts +28 -0
- package/dist/environment.js +21 -0
- package/dist/errorCodes.d.ts +118 -101
- package/dist/errorCodes.js +277 -260
- package/dist/errors.d.ts +170 -165
- package/dist/errors.js +161 -151
- package/dist/index.d.ts +30 -27
- package/dist/index.js +90 -82
- package/dist/interfaces/index.d.ts +108 -133
- package/dist/interfaces/index.js +5 -4
- package/dist/keys/index.d.ts +27 -29
- package/dist/keys/index.js +59 -49
- package/dist/mutators/RecordingTransaction.d.ts +16 -16
- package/dist/mutators/RecordingTransaction.js +31 -37
- package/dist/mutators/Transaction.d.ts +18 -26
- package/dist/mutators/Transaction.js +14 -20
- package/dist/mutators/UndoManager.d.ts +122 -131
- package/dist/mutators/UndoManager.js +149 -155
- package/dist/mutators/defineMutators.d.ts +24 -37
- package/dist/mutators/defineMutators.js +14 -20
- package/dist/mutators/inverseOp.d.ts +12 -15
- package/dist/mutators/inverseOp.js +12 -15
- package/dist/mutators/mutateActions.d.ts +10 -9
- package/dist/mutators/mutateActions.js +1 -1
- package/dist/mutators/readerActions.d.ts +9 -8
- package/dist/mutators/readerActions.js +2 -2
- package/dist/mutators/undoApply.d.ts +31 -27
- package/dist/mutators/undoApply.js +26 -24
- package/dist/policy/index.d.ts +5 -3
- package/dist/policy/index.js +5 -3
- package/dist/policy/types.d.ts +105 -101
- package/dist/policy/types.js +67 -66
- package/dist/query/client.d.ts +32 -16
- package/dist/query/client.js +103 -72
- package/dist/query/types.d.ts +37 -60
- package/dist/query/types.js +13 -33
- package/dist/react/AbloProvider.d.ts +7 -11
- package/dist/react/AbloProvider.js +24 -17
- package/dist/react/context.d.ts +27 -146
- package/dist/react/context.js +9 -10
- package/dist/react/index.d.ts +41 -42
- package/dist/react/index.js +37 -38
- package/dist/react/internalContext.d.ts +17 -19
- package/dist/react/useAblo.d.ts +23 -22
- package/dist/react/useAblo.js +17 -15
- package/dist/react/useCurrentUserId.d.ts +8 -7
- package/dist/react/useCurrentUserId.js +8 -7
- package/dist/react/useErrorListener.d.ts +7 -7
- package/dist/react/useErrorListener.js +11 -12
- package/dist/react/useMutationFailureListener.d.ts +8 -8
- package/dist/react/useMutationFailureListener.js +9 -9
- package/dist/react/useMutators.d.ts +11 -11
- package/dist/react/useMutators.js +10 -4
- package/dist/react/useReactive.js +2 -3
- package/dist/react/useSyncStatus.d.ts +4 -6
- package/dist/react/useUndoScope.d.ts +7 -9
- package/dist/react/useUndoScope.js +3 -3
- package/dist/schema/coordination.d.ts +21 -25
- package/dist/schema/coordination.js +21 -25
- package/dist/schema/ddl.d.ts +43 -39
- package/dist/schema/ddl.js +75 -68
- package/dist/schema/ddlLock.d.ts +35 -0
- package/dist/schema/ddlLock.js +46 -0
- package/dist/schema/diff.d.ts +99 -61
- package/dist/schema/diff.js +43 -34
- package/dist/schema/field.d.ts +37 -42
- package/dist/schema/field.js +36 -49
- package/dist/schema/generate.d.ts +12 -12
- package/dist/schema/generate.js +12 -12
- package/dist/schema/index.d.ts +5 -4
- package/dist/schema/index.js +29 -21
- package/dist/schema/model.d.ts +121 -146
- package/dist/schema/model.js +24 -35
- package/dist/schema/openapi.d.ts +10 -9
- package/dist/schema/openapi.js +7 -1
- package/dist/schema/queries.d.ts +30 -32
- package/dist/schema/queries.js +24 -25
- package/dist/schema/relation.d.ts +89 -99
- package/dist/schema/relation.js +13 -13
- package/dist/schema/residency.d.ts +38 -0
- package/dist/schema/residency.js +30 -0
- package/dist/schema/roles.d.ts +45 -27
- package/dist/schema/roles.js +52 -21
- package/dist/schema/schema.d.ts +36 -45
- package/dist/schema/schema.js +42 -39
- package/dist/schema/select.d.ts +13 -13
- package/dist/schema/select.js +13 -13
- package/dist/schema/serialize.d.ts +36 -39
- package/dist/schema/serialize.js +27 -31
- package/dist/schema/sugar.d.ts +17 -32
- package/dist/schema/sugar.js +14 -29
- package/dist/schema/{sync-delta-row.d.ts → syncDeltaRow.d.ts} +27 -50
- package/dist/schema/syncDeltaRow.js +89 -0
- package/dist/schema/tenancy.d.ts +44 -46
- package/dist/schema/tenancy.js +46 -48
- package/dist/server/adapter.d.ts +58 -58
- package/dist/server/adapter.js +13 -14
- package/dist/server/commit.d.ts +60 -64
- package/dist/server/index.d.ts +9 -10
- package/dist/server/index.js +1 -1
- package/dist/server/readConfig.d.ts +70 -0
- package/dist/server/readConfig.js +8 -0
- package/dist/server/storageMode.d.ts +23 -0
- package/dist/server/storageMode.js +17 -0
- package/dist/source/adapter.d.ts +31 -26
- package/dist/source/adapter.js +10 -10
- package/dist/source/adapters/drizzle.d.ts +28 -23
- package/dist/source/adapters/drizzle.js +34 -28
- package/dist/source/adapters/kysely.d.ts +27 -25
- package/dist/source/adapters/kysely.js +28 -26
- package/dist/source/adapters/memory.d.ts +8 -7
- package/dist/source/adapters/memory.js +10 -9
- package/dist/source/adapters/prisma.d.ts +13 -12
- package/dist/source/adapters/prisma.js +27 -29
- package/dist/source/conformance.d.ts +18 -11
- package/dist/source/conformance.js +27 -19
- package/dist/source/connector.d.ts +31 -32
- package/dist/source/connector.js +30 -28
- package/dist/source/connectorProtocol.d.ts +160 -0
- package/dist/source/connectorProtocol.js +162 -0
- package/dist/source/contract.d.ts +26 -27
- package/dist/source/contract.js +28 -29
- package/dist/source/factory.d.ts +94 -0
- package/dist/source/factory.js +268 -0
- package/dist/source/index.d.ts +10 -462
- package/dist/source/index.js +17 -421
- package/dist/source/migrations.d.ts +9 -9
- package/dist/source/migrations.js +9 -9
- package/dist/source/next.d.ts +10 -11
- package/dist/source/next.js +7 -8
- package/dist/source/pushQueue.d.ts +70 -48
- package/dist/source/pushQueue.js +36 -29
- package/dist/source/signing.d.ts +88 -0
- package/dist/source/signing.js +159 -0
- package/dist/source/types.d.ts +351 -0
- package/dist/source/types.js +43 -0
- package/dist/stores/ObjectStore.d.ts +11 -12
- package/dist/stores/ObjectStore.js +34 -35
- package/dist/stores/ObjectStoreContract.d.ts +12 -15
- package/dist/stores/SyncActionStore.d.ts +8 -12
- package/dist/stores/SyncActionStore.js +77 -46
- package/dist/surface.d.ts +28 -21
- package/dist/surface.js +28 -20
- package/dist/sync/{BootstrapHelper.d.ts → BootstrapFetcher.d.ts} +37 -45
- package/dist/sync/{BootstrapHelper.js → BootstrapFetcher.js} +101 -80
- package/dist/sync/ConnectionManager.d.ts +47 -50
- package/dist/sync/ConnectionManager.js +74 -70
- package/dist/sync/NetworkProbe.d.ts +27 -31
- package/dist/sync/NetworkProbe.js +67 -72
- package/dist/sync/{HydrationCoordinator.d.ts → OnDemandLoader.d.ts} +49 -36
- package/dist/sync/{HydrationCoordinator.js → OnDemandLoader.js} +79 -54
- package/dist/sync/{AreaOfInterestManager.d.ts → SubscriptionManager.d.ts} +45 -59
- package/dist/sync/{AreaOfInterestManager.js → SubscriptionManager.js} +44 -52
- package/dist/sync/SyncWebSocket.d.ts +175 -250
- package/dist/sync/SyncWebSocket.js +431 -769
- package/dist/sync/awaitClaimGrant.d.ts +18 -18
- package/dist/sync/awaitClaimGrant.js +38 -30
- package/dist/sync/bootstrapApply.d.ts +70 -0
- package/dist/sync/bootstrapApply.js +73 -0
- package/dist/sync/commitFrames.d.ts +44 -0
- package/dist/sync/commitFrames.js +94 -0
- package/dist/sync/createClaimStream.d.ts +23 -22
- package/dist/sync/createClaimStream.js +108 -25
- package/dist/sync/createPresenceStream.d.ts +19 -18
- package/dist/sync/createPresenceStream.js +25 -26
- package/dist/sync/createSnapshot.d.ts +13 -17
- package/dist/sync/createSnapshot.js +20 -26
- package/dist/sync/credentialLifecycle.d.ts +175 -0
- package/dist/sync/credentialLifecycle.js +322 -0
- package/dist/sync/deltaPipeline.d.ts +113 -0
- package/dist/sync/deltaPipeline.js +261 -0
- package/dist/sync/groupChange.d.ts +113 -0
- package/dist/sync/groupChange.js +242 -0
- package/dist/sync/heartbeat.d.ts +63 -0
- package/dist/sync/heartbeat.js +91 -0
- package/dist/sync/participants.d.ts +27 -27
- package/dist/sync/schemas.d.ts +3 -2
- package/dist/sync/schemas.js +14 -10
- package/dist/sync/syncCursor.d.ts +40 -0
- package/dist/sync/syncCursor.js +55 -0
- package/dist/sync/syncPlan.d.ts +54 -0
- package/dist/sync/syncPlan.js +50 -0
- package/dist/sync/syncPosition.d.ts +54 -49
- package/dist/sync/syncPosition.js +57 -52
- package/dist/sync/wsFrameHandlers.d.ts +116 -0
- package/dist/sync/wsFrameHandlers.js +374 -0
- package/dist/testing/fixtures/bootstrap.d.ts +21 -17
- package/dist/testing/fixtures/bootstrap.js +12 -6
- package/dist/testing/fixtures/deltas.d.ts +31 -34
- package/dist/testing/fixtures/deltas.js +30 -33
- package/dist/testing/fixtures/models.d.ts +11 -10
- package/dist/testing/fixtures/models.js +12 -10
- package/dist/testing/helpers/{react-wrapper.d.ts → reactWrapper.d.ts} +13 -10
- package/dist/testing/helpers/{react-wrapper.js → reactWrapper.js} +15 -12
- package/dist/testing/helpers/{sync-engine-harness.d.ts → syncEngineHarness.d.ts} +17 -18
- package/dist/testing/helpers/{sync-engine-harness.js → syncEngineHarness.js} +14 -11
- package/dist/testing/helpers/wait.d.ts +13 -8
- package/dist/testing/helpers/wait.js +13 -8
- package/dist/testing/index.d.ts +4 -4
- package/dist/testing/index.js +3 -3
- package/dist/testing/mocks/MockMutationExecutor.d.ts +18 -17
- package/dist/testing/mocks/MockMutationExecutor.js +15 -14
- package/dist/testing/mocks/MockNetworkMonitor.d.ts +8 -8
- package/dist/testing/mocks/MockNetworkMonitor.js +8 -8
- package/dist/testing/mocks/MockSyncContext.d.ts +21 -34
- package/dist/testing/mocks/MockSyncContext.js +16 -45
- package/dist/testing/mocks/MockSyncStore.d.ts +14 -14
- package/dist/testing/mocks/MockSyncStore.js +11 -11
- package/dist/testing/mocks/MockWebSocket.d.ts +28 -24
- package/dist/testing/mocks/MockWebSocket.js +22 -21
- package/dist/transactions/TransactionQueue.d.ts +190 -221
- package/dist/transactions/TransactionQueue.js +424 -822
- package/dist/transactions/TransactionStore.d.ts +20 -0
- package/dist/transactions/TransactionStore.js +53 -0
- package/dist/transactions/UnconfirmedWrites.d.ts +82 -0
- package/dist/transactions/UnconfirmedWrites.js +104 -0
- package/dist/transactions/coalesceRules.d.ts +58 -0
- package/dist/transactions/coalesceRules.js +140 -0
- package/dist/transactions/commitPayload.d.ts +130 -0
- package/dist/transactions/commitPayload.js +143 -0
- package/dist/transactions/deltaConfirmation.d.ts +58 -0
- package/dist/transactions/deltaConfirmation.js +215 -0
- package/dist/transactions/optimisticApply.d.ts +49 -0
- package/dist/transactions/optimisticApply.js +65 -0
- package/dist/transactions/replayValidation.d.ts +99 -0
- package/dist/transactions/replayValidation.js +111 -0
- package/dist/types/global.d.ts +46 -41
- package/dist/types/global.js +20 -19
- package/dist/types/index.d.ts +74 -80
- package/dist/types/index.js +22 -27
- package/dist/types/modelData.d.ts +10 -0
- package/dist/types/modelData.js +9 -0
- package/dist/types/participant.d.ts +20 -0
- package/dist/types/participant.js +10 -0
- package/dist/types/streams.d.ts +216 -209
- package/dist/types/streams.js +7 -7
- package/dist/utils/asyncIterator.d.ts +25 -32
- package/dist/utils/asyncIterator.js +25 -32
- package/dist/utils/duration.d.ts +12 -15
- package/dist/utils/duration.js +12 -15
- package/dist/utils/mobxSetup.d.ts +53 -0
- package/dist/utils/{mobx-setup.js → mobxSetup.js} +44 -100
- package/dist/webhooks/events.d.ts +21 -16
- package/dist/webhooks/events.js +10 -8
- package/dist/webhooks/index.d.ts +5 -7
- package/dist/webhooks/index.js +5 -7
- package/dist/{schema/sync-delta-wire.d.ts → wire/delta.d.ts} +58 -41
- package/dist/wire/delta.js +114 -0
- package/dist/wire/errorEnvelope.d.ts +35 -27
- package/dist/wire/errorEnvelope.js +38 -32
- package/dist/wire/frames.d.ts +150 -67
- package/dist/wire/frames.js +48 -1
- package/dist/wire/index.d.ts +18 -13
- package/dist/wire/index.js +36 -13
- package/dist/wire/listEnvelope.d.ts +16 -23
- package/dist/wire/listEnvelope.js +7 -6
- package/dist/wire/protocol.d.ts +38 -0
- package/dist/wire/protocol.js +38 -0
- package/dist/wire/protocolVersion.d.ts +60 -0
- package/dist/wire/protocolVersion.js +67 -0
- package/docs/api-keys.md +4 -3
- package/docs/coordination.md +59 -0
- package/docs/examples/existing-python-backend.md +3 -3
- package/docs/identity.md +4 -4
- package/docs/integration-guide.md +1 -1
- package/docs/react.md +1 -1
- package/docs/sessions.md +5 -7
- package/package.json +24 -21
- package/dist/ai-sdk/coordinated-tool.d.ts +0 -101
- package/dist/ai-sdk/coordination-context.d.ts +0 -52
- package/dist/client/index.d.ts +0 -36
- package/dist/client/index.js +0 -33
- package/dist/config/index.d.ts +0 -10
- package/dist/config/index.js +0 -12
- package/dist/core/query-utils.d.ts +0 -34
- package/dist/core/query-utils.js +0 -59
- package/dist/interfaces/headless.d.ts +0 -95
- package/dist/interfaces/headless.js +0 -41
- package/dist/query/index.d.ts +0 -6
- package/dist/query/index.js +0 -5
- package/dist/realtime/index.d.ts +0 -10
- package/dist/realtime/index.js +0 -9
- package/dist/schema/plane.d.ts +0 -23
- package/dist/schema/plane.js +0 -19
- package/dist/schema/sync-delta-row.js +0 -103
- package/dist/schema/sync-delta-wire.js +0 -102
- package/dist/server/next.d.ts +0 -51
- package/dist/server/next.js +0 -47
- package/dist/server/read-config.d.ts +0 -67
- package/dist/server/read-config.js +0 -8
- package/dist/server/storage-mode.d.ts +0 -1
- package/dist/server/storage-mode.js +0 -18
- package/dist/source/connector-protocol.d.ts +0 -159
- package/dist/source/connector-protocol.js +0 -161
- package/dist/sync/OfflineFlush.d.ts +0 -9
- package/dist/sync/OfflineFlush.js +0 -22
- package/dist/sync/OfflineTransactionStore.d.ts +0 -37
- package/dist/sync/OfflineTransactionStore.js +0 -263
- package/dist/transactions/OptimisticEchoTracker.d.ts +0 -82
- package/dist/transactions/OptimisticEchoTracker.js +0 -104
- package/dist/transactions/index.d.ts +0 -16
- package/dist/transactions/index.js +0 -7
- package/dist/transactions/mutation-error-handler.d.ts +0 -5
- package/dist/transactions/mutation-error-handler.js +0 -39
- package/dist/utils/mobx-setup.d.ts +0 -42
|
@@ -1,22 +1,42 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* - Automatic reconnection with exponential backoff
|
|
2
|
+
* Manages the WebSocket connection to the sync server. It owns the socket
|
|
3
|
+
* lifecycle (connect, reconnect, disconnect), receives and validates the
|
|
4
|
+
* incoming delta stream, sends commits and claims over the same socket, and
|
|
5
|
+
* reconnects automatically with exponential backoff. Consumers subscribe to its
|
|
6
|
+
* typed events (see {@link CoreSyncEventMap}) to react to deltas, presence, and
|
|
7
|
+
* connection changes.
|
|
9
8
|
*/
|
|
10
9
|
import { EventEmitter } from 'events';
|
|
11
10
|
import { getContext } from '../context.js';
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
|
|
15
|
-
|
|
11
|
+
import { AbloConnectionError, AbloError, SyncSessionError, toAbloError, } from '../errors.js';
|
|
12
|
+
import { clientSyncDeltaSchema } from '../wire/delta.js';
|
|
13
|
+
// Commit-path frame builders (pure) — extracted leaf; the host re-exports
|
|
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';
|
|
18
|
+
// Sync-position state (lastSyncId watermark, version vector, server cursor).
|
|
19
|
+
import { SyncCursor } from './syncCursor.js';
|
|
20
|
+
import { PROTOCOL_VERSION, WS_CLOSE_PROTOCOL_VERSION } from '../wire/protocolVersion.js';
|
|
21
|
+
// Application-level heartbeat timers (see heartbeat.ts for the rationale).
|
|
22
|
+
import { HeartbeatController } from './heartbeat.js';
|
|
16
23
|
import { WS_BEARER_SUBPROTOCOL_PREFIX, WS_SYNC_SUBPROTOCOL, } from '../auth/credentialSource.js';
|
|
24
|
+
// SyncObservability replaced by getContext().observability
|
|
25
|
+
/**
|
|
26
|
+
* How often, while connected, the client polls for any deltas whose best-effort
|
|
27
|
+
* broadcast was lost in transit. This is a client-side eventual-consistency
|
|
28
|
+
* setting, not part of the wire contract, so the server derives nothing from
|
|
29
|
+
* it. That it currently equals the 30-second ping is a coincidence, not a
|
|
30
|
+
* guarantee.
|
|
31
|
+
*/
|
|
32
|
+
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;
|
|
17
38
|
// ---------------------------------------------------------------------------
|
|
18
|
-
//
|
|
19
|
-
// Consumers pass their own event types as TCollaboration generic parameter.
|
|
39
|
+
// Consumers pass their own event types as the TCollaboration generic parameter.
|
|
20
40
|
export class SyncWebSocket extends EventEmitter {
|
|
21
41
|
/**
|
|
22
42
|
* Subscribe to events with automatic cleanup.
|
|
@@ -47,20 +67,20 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
47
67
|
/** Periodic catchup interval — polls for missed deltas every 30s while connected */
|
|
48
68
|
catchupInterval = null;
|
|
49
69
|
/**
|
|
50
|
-
* Application-level heartbeat
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
* silently broke (laptop sleep, NAT timeout, mobile handoff). We send an
|
|
55
|
-
* application-level `{ type: 'ping' }` every 30s and force-close the
|
|
56
|
-
* socket if no inbound traffic arrives within 10s. ANY inbound message
|
|
57
|
-
* counts as proof-of-life — the explicit `pong` is just a guarantee that
|
|
58
|
-
* something will arrive even on an idle stream.
|
|
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.
|
|
59
74
|
*/
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
+
});
|
|
64
84
|
isConnecting = false;
|
|
65
85
|
isManualClose = false;
|
|
66
86
|
/** When true, a session error has been detected (from any path — WS close or HTTP bootstrap).
|
|
@@ -88,11 +108,22 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
88
108
|
lastCloseReason = null;
|
|
89
109
|
lastForceCloseReason = null;
|
|
90
110
|
sessionErrorAt = null;
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
111
|
+
/**
|
|
112
|
+
* Sync-position state: the lastSyncId watermark, version vector, and server
|
|
113
|
+
* cursor. The advance discipline is documented at `sendAck` and `handleDelta`;
|
|
114
|
+
* the state itself lives in {@link SyncCursor}.
|
|
115
|
+
*/
|
|
116
|
+
cursor;
|
|
94
117
|
/** Registered collaboration event keys (colon format) for dispatch in onmessage */
|
|
95
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;
|
|
96
127
|
/**
|
|
97
128
|
* In-flight `commit` mutation requests keyed by clientTxId. Resolved when
|
|
98
129
|
* a matching `mutation_result` frame arrives from the server, or rejected on
|
|
@@ -101,10 +132,10 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
101
132
|
*/
|
|
102
133
|
pendingMutations = new Map();
|
|
103
134
|
/**
|
|
104
|
-
* In-flight `claim` requests keyed by claimId. Resolved when the
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
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.
|
|
108
139
|
*/
|
|
109
140
|
pendingClaims = new Map();
|
|
110
141
|
/**
|
|
@@ -118,28 +149,17 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
118
149
|
pendingSubscriptions = [];
|
|
119
150
|
constructor(options) {
|
|
120
151
|
super();
|
|
121
|
-
// Construct WebSocket URL from base
|
|
152
|
+
// Construct the WebSocket URL from the base server URL.
|
|
122
153
|
const baseUrl = options.baseUrl || options.url || "http://localhost:8080";
|
|
123
154
|
const wsProtocol = baseUrl.startsWith('https') ? 'wss' : 'ws';
|
|
124
155
|
const wsUrl = baseUrl.replace(/^https?/, wsProtocol) + '/api/sync/ws';
|
|
125
156
|
this.options = {
|
|
126
157
|
url: wsUrl,
|
|
127
158
|
reconnectDelay: 1000,
|
|
128
|
-
maxReconnectDelay:
|
|
159
|
+
maxReconnectDelay: MAX_RECONNECT_DELAY_MS,
|
|
129
160
|
collaborationEvents: ['sheet:selection', 'slide:selection', 'slide:cursor'],
|
|
130
161
|
syncGroups: [],
|
|
131
162
|
lastSyncId: 0,
|
|
132
|
-
versions: {
|
|
133
|
-
tasks: 0,
|
|
134
|
-
projects: 0,
|
|
135
|
-
users: 0,
|
|
136
|
-
events: 0,
|
|
137
|
-
inboxitems: 0,
|
|
138
|
-
teams: 0,
|
|
139
|
-
assignments: 0,
|
|
140
|
-
comments: 0,
|
|
141
|
-
threads: 0,
|
|
142
|
-
},
|
|
143
163
|
capabilities: {
|
|
144
164
|
partialBootstrap: true,
|
|
145
165
|
compressedDeltas: true,
|
|
@@ -148,10 +168,25 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
148
168
|
},
|
|
149
169
|
...options,
|
|
150
170
|
};
|
|
151
|
-
this.
|
|
152
|
-
this.versionVector = { ...this.options.versions };
|
|
153
|
-
this.syncCursor = null;
|
|
171
|
+
this.cursor = new SyncCursor(this.options.lastSyncId);
|
|
154
172
|
this.collaborationEventTypes = new Set(options.collaborationEvents ?? ['sheet:selection', 'slide:selection', 'slide:cursor']);
|
|
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
|
+
};
|
|
155
190
|
}
|
|
156
191
|
/**
|
|
157
192
|
* Mark that a session error has been detected (e.g. 401 from HTTP bootstrap).
|
|
@@ -161,6 +196,17 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
161
196
|
this._sessionErrorDetected = true;
|
|
162
197
|
this.sessionErrorAt = Date.now();
|
|
163
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
|
+
}
|
|
164
210
|
/**
|
|
165
211
|
* Connect to the sync engine WebSocket
|
|
166
212
|
*/
|
|
@@ -169,8 +215,15 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
169
215
|
getContext().logger.debug('WebSocket connect suppressed — session error detected');
|
|
170
216
|
return;
|
|
171
217
|
}
|
|
172
|
-
|
|
173
|
-
|
|
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');
|
|
174
227
|
return;
|
|
175
228
|
}
|
|
176
229
|
// Note: onlineStatus is advisory — we'll try to connect and let the WebSocket
|
|
@@ -182,18 +235,18 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
182
235
|
}
|
|
183
236
|
this.isConnecting = true;
|
|
184
237
|
this.isManualClose = false;
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
//
|
|
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.
|
|
189
242
|
const params = new URLSearchParams({
|
|
190
|
-
// Intentionally omit lastSyncId,
|
|
243
|
+
// Intentionally omit lastSyncId, capabilities from URL; these are sent in sync_request
|
|
191
244
|
// and ack messages to avoid stale baselines on reconnect.
|
|
192
|
-
cursor: this.syncCursor || '',
|
|
245
|
+
cursor: this.cursor.syncCursor || '',
|
|
193
246
|
});
|
|
194
|
-
// Participant kind — defaults to `user` for
|
|
195
|
-
//
|
|
196
|
-
//
|
|
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.
|
|
197
250
|
if (this.options.kind && this.options.kind !== 'user') {
|
|
198
251
|
params.set('kind', this.options.kind);
|
|
199
252
|
}
|
|
@@ -202,13 +255,13 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
202
255
|
params.append('syncGroups', group);
|
|
203
256
|
});
|
|
204
257
|
const wsUrl = `${this.options.url}?${params.toString()}`;
|
|
205
|
-
// Carry the bearer in a `Sec-WebSocket-Protocol` value,
|
|
206
|
-
// browser
|
|
207
|
-
// subprotocols — and unlike the query string, those
|
|
208
|
-
// access logs, proxies, or browser history. The server reads
|
|
209
|
-
// `ablo.bearer.<token>` and selects the real `ablo.sync.v1` protocol,
|
|
210
|
-
//
|
|
211
|
-
// which is
|
|
258
|
+
// Carry the bearer in a `Sec-WebSocket-Protocol` value, not the URL. A
|
|
259
|
+
// browser cannot set an Authorization header on a WebSocket, but it can
|
|
260
|
+
// offer subprotocols — and unlike the query string, those do not land in
|
|
261
|
+
// load-balancer access logs, proxies, or browser history. The server reads
|
|
262
|
+
// `ablo.bearer.<token>` and selects the real `ablo.sync.v1` protocol, never
|
|
263
|
+
// echoing the token-bearing value back. The token is the raw `ek_`/`rk_`,
|
|
264
|
+
// which is safe as a subprotocol value (alphanumerics and `_`).
|
|
212
265
|
const authToken = this.resolveAuthToken();
|
|
213
266
|
const protocols = authToken
|
|
214
267
|
? [`${WS_BEARER_SUBPROTOCOL_PREFIX}${authToken}`, WS_SYNC_SUBPROTOCOL]
|
|
@@ -234,11 +287,22 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
234
287
|
* Setup WebSocket event handlers
|
|
235
288
|
*/
|
|
236
289
|
setupEventHandlers() {
|
|
237
|
-
|
|
290
|
+
// Capture the socket this call wires. Every handler below guards on
|
|
291
|
+
// `this.ws === socket` (onclose additionally tolerates a nulled field —
|
|
292
|
+
// see there), so a handler firing late, after `connect()` has replaced the
|
|
293
|
+
// socket, can never clobber the new connection's shared state. Without this
|
|
294
|
+
// guard, an old socket's `onclose` would unconditionally run `this.ws =
|
|
295
|
+
// null; stopCatchupInterval(); stopHeartbeat()` — a reconnect during close
|
|
296
|
+
// teardown then orphaned the fresh socket (a zombie receiving deltas with
|
|
297
|
+
// no timers and broken send paths).
|
|
298
|
+
const socket = this.ws;
|
|
299
|
+
if (!socket)
|
|
238
300
|
return;
|
|
239
|
-
|
|
301
|
+
socket.onopen = () => {
|
|
302
|
+
if (this.ws !== socket)
|
|
303
|
+
return; // stale socket — a newer connect() owns the state
|
|
240
304
|
getContext().observability.breadcrumb('WebSocket connected', 'sync.websocket', 'info', {
|
|
241
|
-
lastSyncId: this.lastSyncId,
|
|
305
|
+
lastSyncId: this.cursor.lastSyncId,
|
|
242
306
|
reconnectAttempts: this.reconnectAttempts,
|
|
243
307
|
});
|
|
244
308
|
this.isConnecting = false;
|
|
@@ -249,376 +313,69 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
249
313
|
// Send presence update with timezone (server sets presence to "online" on connect,
|
|
250
314
|
// this improves localTime accuracy by providing the user's actual timezone)
|
|
251
315
|
this.sendPresenceUpdate('online');
|
|
252
|
-
//
|
|
253
|
-
//
|
|
254
|
-
(
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
getContext().observability.captureOfflineFlushFailure({
|
|
263
|
-
error: e instanceof Error ? e.message : String(e),
|
|
264
|
-
});
|
|
265
|
-
}
|
|
266
|
-
})();
|
|
267
|
-
// Immediately request incremental sync based on our stored cursor/versions
|
|
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
|
+
};
|
|
268
326
|
try {
|
|
269
|
-
if (this.lastSyncId && this.lastSyncId > 0) {
|
|
327
|
+
if (this.cursor.lastSyncId && this.cursor.lastSyncId > 0) {
|
|
270
328
|
// Let server know where we left off before requesting deltas
|
|
271
|
-
this.sendAck(this.lastSyncId);
|
|
329
|
+
this.sendAck(this.cursor.lastSyncId);
|
|
272
330
|
}
|
|
273
|
-
this.requestIncrementalSync();
|
|
274
331
|
}
|
|
275
332
|
catch (e) {
|
|
276
|
-
|
|
277
|
-
error: e instanceof Error ? e.message : String(e),
|
|
278
|
-
});
|
|
333
|
+
reportSyncRequestFailure(e);
|
|
279
334
|
}
|
|
280
|
-
|
|
281
|
-
//
|
|
282
|
-
//
|
|
283
|
-
//
|
|
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.
|
|
284
341
|
this.stopCatchupInterval();
|
|
285
342
|
this.catchupInterval = setInterval(() => {
|
|
286
343
|
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
287
|
-
|
|
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
|
+
});
|
|
288
352
|
}
|
|
289
|
-
},
|
|
290
|
-
// Start application-level heartbeat
|
|
291
|
-
this.
|
|
353
|
+
}, CATCHUP_POLL_INTERVAL_MS);
|
|
354
|
+
// Start the application-level heartbeat (see HeartbeatController).
|
|
355
|
+
this.heartbeat.start();
|
|
292
356
|
};
|
|
293
|
-
|
|
357
|
+
socket.onmessage = (event) => {
|
|
358
|
+
if (this.ws !== socket)
|
|
359
|
+
return; // stale socket — drop, don't feed shared state
|
|
294
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.
|
|
295
364
|
const message = JSON.parse(event.data);
|
|
296
|
-
//
|
|
365
|
+
// Any inbound frame proves the socket is alive — clear the
|
|
297
366
|
// heartbeat-timeout timer so we don't false-trip force-close
|
|
298
367
|
// during normal traffic.
|
|
299
|
-
this.clearHeartbeatTimeout();
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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,
|
|
372
|
+
});
|
|
304
373
|
return;
|
|
305
374
|
}
|
|
306
|
-
//
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
break;
|
|
311
|
-
case 'bootstrap_response':
|
|
312
|
-
this.handleBootstrapResponse(message.payload);
|
|
313
|
-
break;
|
|
314
|
-
case 'presence_update':
|
|
315
|
-
this.handlePresenceUpdate(message);
|
|
316
|
-
break;
|
|
317
|
-
case 'mutation_result': {
|
|
318
|
-
// Ack for a prior `commit` we sent. Canonical shape is
|
|
319
|
-
// `MutationResultMessage` in `@abloatai/ablo/wire`. This stays a
|
|
320
|
-
// DEFENSIVE parse (not a typed cast) because the payload is
|
|
321
|
-
// untrusted wire data that may be malformed or from an older server.
|
|
322
|
-
const p = message.payload ?? message;
|
|
323
|
-
const { clientTxId, success, lastSyncId, error } = p ?? {};
|
|
324
|
-
// Defensive: validate notifications against the canonical schema —
|
|
325
|
-
// untrusted wire data from a possibly-older/newer server.
|
|
326
|
-
const notifications = this.parseNotifications(p?.notifications);
|
|
327
|
-
const pending = typeof clientTxId === 'string'
|
|
328
|
-
? this.pendingMutations.get(clientTxId)
|
|
329
|
-
: undefined;
|
|
330
|
-
if (!pending)
|
|
331
|
-
break;
|
|
332
|
-
clearTimeout(pending.timeout);
|
|
333
|
-
this.pendingMutations.delete(clientTxId);
|
|
334
|
-
if (success) {
|
|
335
|
-
// Coerce defensively — bigint columns serialize as strings
|
|
336
|
-
// from older servers (see normalizeWireDelta).
|
|
337
|
-
const ackedSyncId = Number(lastSyncId);
|
|
338
|
-
// Notify-instead-of-abort: a guarded write's premise moved. Emit
|
|
339
|
-
// the advisory signal so an agent loop can self-heal, AND resolve
|
|
340
|
-
// the receipt with it (the commit still succeeded).
|
|
341
|
-
if (notifications && notifications.length > 0) {
|
|
342
|
-
const txId = typeof clientTxId === 'string' ? clientTxId : '';
|
|
343
|
-
const event = {
|
|
344
|
-
clientTxId: txId,
|
|
345
|
-
rows: notifications.map((n) => ({
|
|
346
|
-
model: n.model,
|
|
347
|
-
id: n.id,
|
|
348
|
-
fields: n.conflictingFields,
|
|
349
|
-
writtenBy: n.writtenBy?.kind,
|
|
350
|
-
})),
|
|
351
|
-
};
|
|
352
|
-
const message = formatConflict(event);
|
|
353
|
-
const ctx = getContext();
|
|
354
|
-
ctx.logger.warn(message);
|
|
355
|
-
ctx.observability.breadcrumb(message, 'sync.coordination', 'warning');
|
|
356
|
-
ctx.observability.captureConflict(event);
|
|
357
|
-
this.emit('conflict:notified', {
|
|
358
|
-
clientTxId: txId,
|
|
359
|
-
notifications,
|
|
360
|
-
});
|
|
361
|
-
}
|
|
362
|
-
pending.resolve({
|
|
363
|
-
lastSyncId: Number.isFinite(ackedSyncId) ? ackedSyncId : 0,
|
|
364
|
-
...(notifications && notifications.length > 0
|
|
365
|
-
? { notifications }
|
|
366
|
-
: {}),
|
|
367
|
-
});
|
|
368
|
-
}
|
|
369
|
-
else {
|
|
370
|
-
// Capture the FULL server error so the user can see what
|
|
371
|
-
// actually rejected the mutation. Without this, every
|
|
372
|
-
// rejection becomes the generic "mutation failed on
|
|
373
|
-
// server" — useless when debugging chart batches that
|
|
374
|
-
// tank 40+ ops at once. We stringify object errors so
|
|
375
|
-
// structured server payloads (e.g., Zod issues, schema
|
|
376
|
-
// violations) survive the trip through `new Error(...)`.
|
|
377
|
-
let errorMessage;
|
|
378
|
-
let errorCode;
|
|
379
|
-
let requiredCapability;
|
|
380
|
-
if (typeof error === 'string') {
|
|
381
|
-
errorMessage = error;
|
|
382
|
-
}
|
|
383
|
-
else if (error != null && typeof error === 'object') {
|
|
384
|
-
const obj = error;
|
|
385
|
-
if (typeof obj.code === 'string')
|
|
386
|
-
errorCode = obj.code;
|
|
387
|
-
if (typeof obj.message === 'string') {
|
|
388
|
-
errorMessage = obj.message;
|
|
389
|
-
}
|
|
390
|
-
else {
|
|
391
|
-
try {
|
|
392
|
-
errorMessage = JSON.stringify(error);
|
|
393
|
-
}
|
|
394
|
-
catch {
|
|
395
|
-
errorMessage = String(error);
|
|
396
|
-
}
|
|
397
|
-
}
|
|
398
|
-
if (obj.requiredCapability != null &&
|
|
399
|
-
typeof obj.requiredCapability === 'object' &&
|
|
400
|
-
typeof obj.requiredCapability.scope === 'string') {
|
|
401
|
-
requiredCapability = obj.requiredCapability;
|
|
402
|
-
}
|
|
403
|
-
}
|
|
404
|
-
else {
|
|
405
|
-
errorMessage = 'mutation failed on server';
|
|
406
|
-
}
|
|
407
|
-
// Coordination collision: a stale-context rejection (the write's
|
|
408
|
-
// readAt premise moved underneath) or a foreign-claim conflict is
|
|
409
|
-
// exactly the collision ClaimLog exists to surface. The notify
|
|
410
|
-
// path (success + notifications) emits captureConflict above; a
|
|
411
|
-
// HARD rejection must too — otherwise observability.collisions()
|
|
412
|
-
// silently misses every rejected write. The conflicted rows ride
|
|
413
|
-
// along on the typed error's `conflicts` detail (see
|
|
414
|
-
// AbloStaleContextError.toJSON / errorEnvelope).
|
|
415
|
-
if (errorCode === 'stale_context' ||
|
|
416
|
-
errorCode === 'claim_conflict' ||
|
|
417
|
-
errorCode === 'entity_claimed' ||
|
|
418
|
-
errorCode?.startsWith('policy:') === true) {
|
|
419
|
-
const rawConflicts = error != null &&
|
|
420
|
-
typeof error === 'object' &&
|
|
421
|
-
Array.isArray(error.conflicts)
|
|
422
|
-
? error
|
|
423
|
-
.conflicts
|
|
424
|
-
: [];
|
|
425
|
-
const conflictEvent = {
|
|
426
|
-
clientTxId: typeof clientTxId === 'string' ? clientTxId : '',
|
|
427
|
-
rows: rawConflicts.map((r) => ({
|
|
428
|
-
model: typeof r.model === 'string' ? r.model : 'unknown',
|
|
429
|
-
id: typeof r.id === 'string' ? r.id : 'unknown',
|
|
430
|
-
fields: [],
|
|
431
|
-
})),
|
|
432
|
-
};
|
|
433
|
-
const ctx = getContext();
|
|
434
|
-
ctx.observability.breadcrumb(formatConflict(conflictEvent), 'sync.coordination', 'warning');
|
|
435
|
-
ctx.observability.captureConflict(conflictEvent);
|
|
436
|
-
}
|
|
437
|
-
// Build the proper typed AbloError from the wire code via the
|
|
438
|
-
// shared factory — the same code→class mapping the HTTP commit
|
|
439
|
-
// path uses (`translateHttpError`). This keeps rejected commits
|
|
440
|
-
// inside the typed hierarchy (capability denials →
|
|
441
|
-
// CapabilityError with `.requiredCapability`; foreign-claim
|
|
442
|
-
// conflicts → AbloClaimedError; everything else → the subclass
|
|
443
|
-
// its registry `httpStatus` implies) instead of a hand-rolled
|
|
444
|
-
// `new Error`, so callers can `instanceof`/`e.type` it and
|
|
445
|
-
// downstream retry logic can read the contract's retryability.
|
|
446
|
-
pending.reject(errorFromWire(errorMessage, {
|
|
447
|
-
code: errorCode,
|
|
448
|
-
requiredCapability,
|
|
449
|
-
}));
|
|
450
|
-
}
|
|
451
|
-
break;
|
|
452
|
-
}
|
|
453
|
-
case 'claim_ack': {
|
|
454
|
-
// Ack for a prior `claim` we sent. Wire format mirrors
|
|
455
|
-
// apps/sync-server/src/hub/types.ts ClaimAckMessage:
|
|
456
|
-
// { type: 'claim_ack',
|
|
457
|
-
// payload: { claimId, success, syncGroups?,
|
|
458
|
-
// ttlSeconds?, error? } }
|
|
459
|
-
const p = message.payload ?? {};
|
|
460
|
-
const { claimId, success, syncGroups, ttlSeconds, error } = p;
|
|
461
|
-
const pending = typeof claimId === 'string'
|
|
462
|
-
? this.pendingClaims.get(claimId)
|
|
463
|
-
: undefined;
|
|
464
|
-
if (!pending)
|
|
465
|
-
break;
|
|
466
|
-
clearTimeout(pending.timeout);
|
|
467
|
-
this.pendingClaims.delete(claimId);
|
|
468
|
-
if (success) {
|
|
469
|
-
pending.resolve({
|
|
470
|
-
syncGroups: Array.isArray(syncGroups) ? syncGroups : [],
|
|
471
|
-
ttlSeconds: typeof ttlSeconds === 'number' ? ttlSeconds : undefined,
|
|
472
|
-
});
|
|
473
|
-
}
|
|
474
|
-
else {
|
|
475
|
-
const code = error?.code && typeof error.code === 'string'
|
|
476
|
-
? error.code
|
|
477
|
-
: 'claim_rejected';
|
|
478
|
-
const msg = error?.message && typeof error.message === 'string'
|
|
479
|
-
? error.message
|
|
480
|
-
: 'claim rejected by server';
|
|
481
|
-
// Capability denials get the typed CapabilityError so
|
|
482
|
-
// callers can read `.requiredCapability` and attenuate-
|
|
483
|
-
// and-retry the claim with a narrower token.
|
|
484
|
-
if (code === 'capability_scope_denied' ||
|
|
485
|
-
code === 'capability_invalid') {
|
|
486
|
-
const rc = error
|
|
487
|
-
?.requiredCapability;
|
|
488
|
-
const requiredCapability = rc != null &&
|
|
489
|
-
typeof rc === 'object' &&
|
|
490
|
-
typeof rc.scope === 'string'
|
|
491
|
-
? rc
|
|
492
|
-
: undefined;
|
|
493
|
-
pending.reject(new CapabilityError(code, msg, requiredCapability));
|
|
494
|
-
}
|
|
495
|
-
else {
|
|
496
|
-
// Route through the shared factory so a failed claim_ack is a
|
|
497
|
-
// typed AbloError (registry code → right subclass), symmetric
|
|
498
|
-
// with the commit `mutation_result` path — never a bare Error.
|
|
499
|
-
pending.reject(errorFromWire(msg, { code }));
|
|
500
|
-
}
|
|
501
|
-
}
|
|
502
|
-
break;
|
|
503
|
-
}
|
|
504
|
-
case 'subscription_ack': {
|
|
505
|
-
// Ack for a prior `update_subscription`. The wire carries no
|
|
506
|
-
// correlation id, so FIFO-match against the oldest pending
|
|
507
|
-
// request — the server applies and acks subscription updates
|
|
508
|
-
// in receive order. Validated through the canonical zod schema
|
|
509
|
-
// (mirrors how the Hub validates inbound frames).
|
|
510
|
-
const pending = this.pendingSubscriptions.shift();
|
|
511
|
-
if (!pending)
|
|
512
|
-
break;
|
|
513
|
-
clearTimeout(pending.timeout);
|
|
514
|
-
const parsed = subscriptionAckPayloadSchema.safeParse(message.payload);
|
|
515
|
-
if (!parsed.success) {
|
|
516
|
-
// Unreadable ack — resolve the pending request as a failure
|
|
517
|
-
// rather than hang it until timeout.
|
|
518
|
-
pending.reject(errorFromWire('malformed subscription_ack from server', {
|
|
519
|
-
code: 'malformed_subscription',
|
|
520
|
-
}));
|
|
521
|
-
break;
|
|
522
|
-
}
|
|
523
|
-
const ack = parsed.data;
|
|
524
|
-
if (ack.success) {
|
|
525
|
-
// Keep the reconnect URL aligned with current interest: a
|
|
526
|
-
// reconnect re-subscribes from `this.options.syncGroups`.
|
|
527
|
-
this.options.syncGroups = ack.syncGroups;
|
|
528
|
-
pending.resolve({ syncGroups: ack.syncGroups });
|
|
529
|
-
}
|
|
530
|
-
else {
|
|
531
|
-
pending.reject(errorFromWire(ack.error?.message ?? 'update_subscription rejected by server', { code: ack.error?.code ?? 'malformed_subscription' }));
|
|
532
|
-
}
|
|
533
|
-
break;
|
|
534
|
-
}
|
|
535
|
-
case 'claim_expired': {
|
|
536
|
-
// Server-initiated expiry notification. Emit as a typed
|
|
537
|
-
// event so consumers can react (re-claim with a fresh
|
|
538
|
-
// capability, or accept the drop). The claim is already
|
|
539
|
-
// inactive server-side by the time this arrives.
|
|
540
|
-
const p = message.payload ?? {};
|
|
541
|
-
if (typeof p.claimId === 'string') {
|
|
542
|
-
this.recordClaim('expired', p);
|
|
543
|
-
this.emit('claim_expired', { claimId: p.claimId });
|
|
544
|
-
}
|
|
545
|
-
break;
|
|
546
|
-
}
|
|
547
|
-
case 'claim_rejected': {
|
|
548
|
-
// Server denied an `claim_begin` because the target is
|
|
549
|
-
// already claimed by another participant. Forward the
|
|
550
|
-
// payload as-is — the ClaimStream consumer interprets
|
|
551
|
-
// the conflict shape (peerId, target, etc.).
|
|
552
|
-
this.recordClaim('rejected', message.payload ?? {});
|
|
553
|
-
this.emit('claim_rejected', message.payload ?? {});
|
|
554
|
-
break;
|
|
555
|
-
}
|
|
556
|
-
case 'claim_acquired': {
|
|
557
|
-
// Opt-in fair queue: the target was free, so the lease is ours
|
|
558
|
-
// immediately (no waiting). Payload carries { claimId, target }.
|
|
559
|
-
this.recordClaim('acquired', message.payload ?? {});
|
|
560
|
-
this.emit('claim_acquired', message.payload ?? {});
|
|
561
|
-
break;
|
|
562
|
-
}
|
|
563
|
-
case 'claim_queue': {
|
|
564
|
-
// Per-entity wait-queue snapshot for reactive `queue(id)`. Not a
|
|
565
|
-
// single claim's state change, so it isn't logged — the per-claim
|
|
566
|
-
// `queued`/`granted` events already tell that story.
|
|
567
|
-
this.emit('claim_queue', message.payload ?? {});
|
|
568
|
-
break;
|
|
569
|
-
}
|
|
570
|
-
case 'claim_queued': {
|
|
571
|
-
// Opt-in fair queue: our claim is waiting in line. Payload
|
|
572
|
-
// carries { claimId, target, position }.
|
|
573
|
-
this.recordClaim('queued', message.payload ?? {});
|
|
574
|
-
this.emit('claim_queued', message.payload ?? {});
|
|
575
|
-
break;
|
|
576
|
-
}
|
|
577
|
-
case 'claim_granted': {
|
|
578
|
-
// Our queued claim reached the head — the lease is now ours.
|
|
579
|
-
this.recordClaim('granted', message.payload ?? {});
|
|
580
|
-
this.emit('claim_granted', message.payload ?? {});
|
|
581
|
-
break;
|
|
582
|
-
}
|
|
583
|
-
case 'claim_lost': {
|
|
584
|
-
// A held/granted claim was taken from us (TTL lapse, revoke).
|
|
585
|
-
this.recordClaim('lost', message.payload ?? {});
|
|
586
|
-
this.emit('claim_lost', message.payload ?? {});
|
|
587
|
-
break;
|
|
588
|
-
}
|
|
589
|
-
case 'delta': {
|
|
590
|
-
const p = message.payload;
|
|
591
|
-
if (p?.actionType || p?.modelName) {
|
|
592
|
-
this.handleDelta(p);
|
|
593
|
-
}
|
|
594
|
-
else if (Array.isArray(p?.deltas)) {
|
|
595
|
-
for (const d of p.deltas) {
|
|
596
|
-
if (d?.actionType || d?.modelName)
|
|
597
|
-
this.handleDelta(d);
|
|
598
|
-
}
|
|
599
|
-
if (p?.newVersions) {
|
|
600
|
-
Object.assign(this.versionVector, p.newVersions);
|
|
601
|
-
}
|
|
602
|
-
}
|
|
603
|
-
break;
|
|
604
|
-
}
|
|
605
|
-
case undefined: // Legacy support: bare delta
|
|
606
|
-
if (message.actionType || message.modelName) {
|
|
607
|
-
this.handleDelta(message);
|
|
608
|
-
}
|
|
609
|
-
break;
|
|
610
|
-
default: {
|
|
611
|
-
// Collaboration events use underscore wire format (e.g., 'sheet_selection')
|
|
612
|
-
// Convert to colon format for the event map (e.g., 'sheet:selection')
|
|
613
|
-
const eventKey = message.type?.replace(/_/g, ':');
|
|
614
|
-
if (eventKey && this.collaborationEventTypes.has(eventKey)) {
|
|
615
|
-
this.emit(eventKey, message.payload);
|
|
616
|
-
}
|
|
617
|
-
else {
|
|
618
|
-
getContext().logger.debug('Received unknown message type', { message });
|
|
619
|
-
}
|
|
620
|
-
}
|
|
621
|
-
}
|
|
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);
|
|
622
379
|
}
|
|
623
380
|
catch (error) {
|
|
624
381
|
getContext().observability.captureWebSocketError({
|
|
@@ -627,7 +384,9 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
627
384
|
});
|
|
628
385
|
}
|
|
629
386
|
};
|
|
630
|
-
|
|
387
|
+
socket.onerror = (_event) => {
|
|
388
|
+
if (this.ws !== socket)
|
|
389
|
+
return; // stale socket — its errors are no longer ours
|
|
631
390
|
// WebSocket errors are DOM Events, not Error objects
|
|
632
391
|
// Check if we're offline first
|
|
633
392
|
if (!getContext().onlineStatus.isOnline()) {
|
|
@@ -635,8 +394,8 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
635
394
|
this.emit('error', new AbloConnectionError('Network is offline', { code: 'bootstrap_offline' }));
|
|
636
395
|
return;
|
|
637
396
|
}
|
|
638
|
-
// After session error, suppress
|
|
639
|
-
// Still emit so
|
|
397
|
+
// After a session error, suppress error capture — the root cause is
|
|
398
|
+
// already reported. Still emit so the store can update UI state.
|
|
640
399
|
const error = new AbloConnectionError(`WebSocket connection failed`);
|
|
641
400
|
if (!this._sessionErrorDetected) {
|
|
642
401
|
getContext().observability.captureWebSocketError({
|
|
@@ -646,7 +405,17 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
646
405
|
}
|
|
647
406
|
this.emit('error', error);
|
|
648
407
|
};
|
|
649
|
-
|
|
408
|
+
socket.onclose = (event) => {
|
|
409
|
+
// Stale-socket close: a newer socket already owns the connection
|
|
410
|
+
// state — don't null it, stop its timers, or schedule a duplicate
|
|
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;
|
|
650
419
|
const everOpened = this._everOpened;
|
|
651
420
|
this.lastCloseAt = Date.now();
|
|
652
421
|
this.lastCloseCode = event.code;
|
|
@@ -663,7 +432,7 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
663
432
|
this.isConnecting = false;
|
|
664
433
|
this.ws = null;
|
|
665
434
|
this.stopCatchupInterval();
|
|
666
|
-
this.
|
|
435
|
+
this.heartbeat.stop();
|
|
667
436
|
// Cancel in-flight mutations — the socket that was carrying them is
|
|
668
437
|
// gone, and the server-side state may or may not have accepted each
|
|
669
438
|
// one. Rejecting promptly is better than hanging the caller forever;
|
|
@@ -706,6 +475,20 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
706
475
|
}
|
|
707
476
|
this.pendingSubscriptions = [];
|
|
708
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
|
+
}
|
|
709
492
|
// Check for session-related close codes
|
|
710
493
|
// 1008 = Policy Violation (often auth)
|
|
711
494
|
// 4001 = Unauthorized (custom)
|
|
@@ -723,21 +506,24 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
723
506
|
reason: event.reason,
|
|
724
507
|
});
|
|
725
508
|
this.emit('session_error', new SyncSessionError(event.reason || 'Session expired', event.code));
|
|
726
|
-
// Don't reconnect
|
|
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.
|
|
727
513
|
this.emit('disconnected', event);
|
|
728
514
|
return;
|
|
729
515
|
}
|
|
730
516
|
// Handshake failure: `onclose` fired before `onopen` ever did, so the
|
|
731
|
-
// server rejected the upgrade (typically 401/403 on a bad cookie, but
|
|
732
|
-
// could also be a CORS/origin reject or
|
|
733
|
-
// the HTTP status behind code 1006, so we
|
|
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.
|
|
734
521
|
//
|
|
735
|
-
// Emit a dedicated event and
|
|
736
|
-
//
|
|
737
|
-
//
|
|
738
|
-
//
|
|
739
|
-
//
|
|
740
|
-
// stale cookies.
|
|
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.
|
|
741
527
|
if (!everOpened && !this.isManualClose) {
|
|
742
528
|
getContext().observability.captureWebSocketError({
|
|
743
529
|
context: 'handshake-failed-close',
|
|
@@ -756,67 +542,97 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
756
542
|
};
|
|
757
543
|
}
|
|
758
544
|
/**
|
|
759
|
-
*
|
|
760
|
-
* (`
|
|
761
|
-
*
|
|
762
|
-
*
|
|
763
|
-
*
|
|
764
|
-
*
|
|
765
|
-
*
|
|
766
|
-
*
|
|
767
|
-
*
|
|
768
|
-
*
|
|
545
|
+
* Validates and normalizes a wire delta at the receive boundary — the single
|
|
546
|
+
* seam every inbound delta (a `delta` frame, a batch element, a `sync_response`
|
|
547
|
+
* replay, or the older bare frame) passes through before it is emitted,
|
|
548
|
+
* persisted, or allowed to advance any watermark.
|
|
549
|
+
*
|
|
550
|
+
* Normalization keeps already-deployed servers compatible:
|
|
551
|
+
* - `id`: the contract says `number`, but some servers have sent the raw
|
|
552
|
+
* Postgres BIGINT serialization — a string — and every downstream watermark
|
|
553
|
+
* gate treats a string as invalid, so acks are withheld, the resume cursor
|
|
554
|
+
* never advances, and every reconnect replays from zero. Coerce it once here.
|
|
555
|
+
* - `transactionId` / `createdBy`: the server projection sends these as
|
|
556
|
+
* nullable (and `createdBy` as a nested reference); the client contract
|
|
557
|
+
* types them as optional strings and never reads them, so normalize them to
|
|
558
|
+
* absent rather than reject every real server delta.
|
|
559
|
+
*
|
|
560
|
+
* Validation runs `clientSyncDeltaSchema.safeParse`, the canonical wire
|
|
561
|
+
* contract. A frame that fails is dropped (returns `null`) with a debug log
|
|
562
|
+
* and an observability breadcrumb; it is never applied. There is one parse per
|
|
563
|
+
* delta — callers must not re-parse.
|
|
769
564
|
*/
|
|
770
|
-
normalizeWireDelta(
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
565
|
+
normalizeWireDelta(raw) {
|
|
566
|
+
let candidate = raw;
|
|
567
|
+
if (isRecord(raw)) {
|
|
568
|
+
const normalized = { ...raw };
|
|
569
|
+
if (typeof normalized.id !== 'number') {
|
|
570
|
+
const coerced = Number(normalized.id);
|
|
571
|
+
normalized.id = Number.isFinite(coerced) ? coerced : 0;
|
|
572
|
+
}
|
|
573
|
+
if (normalized.transactionId === null)
|
|
574
|
+
delete normalized.transactionId;
|
|
575
|
+
if (normalized.createdBy !== undefined && typeof normalized.createdBy !== 'string') {
|
|
576
|
+
delete normalized.createdBy;
|
|
577
|
+
}
|
|
578
|
+
candidate = normalized;
|
|
579
|
+
}
|
|
580
|
+
const parsed = clientSyncDeltaSchema.safeParse(candidate);
|
|
581
|
+
if (!parsed.success) {
|
|
582
|
+
const issue = parsed.error.issues[0];
|
|
583
|
+
const detail = {
|
|
584
|
+
issue: issue ? `${issue.path.join('.')}: ${issue.message}` : 'not an object',
|
|
585
|
+
modelName: isRecord(raw) && typeof raw.modelName === 'string' ? raw.modelName : undefined,
|
|
586
|
+
actionType: isRecord(raw) && typeof raw.actionType === 'string' ? raw.actionType : undefined,
|
|
587
|
+
};
|
|
588
|
+
getContext().logger.debug('[SyncWebSocket] dropped malformed wire delta', detail);
|
|
589
|
+
getContext().observability.breadcrumb('Dropped malformed wire delta', 'sync.websocket', 'warning', detail);
|
|
590
|
+
return null;
|
|
591
|
+
}
|
|
592
|
+
return parsed.data;
|
|
775
593
|
}
|
|
776
594
|
/**
|
|
777
|
-
* Handle incoming sync delta
|
|
595
|
+
* Handle incoming sync delta (untrusted wire input — validated and
|
|
596
|
+
* normalized by {@link normalizeWireDelta}; malformed deltas are dropped).
|
|
778
597
|
*/
|
|
779
598
|
handleDelta(rawDelta) {
|
|
780
599
|
const delta = this.normalizeWireDelta(rawDelta);
|
|
600
|
+
if (!delta)
|
|
601
|
+
return;
|
|
781
602
|
getContext().logger.debug('Received delta', {
|
|
782
603
|
action: delta.actionType,
|
|
783
604
|
model: delta.modelName,
|
|
784
605
|
id: delta.modelId,
|
|
785
606
|
syncId: delta.id,
|
|
786
607
|
});
|
|
787
|
-
//
|
|
788
|
-
// must stay consistent with what
|
|
789
|
-
// next `requestIncrementalSync()` (and the connect-time handshake)
|
|
790
|
-
//
|
|
791
|
-
// landed in
|
|
792
|
-
// which
|
|
793
|
-
//
|
|
794
|
-
// read in the same transaction as the client view" rule.
|
|
608
|
+
// Do not advance `this.cursor.lastSyncId` on receipt. The runtime cursor
|
|
609
|
+
// must stay consistent with what has been persisted locally; otherwise the
|
|
610
|
+
// next `requestIncrementalSync()` (and the connect-time handshake) would
|
|
611
|
+
// send an optimistic cursor and the server would skip deltas that never
|
|
612
|
+
// landed in local storage. `this.cursor.lastSyncId` advances only in
|
|
613
|
+
// `sendAck()`, which the store gates on its persisted-syncId watermark, so
|
|
614
|
+
// the cursor is never read ahead of the persisted client view.
|
|
795
615
|
//
|
|
796
|
-
//
|
|
797
|
-
//
|
|
616
|
+
// The version vector is intentionally not updated here for the same reason;
|
|
617
|
+
// it is left to the persistence-gated path.
|
|
798
618
|
// Emit delta for processing. Ack will be sent by SyncedStore after persistence.
|
|
799
619
|
this.emit('delta', delta);
|
|
800
620
|
}
|
|
801
621
|
/**
|
|
802
|
-
*
|
|
803
|
-
*
|
|
804
|
-
*
|
|
805
|
-
*
|
|
806
|
-
* `
|
|
807
|
-
*
|
|
808
|
-
*
|
|
809
|
-
* cursor never gets ahead of the persisted view, so reconnect/
|
|
810
|
-
* catch-up requests can't accidentally skip un-persisted deltas.
|
|
622
|
+
* Acknowledges received deltas up to the given syncId. This is the only place
|
|
623
|
+
* `this.cursor.lastSyncId` moves forward for live deltas. The store calls it
|
|
624
|
+
* with its persisted-syncId watermark — that is, only after the deltas have
|
|
625
|
+
* committed to local storage. Advancing the cursor here, rather than at
|
|
626
|
+
* receipt in `handleDelta` or `handleSyncResponse`, keeps the cursor from
|
|
627
|
+
* getting ahead of the persisted view, so reconnect and catch-up requests
|
|
628
|
+
* cannot skip un-persisted deltas.
|
|
811
629
|
*/
|
|
812
630
|
sendAck(syncId) {
|
|
813
631
|
// Advance the local cursor *and* the version vector for this ack —
|
|
814
632
|
// these are what `requestIncrementalSync` and the connect handshake
|
|
815
633
|
// will send next, and what `getLastSyncId()` reports for clean-
|
|
816
634
|
// shutdown persistence.
|
|
817
|
-
|
|
818
|
-
this.lastSyncId = syncId;
|
|
819
|
-
}
|
|
635
|
+
this.cursor.ackAdvance(syncId);
|
|
820
636
|
if (this.ws?.readyState !== WebSocket.OPEN)
|
|
821
637
|
return;
|
|
822
638
|
this.send({
|
|
@@ -863,109 +679,16 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
863
679
|
}
|
|
864
680
|
}
|
|
865
681
|
/**
|
|
866
|
-
*
|
|
867
|
-
*
|
|
868
|
-
*
|
|
869
|
-
*
|
|
870
|
-
* drift detection (a `CommitOperation` shape change breaks here) and the lone
|
|
871
|
-
* `as` narrows the validated op `type` to the wire union — the only
|
|
872
|
-
* loosening, localized to this boundary.
|
|
873
|
-
*/
|
|
874
|
-
buildCommitFrame(operations, clientTxId, causedByTaskId, reads) {
|
|
875
|
-
const payload = {
|
|
876
|
-
operations: operations.map((op) => ({
|
|
877
|
-
type: op.type,
|
|
878
|
-
model: op.model,
|
|
879
|
-
id: op.id,
|
|
880
|
-
input: op.input,
|
|
881
|
-
transactionId: op.transactionId,
|
|
882
|
-
readAt: op.readAt,
|
|
883
|
-
onStale: op.onStale,
|
|
884
|
-
})),
|
|
885
|
-
clientTxId,
|
|
886
|
-
};
|
|
887
|
-
if (causedByTaskId)
|
|
888
|
-
payload.causedByTaskId = causedByTaskId;
|
|
889
|
-
// Batch-level read-set (STORM layer): rows/groups the batch was premised on.
|
|
890
|
-
if (reads && reads.length > 0)
|
|
891
|
-
payload.reads = [...reads];
|
|
892
|
-
return { type: 'commit', payload };
|
|
893
|
-
}
|
|
894
|
-
/**
|
|
895
|
-
* Send a `commit` mutation request over the existing WebSocket and
|
|
896
|
-
* resolve when the server's `mutation_result` frame comes back with
|
|
897
|
-
* the same `clientTxId`. The wire-level frame is `{ type: 'commit',
|
|
898
|
-
* payload: { operations, clientTxId } }` — matching the
|
|
899
|
-
* `handleCommit` path on `apps/sync-server/src/hub/Hub.ts` (see the
|
|
900
|
-
* dispatch at Hub.ts:737).
|
|
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 } }`.
|
|
901
686
|
*
|
|
902
|
-
*
|
|
903
|
-
*
|
|
904
|
-
*
|
|
905
|
-
*
|
|
906
|
-
* confusion stops here.
|
|
907
|
-
*
|
|
908
|
-
* Times out after 15s of silence from the server. The socket may close
|
|
909
|
-
* during an in-flight mutation (network flap, server restart); we do
|
|
910
|
-
* NOT auto-retry here — the caller's TransactionQueue owns retry +
|
|
911
|
-
* offline replay semantics and the SDK shouldn't duplicate that logic.
|
|
912
|
-
*/
|
|
913
|
-
/**
|
|
914
|
-
* Defensively validate the optional `notifications` array off a commit ack.
|
|
915
|
-
* Untrusted wire data — a malformed entry is dropped rather than throwing,
|
|
916
|
-
* so a bad notification never sinks an otherwise-successful commit.
|
|
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.
|
|
917
691
|
*/
|
|
918
|
-
parseNotifications(raw) {
|
|
919
|
-
if (!Array.isArray(raw) || raw.length === 0)
|
|
920
|
-
return undefined;
|
|
921
|
-
const out = [];
|
|
922
|
-
for (const entry of raw) {
|
|
923
|
-
const parsed = staleNotificationSchema.safeParse(entry);
|
|
924
|
-
if (parsed.success)
|
|
925
|
-
out.push(parsed.data);
|
|
926
|
-
}
|
|
927
|
-
return out.length > 0 ? out : undefined;
|
|
928
|
-
}
|
|
929
|
-
/**
|
|
930
|
-
* Single instrumentation point for claim events. Every `claim_*` frame routes
|
|
931
|
-
* through here so a developer debugging a collision gets one consistent trace
|
|
932
|
-
* — a console line AND a structured capture — without each dispatch case
|
|
933
|
-
* re-deriving the row/holder shape. The wire payload is loosely typed
|
|
934
|
-
* (`Record<string, unknown>`), so this is the one place that narrows it into
|
|
935
|
-
* a {@link ClaimEvent}.
|
|
936
|
-
*/
|
|
937
|
-
recordClaim(phase, payload) {
|
|
938
|
-
const str = (v) => typeof v === 'string' ? v : undefined;
|
|
939
|
-
// Targets arrive flat ({ entityType, entityId }) or nested under `target`.
|
|
940
|
-
const target = payload.target && typeof payload.target === 'object'
|
|
941
|
-
? payload.target
|
|
942
|
-
: payload;
|
|
943
|
-
const kind = wireParticipantKindSchema.safeParse(payload.participantKind);
|
|
944
|
-
const event = {
|
|
945
|
-
phase,
|
|
946
|
-
claimId: str(payload.claimId),
|
|
947
|
-
model: str(target.entityType) ?? str(target.model),
|
|
948
|
-
id: str(target.entityId) ?? str(target.id),
|
|
949
|
-
field: str(target.field),
|
|
950
|
-
actor: str(payload.actor) ?? str(payload.heldBy),
|
|
951
|
-
participantKind: kind.success ? kind.data : undefined,
|
|
952
|
-
position: typeof payload.position === 'number' ? payload.position : undefined,
|
|
953
|
-
reason: str(payload.policyReason) ?? str(payload.reason),
|
|
954
|
-
};
|
|
955
|
-
const message = formatClaim(event);
|
|
956
|
-
// A rejection or lost lease is the collision a developer is actively
|
|
957
|
-
// debugging → warn (shows at the default log level). The routine events
|
|
958
|
-
// (acquired/queued/granted/expired) are debug-only so they never drown the
|
|
959
|
-
// console until you opt in with `new Ablo({ debug: true })`.
|
|
960
|
-
const isCollision = phase === 'rejected' || phase === 'lost';
|
|
961
|
-
const ctx = getContext();
|
|
962
|
-
if (isCollision)
|
|
963
|
-
ctx.logger.warn(message);
|
|
964
|
-
else
|
|
965
|
-
ctx.logger.debug(message);
|
|
966
|
-
ctx.observability.breadcrumb(message, 'sync.coordination', isCollision ? 'warning' : 'info');
|
|
967
|
-
ctx.observability.captureClaim(event);
|
|
968
|
-
}
|
|
969
692
|
sendCommit(operations, clientTxId, timeoutMs = 15_000, causedByTaskId, reads) {
|
|
970
693
|
if (this.ws?.readyState !== WebSocket.OPEN) {
|
|
971
694
|
return Promise.reject(this.notConnectedError('commit'));
|
|
@@ -981,7 +704,7 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
981
704
|
// an open turn — keeps the wire shape stable for sessions
|
|
982
705
|
// that don't use turns. Servers that don't know the field
|
|
983
706
|
// ignore it; newer servers stamp it onto every delta.
|
|
984
|
-
const frame =
|
|
707
|
+
const frame = buildCommitFrame(operations, clientTxId, causedByTaskId, reads);
|
|
985
708
|
this.ws.send(JSON.stringify(frame));
|
|
986
709
|
}
|
|
987
710
|
catch (error) {
|
|
@@ -1003,25 +726,18 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
1003
726
|
if (this.ws?.readyState !== WebSocket.OPEN) {
|
|
1004
727
|
throw this.notConnectedError('commit');
|
|
1005
728
|
}
|
|
1006
|
-
const frame =
|
|
729
|
+
const frame = buildCommitFrame(operations, clientTxId, causedByTaskId, reads);
|
|
1007
730
|
this.ws.send(JSON.stringify(frame));
|
|
1008
731
|
}
|
|
1009
732
|
/**
|
|
1010
|
-
*
|
|
1011
|
-
*
|
|
1012
|
-
*
|
|
1013
|
-
*
|
|
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.
|
|
1014
737
|
*
|
|
1015
|
-
* Returns a promise that resolves with the server-canonicalized
|
|
1016
|
-
*
|
|
1017
|
-
*
|
|
1018
|
-
* timeout / disconnect.
|
|
1019
|
-
*
|
|
1020
|
-
* Why this exists: the old scoped-participant path opened a separate
|
|
1021
|
-
* WS per scope. With claims, the SDK reuses the existing session/agent
|
|
1022
|
-
* connection — one TCP, N logical participants. See
|
|
1023
|
-
* `apps/sync-server/docs/PARTICIPANT_CLAIMS.md` for the migration
|
|
1024
|
-
* framing (Phase A.1).
|
|
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.
|
|
1025
741
|
*/
|
|
1026
742
|
sendClaim(claimId, syncGroups, options) {
|
|
1027
743
|
if (this.ws?.readyState !== WebSocket.OPEN) {
|
|
@@ -1086,22 +802,21 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
1086
802
|
}
|
|
1087
803
|
}
|
|
1088
804
|
/**
|
|
1089
|
-
*
|
|
1090
|
-
*
|
|
1091
|
-
* area-of-interest
|
|
1092
|
-
*
|
|
1093
|
-
* chosen at connect.
|
|
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.
|
|
1094
809
|
*
|
|
1095
|
-
*
|
|
1096
|
-
*
|
|
1097
|
-
*
|
|
1098
|
-
* requesting a group outside its allowlist), timeout, or disconnect. On
|
|
1099
|
-
* success the new set is recorded as `options.syncGroups
|
|
1100
|
-
*
|
|
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.
|
|
1101
816
|
*
|
|
1102
|
-
* Distinct from {@link sendClaim} (write
|
|
1103
|
-
* the read side
|
|
1104
|
-
* by the connection credential's grant.
|
|
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.
|
|
1105
820
|
*/
|
|
1106
821
|
updateSubscription(syncGroups, options) {
|
|
1107
822
|
if (this.ws?.readyState !== WebSocket.OPEN) {
|
|
@@ -1136,9 +851,9 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
1136
851
|
});
|
|
1137
852
|
}
|
|
1138
853
|
/**
|
|
1139
|
-
*
|
|
1140
|
-
*
|
|
1141
|
-
*
|
|
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.
|
|
1142
857
|
*/
|
|
1143
858
|
setCapabilityToken(token) {
|
|
1144
859
|
this.options.capabilityToken = token;
|
|
@@ -1233,17 +948,17 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
1233
948
|
if (this._sessionErrorDetected) {
|
|
1234
949
|
return;
|
|
1235
950
|
}
|
|
1236
|
-
// Don't attempt reconnection while offline.
|
|
1237
|
-
//
|
|
1238
|
-
//
|
|
1239
|
-
//
|
|
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.
|
|
1240
955
|
if (!getContext().onlineStatus.isOnline()) {
|
|
1241
956
|
this.emit('reconnecting', { attempt: this.reconnectAttempts + 1, delay: 0 });
|
|
1242
957
|
return;
|
|
1243
958
|
}
|
|
1244
|
-
// Give up after MAX_RECONNECT_ATTEMPTS consecutive failures.
|
|
1245
|
-
//
|
|
1246
|
-
//
|
|
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.
|
|
1247
962
|
if (this.reconnectAttempts >= SyncWebSocket.MAX_RECONNECT_ATTEMPTS) {
|
|
1248
963
|
this.emit('reconnect_failed', { attempts: this.reconnectAttempts });
|
|
1249
964
|
return;
|
|
@@ -1281,7 +996,7 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
1281
996
|
disconnect() {
|
|
1282
997
|
this.isManualClose = true;
|
|
1283
998
|
this.stopCatchupInterval();
|
|
1284
|
-
this.
|
|
999
|
+
this.heartbeat.stop();
|
|
1285
1000
|
if (this.reconnectTimer) {
|
|
1286
1001
|
clearTimeout(this.reconnectTimer);
|
|
1287
1002
|
this.reconnectTimer = null;
|
|
@@ -1291,67 +1006,6 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
1291
1006
|
this.ws = null;
|
|
1292
1007
|
}
|
|
1293
1008
|
}
|
|
1294
|
-
/**
|
|
1295
|
-
* Application-level heartbeat. Every `HEARTBEAT_INTERVAL_MS` while
|
|
1296
|
-
* `OPEN`, send `{ type: 'ping' }` and arm a `HEARTBEAT_TIMEOUT_MS`
|
|
1297
|
-
* watchdog. Any inbound frame (handled in `onmessage`) clears the
|
|
1298
|
-
* watchdog. If the watchdog fires, we treat the connection as
|
|
1299
|
-
* zombie and force-close it — `onclose` then triggers the existing
|
|
1300
|
-
* reconnect path.
|
|
1301
|
-
*
|
|
1302
|
-
* Why both sides need this:
|
|
1303
|
-
* - The server sends RFC 6455 protocol pings via `ws.ping()` every
|
|
1304
|
-
* 30s. Browsers auto-respond with a pong but DO NOT expose either
|
|
1305
|
-
* frame to JavaScript, so the client is blind to its own keepalive.
|
|
1306
|
-
* - On a half-open TCP (laptop wake, NAT timeout, mobile handoff)
|
|
1307
|
-
* the browser may keep `readyState === OPEN` for minutes before
|
|
1308
|
-
* the OS surfaces the broken connection. App-level traffic is
|
|
1309
|
-
* the only signal we can observe.
|
|
1310
|
-
*/
|
|
1311
|
-
startHeartbeat() {
|
|
1312
|
-
this.stopHeartbeat();
|
|
1313
|
-
this.heartbeatTimer = setInterval(() => {
|
|
1314
|
-
if (this.ws?.readyState !== WebSocket.OPEN)
|
|
1315
|
-
return;
|
|
1316
|
-
// Send the ping. If `send` throws, the socket is already dead —
|
|
1317
|
-
// force-close so onclose triggers the reconnect cycle.
|
|
1318
|
-
try {
|
|
1319
|
-
this.ws.send(JSON.stringify({ type: 'ping' }));
|
|
1320
|
-
}
|
|
1321
|
-
catch (err) {
|
|
1322
|
-
getContext().observability.captureWebSocketError({
|
|
1323
|
-
context: 'heartbeat-send-failed',
|
|
1324
|
-
error: err instanceof Error ? err.message : String(err),
|
|
1325
|
-
});
|
|
1326
|
-
this.forceClose('heartbeat-send-failed');
|
|
1327
|
-
return;
|
|
1328
|
-
}
|
|
1329
|
-
// Arm the timeout. ANY inbound message clears it (see onmessage).
|
|
1330
|
-
// We don't require an explicit `pong` — a delta or any other frame
|
|
1331
|
-
// is equally good proof-of-life.
|
|
1332
|
-
if (this.heartbeatTimeoutTimer)
|
|
1333
|
-
clearTimeout(this.heartbeatTimeoutTimer);
|
|
1334
|
-
this.heartbeatTimeoutTimer = setTimeout(() => {
|
|
1335
|
-
getContext().observability.captureWebSocketError({
|
|
1336
|
-
context: 'heartbeat-timeout',
|
|
1337
|
-
});
|
|
1338
|
-
this.forceClose('heartbeat-timeout');
|
|
1339
|
-
}, SyncWebSocket.HEARTBEAT_TIMEOUT_MS);
|
|
1340
|
-
}, SyncWebSocket.HEARTBEAT_INTERVAL_MS);
|
|
1341
|
-
}
|
|
1342
|
-
stopHeartbeat() {
|
|
1343
|
-
if (this.heartbeatTimer) {
|
|
1344
|
-
clearInterval(this.heartbeatTimer);
|
|
1345
|
-
this.heartbeatTimer = null;
|
|
1346
|
-
}
|
|
1347
|
-
this.clearHeartbeatTimeout();
|
|
1348
|
-
}
|
|
1349
|
-
clearHeartbeatTimeout() {
|
|
1350
|
-
if (this.heartbeatTimeoutTimer) {
|
|
1351
|
-
clearTimeout(this.heartbeatTimeoutTimer);
|
|
1352
|
-
this.heartbeatTimeoutTimer = null;
|
|
1353
|
-
}
|
|
1354
|
-
}
|
|
1355
1009
|
/**
|
|
1356
1010
|
* Force-close the socket from the client side using a private 4xxx
|
|
1357
1011
|
* code. Callers expect `onclose` to fire; that handler runs the
|
|
@@ -1416,11 +1070,19 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
1416
1070
|
*/
|
|
1417
1071
|
notConnectedError(action) {
|
|
1418
1072
|
const d = this.getConnectionDiagnostics();
|
|
1419
|
-
|
|
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.
|
|
1420
1079
|
if (d.sessionErrorDetected) {
|
|
1421
|
-
|
|
1080
|
+
return Object.assign(new SyncSessionError(`SyncWebSocket not connected — cannot send ${action}: session expired` +
|
|
1081
|
+
(d.lastCloseReason ? ` (${d.lastCloseReason})` : '') +
|
|
1082
|
+
'; re-authenticate'), { diagnostics: d });
|
|
1422
1083
|
}
|
|
1423
|
-
|
|
1084
|
+
let detail;
|
|
1085
|
+
if (d.isManualClose) {
|
|
1424
1086
|
detail = 'manual_close';
|
|
1425
1087
|
}
|
|
1426
1088
|
else if (d.isConnecting) {
|
|
@@ -1444,10 +1106,10 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
1444
1106
|
else {
|
|
1445
1107
|
detail = 'never_connected';
|
|
1446
1108
|
}
|
|
1447
|
-
// Typed so it lands in the AbloError hierarchy
|
|
1448
|
-
//
|
|
1449
|
-
//
|
|
1450
|
-
//
|
|
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.
|
|
1451
1113
|
const err = Object.assign(new AbloConnectionError(`SyncWebSocket not connected — cannot send ${action} (${detail})`, { code: 'ws_not_ready' }), { diagnostics: d });
|
|
1452
1114
|
return err;
|
|
1453
1115
|
}
|
|
@@ -1455,50 +1117,34 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
1455
1117
|
getSyncGroups() {
|
|
1456
1118
|
return this.options.syncGroups;
|
|
1457
1119
|
}
|
|
1120
|
+
// Cursor accessors — thin delegates; the state and semantics live in
|
|
1121
|
+
// SyncCursor.
|
|
1458
1122
|
/**
|
|
1459
1123
|
* Update last sync ID (for persistence)
|
|
1460
1124
|
*/
|
|
1461
1125
|
setLastSyncId(syncId) {
|
|
1462
|
-
this.
|
|
1463
|
-
}
|
|
1464
|
-
/**
|
|
1465
|
-
* Get current version vector
|
|
1466
|
-
*/
|
|
1467
|
-
getVersionVector() {
|
|
1468
|
-
return { ...this.versionVector };
|
|
1469
|
-
}
|
|
1470
|
-
/**
|
|
1471
|
-
* Update version vector for specific entity type
|
|
1472
|
-
*/
|
|
1473
|
-
updateVersionVector(entityType, version) {
|
|
1474
|
-
this.versionVector[entityType] = Math.max(this.versionVector[entityType] || 0, version);
|
|
1475
|
-
}
|
|
1476
|
-
/**
|
|
1477
|
-
* Set version vector (for initialization)
|
|
1478
|
-
*/
|
|
1479
|
-
setVersionVector(versions) {
|
|
1480
|
-
this.versionVector = { ...versions };
|
|
1126
|
+
this.cursor.setLastSyncId(syncId);
|
|
1481
1127
|
}
|
|
1482
1128
|
/**
|
|
1483
1129
|
* Update sync cursor (for incremental sync)
|
|
1484
1130
|
*/
|
|
1485
1131
|
setSyncCursor(cursor) {
|
|
1486
|
-
this.
|
|
1132
|
+
this.cursor.setSyncCursor(cursor);
|
|
1487
1133
|
}
|
|
1488
1134
|
/**
|
|
1489
1135
|
* Get current sync cursor
|
|
1490
1136
|
*/
|
|
1491
1137
|
getSyncCursor() {
|
|
1492
|
-
return this.
|
|
1138
|
+
return this.cursor.getSyncCursor();
|
|
1493
1139
|
}
|
|
1494
1140
|
/**
|
|
1495
1141
|
* Get the highest syncId seen this session (for persistence on clean shutdown)
|
|
1496
1142
|
*/
|
|
1497
1143
|
getLastSyncId() {
|
|
1498
|
-
return this.
|
|
1144
|
+
return this.cursor.getLastSyncId();
|
|
1499
1145
|
}
|
|
1500
1146
|
/**
|
|
1501
|
-
*
|
|
1147
|
+
* Requests an incremental sync from the server, starting at the current cursor.
|
|
1502
1148
|
*/
|
|
1503
1149
|
async requestIncrementalSync() {
|
|
1504
1150
|
if (this.ws?.readyState !== WebSocket.OPEN) {
|
|
@@ -1516,9 +1162,12 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
1516
1162
|
this.send({
|
|
1517
1163
|
type: 'sync_request',
|
|
1518
1164
|
payload: {
|
|
1519
|
-
cursor: this.syncCursor, // Always send lastSyncId to ensure server uses client's current position
|
|
1520
|
-
lastSyncId: this.lastSyncId,
|
|
1165
|
+
cursor: this.cursor.syncCursor, // Always send lastSyncId to ensure server uses client's current position
|
|
1166
|
+
lastSyncId: this.cursor.lastSyncId,
|
|
1521
1167
|
capabilities: capsArr,
|
|
1168
|
+
// Protocol handshake: the server rejects an out-of-range version with
|
|
1169
|
+
// WebSocket close code 4010 before serving any deltas.
|
|
1170
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
1522
1171
|
},
|
|
1523
1172
|
});
|
|
1524
1173
|
}
|
|
@@ -1541,46 +1190,56 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
1541
1190
|
});
|
|
1542
1191
|
}
|
|
1543
1192
|
/**
|
|
1544
|
-
* Handle sync response from server
|
|
1193
|
+
* Handle sync response from server. Untrusted wire input — the envelope
|
|
1194
|
+
* fields are narrowed defensively and every delta is validated through
|
|
1195
|
+
* {@link normalizeWireDelta} (exactly once each; malformed ones drop out
|
|
1196
|
+
* of the batch).
|
|
1545
1197
|
*/
|
|
1546
|
-
handleSyncResponse(
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
//
|
|
1552
|
-
//
|
|
1553
|
-
//
|
|
1554
|
-
//
|
|
1555
|
-
//
|
|
1198
|
+
handleSyncResponse(rawPayload) {
|
|
1199
|
+
const payload = isRecord(rawPayload) ? rawPayload : {};
|
|
1200
|
+
const rawDeltas = Array.isArray(payload.deltas)
|
|
1201
|
+
? payload.deltas
|
|
1202
|
+
: null;
|
|
1203
|
+
// Cursor reconciliation. The server stamps its authoritative `currentSyncId`
|
|
1204
|
+
// on every sync_response. If our local cursor is ahead of the server, our
|
|
1205
|
+
// local view has somehow diverged (corrupted metadata, a regression that
|
|
1206
|
+
// reintroduced an eager advance, or local storage lying about a successful
|
|
1207
|
+
// commit). Trust the server, reset the cursor, and request another sync so
|
|
1208
|
+
// any deltas we should have applied get re-delivered. When the field is
|
|
1209
|
+
// absent (an older server build) we simply skip this step.
|
|
1556
1210
|
//
|
|
1557
|
-
// We only reconcile when the response carries
|
|
1558
|
-
//
|
|
1559
|
-
//
|
|
1560
|
-
//
|
|
1561
|
-
//
|
|
1562
|
-
//
|
|
1563
|
-
//
|
|
1564
|
-
//
|
|
1565
|
-
|
|
1566
|
-
const hasDeltas = Array.isArray(payload.deltas) && payload.deltas.length > 0;
|
|
1211
|
+
// We only reconcile when the response carries no deltas. If deltas are
|
|
1212
|
+
// present they advance our cursor through the normal persistence-gated path
|
|
1213
|
+
// anyway — and the in-flight round-trip means the snapshot's `currentSyncId`
|
|
1214
|
+
// is naturally a few syncIds behind our locally-advanced cursor at receive
|
|
1215
|
+
// time (live deltas may have landed in the meantime). Restricting to
|
|
1216
|
+
// empty-delta responses eliminates that benign false positive while still
|
|
1217
|
+
// catching the real corruption case (server head < local, and the server
|
|
1218
|
+
// has nothing new to send).
|
|
1219
|
+
const hasDeltas = rawDeltas !== null && rawDeltas.length > 0;
|
|
1567
1220
|
if (!hasDeltas && typeof payload.currentSyncId === 'number') {
|
|
1568
1221
|
const serverHead = payload.currentSyncId;
|
|
1569
|
-
if (serverHead < this.lastSyncId) {
|
|
1222
|
+
if (serverHead < this.cursor.lastSyncId) {
|
|
1570
1223
|
getContext().logger.debug('[SyncWebSocket] local cursor ahead of server head — resetting and resyncing', {
|
|
1571
|
-
local: this.lastSyncId,
|
|
1224
|
+
local: this.cursor.lastSyncId,
|
|
1572
1225
|
server: serverHead,
|
|
1573
|
-
drift: this.lastSyncId - serverHead,
|
|
1226
|
+
drift: this.cursor.lastSyncId - serverHead,
|
|
1574
1227
|
});
|
|
1575
|
-
getContext().observability.breadcrumb('Local sync cursor diverged from server — reset', 'sync.websocket', 'warning', { local: this.lastSyncId, server: serverHead });
|
|
1576
|
-
this.lastSyncId = serverHead;
|
|
1228
|
+
getContext().observability.breadcrumb('Local sync cursor diverged from server — reset', 'sync.websocket', 'warning', { local: this.cursor.lastSyncId, server: serverHead });
|
|
1229
|
+
this.cursor.lastSyncId = serverHead;
|
|
1577
1230
|
// Fire a follow-up incremental sync to re-deliver anything we
|
|
1578
1231
|
// were missing. Fire-and-forget — the next response will go
|
|
1579
1232
|
// through this same path. The infinite-loop concern is bounded
|
|
1580
|
-
// by the `serverHead < this.lastSyncId` strict-less check: once
|
|
1233
|
+
// by the `serverHead < this.cursor.lastSyncId` strict-less check: once
|
|
1581
1234
|
// we've reset to `serverHead`, the next response with the same
|
|
1582
|
-
// (or higher) `currentSyncId` won't re-enter this branch.
|
|
1583
|
-
|
|
1235
|
+
// (or higher) `currentSyncId` won't re-enter this branch. A `.catch`
|
|
1236
|
+
// (not `void`) so a failed send logs instead of surfacing as an
|
|
1237
|
+
// unhandled rejection.
|
|
1238
|
+
this.requestIncrementalSync().catch((e) => {
|
|
1239
|
+
getContext().observability.breadcrumb('Post-cursor-reset sync request failed', 'sync.websocket', 'warning', {
|
|
1240
|
+
error: e instanceof Error ? e.message : String(e),
|
|
1241
|
+
});
|
|
1242
|
+
});
|
|
1584
1243
|
}
|
|
1585
1244
|
}
|
|
1586
1245
|
if (payload.requiresBootstrap) {
|
|
@@ -1588,58 +1247,61 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
1588
1247
|
return;
|
|
1589
1248
|
}
|
|
1590
1249
|
// Process incremental deltas
|
|
1591
|
-
if (
|
|
1250
|
+
if (rawDeltas) {
|
|
1592
1251
|
// Process all deltas from sync response - store handles idempotency.
|
|
1593
|
-
// Same receive-boundary normalization as handleDelta —
|
|
1594
|
-
// replays from older servers carry string ids too
|
|
1595
|
-
|
|
1252
|
+
// Same receive-boundary validation + normalization as handleDelta —
|
|
1253
|
+
// catch-up replays from older servers carry string ids too, and
|
|
1254
|
+
// malformed deltas drop out of the batch instead of being applied.
|
|
1255
|
+
const newDeltas = [];
|
|
1256
|
+
for (const d of rawDeltas) {
|
|
1257
|
+
const delta = this.normalizeWireDelta(d);
|
|
1258
|
+
if (delta)
|
|
1259
|
+
newDeltas.push(delta);
|
|
1260
|
+
}
|
|
1596
1261
|
if (newDeltas.length > 0) {
|
|
1597
|
-
// DO NOT pre-advance `this.lastSyncId` here. Same reasoning as
|
|
1262
|
+
// DO NOT pre-advance `this.cursor.lastSyncId` here. Same reasoning as
|
|
1598
1263
|
// `handleDelta`: the runtime cursor must stay consistent with
|
|
1599
1264
|
// IDB. The delta_batch event routes through
|
|
1600
1265
|
// `BaseSyncedStore.processDeltaWithBatching` →
|
|
1601
1266
|
// `flushPendingDeltas`, which calls `acknowledge()` with the
|
|
1602
1267
|
// honest `persistedSyncId` once IDB commits. That ack is what
|
|
1603
|
-
// moves `this.lastSyncId` forward.
|
|
1268
|
+
// moves `this.cursor.lastSyncId` forward.
|
|
1604
1269
|
// Emit ALL deltas as a single batch event
|
|
1605
1270
|
this.emit('delta_batch', newDeltas);
|
|
1606
1271
|
}
|
|
1607
1272
|
}
|
|
1608
|
-
// Update
|
|
1609
|
-
|
|
1610
|
-
|
|
1273
|
+
// Update cursor. (`newVersions` from pre-cutover servers is ignored —
|
|
1274
|
+
// the version vector was removed in W4a; `sync_id` is the causality token.)
|
|
1275
|
+
if (typeof payload.newCursor === 'string' && payload.newCursor) {
|
|
1276
|
+
this.cursor.syncCursor = payload.newCursor;
|
|
1611
1277
|
}
|
|
1612
|
-
else if (payload.cursor) {
|
|
1613
|
-
this.syncCursor = payload.cursor;
|
|
1614
|
-
}
|
|
1615
|
-
if (payload.newVersions) {
|
|
1616
|
-
Object.assign(this.versionVector, payload.newVersions);
|
|
1278
|
+
else if (typeof payload.cursor === 'string' && payload.cursor) {
|
|
1279
|
+
this.cursor.syncCursor = payload.cursor;
|
|
1617
1280
|
}
|
|
1618
1281
|
}
|
|
1619
1282
|
/**
|
|
1620
1283
|
* Handle bootstrap response from server
|
|
1621
1284
|
*/
|
|
1622
1285
|
handleBootstrapResponse(payload) {
|
|
1623
|
-
// Emit bootstrap data for processing
|
|
1286
|
+
// Emit the bootstrap data for processing. (A `version` field from older
|
|
1287
|
+
// servers is ignored; the version vector is no longer used.) The typeof
|
|
1288
|
+
// guards mirror the cursor handling above: the frame is server-produced, so
|
|
1289
|
+
// coercion only bites on a malformed frame.
|
|
1290
|
+
const p = (payload && typeof payload === 'object' ? payload : {});
|
|
1624
1291
|
this.emit('bootstrap_data', {
|
|
1625
|
-
entityType:
|
|
1626
|
-
data:
|
|
1627
|
-
isComplete:
|
|
1628
|
-
cursor:
|
|
1292
|
+
entityType: typeof p.entityType === 'string' ? p.entityType : '',
|
|
1293
|
+
data: p.data,
|
|
1294
|
+
isComplete: p.isComplete === true,
|
|
1295
|
+
cursor: typeof p.cursor === 'string' ? p.cursor : undefined,
|
|
1629
1296
|
});
|
|
1630
|
-
// Update version vector if provided
|
|
1631
|
-
if (payload.version && payload.entityType) {
|
|
1632
|
-
this.updateVersionVector(payload.entityType.toLowerCase(), payload.version);
|
|
1633
|
-
}
|
|
1634
1297
|
}
|
|
1635
1298
|
/**
|
|
1636
|
-
*
|
|
1637
|
-
* forwarded as-is so every consumer
|
|
1638
|
-
*
|
|
1639
|
-
*
|
|
1640
|
-
* `kind`, `activity`, `syncGroups`, `isAgent` for rich consumers.
|
|
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.
|
|
1641
1303
|
*
|
|
1642
|
-
*
|
|
1304
|
+
* The wire frame is:
|
|
1643
1305
|
* { type: 'presence_update', payload: { kind, userId, status,
|
|
1644
1306
|
* syncGroups, activity, isAgent, timestamp, activeClaims } }
|
|
1645
1307
|
*/
|