@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
package/dist/BaseSyncedStore.js
CHANGED
|
@@ -1,29 +1,34 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* The base class that application-specific sync stores extend. It supplies the
|
|
3
|
+
* shared orchestration for reads, writes, delta processing, and bootstrap, and
|
|
4
|
+
* exports the core types those stores build on.
|
|
3
5
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* This file only contains types and the abstract contract — the actual
|
|
11
|
-
* implementation stays in the app's SyncedStore.ts until we incrementally
|
|
12
|
-
* pull generic methods into this base class.
|
|
6
|
+
* A subclass adds its own domain behavior — lazy-loaded relations,
|
|
7
|
+
* collaboration events, and model enrichment — by overriding the protected
|
|
8
|
+
* extension points defined here. The heavy lifting is delegated to injected
|
|
9
|
+
* collaborators: {@link SyncClient} owns pool writes and the transaction
|
|
10
|
+
* queue, {@link Database} owns local persistence, {@link InstanceCache} holds the
|
|
11
|
+
* in-memory models, and {@link ModelRegistry} holds their metadata.
|
|
13
12
|
*/
|
|
14
|
-
import { makeObservable, observable, computed, runInAction } from 'mobx';
|
|
13
|
+
import { makeObservable, observable, action, computed, runInAction } from 'mobx';
|
|
15
14
|
import { AbloConnectionError, AbloValidationError, toAbloError } from './errors.js';
|
|
16
15
|
import { ConnectionManager } from './sync/ConnectionManager.js';
|
|
17
|
-
import {
|
|
16
|
+
import { SubscriptionManager } from './sync/SubscriptionManager.js';
|
|
18
17
|
import { resolveParticipantSyncGroups, } from './sync/participants.js';
|
|
18
|
+
import { ModelRegistry } from './ModelRegistry.js';
|
|
19
19
|
import { PropertyType } from './types/index.js';
|
|
20
20
|
import { SyncWebSocket, } from './sync/SyncWebSocket.js';
|
|
21
21
|
import { QueryProcessor } from './core/QueryProcessor.js';
|
|
22
22
|
import { Model, rowAsModel } from './Model.js';
|
|
23
23
|
import { getContext } from './context.js';
|
|
24
|
-
import { SyncSessionError } from './errors.js';
|
|
25
|
-
import { ModelScope } from './
|
|
24
|
+
import { SyncSessionError, isAccessCredentialExpiryCloseReason } from './errors.js';
|
|
25
|
+
import { ModelScope } from './InstanceCache.js';
|
|
26
26
|
import { LazyReferenceCollection } from './LazyReferenceCollection.js';
|
|
27
|
+
import { deriveSyncPlanFromSchema } from './sync/syncPlan.js';
|
|
28
|
+
import { CredentialLifecycle } from './sync/credentialLifecycle.js';
|
|
29
|
+
import * as groupChange from './sync/groupChange.js';
|
|
30
|
+
import * as bootstrapApply from './sync/bootstrapApply.js';
|
|
31
|
+
import * as deltaPipeline from './sync/deltaPipeline.js';
|
|
27
32
|
/** Bootstrap timeout configuration */
|
|
28
33
|
export const BOOTSTRAP_CONFIG = {
|
|
29
34
|
OVERALL_TIMEOUT_MS: 15_000,
|
|
@@ -32,18 +37,20 @@ export const BOOTSTRAP_CONFIG = {
|
|
|
32
37
|
};
|
|
33
38
|
// Re-export for clean API
|
|
34
39
|
export { ModelScope };
|
|
40
|
+
// deriveSyncPlanFromSchema derives a sync plan from a schema and is
|
|
41
|
+
// re-exported here.
|
|
42
|
+
export { deriveSyncPlanFromSchema } from './sync/syncPlan.js';
|
|
35
43
|
// ── Base class ──────────────────────────────────────────────────────────────
|
|
36
44
|
/**
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
* base class incrementally as they are genericized.
|
|
45
|
+
* The abstract base class that application-specific sync stores extend. It
|
|
46
|
+
* carries the injected collaborators, the observable sync status, and the
|
|
47
|
+
* orchestration for initialization, delta processing, bootstrap, and the
|
|
48
|
+
* read and write API. A subclass supplies its own domain behavior by
|
|
49
|
+
* overriding the protected extension points defined here and by typing its
|
|
50
|
+
* collaboration events through the generic parameter.
|
|
44
51
|
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
52
|
+
* A subclass must call `super(dependencies, config)` and then set up its own
|
|
53
|
+
* MobX observables.
|
|
47
54
|
*
|
|
48
55
|
* Generic over `TCollaboration` — an app-defined event map for real-time
|
|
49
56
|
* collaboration events (cursors, selections, presence beyond the core set).
|
|
@@ -61,57 +68,6 @@ export { ModelScope };
|
|
|
61
68
|
* }
|
|
62
69
|
* }
|
|
63
70
|
*/
|
|
64
|
-
/**
|
|
65
|
-
* Walk a schema and derive the three sync-plan arrays consumed by
|
|
66
|
-
* `BaseSyncedStore`'s constructor: version-vector keys, FK indexes to
|
|
67
|
-
* register on the pool, and the enrichment plan.
|
|
68
|
-
*
|
|
69
|
-
* Version vector keys are derived from each model's `typename` (lowercased
|
|
70
|
-
* to match the server's event-type convention — `'Task'` → `'task'`,
|
|
71
|
-
* `'SlideLayer'` → `'slidelayer'`). A fallback to the schema key applies
|
|
72
|
-
* when `typename` is unset, though `defineSchema()` now always resolves
|
|
73
|
-
* it during assembly so the fallback is defensive-only.
|
|
74
|
-
*
|
|
75
|
-
* FK indexes and enrichment entries are pulled from each `belongsTo`
|
|
76
|
-
* relation where `options.index` / `options.enrich` is set. Relations
|
|
77
|
-
* without those options are skipped — this is an opt-in mechanism so
|
|
78
|
-
* adding a `belongsTo` never silently changes delta or lookup semantics.
|
|
79
|
-
*
|
|
80
|
-
* Pure function: takes a Schema, returns three arrays. No side effects,
|
|
81
|
-
* no class state. Called once at construction time from `BaseSyncedStore`.
|
|
82
|
-
*/
|
|
83
|
-
export function deriveSyncPlanFromSchema(schema) {
|
|
84
|
-
const versionVectorKeys = [];
|
|
85
|
-
const enrichmentPlan = [];
|
|
86
|
-
const foreignKeyIndexes = [];
|
|
87
|
-
for (const [modelName, def] of Object.entries(schema.models)) {
|
|
88
|
-
const typename = def.typename ?? modelName;
|
|
89
|
-
versionVectorKeys.push(typename.toLowerCase());
|
|
90
|
-
for (const [relationKey, rel] of Object.entries(def.relations)) {
|
|
91
|
-
if (rel.type === 'belongsTo') {
|
|
92
|
-
if (rel.options?.index) {
|
|
93
|
-
foreignKeyIndexes.push({ modelName: typename, fieldName: rel.foreignKey });
|
|
94
|
-
}
|
|
95
|
-
if (rel.options?.enrich) {
|
|
96
|
-
enrichmentPlan.push({
|
|
97
|
-
modelName: typename,
|
|
98
|
-
foreignKey: rel.foreignKey,
|
|
99
|
-
relationKey,
|
|
100
|
-
});
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
else if (rel.type === 'hasMany' || rel.type === 'hasOne') {
|
|
104
|
-
// hasMany/hasOne: the FK lives on the TARGET model, not the current model.
|
|
105
|
-
// Register the FK index on the target so getByForeignKey works.
|
|
106
|
-
// Target typename is resolved at registration time from the schema.
|
|
107
|
-
const targetDef = schema.models[rel.target];
|
|
108
|
-
const targetTypename = targetDef?.typename ?? rel.target;
|
|
109
|
-
foreignKeyIndexes.push({ modelName: targetTypename, fieldName: rel.foreignKey });
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
return { versionVectorKeys, enrichmentPlan, foreignKeyIndexes };
|
|
114
|
-
}
|
|
115
71
|
export class BaseSyncedStore {
|
|
116
72
|
// ── Observable sync status for UI ──
|
|
117
73
|
syncStatus = {
|
|
@@ -162,23 +118,23 @@ export class BaseSyncedStore {
|
|
|
162
118
|
// ── Area-of-interest (dynamic read subscription) ─────────────────
|
|
163
119
|
//
|
|
164
120
|
// `enterScope`/`leaveScope` move the connection's read interest as the
|
|
165
|
-
// user navigates (open
|
|
121
|
+
// user navigates (open or close a deck, sheet, or doc); `pinScope`/`unpinScope`
|
|
166
122
|
// express prominence (an active claim keeps a group subscribed). All four
|
|
167
|
-
// resolve the scope to sync-group strings through the
|
|
123
|
+
// resolve the scope to sync-group strings through the same resolver the
|
|
168
124
|
// claim path uses (`resolveParticipantSyncGroups`), so read interest and
|
|
169
|
-
// write claims always agree on the string for a given entity.
|
|
170
|
-
// before the socket exists
|
|
171
|
-
//
|
|
125
|
+
// write claims always agree on the string for a given entity. They are
|
|
126
|
+
// no-ops before the socket exists, and they never reject when the transport
|
|
127
|
+
// is offline (see {@link SubscriptionManager.reconcile}).
|
|
172
128
|
scopeToGroups(scope) {
|
|
173
129
|
return resolveParticipantSyncGroups(scope, this.schema);
|
|
174
130
|
}
|
|
175
131
|
/**
|
|
176
|
-
* Bring a scope into view
|
|
177
|
-
* `{ hydrate: true }`,
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
* and the live
|
|
132
|
+
* Bring a scope into view and subscribe to its sync groups. With
|
|
133
|
+
* `{ hydrate: true }`, also backfill the groups' current state into the pool
|
|
134
|
+
* once the subscription is active. The order matters: subscribing first
|
|
135
|
+
* guarantees no live delta is missed in the gap before the snapshot lands.
|
|
136
|
+
* Hydration is best-effort — a failed backfill never rejects `enterScope`,
|
|
137
|
+
* and the live delta stream keeps flowing regardless.
|
|
182
138
|
*/
|
|
183
139
|
enterScope(scope, opts) {
|
|
184
140
|
const mgr = this.areaOfInterest;
|
|
@@ -191,11 +147,11 @@ export class BaseSyncedStore {
|
|
|
191
147
|
return subscribed.then(() => this.hydrateGroups(groups));
|
|
192
148
|
}
|
|
193
149
|
/**
|
|
194
|
-
* Backfill the current state of `syncGroups` into the pool
|
|
195
|
-
* snapshot fetch
|
|
196
|
-
* (skips groups already hydrated) and single-flight (concurrent
|
|
197
|
-
* same group share one fetch).
|
|
198
|
-
*
|
|
150
|
+
* Backfill the current state of `syncGroups` into the pool with a side-effect-free
|
|
151
|
+
* scoped snapshot fetch followed by the version-guarded scoped apply. The call
|
|
152
|
+
* is idempotent (it skips groups already hydrated) and single-flight (concurrent
|
|
153
|
+
* enters of the same group share one fetch). On error the groups are left
|
|
154
|
+
* unmarked, so a later re-enter retries.
|
|
199
155
|
*/
|
|
200
156
|
async hydrateGroups(syncGroups) {
|
|
201
157
|
const need = syncGroups.filter((g) => !this.hydratedGroups.has(g) && !this.hydratingGroups.has(g));
|
|
@@ -254,29 +210,21 @@ export class BaseSyncedStore {
|
|
|
254
210
|
// ── Internal helpers ──
|
|
255
211
|
queryProcessor;
|
|
256
212
|
/**
|
|
257
|
-
* Runtime behavior flags only — the
|
|
258
|
-
* (`
|
|
259
|
-
*
|
|
260
|
-
*
|
|
261
|
-
* They don't need to persist on `this.config`.
|
|
213
|
+
* Runtime behavior flags only — the schema/config arrays
|
|
214
|
+
* (`enrichmentPlan`, `foreignKeyIndexes`) are consumed at construction
|
|
215
|
+
* time and stored on the instance as `enrichmentPlan` and
|
|
216
|
+
* pool-registered indexes. They don't need to persist on `this.config`.
|
|
262
217
|
*/
|
|
263
218
|
config;
|
|
264
219
|
disposers = [];
|
|
265
220
|
initialized = false;
|
|
266
221
|
dataReady = false;
|
|
267
222
|
// ── User context ──
|
|
268
|
-
//
|
|
269
|
-
//
|
|
270
|
-
//
|
|
271
|
-
// tokens; the SDK should eventually expose this as an opaque
|
|
272
|
-
// `principal` blob so consumers with different identity models
|
|
273
|
-
// aren't forced into user/org. See the architectural note in the
|
|
274
|
-
// README — "currentUserId" is a domain concept, not an SDK
|
|
275
|
-
// primitive, and the host (apps/web/SyncEngineProvider) is the
|
|
276
|
-
// right place to surface it.
|
|
223
|
+
// The identity the consumer supplied to `initialize()`: user id,
|
|
224
|
+
// organization id, and optional team ids. Reads are scoped to this
|
|
225
|
+
// identity, and the sync-group subscription is derived from it.
|
|
277
226
|
userContext = null;
|
|
278
227
|
// ── Smart sync ──
|
|
279
|
-
versionVector;
|
|
280
228
|
/**
|
|
281
229
|
* Declarative enrichment plan: "for model X, when a delta arrives,
|
|
282
230
|
* read data[foreignKey] and attach the matching parent from the pool
|
|
@@ -316,21 +264,19 @@ export class BaseSyncedStore {
|
|
|
316
264
|
this._syncServerUrl = dependencies.url;
|
|
317
265
|
// Set this store as the global Model store
|
|
318
266
|
Model.setStore(this);
|
|
319
|
-
// ── Schema-derived sync plan
|
|
267
|
+
// ── Schema-derived sync plan ───────────────────────────────────────
|
|
320
268
|
//
|
|
321
|
-
// When a schema is provided, derive
|
|
322
|
-
//
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
// without needing a full schema.generated.ts.
|
|
269
|
+
// When a schema is provided, derive foreign-key indexes and the
|
|
270
|
+
// enrichment plan from the declarative annotations on its `belongsTo`
|
|
271
|
+
// relations. Explicit config fields layer on top, so a subclass can
|
|
272
|
+
// pass hardcoded arrays without supplying a full schema.
|
|
326
273
|
//
|
|
327
|
-
// Order matters: schema-derived
|
|
328
|
-
//
|
|
329
|
-
// explicit config entries are
|
|
330
|
-
// accidentally shadowed by schema derivation.
|
|
274
|
+
// Order matters: schema-derived entries are registered first and
|
|
275
|
+
// config entries second, so that when a caller supplies both, the
|
|
276
|
+
// explicit config entries win and are never shadowed by derivation.
|
|
331
277
|
const derived = dependencies.schema
|
|
332
278
|
? deriveSyncPlanFromSchema(dependencies.schema)
|
|
333
|
-
: {
|
|
279
|
+
: { enrichmentPlan: [], foreignKeyIndexes: [] };
|
|
334
280
|
const mergedForeignKeyIndexes = [
|
|
335
281
|
...derived.foreignKeyIndexes,
|
|
336
282
|
...(config.foreignKeyIndexes ?? []),
|
|
@@ -338,9 +284,8 @@ export class BaseSyncedStore {
|
|
|
338
284
|
for (const { modelName, fieldName } of mergedForeignKeyIndexes) {
|
|
339
285
|
this.objectPool.registerForeignKey(modelName, fieldName);
|
|
340
286
|
}
|
|
341
|
-
//
|
|
342
|
-
//
|
|
343
|
-
// Kept for backwards compat; subclasses migrate to config at leisure.
|
|
287
|
+
// Override hook — called after schema-driven registration so a subclass
|
|
288
|
+
// can add more foreign keys on top of the declarative set.
|
|
344
289
|
this.registerForeignKeys();
|
|
345
290
|
this.enrichmentPlan = [
|
|
346
291
|
...derived.enrichmentPlan,
|
|
@@ -361,14 +306,6 @@ export class BaseSyncedStore {
|
|
|
361
306
|
batchingDelay: 100,
|
|
362
307
|
maxBatchSize: 50,
|
|
363
308
|
};
|
|
364
|
-
// Version vector: union of schema-derived keys + explicit config keys,
|
|
365
|
-
// each seeded to 0. Empty when neither source supplies keys (unchanged
|
|
366
|
-
// behavior from pre-Phase-2 defaults).
|
|
367
|
-
const mergedVvKeys = [
|
|
368
|
-
...derived.versionVectorKeys,
|
|
369
|
-
...(config.versionVectorKeys ?? []),
|
|
370
|
-
];
|
|
371
|
-
this.versionVector = Object.fromEntries(mergedVvKeys.map((k) => [k, 0]));
|
|
372
309
|
// Create internal helpers
|
|
373
310
|
this.queryProcessor = new QueryProcessor({
|
|
374
311
|
enableCache: this.config.enableCache,
|
|
@@ -380,15 +317,13 @@ export class BaseSyncedStore {
|
|
|
380
317
|
this.queryProcessor.invalidateCache(`.*${name}.*`);
|
|
381
318
|
}
|
|
382
319
|
});
|
|
383
|
-
// Make sync
|
|
320
|
+
// Make the sync-status fields observable so consumer code can do
|
|
384
321
|
// reaction(() => store.isReady, ...)
|
|
385
322
|
// observer(() => store.isOffline)
|
|
386
323
|
// and actually receive notifications. Without these annotations,
|
|
387
|
-
// `syncStatus`
|
|
388
|
-
// getters (isReady, isSyncing, isOffline,
|
|
389
|
-
// signals — a
|
|
390
|
-
// (one stuck forever on the loading skeleton because `reaction`
|
|
391
|
-
// to `store.isReady` never fired). Explicit > accidental.
|
|
324
|
+
// `syncStatus` and `dataReady` are plain properties, and the derived
|
|
325
|
+
// getters (isReady, isSyncing, isOffline, and the rest) never emit
|
|
326
|
+
// change signals — so a `reaction` on `store.isReady` would never fire.
|
|
392
327
|
makeObservable(this, {
|
|
393
328
|
syncStatus: observable,
|
|
394
329
|
dataReady: observable,
|
|
@@ -402,17 +337,17 @@ export class BaseSyncedStore {
|
|
|
402
337
|
}
|
|
403
338
|
// ── Protected extension points ────────────────────────────────────────────
|
|
404
339
|
/**
|
|
405
|
-
* Register foreign
|
|
340
|
+
* Register foreign-key indexes for constant-time lookups.
|
|
406
341
|
*
|
|
407
|
-
*
|
|
408
|
-
*
|
|
409
|
-
*
|
|
410
|
-
*
|
|
411
|
-
*
|
|
342
|
+
* This is an override hook. The preferred way to declare a foreign-key
|
|
343
|
+
* index is `config.foreignKeyIndexes` at construction time, or marking the
|
|
344
|
+
* `belongsTo` relation with `{ index: true }` in the schema. The hook fires
|
|
345
|
+
* after the schema-derived and config registrations, so a subclass can
|
|
346
|
+
* layer additional indexes on top.
|
|
412
347
|
*/
|
|
413
348
|
registerForeignKeys() { }
|
|
414
349
|
/**
|
|
415
|
-
* Enrich delta data with related models from the
|
|
350
|
+
* Enrich delta data with related models from the InstanceCache.
|
|
416
351
|
*
|
|
417
352
|
* Base implementation walks `this.enrichmentPlan` — entries populated
|
|
418
353
|
* from the schema's `{ enrich: true }` relations and from
|
|
@@ -465,23 +400,21 @@ export class BaseSyncedStore {
|
|
|
465
400
|
*/
|
|
466
401
|
connectionManager = null;
|
|
467
402
|
/**
|
|
468
|
-
*
|
|
469
|
-
*
|
|
470
|
-
*
|
|
471
|
-
*
|
|
472
|
-
*
|
|
473
|
-
*
|
|
474
|
-
*
|
|
475
|
-
* `apiKey` deployment whose credential source refreshes out-of-band).
|
|
403
|
+
* Access-credential re-mint + proactive pre-roll — extracted to
|
|
404
|
+
* sync/credentialLifecycle.ts. Owns the refresher hook, the single-flight
|
|
405
|
+
* guard, and the browser-only refresh timer / wake listener; talks back
|
|
406
|
+
* through three lazily-resolved callbacks (the ConnectionManager doesn't
|
|
407
|
+
* exist until `setupWebSocketSync`). The `setCredentialRefresher` /
|
|
408
|
+
* `performCredentialRefresh` / `startCredentialLifecycle` methods below
|
|
409
|
+
* are thin delegates so the store's public surface is unchanged.
|
|
476
410
|
*/
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
credentialLifecycleTeardown = null;
|
|
411
|
+
credentialLifecycle = new CredentialLifecycle({
|
|
412
|
+
setAuthToken: (token) => { this.auth?.setAuthToken(token); },
|
|
413
|
+
nudgeReconnect: () => { this.nudgeReconnect(); },
|
|
414
|
+
reportSessionExpired: () => {
|
|
415
|
+
this.connectionManager?.send({ type: 'BOOTSTRAP_FAILED_SESSION' });
|
|
416
|
+
},
|
|
417
|
+
});
|
|
485
418
|
/**
|
|
486
419
|
* Listeners registered via `subscribeSessionError()`. Fired when the
|
|
487
420
|
* WebSocket closes with a session-invalid code (1008/4001/4003) or a
|
|
@@ -525,7 +458,7 @@ export class BaseSyncedStore {
|
|
|
525
458
|
}
|
|
526
459
|
/**
|
|
527
460
|
* Observe the LOCAL mutation stream for undo recording (see
|
|
528
|
-
* {@link import('./
|
|
461
|
+
* {@link import('./core/storeContract.js').LocalMutation}). Taps the
|
|
529
462
|
* TransactionQueue's `transaction:created` event — fired once per local
|
|
530
463
|
* create/update/delete/archive with `previousData` already captured.
|
|
531
464
|
* Remote/collaborator deltas apply via `applyDeltaBatchToPool` and never
|
|
@@ -538,7 +471,7 @@ export class BaseSyncedStore {
|
|
|
538
471
|
// the queue's emitter does) — so undo recorded nothing. See
|
|
539
472
|
// `SyncClient.onLocalTransaction` for the full rationale.
|
|
540
473
|
return this.syncClient.onLocalTransaction((tx) => {
|
|
541
|
-
if (!tx
|
|
474
|
+
if (!tx?.type || !tx.modelName || !tx.modelId)
|
|
542
475
|
return;
|
|
543
476
|
handler({
|
|
544
477
|
type: tx.type,
|
|
@@ -695,75 +628,30 @@ export class BaseSyncedStore {
|
|
|
695
628
|
/**
|
|
696
629
|
* Register the access-credential re-mint hook. Called by the React provider
|
|
697
630
|
* with a thunk that mints a fresh `ek_`/`rk_` (typically its `getToken`).
|
|
698
|
-
* See {@link
|
|
631
|
+
* See {@link CredentialLifecycle.setRefresher}.
|
|
699
632
|
*/
|
|
700
633
|
setCredentialRefresher(refresher) {
|
|
701
|
-
this.
|
|
634
|
+
this.credentialLifecycle.setRefresher(refresher);
|
|
702
635
|
}
|
|
703
636
|
/**
|
|
704
637
|
* Re-mint the short-lived access credential and push it into the credential
|
|
705
638
|
* source, reporting a tri-state outcome the {@link ConnectionManager} maps to
|
|
706
|
-
* its FSM.
|
|
707
|
-
*
|
|
708
|
-
* return/throw):
|
|
709
|
-
* - token string → `'refreshed'` (fresh key in place; re-probe & reconnect)
|
|
710
|
-
* - `null` → `'session_error'` (login itself is gone → terminal, sign out)
|
|
711
|
-
* - throw → `'network_error'` (couldn't reach the mint endpoint → transient)
|
|
712
|
-
*
|
|
713
|
-
* SINGLE-FLIGHT: concurrent callers (a wake nudge, an in-flight request, the
|
|
714
|
-
* probe) share one in-flight promise so we never double-mint — the canonical
|
|
715
|
-
* fix for the "every 401 mints a token → thrash → spurious logout" anti-pattern.
|
|
716
|
-
*
|
|
717
|
-
* No refresher wired ⇒ `'refreshed'` (a no-op re-probe): a static-`apiKey`
|
|
718
|
-
* deployment has no session to re-mint from; its credential source refreshes
|
|
719
|
-
* out-of-band, so we just re-probe with whatever it currently holds.
|
|
639
|
+
* its FSM. Single-flight; no refresher wired ⇒ `'refreshed'` (a no-op
|
|
640
|
+
* re-probe). Full contract on {@link CredentialLifecycle.refresh}.
|
|
720
641
|
*/
|
|
721
642
|
async performCredentialRefresh() {
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
}
|
|
735
|
-
this.auth?.setAuthToken(token);
|
|
736
|
-
return 'refreshed';
|
|
737
|
-
}
|
|
738
|
-
catch (error) {
|
|
739
|
-
// A throw = transient (offline / mint endpoint unreachable / 5xx). The
|
|
740
|
-
// login may be perfectly valid; never sign out for this — back off and
|
|
741
|
-
// retry. Mirrors the `getToken` throw-vs-null contract end-to-end.
|
|
742
|
-
const message = error?.message ?? String(error);
|
|
743
|
-
// A relative-URL resolver invoked server-side (Node fetch has no origin
|
|
744
|
-
// to resolve against) emits the opaque "Failed to parse URL" / "Only
|
|
745
|
-
// absolute URLs are supported". Translate it into something actionable
|
|
746
|
-
// instead of a mystery transient blip — the proactive refresh is now
|
|
747
|
-
// browser-only, so hitting this means the resolver fired from SSR/RSC or
|
|
748
|
-
// a server route.
|
|
749
|
-
if (typeof window === 'undefined' && /parse URL|absolute URLs?/i.test(message)) {
|
|
750
|
-
getContext().logger.warn('credential resolver ran on the server with a relative URL — Node fetch needs an absolute URL. ' +
|
|
751
|
-
'Refresh the Ablo client in the browser, or build an absolute URL server-side ' +
|
|
752
|
-
"(e.g. new URL('/api/ablo-session', process.env.NEXT_PUBLIC_APP_URL)).", { error: message });
|
|
753
|
-
}
|
|
754
|
-
else {
|
|
755
|
-
getContext().logger.debug('access-credential re-mint failed (transient)', { error: message });
|
|
756
|
-
}
|
|
757
|
-
return 'network_error';
|
|
758
|
-
}
|
|
759
|
-
})();
|
|
760
|
-
this.inFlightCredentialRefresh = run;
|
|
761
|
-
try {
|
|
762
|
-
return await run;
|
|
763
|
-
}
|
|
764
|
-
finally {
|
|
765
|
-
this.inFlightCredentialRefresh = null;
|
|
766
|
-
}
|
|
643
|
+
return this.credentialLifecycle.refresh();
|
|
644
|
+
}
|
|
645
|
+
/**
|
|
646
|
+
* The authentication-recovery path for HTTP transports, such as the lazy
|
|
647
|
+
* query lane. It runs a single-flight credential re-mint driven by the
|
|
648
|
+
* rejection's recovery class, routing outcomes through the same state
|
|
649
|
+
* machine the WebSocket probe uses. `'retry'` means a fresh credential is
|
|
650
|
+
* now in the credential source and the request should be replayed once.
|
|
651
|
+
* Full contract on {@link CredentialLifecycle.recoverFromAuthRejection}.
|
|
652
|
+
*/
|
|
653
|
+
async recoverFromAuthRejection(recovery) {
|
|
654
|
+
return this.credentialLifecycle.recoverFromAuthRejection(recovery);
|
|
767
655
|
}
|
|
768
656
|
/**
|
|
769
657
|
* Nudge the connection FSM to re-probe with the current credential. Idempotent
|
|
@@ -776,227 +664,71 @@ export class BaseSyncedStore {
|
|
|
776
664
|
this.connectionManager?.send({ type: 'CREDENTIAL_REFRESHED' });
|
|
777
665
|
}
|
|
778
666
|
/**
|
|
779
|
-
* Install the access-credential lifecycle
|
|
780
|
-
*
|
|
781
|
-
*
|
|
782
|
-
*
|
|
783
|
-
*
|
|
784
|
-
* timer inside the TTL, plus re-mint on OS wake. The ENTIRE proactive
|
|
785
|
-
* block is browser-gated (`typeof window`): server/SSR has no socket to
|
|
786
|
-
* keep warm and the resolver is browser-oriented, so arming it in Node
|
|
787
|
-
* would fire a relative-URL fetch and throw. (Agents pass a static
|
|
788
|
-
* `apiKey` with no resolver, so this method is never called for them.)
|
|
789
|
-
*
|
|
790
|
-
* Config-driven and invisible, like Supabase's `autoRefreshToken` — consumers
|
|
791
|
-
* never call a refresh method. Idempotent (a second call replaces the first);
|
|
792
|
-
* torn down on {@link disconnect}.
|
|
667
|
+
* Install the client-owned access-credential lifecycle: register `getToken`
|
|
668
|
+
* as the reactive re-mint hook and arm the browser-only proactive refresh
|
|
669
|
+
* (a refresh timer plus an OS-wake re-mint). Idempotent — a second call
|
|
670
|
+
* replaces the first — and torn down on {@link disconnect}. Full rationale
|
|
671
|
+
* on {@link CredentialLifecycle.start}.
|
|
793
672
|
*/
|
|
794
|
-
startCredentialLifecycle(getToken) {
|
|
795
|
-
this.
|
|
796
|
-
this.setCredentialRefresher(getToken);
|
|
797
|
-
// Re-mint through the SAME single-flight path the FSM's reactive probe uses
|
|
798
|
-
// (`performCredentialRefresh`) rather than calling `getToken()` directly. Two
|
|
799
|
-
// wins over the old direct call:
|
|
800
|
-
// - SINGLE-FLIGHT: a wake nudge, an in-flight probe, and this proactive
|
|
801
|
-
// roll share one in-flight promise — no double-mint thrash.
|
|
802
|
-
// - The tri-state is HONOURED. The old code did `if (token) {…}` and
|
|
803
|
-
// dropped a `null` on the floor — a zombie session that re-minted on
|
|
804
|
-
// every tab focus and logged "signing out" forever without ever signing
|
|
805
|
-
// out. `session_error` now drives the FSM to actually expire.
|
|
806
|
-
const refresh = async () => {
|
|
807
|
-
const outcome = await this.performCredentialRefresh();
|
|
808
|
-
if (outcome === 'refreshed') {
|
|
809
|
-
// Fresh key already pushed into the credential source by
|
|
810
|
-
// `performCredentialRefresh`; nudge a parked connection to re-probe.
|
|
811
|
-
this.nudgeReconnect();
|
|
812
|
-
}
|
|
813
|
-
else if (outcome === 'session_error') {
|
|
814
|
-
// The long-lived login is gone (mint answered 401/403). Surface it —
|
|
815
|
-
// the proactive path's job is to report this, not hide it. A no-op in
|
|
816
|
-
// FSM states that don't accept the event (the probe converges on
|
|
817
|
-
// sign-out there anyway); `session_expired`'s onEnter owns the log.
|
|
818
|
-
this.connectionManager?.send({ type: 'BOOTSTRAP_FAILED_SESSION' });
|
|
819
|
-
}
|
|
820
|
-
// 'network_error' → transient (offline / mint hiccup); the next timer tick
|
|
821
|
-
// or the FSM's own probe retries. Never sign out for it.
|
|
822
|
-
};
|
|
823
|
-
const teardowns = [];
|
|
824
|
-
// The ENTIRE proactive pre-roll is BROWSER-ONLY. On the server (Next.js
|
|
825
|
-
// SSR/RSC evaluating the `providers` module) there is no live socket to keep
|
|
826
|
-
// warm AND the scaffolded credential resolver is browser-oriented (a
|
|
827
|
-
// relative-URL `fetch('/api/ablo-session')`). Arming the timer server-side
|
|
828
|
-
// fires that resolver in Node, where fetch has no origin to resolve a
|
|
829
|
-
// relative URL against → "Failed to parse URL" on every tick. Browser-only
|
|
830
|
-
// refresh is the unanimous vendor model (Supabase `autoRefreshToken: isBrowser()`,
|
|
831
|
-
// Clerk/Ably/Stripe refresh client-side). The reactive re-mint hook
|
|
832
|
-
// (`setCredentialRefresher` above) stays UNCONDITIONAL: it only fires on a
|
|
833
|
-
// real connection probe, which can't happen during a bare SSR module eval.
|
|
834
|
-
if (typeof window !== 'undefined') {
|
|
835
|
-
// Comfortably inside the 15m `ek_` TTL; a missed (background-throttled)
|
|
836
|
-
// tick is recovered by the next, or by the reactive probe. The timer is
|
|
837
|
-
// the sole proactive PRE-ROLL — it keeps the key warm ahead of expiry even
|
|
838
|
-
// while the socket sits healthy-`connected` (a state the FSM never probes).
|
|
839
|
-
const REFRESH_INTERVAL_MS = 10 * 60 * 1000;
|
|
840
|
-
const timer = setInterval(() => void refresh(), REFRESH_INTERVAL_MS);
|
|
841
|
-
teardowns.push(() => clearInterval(timer));
|
|
842
|
-
// OS-wake (desktop only): the Electron shell bridges `powerMonitor`
|
|
843
|
-
// 'resume' to this DOM event. This is the ONE event-trigger the lifecycle
|
|
844
|
-
// still owns, because `visibilitychange` does NOT fire on wake-from-sleep
|
|
845
|
-
// and — unlike `online`/`visibilitychange` — the ConnectionManager's own
|
|
846
|
-
// browser listeners (`setupBrowserListeners`) don't cover wake.
|
|
847
|
-
//
|
|
848
|
-
// The `online` and `visibilitychange` listeners that used to live here
|
|
849
|
-
// were REMOVED: the FSM already re-probes on NETWORK_ONLINE / TAB_VISIBLE
|
|
850
|
-
// through this exact credential path, so registering them here too only
|
|
851
|
-
// fired a second, null-swallowing mint per focus — the "session-key
|
|
852
|
-
// POSTed on every tab focus" spam in the console.
|
|
853
|
-
const onWake = () => void refresh();
|
|
854
|
-
window.addEventListener('ablo:wake', onWake);
|
|
855
|
-
teardowns.push(() => window.removeEventListener('ablo:wake', onWake));
|
|
856
|
-
}
|
|
857
|
-
this.credentialLifecycleTeardown = () => {
|
|
858
|
-
for (const t of teardowns)
|
|
859
|
-
t();
|
|
860
|
-
};
|
|
673
|
+
startCredentialLifecycle(getToken, opts) {
|
|
674
|
+
this.credentialLifecycle.start(getToken, opts);
|
|
861
675
|
}
|
|
862
676
|
/** Tear down the proactive credential lifecycle (idempotent). */
|
|
863
677
|
stopCredentialLifecycle() {
|
|
864
|
-
this.
|
|
865
|
-
|
|
678
|
+
this.credentialLifecycle.stop();
|
|
679
|
+
}
|
|
680
|
+
// ── Sync group management ────────────────────────────────────────────────
|
|
681
|
+
//
|
|
682
|
+
// The implementation lives in the sync/groupChange module. The methods
|
|
683
|
+
// below are thin protected delegates that keep their signatures, so
|
|
684
|
+
// subclass override points still work; the module routes cross-handler
|
|
685
|
+
// calls back through `groupChangeContext()` to preserve dynamic dispatch.
|
|
686
|
+
/** Narrow context the group-change leaf talks back through. */
|
|
687
|
+
groupChangeContext() {
|
|
688
|
+
return {
|
|
689
|
+
database: this.database,
|
|
690
|
+
objectPool: this.objectPool,
|
|
691
|
+
getSubscribedSyncGroups: () => this.syncWebSocket?.getSyncGroups() ?? [],
|
|
692
|
+
getCurrentSyncGroups: () => this.userContext ? this.resolveSyncGroups(this.userContext) : null,
|
|
693
|
+
getBootstrapMode: () => this.userContext?.bootstrapMode,
|
|
694
|
+
disconnectWebSocket: () => { this.syncWebSocket?.disconnect(); },
|
|
695
|
+
emitConnectionEvent: (event) => { this.onConnectionEvent?.(event); },
|
|
696
|
+
handleGroupAdded: (payload, syncId) => this.handleGroupAdded(payload, syncId),
|
|
697
|
+
computeUpdatedSyncGroups: (payload) => this.computeUpdatedSyncGroups(payload),
|
|
698
|
+
forceFullRebootstrap: () => { this.forceFullRebootstrap(); },
|
|
699
|
+
};
|
|
866
700
|
}
|
|
867
|
-
// ── Sync Group Management ────────────────────────────────────────────────
|
|
868
701
|
/**
|
|
869
|
-
* Handle an actionType 'G' delta
|
|
870
|
-
*
|
|
871
|
-
*
|
|
872
|
-
* shape:
|
|
873
|
-
*
|
|
874
|
-
* Incremental (EmitGroupAdded): { group, userId }
|
|
875
|
-
* - The recipient was added to a single sync group.
|
|
876
|
-
* - Subsequent 'C' (Covering) deltas deliver each newly-visible entity.
|
|
877
|
-
* - No re-bootstrap — entities arrive via the normal insert path.
|
|
878
|
-
*
|
|
879
|
-
* Legacy (EmitGroupChange): { addedGroups, removedGroups }
|
|
880
|
-
* - Single delta carrying the full group membership diff.
|
|
881
|
-
* - Forces a full re-bootstrap (disconnect + reconnect + fetch all).
|
|
882
|
-
* - Deprecated on the server; kept here for wire-level backward compat.
|
|
702
|
+
* Handle an actionType 'G' delta — incremental `{ group, userId }` or
|
|
703
|
+
* legacy `{ addedGroups, removedGroups }` payloads. Full pathway doc on
|
|
704
|
+
* {@link groupChange.handleSyncGroupChange}.
|
|
883
705
|
*/
|
|
884
706
|
async handleSyncGroupChange(delta) {
|
|
885
|
-
|
|
886
|
-
const rawObj = (raw ?? {});
|
|
887
|
-
// Detect incremental payload shape: { group, userId }
|
|
888
|
-
if (typeof rawObj.group === 'string' && typeof rawObj.userId === 'string') {
|
|
889
|
-
const incremental = {
|
|
890
|
-
group: rawObj.group,
|
|
891
|
-
userId: rawObj.userId,
|
|
892
|
-
};
|
|
893
|
-
await this.handleGroupAdded(incremental, delta.id);
|
|
894
|
-
return;
|
|
895
|
-
}
|
|
896
|
-
// Legacy payload: { addedGroups, removedGroups }
|
|
897
|
-
const payload = {
|
|
898
|
-
removedGroups: rawObj.removedGroups ?? [],
|
|
899
|
-
addedGroups: rawObj.addedGroups ?? [],
|
|
900
|
-
};
|
|
901
|
-
getContext().logger.info('[BaseSyncedStore] Sync group change received (legacy)', {
|
|
902
|
-
removedGroups: payload.removedGroups,
|
|
903
|
-
addedGroups: payload.addedGroups,
|
|
904
|
-
syncId: delta.id,
|
|
905
|
-
});
|
|
906
|
-
// SECURITY: If groups were removed, clear cached data immediately.
|
|
907
|
-
// This prevents revoked data from persisting if the device goes offline
|
|
908
|
-
// before the full re-bootstrap completes.
|
|
909
|
-
if (payload.removedGroups.length > 0) {
|
|
910
|
-
await this.database.clear();
|
|
911
|
-
this.objectPool.clear();
|
|
912
|
-
getContext().logger.info('[BaseSyncedStore] Cleared cached data due to revoked sync groups', {
|
|
913
|
-
removedGroups: payload.removedGroups,
|
|
914
|
-
});
|
|
915
|
-
}
|
|
916
|
-
const updatedGroups = this.computeUpdatedSyncGroups(payload);
|
|
917
|
-
await this.database.updateWorkspaceMetadata({ subscribedSyncGroups: updatedGroups });
|
|
918
|
-
this.forceFullRebootstrap();
|
|
707
|
+
return groupChange.handleSyncGroupChange(this.groupChangeContext(), delta);
|
|
919
708
|
}
|
|
920
709
|
/**
|
|
921
|
-
* Handle an incremental GroupAdded delta
|
|
922
|
-
*
|
|
923
|
-
* Adds the new group to the subscription metadata without triggering a
|
|
924
|
-
* re-bootstrap. The server will follow up with 'C' (Covering) deltas for
|
|
925
|
-
* each newly-visible entity, which flow through the normal insert path.
|
|
710
|
+
* Handle an incremental GroupAdded delta — metadata only, no re-bootstrap
|
|
711
|
+
* (covering deltas bring the entities). See {@link groupChange.handleGroupAdded}.
|
|
926
712
|
*/
|
|
927
713
|
async handleGroupAdded(payload, syncId) {
|
|
928
|
-
|
|
929
|
-
group: payload.group,
|
|
930
|
-
syncId,
|
|
931
|
-
});
|
|
932
|
-
const current = new Set(this.syncWebSocket?.getSyncGroups() ?? []);
|
|
933
|
-
current.add(payload.group);
|
|
934
|
-
await this.database.updateWorkspaceMetadata({ subscribedSyncGroups: Array.from(current) });
|
|
935
|
-
// Note: no forceFullRebootstrap() — covering deltas will bring the entities.
|
|
714
|
+
return groupChange.handleGroupAdded(this.groupChangeContext(), payload, syncId);
|
|
936
715
|
}
|
|
937
716
|
/**
|
|
938
|
-
* Handle an actionType 'S' (GroupRemoved) delta
|
|
939
|
-
*
|
|
940
|
-
*
|
|
941
|
-
* the client does not track per-entity group membership, we can't
|
|
942
|
-
* selectively purge entities belonging to that group. The safe fallback
|
|
943
|
-
* is the legacy behavior: clear local state and force a re-bootstrap
|
|
944
|
-
* with the updated group list.
|
|
945
|
-
*
|
|
946
|
-
* Future optimization: track group membership in the ObjectPool so 'S'
|
|
947
|
-
* can do a targeted purge instead of a full re-bootstrap.
|
|
717
|
+
* Handle an actionType 'S' (GroupRemoved) delta: for safety, clear the
|
|
718
|
+
* revoked local state and trigger a full re-bootstrap. See
|
|
719
|
+
* {@link groupChange.handleGroupRemoved}.
|
|
948
720
|
*/
|
|
949
721
|
async handleGroupRemoved(delta) {
|
|
950
|
-
|
|
951
|
-
const rawObj = (raw ?? {});
|
|
952
|
-
const groupKey = typeof rawObj.group === 'string' ? rawObj.group : undefined;
|
|
953
|
-
if (!groupKey) {
|
|
954
|
-
getContext().logger.debug('[BaseSyncedStore] Group removed delta missing group key', {
|
|
955
|
-
syncId: delta.id,
|
|
956
|
-
});
|
|
957
|
-
return;
|
|
958
|
-
}
|
|
959
|
-
getContext().logger.info('[BaseSyncedStore] Group removed', {
|
|
960
|
-
group: groupKey,
|
|
961
|
-
syncId: delta.id,
|
|
962
|
-
});
|
|
963
|
-
// SECURITY: Clear cached data before re-bootstrap. This prevents
|
|
964
|
-
// revoked-group data from persisting if the device goes offline
|
|
965
|
-
// between receiving 'S' and completing the re-bootstrap.
|
|
966
|
-
await this.database.clear();
|
|
967
|
-
this.objectPool.clear();
|
|
968
|
-
// Update subscription metadata so the re-bootstrap fetches the
|
|
969
|
-
// correct set of groups.
|
|
970
|
-
const current = new Set(this.syncWebSocket?.getSyncGroups() ?? []);
|
|
971
|
-
current.delete(groupKey);
|
|
972
|
-
await this.database.updateWorkspaceMetadata({ subscribedSyncGroups: Array.from(current) });
|
|
973
|
-
this.forceFullRebootstrap();
|
|
722
|
+
return groupChange.handleGroupRemoved(this.groupChangeContext(), delta);
|
|
974
723
|
}
|
|
975
724
|
/** Compute new sync groups after applying additions and removals */
|
|
976
725
|
computeUpdatedSyncGroups(payload) {
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
current.add(g);
|
|
982
|
-
return Array.from(current);
|
|
983
|
-
}
|
|
984
|
-
/** Force a full re-bootstrap via connection lifecycle event.
|
|
985
|
-
*
|
|
986
|
-
* No-op for `bootstrapMode: 'none'` participants — they never pull
|
|
987
|
-
* baseline state, so a "force re-bootstrap" trigger (sync-group
|
|
988
|
-
* shrink, scope revocation) instead just flushes the local pool and
|
|
989
|
-
* relies on covering deltas to repopulate the data they actually
|
|
990
|
-
* subscribe to.
|
|
991
|
-
*/
|
|
726
|
+
return groupChange.computeUpdatedSyncGroups(this.groupChangeContext(), payload);
|
|
727
|
+
}
|
|
728
|
+
/** Force a full re-bootstrap via connection lifecycle event (no-op for
|
|
729
|
+
* `bootstrapMode: 'none'` participants — see {@link groupChange.forceFullRebootstrap}). */
|
|
992
730
|
forceFullRebootstrap() {
|
|
993
|
-
|
|
994
|
-
getContext().logger.info('[BaseSyncedStore] forceFullRebootstrap skipped (bootstrapMode=none)');
|
|
995
|
-
return;
|
|
996
|
-
}
|
|
997
|
-
this.database.markRequiresFullBootstrap();
|
|
998
|
-
this.syncWebSocket?.disconnect();
|
|
999
|
-
this.onConnectionEvent?.('WS_DISCONNECTED');
|
|
731
|
+
groupChange.forceFullRebootstrap(this.groupChangeContext());
|
|
1000
732
|
}
|
|
1001
733
|
/**
|
|
1002
734
|
* Single source of truth for the sync-group list this session is
|
|
@@ -1007,71 +739,36 @@ export class BaseSyncedStore {
|
|
|
1007
739
|
* check can never disagree.
|
|
1008
740
|
*/
|
|
1009
741
|
resolveSyncGroups(context) {
|
|
1010
|
-
|
|
1011
|
-
return context.syncGroups;
|
|
1012
|
-
}
|
|
1013
|
-
return [];
|
|
742
|
+
return groupChange.resolveSyncGroups(context);
|
|
1014
743
|
}
|
|
1015
744
|
/** Check if sync groups shrank since last session — force full bootstrap if so */
|
|
1016
745
|
async checkSyncGroupShrinkage() {
|
|
1017
|
-
|
|
1018
|
-
return;
|
|
1019
|
-
try {
|
|
1020
|
-
const metadata = await this.database.getWorkspaceMetadata();
|
|
1021
|
-
const stored = metadata?.subscribedSyncGroups ?? [];
|
|
1022
|
-
if (stored.length === 0)
|
|
1023
|
-
return;
|
|
1024
|
-
const currentGroups = new Set(this.resolveSyncGroups(this.userContext));
|
|
1025
|
-
const removedGroups = stored.filter((g) => !currentGroups.has(g));
|
|
1026
|
-
if (removedGroups.length > 0) {
|
|
1027
|
-
getContext().logger.info('[BaseSyncedStore] Sync groups shrank — forcing full bootstrap', {
|
|
1028
|
-
removedGroups,
|
|
1029
|
-
storedCount: stored.length,
|
|
1030
|
-
currentCount: currentGroups.size,
|
|
1031
|
-
});
|
|
1032
|
-
// SECURITY: Clear cached data before re-bootstrap to prevent
|
|
1033
|
-
// revoked-group data from persisting if device goes offline
|
|
1034
|
-
await this.database.clear();
|
|
1035
|
-
this.objectPool.clear();
|
|
1036
|
-
this.database.markRequiresFullBootstrap();
|
|
1037
|
-
}
|
|
1038
|
-
await this.database.updateWorkspaceMetadata({
|
|
1039
|
-
subscribedSyncGroups: Array.from(currentGroups),
|
|
1040
|
-
});
|
|
1041
|
-
}
|
|
1042
|
-
catch (error) {
|
|
1043
|
-
getContext().logger.debug('[BaseSyncedStore] Failed to check sync group shrinkage', {
|
|
1044
|
-
error: error instanceof Error ? error.message : String(error),
|
|
1045
|
-
});
|
|
1046
|
-
}
|
|
746
|
+
return groupChange.checkSyncGroupShrinkage(this.groupChangeContext());
|
|
1047
747
|
}
|
|
1048
|
-
|
|
1049
|
-
|
|
748
|
+
// ── Bootstrap apply ──────────────────────────────────────────────────────
|
|
749
|
+
//
|
|
750
|
+
// The implementation lives in the sync/bootstrapApply module. The protected
|
|
751
|
+
// delegates below keep their signatures and subclass overridability; the
|
|
752
|
+
// module talks back through `poolContext()`, with enrichment pre-bound to
|
|
753
|
+
// `this.enrichRelations` so that override point still applies.
|
|
754
|
+
/** Narrow context the bootstrap-apply leaf talks back through. */
|
|
755
|
+
poolContext() {
|
|
756
|
+
const store = this;
|
|
757
|
+
return {
|
|
758
|
+
applyDeltaBatchToPool: (results) => {
|
|
759
|
+
this.syncClient.applyDeltaBatchToPool(results, (name, data) => this.enrichRelations(name, data));
|
|
760
|
+
},
|
|
761
|
+
applyBootstrapDataToPool: (bootstrapData, protectedIds) => this.syncClient.applyBootstrapDataToPool(bootstrapData, protectedIds),
|
|
762
|
+
getPoolSize: () => this.objectPool.size,
|
|
763
|
+
getAllPoolIds: () => this.objectPool.getAllIds(),
|
|
764
|
+
get bootstrapDeltaQueue() { return store.bootstrapDeltaQueue; },
|
|
765
|
+
set bootstrapDeltaQueue(queue) { store.bootstrapDeltaQueue = queue; },
|
|
766
|
+
applyDeltaFrame: (deltas) => { this.applyDeltaFrame(deltas); },
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
/** Apply bootstrap data to the {@link InstanceCache}, removing entities that are no longer present (ghost removal). Pool writes are delegated to {@link SyncClient}. */
|
|
1050
770
|
applyBootstrapToPool(bootstrapResult, protectedIds) {
|
|
1051
|
-
|
|
1052
|
-
// Partial bootstrap: Database.processDeltaBatch already wrote the deltas
|
|
1053
|
-
// to IDB. Route the same results through the delta-apply path so the
|
|
1054
|
-
// in-memory pool evicts deleted entities (and updates modified ones).
|
|
1055
|
-
// Without this, reconnect DELETEs persist to IDB but the canvas keeps
|
|
1056
|
-
// showing ghost layers until a full reload.
|
|
1057
|
-
if (bootstrapData.type === 'partial') {
|
|
1058
|
-
const deltaResults = bootstrapResult.deltaResults;
|
|
1059
|
-
if (deltaResults && deltaResults.length > 0) {
|
|
1060
|
-
this.syncClient.applyDeltaBatchToPool(deltaResults, (name, data) => this.enrichRelations(name, data));
|
|
1061
|
-
}
|
|
1062
|
-
return { added: 0, updated: 0, removed: 0, skipped: 0, healed: 0, elapsedMs: 0 };
|
|
1063
|
-
}
|
|
1064
|
-
if (!bootstrapData.models) {
|
|
1065
|
-
return { added: 0, updated: 0, removed: 0, skipped: 0, healed: 0, elapsedMs: 0 };
|
|
1066
|
-
}
|
|
1067
|
-
const start = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
1068
|
-
// SyncClient owns: model creation, healing, pool upsert, ghost removal
|
|
1069
|
-
const stats = this.syncClient.applyBootstrapDataToPool(bootstrapData, protectedIds);
|
|
1070
|
-
const elapsedMs = Math.round((typeof performance !== 'undefined' ? performance.now() : Date.now()) - start);
|
|
1071
|
-
getContext().logger.info('[BaseSyncedStore] Bootstrap applied', {
|
|
1072
|
-
...stats, elapsedMs, poolSize: this.objectPool.size,
|
|
1073
|
-
});
|
|
1074
|
-
return { ...stats, elapsedMs };
|
|
771
|
+
return bootstrapApply.applyBootstrapToPool(this.poolContext(), bootstrapResult, protectedIds);
|
|
1075
772
|
}
|
|
1076
773
|
// ── Initialize + Lifecycle ───────────────────────────────────────────────
|
|
1077
774
|
/**
|
|
@@ -1082,14 +779,11 @@ export class BaseSyncedStore {
|
|
|
1082
779
|
if (this.initialized)
|
|
1083
780
|
return { success: true };
|
|
1084
781
|
this.userContext = context;
|
|
1085
|
-
// Propagate identity to SyncClient. Without this, every mutation
|
|
1086
|
-
// silently drops in
|
|
1087
|
-
//
|
|
1088
|
-
//
|
|
1089
|
-
//
|
|
1090
|
-
// the drop was invisible because both guard sites just early-return
|
|
1091
|
-
// rather than throw. The right fix is to do it here where the store
|
|
1092
|
-
// receives the context, so identity is one source of truth.
|
|
782
|
+
// Propagate identity to the SyncClient. Without this, every mutation
|
|
783
|
+
// silently drops in the mutation-staging path with a null userId and
|
|
784
|
+
// organizationId, because those guards early-return rather than throw.
|
|
785
|
+
// Setting it here, where the store receives the context, keeps identity
|
|
786
|
+
// in a single place.
|
|
1093
787
|
yield this.syncClient.initialize(context.userId, context.organizationId);
|
|
1094
788
|
try {
|
|
1095
789
|
this.updateSyncStatus({ state: 'syncing', progress: 0 });
|
|
@@ -1108,12 +802,6 @@ export class BaseSyncedStore {
|
|
|
1108
802
|
// Get sync baseline for WebSocket
|
|
1109
803
|
const lastSyncId = (yield this.database.getLastSyncId());
|
|
1110
804
|
this.syncClient.position.advancePersisted(lastSyncId || 0);
|
|
1111
|
-
try {
|
|
1112
|
-
const versions = (yield this.database.getVersionVector());
|
|
1113
|
-
if (versions && typeof versions === 'object')
|
|
1114
|
-
Object.assign(this.versionVector, versions);
|
|
1115
|
-
}
|
|
1116
|
-
catch { }
|
|
1117
805
|
// If local data available, show UI immediately
|
|
1118
806
|
if (hasLocalData) {
|
|
1119
807
|
this.dataReady = true;
|
|
@@ -1154,8 +842,15 @@ export class BaseSyncedStore {
|
|
|
1154
842
|
}
|
|
1155
843
|
else if (requirements.type !== 'local') {
|
|
1156
844
|
if (hasLocalData) {
|
|
1157
|
-
// Background bootstrap — don't block UI
|
|
1158
|
-
this
|
|
845
|
+
// Background bootstrap — don't block UI. The method captures its own
|
|
846
|
+
// operational failures; this backstop covers escapes from the
|
|
847
|
+
// delta-queue replay in withDeltaQueuing's finally (and the error
|
|
848
|
+
// handler itself), which would otherwise vanish unhandled.
|
|
849
|
+
void this.performBackgroundBootstrap(requirements, context, signal).catch((error) => {
|
|
850
|
+
getContext().observability.captureBootstrapFailure(error, {
|
|
851
|
+
type: 'background-orchestration',
|
|
852
|
+
});
|
|
853
|
+
});
|
|
1159
854
|
}
|
|
1160
855
|
else {
|
|
1161
856
|
// First load — must wait for server data
|
|
@@ -1253,27 +948,11 @@ export class BaseSyncedStore {
|
|
|
1253
948
|
}
|
|
1254
949
|
/** Collect IDs that must survive ghost removal (added by deltas during bootstrap) */
|
|
1255
950
|
collectDeltaProtectedIds(preBootstrapIds) {
|
|
1256
|
-
|
|
1257
|
-
for (const id of this.objectPool.getAllIds()) {
|
|
1258
|
-
if (!preBootstrapIds.has(id))
|
|
1259
|
-
protectedIds.add(id);
|
|
1260
|
-
}
|
|
1261
|
-
for (const delta of this.bootstrapDeltaQueue ?? []) {
|
|
1262
|
-
if (delta.actionType !== 'D' && delta.modelId)
|
|
1263
|
-
protectedIds.add(delta.modelId);
|
|
1264
|
-
}
|
|
1265
|
-
return protectedIds;
|
|
951
|
+
return bootstrapApply.collectDeltaProtectedIds(this.poolContext(), preBootstrapIds);
|
|
1266
952
|
}
|
|
1267
|
-
/** Replay deltas queued during bootstrap */
|
|
953
|
+
/** Replay deltas queued during bootstrap (atomically, via `applyDeltaFrame`). */
|
|
1268
954
|
replayQueuedDeltas() {
|
|
1269
|
-
|
|
1270
|
-
this.bootstrapDeltaQueue = null;
|
|
1271
|
-
if (!queue || queue.length === 0)
|
|
1272
|
-
return;
|
|
1273
|
-
// Deltas that landed during bootstrap are a complete frame — apply
|
|
1274
|
-
// them atomically (one flush, one re-render) rather than dribbling
|
|
1275
|
-
// each back through the live debounce path.
|
|
1276
|
-
this.applyDeltaFrame(queue);
|
|
955
|
+
bootstrapApply.replayQueuedDeltas(this.poolContext());
|
|
1277
956
|
}
|
|
1278
957
|
/**
|
|
1279
958
|
* Factory for the internal `ConnectionManager`. Override to return
|
|
@@ -1336,6 +1015,10 @@ export class BaseSyncedStore {
|
|
|
1336
1015
|
}
|
|
1337
1016
|
this.syncClient.disconnect();
|
|
1338
1017
|
this.queryProcessor.clearCache();
|
|
1018
|
+
// Stop the pool's GC interval — the one timer the pool arms itself.
|
|
1019
|
+
// Without this a discarded store retains its whole pool via the interval
|
|
1020
|
+
// closure (and a Node process without `unref` support can't exit).
|
|
1021
|
+
this.objectPool.stopGC();
|
|
1339
1022
|
this.updateSyncStatus({ state: 'offline' });
|
|
1340
1023
|
}
|
|
1341
1024
|
/**
|
|
@@ -1434,7 +1117,6 @@ export class BaseSyncedStore {
|
|
|
1434
1117
|
organizationId: context.organizationId,
|
|
1435
1118
|
syncGroups: [...this.resolveSyncGroups(context)],
|
|
1436
1119
|
lastSyncId,
|
|
1437
|
-
versions: this.versionVector,
|
|
1438
1120
|
kind: context.kind,
|
|
1439
1121
|
capabilityToken: context.capabilityToken,
|
|
1440
1122
|
getAuthToken: this.auth?.getAuthToken,
|
|
@@ -1449,7 +1131,7 @@ export class BaseSyncedStore {
|
|
|
1449
1131
|
// connection. baseGroups (the org/user scopes) are always subscribed;
|
|
1450
1132
|
// enterScope/leaveScope move per-entity interest. Recreated with the
|
|
1451
1133
|
// socket; torn down via the disposer pushed below.
|
|
1452
|
-
this.areaOfInterest = new
|
|
1134
|
+
this.areaOfInterest = new SubscriptionManager({
|
|
1453
1135
|
transport: this.syncWebSocket,
|
|
1454
1136
|
baseGroups: this.resolveSyncGroups(context),
|
|
1455
1137
|
});
|
|
@@ -1506,7 +1188,9 @@ export class BaseSyncedStore {
|
|
|
1506
1188
|
this.updateSyncStatus({ state: 'error', error });
|
|
1507
1189
|
}
|
|
1508
1190
|
});
|
|
1509
|
-
|
|
1191
|
+
// Terminal session loss (revocation / the login itself is gone): notify,
|
|
1192
|
+
// route the FSM to its terminal state, and clear local data.
|
|
1193
|
+
const handleTerminalSessionError = (error) => {
|
|
1510
1194
|
getContext().observability.captureWebSocketError({ context: 'session-error', error: error.message });
|
|
1511
1195
|
this.onConnectionEvent?.('WS_SESSION_ERROR');
|
|
1512
1196
|
for (const listener of this.sessionErrorListeners) {
|
|
@@ -1524,6 +1208,54 @@ export class BaseSyncedStore {
|
|
|
1524
1208
|
getContext().logger.debug('[BaseSyncedStore] Failed to clear database on session error', clearErr);
|
|
1525
1209
|
});
|
|
1526
1210
|
this.objectPool.clear();
|
|
1211
|
+
};
|
|
1212
|
+
const onSessionError = this.syncWebSocket.subscribe('session_error', (error) => {
|
|
1213
|
+
// WS analog of HTTP's `apikey_expired` (see SyncSessionError.
|
|
1214
|
+
// isSessionErrorResponse): the hub's keepalive reaper closes sockets
|
|
1215
|
+
// whose SHORT-LIVED access credential (`ek_`/`rk_`) passed its expiry
|
|
1216
|
+
// with `4001 credential_expired`. That is re-mintable from the
|
|
1217
|
+
// still-valid login — recover silently (un-latch, single-flight
|
|
1218
|
+
// re-mint, reconnect) instead of signing out and clearing local data.
|
|
1219
|
+
// Only a mint that answers `null` (the login itself is gone) falls
|
|
1220
|
+
// through to the terminal path. Without this branch, every credential
|
|
1221
|
+
// TTL elapse wedged the socket behind the write-once session latch.
|
|
1222
|
+
if (SyncSessionError.isSessionError(error) && isAccessCredentialExpiryCloseReason(error.message)) {
|
|
1223
|
+
getContext().observability.breadcrumb('WebSocket closed for expired access credential — re-minting', 'sync.websocket', 'warning');
|
|
1224
|
+
// Un-latch BEFORE the async mint so the FSM's own recovery
|
|
1225
|
+
// (probe → refreshing_credential → reconnect) is never blocked on
|
|
1226
|
+
// our .then() ordering.
|
|
1227
|
+
this.syncWebSocket?.clearSessionError();
|
|
1228
|
+
void this.performCredentialRefresh().then((outcome) => {
|
|
1229
|
+
if (outcome === 'refreshed') {
|
|
1230
|
+
if (this.connectionManager) {
|
|
1231
|
+
// Kick a parked FSM; a concurrent probe joins the same
|
|
1232
|
+
// single-flight mint, so this never double-mints.
|
|
1233
|
+
this.nudgeReconnect();
|
|
1234
|
+
}
|
|
1235
|
+
else {
|
|
1236
|
+
// Agent/system clients have no connection FSM
|
|
1237
|
+
// (createConnectionManager returns null for kind 'agent') —
|
|
1238
|
+
// reconnect the socket directly; connect() reads the
|
|
1239
|
+
// freshly-minted credential from the credential source.
|
|
1240
|
+
this.syncWebSocket?.resetReconnectAttempts();
|
|
1241
|
+
this.syncWebSocket?.connect();
|
|
1242
|
+
}
|
|
1243
|
+
return;
|
|
1244
|
+
}
|
|
1245
|
+
if (outcome === 'session_error') {
|
|
1246
|
+
// The mint endpoint rejected: the long-lived login is gone.
|
|
1247
|
+
// Re-latch so writes reject with the permanent session type
|
|
1248
|
+
// (see SyncWebSocket.notConnectedError) instead of parking.
|
|
1249
|
+
this.syncWebSocket?.setSessionErrorDetected();
|
|
1250
|
+
handleTerminalSessionError(error);
|
|
1251
|
+
}
|
|
1252
|
+
// 'network_error' → transient mint failure. The WS_DISCONNECTED
|
|
1253
|
+
// that follows this event already put the FSM on its probe/backoff
|
|
1254
|
+
// loop, which retries through the same single-flight refresh.
|
|
1255
|
+
});
|
|
1256
|
+
return;
|
|
1257
|
+
}
|
|
1258
|
+
handleTerminalSessionError(error);
|
|
1527
1259
|
});
|
|
1528
1260
|
// Handshake failed: WS close before open. The HTTP status is hidden
|
|
1529
1261
|
// behind close code 1006, so we can't tell whether the server rejected
|
|
@@ -1647,69 +1379,66 @@ export class BaseSyncedStore {
|
|
|
1647
1379
|
this.disposers.push(unsubCreated, unsubCompleted, unsubFailed);
|
|
1648
1380
|
this.syncWebSocket.connect();
|
|
1649
1381
|
}
|
|
1650
|
-
// ── Delta
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1382
|
+
// ── Delta processing pipeline ─────────────────────────────────────────────
|
|
1383
|
+
//
|
|
1384
|
+
// The implementation lives in the sync/deltaPipeline module (deduplication,
|
|
1385
|
+
// enqueue bookkeeping, debounce, flush). The methods below are thin protected
|
|
1386
|
+
// delegates with unchanged signatures, and the module routes every call to a
|
|
1387
|
+
// protected override point back through `deltaPipelineContext`, so subclass
|
|
1388
|
+
// dynamic dispatch is preserved. `applyDeltaFrame`, the authoritative-apply
|
|
1389
|
+
// correctness point, deliberately stays here.
|
|
1390
|
+
/** Memoized pipeline context — `enqueueDelta` runs once per delta, so the
|
|
1391
|
+
* accessor object is built once and reused (the get/set accessors always
|
|
1392
|
+
* read the live host fields). */
|
|
1393
|
+
_deltaPipelineContext = null;
|
|
1394
|
+
get deltaPipelineContext() {
|
|
1395
|
+
if (this._deltaPipelineContext)
|
|
1396
|
+
return this._deltaPipelineContext;
|
|
1397
|
+
const store = this;
|
|
1398
|
+
this._deltaPipelineContext = {
|
|
1399
|
+
// Shared pipeline state, backed by the host fields.
|
|
1400
|
+
get pendingDeltas() { return store.pendingDeltas; },
|
|
1401
|
+
set pendingDeltas(deltas) { store.pendingDeltas = deltas; },
|
|
1402
|
+
get batchTimer() { return store.batchTimer; },
|
|
1403
|
+
set batchTimer(timer) { store.batchTimer = timer; },
|
|
1404
|
+
get bootstrapDeltaQueue() { return store.bootstrapDeltaQueue; },
|
|
1405
|
+
get smartSyncOptions() { return store.smartSyncOptions; },
|
|
1406
|
+
get highestProcessedSyncId() { return store.highestProcessedSyncId; },
|
|
1407
|
+
get lastAckedId() { return store.lastAckedId; },
|
|
1408
|
+
// SyncClient position/transaction bookkeeping.
|
|
1409
|
+
onDeltaReceived: (syncId) => { this.syncClient.onDeltaReceived(syncId); },
|
|
1410
|
+
advanceApplied: (syncId) => { this.syncClient.position.advanceApplied(syncId); },
|
|
1411
|
+
advancePersisted: (syncId) => { this.syncClient.position.advancePersisted(syncId); },
|
|
1412
|
+
// Persistence + pool writes.
|
|
1413
|
+
processDeltaBatch: (deltas) => this.database.processDeltaBatch(deltas),
|
|
1414
|
+
applyDeltaBatchToPool: (results) => {
|
|
1415
|
+
this.syncClient.applyDeltaBatchToPool(results, (name, data) => this.enrichRelations(name, data));
|
|
1416
|
+
},
|
|
1417
|
+
acknowledge: (syncId) => { this.syncWebSocket?.acknowledge?.(syncId); },
|
|
1418
|
+
get objectPool() { return store.objectPool; },
|
|
1419
|
+
// Dynamic-dispatch hooks — protected override points on this class.
|
|
1420
|
+
getStateFields: (modelName) => this.getStateFields(modelName),
|
|
1421
|
+
isCustomEntity: (modelName) => this.isCustomEntity(modelName),
|
|
1422
|
+
createCustomEntity: (modelName, modelId, data) => this.createCustomEntity(modelName, modelId, data),
|
|
1423
|
+
deduplicateDeltas: (deltas) => this.deduplicateDeltas(deltas),
|
|
1424
|
+
flushPendingDeltas: () => this.flushPendingDeltas(),
|
|
1425
|
+
handleFlushError: (error) => { this.handleFlushError(error); },
|
|
1426
|
+
handleSyncGroupChange: (delta) => this.handleSyncGroupChange(delta),
|
|
1427
|
+
handleGroupRemoved: (delta) => this.handleGroupRemoved(delta),
|
|
1428
|
+
forceFullRebootstrap: () => { this.forceFullRebootstrap(); },
|
|
1429
|
+
cascadeCancelTransactionsForDeletedParent: (parentModelName, parentId) => {
|
|
1430
|
+
this.cascadeCancelTransactionsForDeletedParent(parentModelName, parentId);
|
|
1431
|
+
},
|
|
1663
1432
|
};
|
|
1664
|
-
|
|
1665
|
-
if (field in data)
|
|
1666
|
-
signature[field] = data[field];
|
|
1667
|
-
}
|
|
1668
|
-
return signature;
|
|
1433
|
+
return this._deltaPipelineContext;
|
|
1669
1434
|
}
|
|
1670
1435
|
/** Get fields that represent meaningful state for deduplication. Override for model-specific fields. */
|
|
1671
1436
|
getStateFields(_modelName) {
|
|
1672
1437
|
return ['status', 'state', 'isActive'];
|
|
1673
1438
|
}
|
|
1674
|
-
isSameState(a, b) {
|
|
1675
|
-
if (!a || !b)
|
|
1676
|
-
return false;
|
|
1677
|
-
const keys = Object.keys(a);
|
|
1678
|
-
if (keys.length !== Object.keys(b).length)
|
|
1679
|
-
return false;
|
|
1680
|
-
return keys.every((k) => a[k] === b[k]);
|
|
1681
|
-
}
|
|
1682
1439
|
/** Deduplicate deltas to the same entity — keep meaningful state transitions only */
|
|
1683
1440
|
deduplicateDeltas(deltas) {
|
|
1684
|
-
|
|
1685
|
-
for (const d of deltas) {
|
|
1686
|
-
const key = `${d.modelName}:${d.modelId}`;
|
|
1687
|
-
if (!byEntity.has(key))
|
|
1688
|
-
byEntity.set(key, []);
|
|
1689
|
-
byEntity.get(key).push(d);
|
|
1690
|
-
}
|
|
1691
|
-
const result = [];
|
|
1692
|
-
for (const entityDeltas of byEntity.values()) {
|
|
1693
|
-
const sorted = entityDeltas.sort((a, b) => a.id - b.id);
|
|
1694
|
-
// DELETE wins — it's the final state
|
|
1695
|
-
const del = sorted.find((d) => d.actionType === 'D');
|
|
1696
|
-
if (del) {
|
|
1697
|
-
result.push(del);
|
|
1698
|
-
continue;
|
|
1699
|
-
}
|
|
1700
|
-
// Keep deltas that represent different states
|
|
1701
|
-
const unique = [];
|
|
1702
|
-
let prev = null;
|
|
1703
|
-
for (const d of sorted) {
|
|
1704
|
-
const sig = this.extractStateSignature(d);
|
|
1705
|
-
if (!this.isSameState(prev, sig)) {
|
|
1706
|
-
unique.push(d);
|
|
1707
|
-
prev = sig;
|
|
1708
|
-
}
|
|
1709
|
-
}
|
|
1710
|
-
result.push(...(unique.length > 0 ? unique : [sorted[sorted.length - 1]]));
|
|
1711
|
-
}
|
|
1712
|
-
return result.sort((a, b) => a.id - b.id);
|
|
1441
|
+
return deltaPipeline.deduplicateDeltas(this.deltaPipelineContext, deltas);
|
|
1713
1442
|
}
|
|
1714
1443
|
/** Process incoming delta with smart batching */
|
|
1715
1444
|
processDeltaWithBatching(delta) {
|
|
@@ -1720,35 +1449,36 @@ export class BaseSyncedStore {
|
|
|
1720
1449
|
/**
|
|
1721
1450
|
* Apply a complete, server-delivered delta frame atomically.
|
|
1722
1451
|
*
|
|
1723
|
-
* A `delta_batch`
|
|
1724
|
-
* the
|
|
1725
|
-
* `processDeltaWithBatching` path re-
|
|
1726
|
-
* debounce timer
|
|
1727
|
-
*
|
|
1728
|
-
*
|
|
1729
|
-
*
|
|
1452
|
+
* A `delta_batch` WebSocket event (a reconnect or catch-up replay) already
|
|
1453
|
+
* carries the full set of missed deltas. Routing it through the per-delta
|
|
1454
|
+
* `processDeltaWithBatching` path would re-chunk it via the live-traffic
|
|
1455
|
+
* debounce timer and `maxBatchSize` force-flush, so a 300-delta catch-up
|
|
1456
|
+
* would fan out into several separate `flushPendingDeltas` cycles — each its
|
|
1457
|
+
* own local write, pool mutation, `models:changed` emit, and re-render, so
|
|
1458
|
+
* the UI visibly repaints once per chunk.
|
|
1730
1459
|
*
|
|
1731
|
-
*
|
|
1732
|
-
* watermark,
|
|
1733
|
-
* a flush, then
|
|
1734
|
-
*
|
|
1735
|
-
*
|
|
1460
|
+
* Instead, this runs the per-delta bookkeeping (deduplication, ack, version
|
|
1461
|
+
* vector, watermark, group-change routing, delete cascade) for every delta
|
|
1462
|
+
* without scheduling a flush, then flushes once — collapsing the whole frame
|
|
1463
|
+
* into a single local write, pool mutation, `models:changed` emit, and
|
|
1464
|
+
* re-render. The post-bootstrap replay of deltas queued during bootstrap
|
|
1465
|
+
* uses the same path.
|
|
1736
1466
|
*
|
|
1737
|
-
*
|
|
1738
|
-
* with
|
|
1739
|
-
* eventually drives through `flushPendingDeltas`.
|
|
1467
|
+
* It is named `applyDeltaFrame`, not `processDeltaBatch`, to avoid confusion
|
|
1468
|
+
* with {@link Database.processDeltaBatch} — the lower-level local write this
|
|
1469
|
+
* eventually drives through `flushPendingDeltas`.
|
|
1740
1470
|
*/
|
|
1741
1471
|
applyDeltaFrame(deltas) {
|
|
1742
1472
|
let enqueuedAny = false;
|
|
1743
1473
|
for (const delta of deltas) {
|
|
1744
|
-
// A delta_batch frame is the server's
|
|
1474
|
+
// A delta_batch frame is the server's authoritative, ordered answer to
|
|
1745
1475
|
// "everything in my stream after cursor C" (reconnect/catch-up replay or
|
|
1746
|
-
// post-bootstrap drain). Apply every delta it carries
|
|
1476
|
+
// post-bootstrap drain). Apply every delta it carries; do not subject it
|
|
1747
1477
|
// to the live-traffic watermark dedup (`id <= applied`).
|
|
1748
1478
|
//
|
|
1749
1479
|
// That watermark is only valid under in-order delivery, and reconnect
|
|
1750
|
-
// breaks the assumption: an in-flight
|
|
1751
|
-
// land out of order
|
|
1480
|
+
// breaks the assumption: an in-flight live broadcast for a gap delta can
|
|
1481
|
+
// land out of order before the catch-up fills the ids below it (e.g. the
|
|
1752
1482
|
// server acks a write, then the test/client reconnects, then that write's
|
|
1753
1483
|
// pending broadcast arrives on the fresh socket — id 4 live before the
|
|
1754
1484
|
// catch-up's [2,3,4]). Applying id 4 advances `applied` to 4, and the
|
|
@@ -1781,68 +1511,11 @@ export class BaseSyncedStore {
|
|
|
1781
1511
|
* whether to debounce (live) or flush atomically (catch-up frame).
|
|
1782
1512
|
*/
|
|
1783
1513
|
enqueueDelta(delta, options = {}) {
|
|
1784
|
-
|
|
1785
|
-
// valid skip threshold ONLY for in-order live traffic; an authoritative
|
|
1786
|
-
// catch-up frame bypasses it (see `applyDeltaFrame`) so an out-of-order
|
|
1787
|
-
// live delta that advanced the watermark can't cause the frame's lower ids
|
|
1788
|
-
// to be silently dropped.
|
|
1789
|
-
if (!options.authoritative && delta.id > 0 && delta.id <= this.highestProcessedSyncId) {
|
|
1790
|
-
return false;
|
|
1791
|
-
}
|
|
1792
|
-
// Confirm awaiting transactions via sync ID threshold (before batching)
|
|
1793
|
-
this.syncClient.onDeltaReceived(delta.id);
|
|
1794
|
-
// Update version vector
|
|
1795
|
-
const entityType = delta.modelName.toLowerCase();
|
|
1796
|
-
if (this.versionVector[entityType] !== undefined) {
|
|
1797
|
-
this.versionVector[entityType] = Math.max(this.versionVector[entityType], delta.id);
|
|
1798
|
-
}
|
|
1799
|
-
// Queue during active bootstrap
|
|
1800
|
-
if (this.bootstrapDeltaQueue !== null) {
|
|
1801
|
-
this.bootstrapDeltaQueue.push(delta);
|
|
1802
|
-
return false;
|
|
1803
|
-
}
|
|
1804
|
-
// Advance watermark
|
|
1805
|
-
this.syncClient.position.advanceApplied(delta.id);
|
|
1806
|
-
// Sync group added — handle immediately. Supports both legacy
|
|
1807
|
-
// (addedGroups/removedGroups) and incremental (group/userId) payloads.
|
|
1808
|
-
if (delta.actionType === 'G') {
|
|
1809
|
-
void this.handleSyncGroupChange(delta);
|
|
1810
|
-
return false;
|
|
1811
|
-
}
|
|
1812
|
-
// Sync group removed — handle immediately. Clears affected local state
|
|
1813
|
-
// and forces re-bootstrap with the updated group list.
|
|
1814
|
-
if (delta.actionType === 'S') {
|
|
1815
|
-
void this.handleGroupRemoved(delta);
|
|
1816
|
-
return false;
|
|
1817
|
-
}
|
|
1818
|
-
// DELETE — fire the cascade cancel immediately (O(1) via FK index;
|
|
1819
|
-
// must run BEFORE any subsequent update on the same model lands so
|
|
1820
|
-
// pending update transactions for soon-deleted children don't race
|
|
1821
|
-
// their parent's delete) but route the IDB+pool write through the
|
|
1822
|
-
// same batched path as UPDATEs. The previous immediate-flush path
|
|
1823
|
-
// produced N IDB writes + N pool mutations + N `models:changed`
|
|
1824
|
-
// events when a peer deleted a chart with N layers; the batched
|
|
1825
|
-
// path produces one of each per microtask flush. Dedup in
|
|
1826
|
-
// `flushPendingDeltas` handles the U-then-D-on-same-model case
|
|
1827
|
-
// correctly via arrival-order replay through `processDeltaBatch`.
|
|
1828
|
-
if (delta.actionType === 'D') {
|
|
1829
|
-
this.cascadeCancelTransactionsForDeletedParent(delta.modelName, delta.modelId);
|
|
1830
|
-
}
|
|
1831
|
-
this.pendingDeltas.push(delta);
|
|
1832
|
-
return true;
|
|
1514
|
+
return deltaPipeline.enqueueDelta(this.deltaPipelineContext, delta, options);
|
|
1833
1515
|
}
|
|
1834
1516
|
/** Debounce a flush for live single-delta traffic. */
|
|
1835
1517
|
scheduleDeltaFlush() {
|
|
1836
|
-
|
|
1837
|
-
clearTimeout(this.batchTimer);
|
|
1838
|
-
if (this.pendingDeltas.length >= this.smartSyncOptions.maxBatchSize) {
|
|
1839
|
-
void this.flushPendingDeltas().catch(this.handleFlushError);
|
|
1840
|
-
}
|
|
1841
|
-
else {
|
|
1842
|
-
this.batchTimer = setTimeout(() => {
|
|
1843
|
-
void this.flushPendingDeltas().catch(this.handleFlushError);
|
|
1844
|
-
}, this.smartSyncOptions.batchingDelay);
|
|
1845
|
-
}
|
|
1518
|
+
deltaPipeline.scheduleDeltaFlush(this.deltaPipelineContext);
|
|
1846
1519
|
}
|
|
1847
1520
|
/**
|
|
1848
1521
|
* Cancel pending transactions for child entities when a parent is deleted.
|
|
@@ -1878,88 +1551,15 @@ export class BaseSyncedStore {
|
|
|
1878
1551
|
});
|
|
1879
1552
|
}
|
|
1880
1553
|
}
|
|
1881
|
-
/** Flush pending deltas with deduplication
|
|
1882
|
-
/** Flush pending deltas with deduplication. Delegates pool writes to SyncClient. */
|
|
1554
|
+
/** Flush pending deltas with deduplication. Pool writes are delegated to {@link SyncClient}. */
|
|
1883
1555
|
async flushPendingDeltas() {
|
|
1884
|
-
|
|
1885
|
-
return;
|
|
1886
|
-
const deduplicatedDeltas = this.deduplicateDeltas(this.pendingDeltas);
|
|
1887
|
-
// Custom entities → apply directly to ObjectPool (skip IDB)
|
|
1888
|
-
const customDeltas = deduplicatedDeltas.filter((d) => this.isCustomEntity(d.modelName));
|
|
1889
|
-
if (customDeltas.length > 0) {
|
|
1890
|
-
runInAction(() => {
|
|
1891
|
-
for (const delta of customDeltas) {
|
|
1892
|
-
const data = typeof delta.data === 'string'
|
|
1893
|
-
? JSON.parse(delta.data)
|
|
1894
|
-
: delta.data;
|
|
1895
|
-
// 'C' (Covering) is treated identically to 'I' here — the client
|
|
1896
|
-
// gained permission to see the entity, so we insert it into the
|
|
1897
|
-
// pool as if newly created.
|
|
1898
|
-
if (delta.actionType === 'I' || delta.actionType === 'U' || delta.actionType === 'C') {
|
|
1899
|
-
const existing = this.objectPool.get(delta.modelId);
|
|
1900
|
-
if (existing) {
|
|
1901
|
-
existing.updateFromData(data);
|
|
1902
|
-
}
|
|
1903
|
-
else {
|
|
1904
|
-
const model = this.createCustomEntity(delta.modelName, delta.modelId, data);
|
|
1905
|
-
if (model) {
|
|
1906
|
-
model.markAsPersisted();
|
|
1907
|
-
this.objectPool.add(model, ModelScope.live);
|
|
1908
|
-
}
|
|
1909
|
-
}
|
|
1910
|
-
}
|
|
1911
|
-
else if (delta.actionType === 'D') {
|
|
1912
|
-
this.objectPool.remove(delta.modelId);
|
|
1913
|
-
}
|
|
1914
|
-
}
|
|
1915
|
-
});
|
|
1916
|
-
}
|
|
1917
|
-
// Regular deltas → IDB then ObjectPool via SyncClient.
|
|
1918
|
-
// 'G' and 'S' deltas are routed upstream (handleSyncGroupChange,
|
|
1919
|
-
// handleGroupRemoved) and never reach flushPendingDeltas, but the
|
|
1920
|
-
// Database.processDelta signature accepts them defensively.
|
|
1921
|
-
const regularDeltas = deduplicatedDeltas.filter((d) => !this.isCustomEntity(d.modelName));
|
|
1922
|
-
const batch = await this.database.processDeltaBatch(regularDeltas.map((d) => ({
|
|
1923
|
-
syncId: d.id,
|
|
1924
|
-
actionType: d.actionType,
|
|
1925
|
-
modelName: d.modelName,
|
|
1926
|
-
modelId: d.modelId,
|
|
1927
|
-
data: typeof d.data === 'string' ? JSON.parse(d.data) : d.data,
|
|
1928
|
-
// Thread `transactionId` through so the receive layer can
|
|
1929
|
-
// recognize echoes of locally-applied transactions and skip
|
|
1930
|
-
// the pool mutation. See `OPTIMISTIC_RECONCILIATION.md`.
|
|
1931
|
-
transactionId: d.transactionId,
|
|
1932
|
-
})));
|
|
1933
|
-
const dbResults = batch.results;
|
|
1934
|
-
// Delegate ObjectPool writes to SyncClient (owns pool operations)
|
|
1935
|
-
this.syncClient.applyDeltaBatchToPool(dbResults, (name, data) => this.enrichRelations(name, data));
|
|
1936
|
-
// Acknowledge + advance sync cursor — gated on IDB persistence.
|
|
1937
|
-
//
|
|
1938
|
-
// We MUST ack `persistedSyncId` (the high-water mark of deltas whose
|
|
1939
|
-
// store transaction actually committed), NOT the input batch's last
|
|
1940
|
-
// delta id. Acking by input range advances the server's view past
|
|
1941
|
-
// deltas that never wrote to IDB; the next catch-up request would
|
|
1942
|
-
// then send the advanced cursor and the server replies "you're up
|
|
1943
|
-
// to date" — losing the un-persisted delta forever. This is the
|
|
1944
|
-
// Replicache "same-transaction" invariant: the cursor and the
|
|
1945
|
-
// persisted view must be consistent.
|
|
1946
|
-
const persistedSyncId = batch.persistedSyncId;
|
|
1947
|
-
if (persistedSyncId > this.lastAckedId) {
|
|
1948
|
-
this.syncWebSocket?.acknowledge?.(persistedSyncId);
|
|
1949
|
-
this.syncClient.position.advancePersisted(persistedSyncId);
|
|
1950
|
-
}
|
|
1951
|
-
// Cache invalidation is automatic via SyncClient 'models:changed' event
|
|
1952
|
-
this.pendingDeltas = [];
|
|
1953
|
-
if (this.batchTimer) {
|
|
1954
|
-
clearTimeout(this.batchTimer);
|
|
1955
|
-
this.batchTimer = null;
|
|
1956
|
-
}
|
|
1556
|
+
return deltaPipeline.flushPendingDeltas(this.deltaPipelineContext);
|
|
1957
1557
|
}
|
|
1958
|
-
// ── Core
|
|
1558
|
+
// ── Core mutations (thin delegation to SyncClient) ────────────────────────
|
|
1959
1559
|
//
|
|
1960
|
-
//
|
|
1961
|
-
//
|
|
1962
|
-
//
|
|
1560
|
+
// This class orchestrates; it does not implement the writes. {@link SyncClient}
|
|
1561
|
+
// owns the object-pool operations, the transaction queue, and local writes.
|
|
1562
|
+
// This class owns validation, lifecycle hooks, and pending-delete tracking.
|
|
1963
1563
|
/** Check if a model type is local-only (no sync). Override for domain-specific models. */
|
|
1964
1564
|
isLocalOnlyModel(_modelName) {
|
|
1965
1565
|
return false;
|
|
@@ -2031,9 +1631,9 @@ export class BaseSyncedStore {
|
|
|
2031
1631
|
this.syncClient.update(model);
|
|
2032
1632
|
}
|
|
2033
1633
|
// ── Query API ────────────────────────────────────────────────────────────
|
|
2034
|
-
// `
|
|
2035
|
-
//
|
|
2036
|
-
// `tx.<model
|
|
1634
|
+
// `ablo.<model>.get` / `ablo.<model>.getAll` is the read surface for
|
|
1635
|
+
// application code. Custom mutators read transactionally through
|
|
1636
|
+
// `tx.<model>`, backed by `createReaderActions`.
|
|
2037
1637
|
/** Retrieve a single entity by id. Synchronous pool read. */
|
|
2038
1638
|
retrieve(_modelClass, id) {
|
|
2039
1639
|
return this.objectPool.get(id);
|
|
@@ -2077,10 +1677,10 @@ export class BaseSyncedStore {
|
|
|
2077
1677
|
return this.objectPool.create(wireTypename, data);
|
|
2078
1678
|
}
|
|
2079
1679
|
/**
|
|
2080
|
-
*
|
|
2081
|
-
*
|
|
2082
|
-
*
|
|
2083
|
-
*
|
|
1680
|
+
* Query entry point for callers that hold a {@link Model} constructor and an
|
|
1681
|
+
* options object. It filters, orders, and paginates the matching models from
|
|
1682
|
+
* the pool. Prefer the schema-typed read surface (`ablo.<model>.list`) where
|
|
1683
|
+
* you can, since it infers concrete row types without a class value or cast.
|
|
2084
1684
|
*/
|
|
2085
1685
|
queryByClass(modelClass, options) {
|
|
2086
1686
|
const modelName = this.objectPool.registry.getModelNameFromConstructor(modelClass);
|
|
@@ -2189,8 +1789,7 @@ export class BaseSyncedStore {
|
|
|
2189
1789
|
return this.lastAckedId;
|
|
2190
1790
|
}
|
|
2191
1791
|
// ── Status convenience getters ──────────────────────────────────────────
|
|
2192
|
-
// Thin wrappers over syncStatus for consumer ergonomics.
|
|
2193
|
-
// SyncedStore; moved here so createSyncEngine consumers get them too.
|
|
1792
|
+
// Thin wrappers over `syncStatus` for consumer ergonomics.
|
|
2194
1793
|
get isReady() {
|
|
2195
1794
|
// Ready if: fully synced (idle + 100%) OR local data loaded (dataReady + syncing in background)
|
|
2196
1795
|
return (this.syncStatus.state === 'idle' && this.syncStatus.progress >= 100)
|