@abloatai/humans 0.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -0
- package/dist/Ablo.d.ts +208 -0
- package/dist/Ablo.js +120 -0
- package/dist/client.d.ts +317 -0
- package/dist/client.js +13 -0
- package/dist/core.d.ts +35 -0
- package/dist/core.js +48 -0
- package/dist/humans.d.ts +28 -0
- package/dist/humans.js +34 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +6 -0
- package/dist/local/BaseSyncedStore.d.ts +807 -0
- package/dist/local/BaseSyncedStore.js +1516 -0
- package/dist/local/Database.d.ts +322 -0
- package/dist/local/Database.js +1589 -0
- package/dist/local/InstanceCache.d.ts +255 -0
- package/dist/local/InstanceCache.js +1263 -0
- package/dist/local/LazyReferenceCollection.d.ts +177 -0
- package/dist/local/LazyReferenceCollection.js +461 -0
- package/dist/local/Model.d.ts +475 -0
- package/dist/local/Model.js +950 -0
- package/dist/local/ModelRegistry.d.ts +225 -0
- package/dist/local/ModelRegistry.js +539 -0
- package/dist/local/NetworkMonitor.d.ts +28 -0
- package/dist/local/NetworkMonitor.js +79 -0
- package/dist/local/RuntimeContext.d.ts +52 -0
- package/dist/local/RuntimeContext.js +80 -0
- package/dist/local/SyncClient.d.ts +516 -0
- package/dist/local/SyncClient.js +1754 -0
- package/dist/local/adapters/alwaysOnline.d.ts +14 -0
- package/dist/local/adapters/alwaysOnline.js +17 -0
- package/dist/local/adapters/inMemoryStorage.d.ts +37 -0
- package/dist/local/adapters/inMemoryStorage.js +122 -0
- package/dist/local/client/clientPrelude.d.ts +52 -0
- package/dist/local/client/clientPrelude.js +60 -0
- package/dist/local/client/consoleLogger.d.ts +35 -0
- package/dist/local/client/consoleLogger.js +44 -0
- package/dist/local/client/createInternalComponents.d.ts +50 -0
- package/dist/local/client/createInternalComponents.js +98 -0
- package/dist/local/client/createModelProxy.d.ts +248 -0
- package/dist/local/client/createModelProxy.js +880 -0
- package/dist/local/client/modelRegistration.d.ts +10 -0
- package/dist/local/client/modelRegistration.js +316 -0
- package/dist/local/client/options.d.ts +440 -0
- package/dist/local/client/options.js +7 -0
- package/dist/local/client/reactiveEngine.d.ts +53 -0
- package/dist/local/client/reactiveEngine.js +705 -0
- package/dist/local/client/resourceTypes.d.ts +12 -0
- package/dist/local/client/resourceTypes.js +10 -0
- package/dist/local/client/schemaConfig.d.ts +44 -0
- package/dist/local/client/schemaConfig.js +185 -0
- package/dist/local/client/storeCluster.d.ts +46 -0
- package/dist/local/client/storeCluster.js +133 -0
- package/dist/local/client/storeLifecycle.d.ts +61 -0
- package/dist/local/client/storeLifecycle.js +236 -0
- package/dist/local/client/validateAbloOptions.d.ts +42 -0
- package/dist/local/client/validateAbloOptions.js +43 -0
- package/dist/local/client/wsMutationExecutor.d.ts +27 -0
- package/dist/local/client/wsMutationExecutor.js +72 -0
- package/dist/local/context.d.ts +42 -0
- package/dist/local/context.js +81 -0
- package/dist/local/coordination/ClaimLog.d.ts +26 -0
- package/dist/local/coordination/ClaimLog.js +32 -0
- package/dist/local/interfaces/index.d.ts +311 -0
- package/dist/local/interfaces/index.js +9 -0
- package/dist/local/localModelContract.d.ts +14 -0
- package/dist/local/localModelContract.js +1 -0
- package/dist/local/logPosition.d.ts +31 -0
- package/dist/local/logPosition.js +53 -0
- package/dist/local/mutationPersistence.d.ts +6 -0
- package/dist/local/mutationPersistence.js +1 -0
- package/dist/local/mutators/RecordingMutation.d.ts +36 -0
- package/dist/local/mutators/RecordingMutation.js +182 -0
- package/dist/local/mutators/Transaction.d.ts +40 -0
- package/dist/local/mutators/Transaction.js +58 -0
- package/dist/local/mutators/UndoManager.d.ts +258 -0
- package/dist/local/mutators/UndoManager.js +665 -0
- package/dist/local/mutators/defineMutators.d.ts +60 -0
- package/dist/local/mutators/defineMutators.js +18 -0
- package/dist/local/mutators/inverseOp.d.ts +126 -0
- package/dist/local/mutators/inverseOp.js +71 -0
- package/dist/local/mutators/mutateActions.d.ts +45 -0
- package/dist/local/mutators/mutateActions.js +105 -0
- package/dist/local/mutators/readerActions.d.ts +33 -0
- package/dist/local/mutators/readerActions.js +57 -0
- package/dist/local/mutators/undoApply.d.ts +51 -0
- package/dist/local/mutators/undoApply.js +117 -0
- package/dist/local/persistence.d.ts +7 -0
- package/dist/local/persistence.js +9 -0
- package/dist/local/query/QueryProcessor.d.ts +75 -0
- package/dist/local/query/QueryProcessor.js +255 -0
- package/dist/local/query/client.d.ts +64 -0
- package/dist/local/query/client.js +138 -0
- package/dist/local/query/types.d.ts +85 -0
- package/dist/local/query/types.js +16 -0
- package/dist/local/schema/serialize.d.ts +1 -0
- package/dist/local/schema/serialize.js +1 -0
- package/dist/local/store/queryApi.d.ts +13 -0
- package/dist/local/store/queryApi.js +35 -0
- package/dist/local/storeContract.d.ts +145 -0
- package/dist/local/storeContract.js +12 -0
- package/dist/local/stores/DatabaseManager.d.ts +112 -0
- package/dist/local/stores/DatabaseManager.js +400 -0
- package/dist/local/stores/ObjectStore.d.ts +115 -0
- package/dist/local/stores/ObjectStore.js +393 -0
- package/dist/local/stores/ObjectStoreContract.d.ts +38 -0
- package/dist/local/stores/ObjectStoreContract.js +1 -0
- package/dist/local/stores/StoreManager.d.ts +114 -0
- package/dist/local/stores/StoreManager.js +304 -0
- package/dist/local/stores/SyncActionStore.d.ts +99 -0
- package/dist/local/stores/SyncActionStore.js +506 -0
- package/dist/local/stores/openIDBWithTimeout.d.ts +65 -0
- package/dist/local/stores/openIDBWithTimeout.js +153 -0
- package/dist/local/stores/persistenceCleanup.d.ts +7 -0
- package/dist/local/stores/persistenceCleanup.js +26 -0
- package/dist/local/stores/persistenceIdentity.d.ts +27 -0
- package/dist/local/stores/persistenceIdentity.js +38 -0
- package/dist/local/stores/syncAction.d.ts +26 -0
- package/dist/local/stores/syncAction.js +16 -0
- package/dist/local/stores/v1PersistenceDeletion.d.ts +8 -0
- package/dist/local/stores/v1PersistenceDeletion.js +16 -0
- package/dist/local/sync/BootstrapFetcher.d.ts +284 -0
- package/dist/local/sync/BootstrapFetcher.js +964 -0
- package/dist/local/sync/ConnectionManager.d.ts +8 -0
- package/dist/local/sync/ConnectionManager.js +8 -0
- package/dist/local/sync/OnDemandLoader.d.ts +231 -0
- package/dist/local/sync/OnDemandLoader.js +743 -0
- package/dist/local/sync/SubscriptionManager.d.ts +159 -0
- package/dist/local/sync/SubscriptionManager.js +243 -0
- package/dist/local/sync/SyncWebSocket.d.ts +173 -0
- package/dist/local/sync/SyncWebSocket.js +438 -0
- package/dist/local/sync/bootstrapApply.d.ts +73 -0
- package/dist/local/sync/bootstrapApply.js +73 -0
- package/dist/local/sync/commitFrames.d.ts +8 -0
- package/dist/local/sync/commitFrames.js +8 -0
- package/dist/local/sync/connectionManagerLifecycle.d.ts +23 -0
- package/dist/local/sync/connectionManagerLifecycle.js +126 -0
- package/dist/local/sync/contextPorts.d.ts +18 -0
- package/dist/local/sync/contextPorts.js +31 -0
- package/dist/local/sync/createClaimStream.d.ts +64 -0
- package/dist/local/sync/createClaimStream.js +475 -0
- package/dist/local/sync/createSnapshot.d.ts +29 -0
- package/dist/local/sync/createSnapshot.js +116 -0
- package/dist/local/sync/credentialLifecycle.d.ts +7 -0
- package/dist/local/sync/credentialLifecycle.js +7 -0
- package/dist/local/sync/deltaPipeline.d.ts +116 -0
- package/dist/local/sync/deltaPipeline.js +357 -0
- package/dist/local/sync/groupChange.d.ts +116 -0
- package/dist/local/sync/groupChange.js +244 -0
- package/dist/local/sync/initialize.d.ts +27 -0
- package/dist/local/sync/initialize.js +137 -0
- package/dist/local/sync/participants.d.ts +132 -0
- package/dist/local/sync/participants.js +342 -0
- package/dist/local/sync/persistedPrefix.d.ts +12 -0
- package/dist/local/sync/persistedPrefix.js +22 -0
- package/dist/local/sync/reconnect.d.ts +23 -0
- package/dist/local/sync/reconnect.js +55 -0
- package/dist/local/sync/schemaDrift.d.ts +55 -0
- package/dist/local/sync/schemaDrift.js +53 -0
- package/dist/local/sync/schemas.d.ts +71 -0
- package/dist/local/sync/schemas.js +94 -0
- package/dist/local/sync/socketEventWiring.d.ts +31 -0
- package/dist/local/sync/socketEventWiring.js +130 -0
- package/dist/local/sync/syncCursor.d.ts +40 -0
- package/dist/local/sync/syncCursor.js +55 -0
- package/dist/local/sync/syncPlan.d.ts +54 -0
- package/dist/local/sync/syncPlan.js +50 -0
- package/dist/local/sync/terminalSessionLifecycle.d.ts +20 -0
- package/dist/local/sync/terminalSessionLifecycle.js +50 -0
- package/dist/local/sync/wsFrameHandlers.d.ts +8 -0
- package/dist/local/sync/wsFrameHandlers.js +8 -0
- package/dist/local/transactions/databaseCommitOutbox.d.ts +15 -0
- package/dist/local/transactions/databaseCommitOutbox.js +16 -0
- package/dist/local/transactions/localMutation.d.ts +10 -0
- package/dist/local/transactions/localMutation.js +37 -0
- package/dist/local/transactions/mutations/MutationQueue.d.ts +511 -0
- package/dist/local/transactions/mutations/MutationQueue.js +1498 -0
- package/dist/local/transactions/mutations/MutationStore.d.ts +20 -0
- package/dist/local/transactions/mutations/MutationStore.js +53 -0
- package/dist/local/transactions/mutations/UnconfirmedWrites.d.ts +82 -0
- package/dist/local/transactions/mutations/UnconfirmedWrites.js +104 -0
- package/dist/local/transactions/mutations/batchProcessing.d.ts +64 -0
- package/dist/local/transactions/mutations/batchProcessing.js +349 -0
- package/dist/local/transactions/mutations/coalesceRules.d.ts +58 -0
- package/dist/local/transactions/mutations/coalesceRules.js +140 -0
- package/dist/local/transactions/mutations/commitApi.d.ts +19 -0
- package/dist/local/transactions/mutations/commitApi.js +74 -0
- package/dist/local/transactions/mutations/commitLane.d.ts +70 -0
- package/dist/local/transactions/mutations/commitLane.js +140 -0
- package/dist/local/transactions/mutations/commitLatency.d.ts +52 -0
- package/dist/local/transactions/mutations/commitLatency.js +130 -0
- package/dist/local/transactions/mutations/commitPayload.d.ts +165 -0
- package/dist/local/transactions/mutations/commitPayload.js +152 -0
- package/dist/local/transactions/mutations/commitTransport.d.ts +36 -0
- package/dist/local/transactions/mutations/commitTransport.js +104 -0
- package/dist/local/transactions/mutations/deltaConfirmation.d.ts +63 -0
- package/dist/local/transactions/mutations/deltaConfirmation.js +235 -0
- package/dist/local/transactions/mutations/durableCommitRestore.d.ts +17 -0
- package/dist/local/transactions/mutations/durableCommitRestore.js +95 -0
- package/dist/local/transactions/mutations/durableWriteStore.d.ts +14 -0
- package/dist/local/transactions/mutations/durableWriteStore.js +12 -0
- package/dist/local/transactions/mutations/executionSelection.d.ts +6 -0
- package/dist/local/transactions/mutations/executionSelection.js +42 -0
- package/dist/local/transactions/mutations/failureHandling.d.ts +16 -0
- package/dist/local/transactions/mutations/failureHandling.js +130 -0
- package/dist/local/transactions/mutations/failurePolicy.d.ts +13 -0
- package/dist/local/transactions/mutations/failurePolicy.js +48 -0
- package/dist/local/transactions/mutations/localMutation.d.ts +58 -0
- package/dist/local/transactions/mutations/localMutation.js +75 -0
- package/dist/local/transactions/mutations/modelOperations.d.ts +44 -0
- package/dist/local/transactions/mutations/modelOperations.js +144 -0
- package/dist/local/transactions/mutations/mutationPersistence.d.ts +25 -0
- package/dist/local/transactions/mutations/mutationPersistence.js +188 -0
- package/dist/local/transactions/mutations/pendingDrain.d.ts +33 -0
- package/dist/local/transactions/mutations/pendingDrain.js +112 -0
- package/dist/local/transactions/mutations/processingScheduler.d.ts +14 -0
- package/dist/local/transactions/mutations/processingScheduler.js +24 -0
- package/dist/local/transactions/mutations/queueCoalescing.d.ts +13 -0
- package/dist/local/transactions/mutations/queueCoalescing.js +35 -0
- package/dist/local/transactions/mutations/replayValidation.d.ts +187 -0
- package/dist/local/transactions/mutations/replayValidation.js +164 -0
- package/dist/local/transactions/reconnectDrain.d.ts +11 -0
- package/dist/local/transactions/reconnectDrain.js +13 -0
- package/dist/local/utils/mobxSetup.d.ts +53 -0
- package/dist/local/utils/mobxSetup.js +330 -0
- package/dist/local/views/QueryView.d.ts +79 -0
- package/dist/local/views/QueryView.js +218 -0
- package/dist/local/views/ViewRegistry.d.ts +20 -0
- package/dist/local/views/ViewRegistry.js +57 -0
- package/dist/local/views/incrementalView.d.ts +45 -0
- package/dist/local/views/incrementalView.js +69 -0
- package/dist/plugin.d.ts +285 -0
- package/dist/plugin.js +106 -0
- package/dist/presenceStream.d.ts +69 -0
- package/dist/presenceStream.js +200 -0
- package/dist/react/AbloProvider.d.ts +242 -0
- package/dist/react/AbloProvider.js +456 -0
- package/dist/react/ClientSideSuspense.d.ts +36 -0
- package/dist/react/ClientSideSuspense.js +17 -0
- package/dist/react/DefaultFallback.d.ts +24 -0
- package/dist/react/DefaultFallback.js +43 -0
- package/dist/react/context.d.ts +55 -0
- package/dist/react/context.js +29 -0
- package/dist/react/createAbloReact.d.ts +50 -0
- package/dist/react/createAbloReact.js +48 -0
- package/dist/react/internalContext.d.ts +33 -0
- package/dist/react/internalContext.js +3 -0
- package/dist/react/useAblo.d.ts +96 -0
- package/dist/react/useAblo.js +120 -0
- package/dist/react/useCurrentUserId.d.ts +2 -0
- package/dist/react/useCurrentUserId.js +12 -0
- package/dist/react/useErrorListener.d.ts +2 -0
- package/dist/react/useErrorListener.js +14 -0
- package/dist/react/useMutationFailureListener.d.ts +8 -0
- package/dist/react/useMutationFailureListener.js +19 -0
- package/dist/react/useMutators.d.ts +56 -0
- package/dist/react/useMutators.js +84 -0
- package/dist/react/useSyncStatus.d.ts +19 -0
- package/dist/react/useSyncStatus.js +37 -0
- package/dist/react/useUndoScope.d.ts +34 -0
- package/dist/react/useUndoScope.js +73 -0
- package/dist/react.d.ts +18 -0
- package/dist/react.js +14 -0
- package/dist/reactRuntime.d.ts +4 -0
- package/dist/reactRuntime.js +2 -0
- package/dist/surface.d.ts +36 -0
- package/dist/surface.js +77 -0
- package/dist/useReactive.d.ts +6 -0
- package/dist/useReactive.js +43 -0
- package/package.json +119 -0
- package/src/Ablo.ts +456 -0
- package/src/client.ts +374 -0
- package/src/core.ts +104 -0
- package/src/humans.ts +61 -0
- package/src/index.ts +40 -0
- package/src/local/BaseSyncedStore.ts +1991 -0
- package/src/local/Database.ts +2052 -0
- package/src/local/InstanceCache.ts +1503 -0
- package/src/local/LazyReferenceCollection.ts +563 -0
- package/src/local/Model.ts +1124 -0
- package/src/local/ModelRegistry.ts +762 -0
- package/src/local/NetworkMonitor.ts +88 -0
- package/src/local/RuntimeContext.ts +141 -0
- package/src/local/SyncClient.ts +2131 -0
- package/src/local/adapters/alwaysOnline.ts +20 -0
- package/src/local/adapters/inMemoryStorage.ts +141 -0
- package/src/local/client/clientPrelude.ts +112 -0
- package/src/local/client/consoleLogger.ts +60 -0
- package/src/local/client/createInternalComponents.ts +163 -0
- package/src/local/client/createModelProxy.ts +1390 -0
- package/src/local/client/modelRegistration.ts +350 -0
- package/src/local/client/options.ts +526 -0
- package/src/local/client/reactiveEngine.ts +935 -0
- package/src/local/client/resourceTypes.ts +37 -0
- package/src/local/client/schemaConfig.ts +194 -0
- package/src/local/client/storeCluster.ts +172 -0
- package/src/local/client/storeLifecycle.ts +343 -0
- package/src/local/client/validateAbloOptions.ts +95 -0
- package/src/local/client/wsMutationExecutor.ts +110 -0
- package/src/local/context.ts +96 -0
- package/src/local/coordination/ClaimLog.ts +39 -0
- package/src/local/interfaces/index.ts +468 -0
- package/src/local/localModelContract.ts +15 -0
- package/src/local/logPosition.ts +84 -0
- package/src/local/mutationPersistence.ts +7 -0
- package/src/local/mutators/RecordingMutation.ts +222 -0
- package/src/local/mutators/Transaction.ts +97 -0
- package/src/local/mutators/UndoManager.ts +741 -0
- package/src/local/mutators/defineMutators.ts +76 -0
- package/src/local/mutators/inverseOp.ts +83 -0
- package/src/local/mutators/mutateActions.ts +167 -0
- package/src/local/mutators/readerActions.ts +99 -0
- package/src/local/mutators/undoApply.ts +141 -0
- package/src/local/persistence.ts +16 -0
- package/src/local/query/QueryProcessor.ts +347 -0
- package/src/local/query/client.ts +197 -0
- package/src/local/query/types.ts +102 -0
- package/src/local/schema/serialize.ts +1 -0
- package/src/local/store/queryApi.ts +56 -0
- package/src/local/storeContract.ts +146 -0
- package/src/local/stores/DatabaseManager.ts +507 -0
- package/src/local/stores/ObjectStore.ts +449 -0
- package/src/local/stores/ObjectStoreContract.ts +48 -0
- package/src/local/stores/StoreManager.ts +388 -0
- package/src/local/stores/SyncActionStore.ts +579 -0
- package/src/local/stores/openIDBWithTimeout.ts +195 -0
- package/src/local/stores/persistenceCleanup.ts +43 -0
- package/src/local/stores/persistenceIdentity.ts +83 -0
- package/src/local/stores/syncAction.ts +21 -0
- package/src/local/stores/v1PersistenceDeletion.ts +21 -0
- package/src/local/sync/BootstrapFetcher.ts +1224 -0
- package/src/local/sync/ConnectionManager.ts +15 -0
- package/src/local/sync/OnDemandLoader.ts +927 -0
- package/src/local/sync/SubscriptionManager.ts +300 -0
- package/src/local/sync/SyncWebSocket.ts +584 -0
- package/src/local/sync/bootstrapApply.ts +130 -0
- package/src/local/sync/commitFrames.ts +16 -0
- package/src/local/sync/connectionManagerLifecycle.ts +158 -0
- package/src/local/sync/contextPorts.ts +37 -0
- package/src/local/sync/createClaimStream.ts +668 -0
- package/src/local/sync/createSnapshot.ts +160 -0
- package/src/local/sync/credentialLifecycle.ts +18 -0
- package/src/local/sync/deltaPipeline.ts +473 -0
- package/src/local/sync/groupChange.ts +343 -0
- package/src/local/sync/initialize.ts +205 -0
- package/src/local/sync/participants.ts +564 -0
- package/src/local/sync/persistedPrefix.ts +27 -0
- package/src/local/sync/reconnect.ts +88 -0
- package/src/local/sync/schemaDrift.ts +86 -0
- package/src/local/sync/schemas.ts +118 -0
- package/src/local/sync/socketEventWiring.ts +196 -0
- package/src/local/sync/syncCursor.ts +62 -0
- package/src/local/sync/syncPlan.ts +89 -0
- package/src/local/sync/terminalSessionLifecycle.ts +66 -0
- package/src/local/sync/wsFrameHandlers.ts +20 -0
- package/src/local/transactions/databaseCommitOutbox.ts +33 -0
- package/src/local/transactions/localMutation.ts +58 -0
- package/src/local/transactions/mutations/MutationQueue.ts +1998 -0
- package/src/local/transactions/mutations/MutationStore.ts +65 -0
- package/src/local/transactions/mutations/UnconfirmedWrites.ts +133 -0
- package/src/local/transactions/mutations/batchProcessing.ts +469 -0
- package/src/local/transactions/mutations/coalesceRules.ts +192 -0
- package/src/local/transactions/mutations/commitApi.ts +97 -0
- package/src/local/transactions/mutations/commitLane.ts +191 -0
- package/src/local/transactions/mutations/commitLatency.ts +164 -0
- package/src/local/transactions/mutations/commitPayload.ts +281 -0
- package/src/local/transactions/mutations/commitTransport.ts +174 -0
- package/src/local/transactions/mutations/deltaConfirmation.ts +298 -0
- package/src/local/transactions/mutations/durableCommitRestore.ts +128 -0
- package/src/local/transactions/mutations/durableWriteStore.ts +21 -0
- package/src/local/transactions/mutations/executionSelection.ts +42 -0
- package/src/local/transactions/mutations/failureHandling.ts +154 -0
- package/src/local/transactions/mutations/failurePolicy.ts +61 -0
- package/src/local/transactions/mutations/localMutation.ts +135 -0
- package/src/local/transactions/mutations/modelOperations.ts +210 -0
- package/src/local/transactions/mutations/mutationPersistence.ts +231 -0
- package/src/local/transactions/mutations/pendingDrain.ts +160 -0
- package/src/local/transactions/mutations/processingScheduler.ts +35 -0
- package/src/local/transactions/mutations/queueCoalescing.ts +45 -0
- package/src/local/transactions/mutations/replayValidation.ts +192 -0
- package/src/local/transactions/reconnectDrain.ts +24 -0
- package/src/local/utils/mobxSetup.ts +388 -0
- package/src/local/views/QueryView.ts +311 -0
- package/src/local/views/ViewRegistry.ts +61 -0
- package/src/local/views/incrementalView.ts +92 -0
- package/src/plugin.ts +396 -0
- package/src/presenceStream.ts +279 -0
- package/src/react/AbloProvider.tsx +744 -0
- package/src/react/ClientSideSuspense.tsx +57 -0
- package/src/react/DefaultFallback.tsx +60 -0
- package/src/react/context.ts +89 -0
- package/src/react/createAbloReact.ts +116 -0
- package/src/react/internalContext.ts +38 -0
- package/src/react/useAblo.ts +280 -0
- package/src/react/useCurrentUserId.ts +17 -0
- package/src/react/useErrorListener.ts +22 -0
- package/src/react/useMutationFailureListener.ts +34 -0
- package/src/react/useMutators.ts +184 -0
- package/src/react/useSyncStatus.ts +42 -0
- package/src/react/useUndoScope.ts +143 -0
- package/src/react.ts +69 -0
- package/src/reactRuntime.ts +10 -0
- package/src/surface.ts +106 -0
- package/src/useReactive.ts +51 -0
|
@@ -0,0 +1,2131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Applies model mutations and manages the offline write queue. The
|
|
3
|
+
* SyncClient turns local create, update, delete, and archive calls into
|
|
4
|
+
* optimistic changes, holds them while the client is offline, sends them to
|
|
5
|
+
* the server when connectivity returns, and resolves conflicts when the
|
|
6
|
+
* server's version of a row disagrees with the local one. It sits between the
|
|
7
|
+
* reactive object pool and the {@link MutationQueue} that delivers writes
|
|
8
|
+
* over the network.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { runInAction } from 'mobx';
|
|
12
|
+
import { InstanceCache, ModelScope } from './InstanceCache.js';
|
|
13
|
+
import { Model } from './Model.js';
|
|
14
|
+
import type { ModelData } from '@abloatai/transaction/types/modelData';
|
|
15
|
+
import type { AppliedChange } from '../plugin.js';
|
|
16
|
+
import { snapshotJsonValue } from '@abloatai/transaction/utils/json';
|
|
17
|
+
// ModelRegistry instance accessed via this.objectPool.registry
|
|
18
|
+
import { LoadStrategy } from '@abloatai/transaction/types';
|
|
19
|
+
import { globalRuntime } from './context.js';
|
|
20
|
+
import type { RuntimeContext } from './RuntimeContext.js';
|
|
21
|
+
import { AbloAuthenticationError, AbloError, AbloValidationError } from '@abloatai/transaction/errors';
|
|
22
|
+
import { EventEmitter } from 'events';
|
|
23
|
+
import { NetworkMonitor } from './NetworkMonitor.js';
|
|
24
|
+
import {
|
|
25
|
+
MutationQueue,
|
|
26
|
+
type QueuedMutation,
|
|
27
|
+
} from './transactions/mutations/MutationQueue.js';
|
|
28
|
+
import {
|
|
29
|
+
observeCommitLatency,
|
|
30
|
+
type CommitLatencySample,
|
|
31
|
+
} from './transactions/mutations/commitLatency.js';
|
|
32
|
+
import {
|
|
33
|
+
UnconfirmedWrites,
|
|
34
|
+
type UnconfirmedWritesMetrics,
|
|
35
|
+
} from './transactions/mutations/UnconfirmedWrites.js';
|
|
36
|
+
import type { DurableWriteStore } from './transactions/mutations/durableWriteStore.js';
|
|
37
|
+
import type { Database } from './Database.js';
|
|
38
|
+
import type { MutationPersistencePort } from './mutationPersistence.js';
|
|
39
|
+
import type { WriteOptions } from './interfaces/index.js';
|
|
40
|
+
import { LogPosition } from './logPosition.js';
|
|
41
|
+
import { createLocalMutationPort } from './transactions/localMutation.js';
|
|
42
|
+
import { createReconnectDrain } from './transactions/reconnectDrain.js';
|
|
43
|
+
import { DatabaseCommitOutboxStore } from './transactions/databaseCommitOutbox.js';
|
|
44
|
+
|
|
45
|
+
interface SyncObserver {
|
|
46
|
+
onSync?: (event: SyncEvent) => void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface SyncEvent {
|
|
50
|
+
type: 'create' | 'update' | 'delete' | 'archive' | 'rollback';
|
|
51
|
+
modelType: string;
|
|
52
|
+
model?: Model;
|
|
53
|
+
modelId?: string;
|
|
54
|
+
transactionType?: string; // Original transaction type that was rolled back
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface SyncState {
|
|
58
|
+
connectionState: 'connected' | 'disconnected' | 'connecting';
|
|
59
|
+
pendingMutations: number;
|
|
60
|
+
lastSyncAt?: Date;
|
|
61
|
+
error?: Error;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface RehydrationStats {
|
|
65
|
+
added: number;
|
|
66
|
+
updated: number;
|
|
67
|
+
removed: number;
|
|
68
|
+
skipped: number;
|
|
69
|
+
healed: number;
|
|
70
|
+
elapsedMs: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
type EventHandler = () => void;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Reports whether an incoming snapshot record is strictly newer than the
|
|
77
|
+
* model already in the pool. The comparison uses the server-stamped
|
|
78
|
+
* `updatedAt` timestamp, since rows carry no numeric version and the delta
|
|
79
|
+
* pipeline resolves order by arrival (last write wins). An undefined incoming
|
|
80
|
+
* timestamp counts as not newer, so a known row is never clobbered; an
|
|
81
|
+
* undefined existing timestamp means the pooled row is unversioned, so the
|
|
82
|
+
* incoming record wins. The scoped hydrate-on-enter path uses this to drop
|
|
83
|
+
* snapshot rows that a live delta has already advanced past.
|
|
84
|
+
*/
|
|
85
|
+
function rawRecordIsNewer(data: Record<string, unknown>, existing: Model): boolean {
|
|
86
|
+
const raw = data.updatedAt;
|
|
87
|
+
const inMs =
|
|
88
|
+
raw instanceof Date
|
|
89
|
+
? raw.getTime()
|
|
90
|
+
: typeof raw === 'string'
|
|
91
|
+
? (Number.isNaN(Date.parse(raw)) ? undefined : Date.parse(raw))
|
|
92
|
+
: typeof raw === 'number'
|
|
93
|
+
? raw
|
|
94
|
+
: undefined;
|
|
95
|
+
const exMs = existing.updatedAt instanceof Date ? existing.updatedAt.getTime() : undefined;
|
|
96
|
+
if (inMs === undefined) return false;
|
|
97
|
+
if (exMs === undefined) return true;
|
|
98
|
+
return inMs > exMs;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Converts an untyped server `updatedAt` value — an ISO string, epoch number,
|
|
103
|
+
* or Date read off an untyped row — into epoch milliseconds for
|
|
104
|
+
* last-write-wins comparison. Falsy or non-date values become 0, matching the
|
|
105
|
+
* conflict resolver's rule that a missing timestamp sorts as the epoch.
|
|
106
|
+
*/
|
|
107
|
+
function toEpochMs(value: unknown): number {
|
|
108
|
+
if (!value) return 0;
|
|
109
|
+
if (value instanceof Date) return value.getTime();
|
|
110
|
+
if (typeof value === 'string' || typeof value === 'number') {
|
|
111
|
+
return new Date(value).getTime();
|
|
112
|
+
}
|
|
113
|
+
return 0;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export class SyncClient extends EventEmitter {
|
|
117
|
+
private objectPool: InstanceCache;
|
|
118
|
+
private database: Database;
|
|
119
|
+
private readonly mutationPersistence: MutationPersistencePort;
|
|
120
|
+
private readonly reconnectDrain = createReconnectDrain();
|
|
121
|
+
private get mutationExecutor() { return this.runtime.mutationExecutor; }
|
|
122
|
+
private networkMonitor: NetworkMonitor;
|
|
123
|
+
/**
|
|
124
|
+
* @internal — test seam, stripped from the published declarations by
|
|
125
|
+
* `stripInternal`. Unit suites deliver queue lifecycle events directly.
|
|
126
|
+
*/
|
|
127
|
+
readonly mutationQueue: MutationQueue;
|
|
128
|
+
private observers = new Set<SyncObserver>();
|
|
129
|
+
|
|
130
|
+
// Authentication context
|
|
131
|
+
private userId: string | null = null;
|
|
132
|
+
private organizationId: string | null = null;
|
|
133
|
+
|
|
134
|
+
// The MutationQueue is the sole owner of queued transaction state.
|
|
135
|
+
private pendingStages = new Set<Promise<void>>();
|
|
136
|
+
private readonly commitOutboxNamespace: string;
|
|
137
|
+
|
|
138
|
+
/** Compatibility view for diagnostics; transaction state lives in the queue. */
|
|
139
|
+
private get pendingMutations() {
|
|
140
|
+
const transactions = [...this.mutationQueue.getOutstandingTransactions()];
|
|
141
|
+
const deferredCount = this.mutationQueue.getOutstandingTransactionCount() - transactions.length;
|
|
142
|
+
return deferredCount > 0
|
|
143
|
+
? [...transactions, ...Array.from({ length: deferredCount })]
|
|
144
|
+
: transactions;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Tracks the ids of transactions the client has applied optimistically but
|
|
149
|
+
* the server has not yet confirmed. When a delta arrives, the receive path
|
|
150
|
+
* consults this set to recognize the echo of the client's own mutation and
|
|
151
|
+
* skip the now-redundant pool update; the IndexedDB write still runs,
|
|
152
|
+
* because the delta is the authoritative version of the row. Without this
|
|
153
|
+
* discriminator, an optimistically applied delete followed by a
|
|
154
|
+
* server-confirmed create echo would resurrect the row for the window
|
|
155
|
+
* between the two confirmations.
|
|
156
|
+
*
|
|
157
|
+
* The set is bounded with first-in-first-out eviction, and
|
|
158
|
+
* {@link SyncClient.getEchoMetrics} exposes its counters.
|
|
159
|
+
*/
|
|
160
|
+
private readonly echoTracker = new UnconfirmedWrites();
|
|
161
|
+
|
|
162
|
+
// Connection state
|
|
163
|
+
private connectionState: 'connected' | 'disconnected' | 'connecting' = 'disconnected';
|
|
164
|
+
|
|
165
|
+
// Configuration
|
|
166
|
+
private isDisposed = false;
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The client's position in the global delta order, held as the single
|
|
170
|
+
* canonical {@link LogPosition} instance. The store advances `applied` and
|
|
171
|
+
* `persisted` as deltas land, the queue advances `acked` on commit
|
|
172
|
+
* responses, and snapshots and claims read `readFloor`.
|
|
173
|
+
*/
|
|
174
|
+
readonly position = new LogPosition();
|
|
175
|
+
|
|
176
|
+
constructor(
|
|
177
|
+
objectPool: InstanceCache,
|
|
178
|
+
database: Database,
|
|
179
|
+
commitOutbox: DurableWriteStore = new DatabaseCommitOutboxStore(database),
|
|
180
|
+
commitOutboxNamespace = 'default',
|
|
181
|
+
private readonly runtime: RuntimeContext = globalRuntime,
|
|
182
|
+
) {
|
|
183
|
+
super();
|
|
184
|
+
this.objectPool = objectPool;
|
|
185
|
+
this.database = database;
|
|
186
|
+
this.mutationPersistence = {
|
|
187
|
+
saveTransaction: (transaction) => database.saveTransaction(
|
|
188
|
+
snapshotJsonValue(
|
|
189
|
+
transaction,
|
|
190
|
+
'$.pendingMutation',
|
|
191
|
+
) as Parameters<Database['saveTransaction']>[0],
|
|
192
|
+
),
|
|
193
|
+
removeTransaction: (id) => database.removeTransaction(id),
|
|
194
|
+
getPersistedTransactions: () => database.getPersistedTransactions(),
|
|
195
|
+
};
|
|
196
|
+
this.commitOutboxNamespace = commitOutboxNamespace;
|
|
197
|
+
this.networkMonitor = new NetworkMonitor(this.runtime);
|
|
198
|
+
|
|
199
|
+
// Initialize MutationQueue with proper configuration
|
|
200
|
+
const localMutationPort = createLocalMutationPort((event, payload) => {
|
|
201
|
+
this.mutationQueue.emit(event, payload);
|
|
202
|
+
});
|
|
203
|
+
this.mutationQueue = new MutationQueue({
|
|
204
|
+
position: this.position,
|
|
205
|
+
runtime: this.runtime,
|
|
206
|
+
localMutationPort,
|
|
207
|
+
maxBatchSize: 50, // Larger batches keep the batch count low for bulk operations
|
|
208
|
+
// A short delay keeps writes responsive; coalescing still groups them
|
|
209
|
+
batchDelay: 150,
|
|
210
|
+
maxRetries: 3,
|
|
211
|
+
enableOptimistic: true,
|
|
212
|
+
enablePersistence: true,
|
|
213
|
+
conflictResolution: {
|
|
214
|
+
strategy: 'last-write-wins',
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
this.mutationQueue.setPersistence(this.mutationPersistence);
|
|
218
|
+
this.mutationQueue.setCommitOutbox(commitOutbox);
|
|
219
|
+
|
|
220
|
+
// Provide connection state to MutationQueue - prevents rollbacks during disconnection
|
|
221
|
+
this.mutationQueue.setConnectionChecker(() => this.connectionState === 'connected');
|
|
222
|
+
|
|
223
|
+
// Restore object-pool state when a transaction is rolled back. If the
|
|
224
|
+
// server rejects a write or it times out, the model's previous state is
|
|
225
|
+
// put back. Because writes are no longer applied to IndexedDB
|
|
226
|
+
// optimistically, that store already holds the correct state.
|
|
227
|
+
this.setupTransactionRollbackHandling();
|
|
228
|
+
|
|
229
|
+
// Forward reconciliation requests from the transaction queue. When delta
|
|
230
|
+
// confirmation times out, the client cycles the WebSocket connection to
|
|
231
|
+
// trigger a catch-up from the server rather than rolling the write back.
|
|
232
|
+
this.setupReconciliationForwarding();
|
|
233
|
+
|
|
234
|
+
// Persist unconfirmed transactions to IndexedDB. When delta retries are
|
|
235
|
+
// exhausted, the write is cached so it survives a tab close.
|
|
236
|
+
this.setupAwaitingTransactionPersistence();
|
|
237
|
+
|
|
238
|
+
// Setup network monitoring
|
|
239
|
+
this.setupNetworkMonitoring();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Setup network monitoring handlers
|
|
244
|
+
*/
|
|
245
|
+
private setupNetworkMonitoring(): void {
|
|
246
|
+
// Both handlers emit to external listeners (which can throw) before/around
|
|
247
|
+
// their own try/catch — route rejections into observability rather than
|
|
248
|
+
// losing a failed reconnect flush silently.
|
|
249
|
+
this.networkMonitor.on('online', () => {
|
|
250
|
+
void this.handleReconnection().catch((error: unknown) => {
|
|
251
|
+
this.runtime.observability.captureMutationFailure({
|
|
252
|
+
context: 'network-online-reconnection',
|
|
253
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
this.networkMonitor.on('offline', () => {
|
|
258
|
+
void this.handleDisconnection().catch((error: unknown) => {
|
|
259
|
+
this.runtime.observability.captureMutationFailure({
|
|
260
|
+
context: 'network-offline-handler',
|
|
261
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Handle transaction rollback. Two distinct shapes flow through this
|
|
269
|
+
* event:
|
|
270
|
+
*
|
|
271
|
+
* 1. **Server-rejected rollback** (`reason === 'permanent_error'`,
|
|
272
|
+
* `'max_retries_exhausted'`, `'conflict_server_wins'`) — the
|
|
273
|
+
* optimistic state is wrong, the row exists, restore previous
|
|
274
|
+
* state and notify the UI.
|
|
275
|
+
*
|
|
276
|
+
* 2. **Local-cancellation cleanup** (`reason === 'model_cancelled'`,
|
|
277
|
+
* `'cascade_parent_deleted'`) — the user deleted this model (or
|
|
278
|
+
* its parent), so a pending UPDATE on it gets cancelled. There's
|
|
279
|
+
* nothing to restore (the model is doomed) and no UI notification
|
|
280
|
+
* needed (the delete itself already triggered re-renders). Just
|
|
281
|
+
* discard the optimistic state silently.
|
|
282
|
+
*
|
|
283
|
+
* Treating both paths the same caused the deletion-flicker bug: every
|
|
284
|
+
* cancelled update on a multi-child record fired a per-model observer
|
|
285
|
+
* event and a `[SyncClient.rollback]` warn, producing N renders and N
|
|
286
|
+
* spam log lines for one user-initiated delete.
|
|
287
|
+
*/
|
|
288
|
+
private setupTransactionRollbackHandling(): void {
|
|
289
|
+
this.mutationQueue.on(
|
|
290
|
+
'optimistic:rollback',
|
|
291
|
+
(event: {
|
|
292
|
+
model: Model;
|
|
293
|
+
previousState: unknown;
|
|
294
|
+
transaction: { id: string; type: string; modelName: string; modelId: string };
|
|
295
|
+
reason?: string;
|
|
296
|
+
error?: Error;
|
|
297
|
+
}) => {
|
|
298
|
+
const { model, previousState, transaction, reason, error } = event;
|
|
299
|
+
|
|
300
|
+
// Local cleanup path — discard quietly. The optimistic state was
|
|
301
|
+
// applied to a model that's already disposed by the cascading
|
|
302
|
+
// delete, and emitting per-model observer events here would
|
|
303
|
+
// re-render N times for one user-initiated cascade.
|
|
304
|
+
if (reason === 'model_cancelled' || reason === 'cascade_parent_deleted') {
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Surface the typed AbloError fields directly — `type`/`code`/
|
|
309
|
+
// `httpStatus`/`requestId` are what tell us the rollback cause
|
|
310
|
+
// (e.g. `AbloValidationError` with `code: 'schema_...'`,
|
|
311
|
+
// `AbloServerError` with `httpStatus: 500`). Falling back to
|
|
312
|
+
// generic message lets us still see unstructured errors.
|
|
313
|
+
// Mechanic-level breadcrumb only. The authoritative, user-facing
|
|
314
|
+
// reason is logged once at `warn` by `MutationQueue.handleFailure`
|
|
315
|
+
// (`Permanent error - rolling back`). Logging the same typed cause
|
|
316
|
+
// again here at `warn` is what produced three identical dumps per
|
|
317
|
+
// rejected write — keep it at `debug` so the rollback mechanics are
|
|
318
|
+
// available when debugging but don't double the console noise.
|
|
319
|
+
const abloErr = error instanceof AbloError ? error : undefined;
|
|
320
|
+
this.runtime.logger.debug('[SyncClient.rollback]', {
|
|
321
|
+
txType: transaction.type,
|
|
322
|
+
modelName: transaction.modelName,
|
|
323
|
+
modelId: transaction.modelId.slice(0, 12),
|
|
324
|
+
reason: reason ?? 'unknown',
|
|
325
|
+
errorType: abloErr?.type ?? error?.name,
|
|
326
|
+
errorCode: abloErr?.code,
|
|
327
|
+
httpStatus: abloErr?.httpStatus,
|
|
328
|
+
requestId: abloErr?.requestId,
|
|
329
|
+
message: error?.message,
|
|
330
|
+
});
|
|
331
|
+
this.runtime.observability.captureRollback({
|
|
332
|
+
transactionType: transaction.type,
|
|
333
|
+
modelName: transaction.modelName,
|
|
334
|
+
modelId: transaction.modelId,
|
|
335
|
+
reason: reason ?? 'unknown',
|
|
336
|
+
error: error?.message,
|
|
337
|
+
connectionState: this.connectionState,
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
try {
|
|
341
|
+
if (transaction.type === 'create') {
|
|
342
|
+
// CREATE rollback: remove the optimistically created entity
|
|
343
|
+
this.objectPool.remove(transaction.modelId);
|
|
344
|
+
} else if (
|
|
345
|
+
transaction.type === 'delete' &&
|
|
346
|
+
reason === 'permanent_error' &&
|
|
347
|
+
error?.message?.includes('not found')
|
|
348
|
+
) {
|
|
349
|
+
// DELETE "not found" rollback: the entity doesn't exist on the server.
|
|
350
|
+
// Instead of restoring a ghost entity, remove it locally too.
|
|
351
|
+
// Both sides agree: this entity should not exist.
|
|
352
|
+
this.runtime.observability.breadcrumb(
|
|
353
|
+
'DELETE rolled back with "not found" - removing ghost entity',
|
|
354
|
+
'sync.conflict',
|
|
355
|
+
'info',
|
|
356
|
+
{
|
|
357
|
+
modelId: transaction.modelId,
|
|
358
|
+
modelName: transaction.modelName,
|
|
359
|
+
}
|
|
360
|
+
);
|
|
361
|
+
this.objectPool.remove(transaction.modelId);
|
|
362
|
+
} else if (model) {
|
|
363
|
+
// For update/delete/archive: restore model (with previousState if available)
|
|
364
|
+
// Guard: if the model was disposed (e.g. by a concurrent DELETE rollback or
|
|
365
|
+
// cascade), don't re-add it — Object.assign cannot restore the private
|
|
366
|
+
// isDisposed flag, so the model would be added in a broken state.
|
|
367
|
+
if (model.disposed) {
|
|
368
|
+
// Follow-on of an already-logged permanent error, not its own
|
|
369
|
+
// problem: the tx that failed has already surfaced the cause in
|
|
370
|
+
// MutationQueue. Restoring a disposed model is a no-op by
|
|
371
|
+
// design (can't revive the private isDisposed flag), so keep this
|
|
372
|
+
// at `debug` instead of emitting a second `warn` that reads as a
|
|
373
|
+
// distinct failure in the console.
|
|
374
|
+
this.runtime.logger.debug('[SyncClient] Rollback skipped restore (model already disposed)', {
|
|
375
|
+
modelId: transaction.modelId,
|
|
376
|
+
modelName: transaction.modelName,
|
|
377
|
+
reason,
|
|
378
|
+
});
|
|
379
|
+
} else {
|
|
380
|
+
if (previousState) Object.assign(model, previousState);
|
|
381
|
+
this.objectPool.add(model, ModelScope.live);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
this.notifyObservers({
|
|
386
|
+
type: 'rollback',
|
|
387
|
+
modelType: transaction.modelName,
|
|
388
|
+
modelId: transaction.modelId,
|
|
389
|
+
transactionType: transaction.type,
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
// Emit event so SyncedStore can clear pendingDeletes on delete rollback
|
|
393
|
+
this.emit('sync:rollback', {
|
|
394
|
+
modelId: transaction.modelId,
|
|
395
|
+
modelName: transaction.modelName,
|
|
396
|
+
transactionType: transaction.type,
|
|
397
|
+
reason,
|
|
398
|
+
});
|
|
399
|
+
} catch (error) {
|
|
400
|
+
this.runtime.observability.captureMutationFailure({
|
|
401
|
+
context: 'rollback-failed',
|
|
402
|
+
transactionId: transaction.id,
|
|
403
|
+
modelName: transaction.modelName,
|
|
404
|
+
modelId: transaction.modelId,
|
|
405
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Forward reconciliation requests from the {@link MutationQueue} to the
|
|
414
|
+
* sync layer. When delta confirmation times out, the queue emits
|
|
415
|
+
* `reconciliation:needed` instead of rolling back, so optimistic state the
|
|
416
|
+
* server may already have committed is never destroyed.
|
|
417
|
+
*/
|
|
418
|
+
private setupReconciliationForwarding(): void {
|
|
419
|
+
this.mutationQueue.on(
|
|
420
|
+
'reconciliation:needed',
|
|
421
|
+
(event: {
|
|
422
|
+
reason: string;
|
|
423
|
+
txId: string;
|
|
424
|
+
model: string;
|
|
425
|
+
modelId: string;
|
|
426
|
+
syncIdNeeded?: number;
|
|
427
|
+
lastSeenSyncId: number;
|
|
428
|
+
retryCount: number;
|
|
429
|
+
}) => {
|
|
430
|
+
this.runtime.observability.captureReconciliation({
|
|
431
|
+
reason: event.reason,
|
|
432
|
+
model: event.model,
|
|
433
|
+
modelId: event.modelId,
|
|
434
|
+
syncIdNeeded: event.syncIdNeeded,
|
|
435
|
+
lastSeenSyncId: event.lastSeenSyncId,
|
|
436
|
+
retryCount: event.retryCount,
|
|
437
|
+
connectionState: this.connectionState,
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
// Forward to SyncedStore via event — it has access to the WebSocket
|
|
441
|
+
this.emit('reconciliation:needed', event);
|
|
442
|
+
}
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Persist unconfirmed transactions to IndexedDB. When delta-confirmation
|
|
448
|
+
* retries are exhausted, the transaction is cached so it survives a tab
|
|
449
|
+
* close. On the next session, a WebSocket reconnect and delta catch-up
|
|
450
|
+
* deliver the missing deltas and confirm the transaction.
|
|
451
|
+
*/
|
|
452
|
+
private setupAwaitingTransactionPersistence(): void {
|
|
453
|
+
this.mutationQueue.on(
|
|
454
|
+
'transaction:persist_awaiting',
|
|
455
|
+
(event: {
|
|
456
|
+
txId: string;
|
|
457
|
+
model: string;
|
|
458
|
+
modelId: string;
|
|
459
|
+
operationType: string;
|
|
460
|
+
syncIdNeeded?: number;
|
|
461
|
+
}) => {
|
|
462
|
+
// void is safe: the handler's body is fully try/catch'd.
|
|
463
|
+
void this.persistAwaitingTransaction(event);
|
|
464
|
+
}
|
|
465
|
+
);
|
|
466
|
+
|
|
467
|
+
// Clean up persisted awaiting transactions when they're finally confirmed
|
|
468
|
+
this.mutationQueue.on(
|
|
469
|
+
'transaction:completed',
|
|
470
|
+
(tx: { id: string; modelName: string; modelId: string }) => {
|
|
471
|
+
// void is safe: the handler's body is fully try/catch'd.
|
|
472
|
+
void this.removeAwaitingTransaction(tx.id);
|
|
473
|
+
}
|
|
474
|
+
);
|
|
475
|
+
|
|
476
|
+
// Echo detection bridge. When the queue stages a transaction, the
|
|
477
|
+
// client has already optimistically applied the change to the
|
|
478
|
+
// pool — record the tx id so the matching server delta echo gets
|
|
479
|
+
// recognized in `applyDeltaBatchToPool`. The set is drained when
|
|
480
|
+
// the echo lands; if a transaction is rolled back before the
|
|
481
|
+
// server processes it, we drain on rollback too so a stale id
|
|
482
|
+
// doesn't permanently silence a foreign delta sharing the same id
|
|
483
|
+
// (vanishingly unlikely for UUIDs, but cheap insurance).
|
|
484
|
+
this.mutationQueue.on(
|
|
485
|
+
'transaction:created',
|
|
486
|
+
(tx: { id: string; localOnly?: boolean }) => {
|
|
487
|
+
if (!tx.localOnly) this.echoTracker.markPending(tx.id);
|
|
488
|
+
},
|
|
489
|
+
);
|
|
490
|
+
this.mutationQueue.on(
|
|
491
|
+
'optimistic:rollback',
|
|
492
|
+
(event: { transaction: { id: string } }) => {
|
|
493
|
+
this.echoTracker.drainOnRollback(event.transaction.id);
|
|
494
|
+
},
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/** Persist an unconfirmed transaction to IndexedDB (never rejects — failures are captured). */
|
|
499
|
+
private async persistAwaitingTransaction(event: {
|
|
500
|
+
txId: string;
|
|
501
|
+
model: string;
|
|
502
|
+
modelId: string;
|
|
503
|
+
operationType: string;
|
|
504
|
+
syncIdNeeded?: number;
|
|
505
|
+
}): Promise<void> {
|
|
506
|
+
if (!this.database) return;
|
|
507
|
+
|
|
508
|
+
try {
|
|
509
|
+
await this.database.saveTransaction({
|
|
510
|
+
id: `awaiting_${event.txId}`,
|
|
511
|
+
type: 'awaiting_delta',
|
|
512
|
+
timestamp: Date.now(),
|
|
513
|
+
awaitingDelta: {
|
|
514
|
+
syncIdNeeded: event.syncIdNeeded ?? 0,
|
|
515
|
+
modelName: event.model,
|
|
516
|
+
modelId: event.modelId,
|
|
517
|
+
operationType: event.operationType,
|
|
518
|
+
},
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
this.runtime.observability.breadcrumb(
|
|
522
|
+
'Persisted unconfirmed transaction to IDB',
|
|
523
|
+
'sync.transaction',
|
|
524
|
+
'info',
|
|
525
|
+
{
|
|
526
|
+
txId: event.txId,
|
|
527
|
+
model: event.model,
|
|
528
|
+
modelId: event.modelId,
|
|
529
|
+
}
|
|
530
|
+
);
|
|
531
|
+
} catch (error) {
|
|
532
|
+
this.runtime.observability.captureMutationFailure({
|
|
533
|
+
context: 'persist-awaiting-transaction',
|
|
534
|
+
modelName: event.model,
|
|
535
|
+
modelId: event.modelId,
|
|
536
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** Drop the persisted awaiting-row once confirmed (never rejects). */
|
|
542
|
+
private async removeAwaitingTransaction(txId: string): Promise<void> {
|
|
543
|
+
if (!this.database) return;
|
|
544
|
+
try {
|
|
545
|
+
await this.database.removeTransaction(`awaiting_${txId}`);
|
|
546
|
+
} catch {
|
|
547
|
+
// Ignore — might not have been persisted
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Initialize sync client with authentication
|
|
553
|
+
*/
|
|
554
|
+
async initialize(userId: string, organizationId: string): Promise<void> {
|
|
555
|
+
this.userId = userId;
|
|
556
|
+
this.organizationId = organizationId;
|
|
557
|
+
|
|
558
|
+
this.runtime.observability.setContext(userId, organizationId);
|
|
559
|
+
|
|
560
|
+
await this.mutationQueue.setCommitOutboxScope({
|
|
561
|
+
organizationId,
|
|
562
|
+
participantId: userId,
|
|
563
|
+
namespace: this.commitOutboxNamespace,
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
// Restore exact, already-sealed requests first. The returned source ids
|
|
567
|
+
// suppress any legacy queue entry left behind by an older non-atomic
|
|
568
|
+
// handoff.
|
|
569
|
+
const sealedMutationIds = await this.mutationQueue.restoreDurableCommits();
|
|
570
|
+
await this.mutationQueue.loadPersistedTransactions(this.mutationPersistence, sealedMutationIds);
|
|
571
|
+
|
|
572
|
+
// Read the initial network status from the injected OnlineStatusProvider.
|
|
573
|
+
// In the browser this reflects the host's connectivity signal; in Node it
|
|
574
|
+
// reports online by default. NetworkMonitor drives the ongoing
|
|
575
|
+
// online/offline transitions below — this read is only the initial
|
|
576
|
+
// snapshot taken when identity is set.
|
|
577
|
+
if (this.runtime.onlineStatus.isOnline()) {
|
|
578
|
+
this.setConnectionState('connected');
|
|
579
|
+
} else {
|
|
580
|
+
// Offline - start in offline mode
|
|
581
|
+
this.setConnectionState('disconnected');
|
|
582
|
+
this.emit('sync:offline');
|
|
583
|
+
}
|
|
584
|
+
if (this.mutationQueue.getOutstandingTransactionCount() > 0) {
|
|
585
|
+
this.scheduleSync();
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* The organization this client writes under (set by `initialize`).
|
|
591
|
+
* Read by the model proxy so `create()` defaults `organizationId` the
|
|
592
|
+
* same way the mutator path does — `null` until identity is wired.
|
|
593
|
+
*/
|
|
594
|
+
getOrganizationId(): string | null {
|
|
595
|
+
return this.organizationId;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* Self-healing helper for individual model records.
|
|
600
|
+
*
|
|
601
|
+
* Two registry-driven repair passes run on every row hydrated from
|
|
602
|
+
* IndexedDB or merged from a delta:
|
|
603
|
+
*
|
|
604
|
+
* 1. **Auto-fill** — for each `autoFill` rule the consumer's schema
|
|
605
|
+
* declares on this model, copy the corresponding identity value
|
|
606
|
+
* (`organizationId` / `userId`) onto the row when it's missing.
|
|
607
|
+
* Repairs rows from a past version that didn't write the field.
|
|
608
|
+
*
|
|
609
|
+
* 2. **Required-field gate** — if the row is missing any field listed
|
|
610
|
+
* in the model's `requiredFields`, return `null` so the caller
|
|
611
|
+
* skips this record. Used for FK columns whose absence renders the
|
|
612
|
+
* row unrecoverable (e.g. a Block with no sectionId).
|
|
613
|
+
*
|
|
614
|
+
* The engine itself is product-neutral: model identity (which fields
|
|
615
|
+
* to back-fill, which absences are fatal) lives entirely in the
|
|
616
|
+
* consumer schema.
|
|
617
|
+
*/
|
|
618
|
+
healModelRecord(
|
|
619
|
+
modelType: string,
|
|
620
|
+
data: Record<string, unknown>
|
|
621
|
+
): { data: Record<string, unknown>; healed: boolean } | null {
|
|
622
|
+
const meta = this.objectPool.registry.getMetadata(modelType);
|
|
623
|
+
if (!meta) return { data, healed: false };
|
|
624
|
+
|
|
625
|
+
const idPrefix = (data.id as string)?.slice(0, 8) ?? 'unknown';
|
|
626
|
+
let result = data;
|
|
627
|
+
let healed = false;
|
|
628
|
+
|
|
629
|
+
if (meta.autoFill) {
|
|
630
|
+
for (const rule of meta.autoFill) {
|
|
631
|
+
if (result[rule.field]) continue;
|
|
632
|
+
const replacement =
|
|
633
|
+
rule.from === 'organizationId' ? this.organizationId : this.userId;
|
|
634
|
+
if (!replacement) continue;
|
|
635
|
+
this.runtime.observability.captureSelfHealing({
|
|
636
|
+
modelName: modelType,
|
|
637
|
+
modelId: idPrefix,
|
|
638
|
+
field: rule.field,
|
|
639
|
+
action: `added missing ${rule.field}`,
|
|
640
|
+
});
|
|
641
|
+
result = { ...result, [rule.field]: replacement };
|
|
642
|
+
healed = true;
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
if (meta.requiredFields) {
|
|
647
|
+
for (const field of meta.requiredFields) {
|
|
648
|
+
if (result[field]) continue;
|
|
649
|
+
this.runtime.observability.captureSelfHealing({
|
|
650
|
+
modelName: modelType,
|
|
651
|
+
modelId: idPrefix,
|
|
652
|
+
field,
|
|
653
|
+
action: `skipped corrupted ${modelType} - missing ${field}`,
|
|
654
|
+
});
|
|
655
|
+
return null;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
return { data: result, healed };
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* Hydrate InstanceCache with data from Database
|
|
664
|
+
* Called after bootstrap is complete
|
|
665
|
+
*/
|
|
666
|
+
async hydrateFromDatabase(): Promise<void> {
|
|
667
|
+
if (!this.database) {
|
|
668
|
+
throw new AbloValidationError('Database not available for hydration', {
|
|
669
|
+
code: 'sync_client_db_missing',
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// Get model types that should be hydrated on startup (skip lazy per LSE)
|
|
674
|
+
const modelTypes = this.objectPool.registry.getRegisteredModelNames().filter((name) => {
|
|
675
|
+
const meta = this.objectPool.registry.getMetadata(name);
|
|
676
|
+
return meta?.loadStrategy === LoadStrategy.instant;
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
const totalStart = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
680
|
+
|
|
681
|
+
// Phase 1: Fetch all data from IndexedDB and create model instances (async I/O).
|
|
682
|
+
// We collect all models across ALL types before touching MobX, so that Phase 2
|
|
683
|
+
// can add them in a single addBatch() call → ONE MobX action → ONE re-render.
|
|
684
|
+
const allModelsToAdd: Model[] = [];
|
|
685
|
+
const perTypePerfLogs: {
|
|
686
|
+
type: string;
|
|
687
|
+
fetched: number;
|
|
688
|
+
added: number;
|
|
689
|
+
fetchMs: string;
|
|
690
|
+
createMs: string;
|
|
691
|
+
}[] = [];
|
|
692
|
+
|
|
693
|
+
for (const modelType of modelTypes) {
|
|
694
|
+
const typeStart = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
695
|
+
try {
|
|
696
|
+
// Get raw data from Database (via StoreManager)
|
|
697
|
+
const rawData = await this.database.hydrateModels(modelType);
|
|
698
|
+
const afterFetch = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
699
|
+
|
|
700
|
+
// Create models in batch first, collect for deferred addBatch
|
|
701
|
+
const modelsForType: Model[] = [];
|
|
702
|
+
const recordsToHeal: { id: string; data: Record<string, unknown> }[] = [];
|
|
703
|
+
|
|
704
|
+
for (const data of rawData) {
|
|
705
|
+
let withType =
|
|
706
|
+
data && typeof data === 'object' && !data.__typename
|
|
707
|
+
? { __typename: modelType, ...data }
|
|
708
|
+
: data;
|
|
709
|
+
|
|
710
|
+
// Self-healing: Fix corrupted IndexedDB records missing essential fields
|
|
711
|
+
const healResult = this.healModelRecord(modelType, withType);
|
|
712
|
+
if (healResult === null) {
|
|
713
|
+
continue; // Record is corrupted beyond repair — skip
|
|
714
|
+
}
|
|
715
|
+
withType = healResult.data;
|
|
716
|
+
if (healResult.healed) {
|
|
717
|
+
recordsToHeal.push({ id: healResult.data.id as string, data: healResult.data });
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
const model = this.objectPool.createFromData(withType);
|
|
721
|
+
|
|
722
|
+
if (model) {
|
|
723
|
+
modelsForType.push(model);
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// Collect models for the single batched addBatch call in Phase 2
|
|
728
|
+
allModelsToAdd.push(...modelsForType);
|
|
729
|
+
|
|
730
|
+
// Persist healed records back to IndexedDB (fire-and-forget, non-blocking)
|
|
731
|
+
if (recordsToHeal.length > 0 && this.database) {
|
|
732
|
+
this.runtime.logger.info(
|
|
733
|
+
`[SyncClient.hydrate] Persisting ${recordsToHeal.length} healed ${modelType} records to IndexedDB`
|
|
734
|
+
);
|
|
735
|
+
// Use fire-and-forget to not block hydration.
|
|
736
|
+
// void is safe: the handler's body is fully try/catch'd.
|
|
737
|
+
void Promise.resolve().then(async () => {
|
|
738
|
+
try {
|
|
739
|
+
for (const { id, data } of recordsToHeal) {
|
|
740
|
+
await this.database.putRecord(modelType, id, data);
|
|
741
|
+
}
|
|
742
|
+
this.runtime.logger.info(
|
|
743
|
+
`[SyncClient.hydrate] Successfully healed ${recordsToHeal.length} ${modelType} records`
|
|
744
|
+
);
|
|
745
|
+
} catch (err) {
|
|
746
|
+
this.runtime.observability.captureMutationFailure({
|
|
747
|
+
context: 'persist-healed-records',
|
|
748
|
+
modelName: modelType,
|
|
749
|
+
error: err instanceof Error ? err : new Error(String(err)),
|
|
750
|
+
});
|
|
751
|
+
}
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
const typeEnd = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
756
|
+
|
|
757
|
+
perTypePerfLogs.push({
|
|
758
|
+
type: modelType,
|
|
759
|
+
fetched: rawData.length,
|
|
760
|
+
added: modelsForType.length,
|
|
761
|
+
fetchMs: (afterFetch - typeStart).toFixed(2),
|
|
762
|
+
createMs: (typeEnd - afterFetch).toFixed(2),
|
|
763
|
+
});
|
|
764
|
+
} catch (error) {
|
|
765
|
+
this.runtime.observability.captureBootstrapFailure(error, { type: `hydrate-${modelType}` });
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// Phase 2: Single MobX action — add ALL models across all types at once.
|
|
770
|
+
const addStart = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
771
|
+
const totalAdded = this.objectPool.addBatch(allModelsToAdd, ModelScope.live);
|
|
772
|
+
const addEnd = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
773
|
+
|
|
774
|
+
// Log per-type perf after the batched add (so logs still show per-type breakdown)
|
|
775
|
+
for (const entry of perTypePerfLogs) {
|
|
776
|
+
this.runtime.logger.debug('hydrate:type', parseFloat(entry.fetchMs) + parseFloat(entry.createMs), {
|
|
777
|
+
type: entry.type,
|
|
778
|
+
fetched: entry.fetched,
|
|
779
|
+
added: entry.added,
|
|
780
|
+
fetchMs: entry.fetchMs,
|
|
781
|
+
createMs: entry.createMs,
|
|
782
|
+
});
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
const totalEnd = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
786
|
+
this.runtime.logger.debug('hydrate:total', totalEnd - totalStart, {
|
|
787
|
+
totalModels: totalAdded,
|
|
788
|
+
addBatchMs: (addEnd - addStart).toFixed(2),
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
// One-line startup summary: types pre-seeded and items per type
|
|
792
|
+
try {
|
|
793
|
+
const preseededTypes = this.objectPool.registry.getRegisteredModelNames();
|
|
794
|
+
const stats = this.objectPool.getStats();
|
|
795
|
+
this.runtime.logger.info('startup_summary', {
|
|
796
|
+
typesPreseeded: preseededTypes.length,
|
|
797
|
+
poolSize: stats.size,
|
|
798
|
+
typeCounts: stats.typeCounts,
|
|
799
|
+
});
|
|
800
|
+
} catch {}
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* Re-hydrate InstanceCache from IndexedDB when the pool already has data.
|
|
805
|
+
*
|
|
806
|
+
* Unlike hydrateFromDatabase() (which uses addBatch and skips existing IDs),
|
|
807
|
+
* this method properly:
|
|
808
|
+
* 1. Upserts models — updates existing models in-place, adds new ones
|
|
809
|
+
* 2. Removes ghosts — deletes models from the pool that no longer exist in IndexedDB
|
|
810
|
+
*
|
|
811
|
+
* Used by background bootstrap, network recovery, and server-triggered re-bootstrap.
|
|
812
|
+
*/
|
|
813
|
+
async rehydrateFromDatabase(): Promise<RehydrationStats> {
|
|
814
|
+
if (!this.database) {
|
|
815
|
+
throw new AbloValidationError('Database not available for rehydration', {
|
|
816
|
+
code: 'sync_client_db_missing',
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
const totalStart = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
821
|
+
|
|
822
|
+
// Model types to rehydrate (same filter as hydrateFromDatabase)
|
|
823
|
+
const modelTypes = this.objectPool.registry.getRegisteredModelNames().filter((name) => {
|
|
824
|
+
const meta = this.objectPool.registry.getMetadata(name);
|
|
825
|
+
return meta?.loadStrategy === LoadStrategy.instant;
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
// ── Phase 1: Read from IndexedDB & create model instances (async I/O) ──
|
|
829
|
+
const allModels: Model[] = [];
|
|
830
|
+
const idbIdsByType = new Map<string, Set<string>>();
|
|
831
|
+
let healedCount = 0;
|
|
832
|
+
let skippedCount = 0;
|
|
833
|
+
|
|
834
|
+
for (const modelType of modelTypes) {
|
|
835
|
+
try {
|
|
836
|
+
const rawData = await this.database.hydrateModels(modelType);
|
|
837
|
+
const idsForType = new Set<string>();
|
|
838
|
+
idbIdsByType.set(modelType, idsForType);
|
|
839
|
+
|
|
840
|
+
for (const data of rawData) {
|
|
841
|
+
let withType =
|
|
842
|
+
data && typeof data === 'object' && !data.__typename
|
|
843
|
+
? { __typename: modelType, ...data }
|
|
844
|
+
: data;
|
|
845
|
+
|
|
846
|
+
// Self-healing
|
|
847
|
+
const healResult = this.healModelRecord(modelType, withType);
|
|
848
|
+
if (healResult === null) {
|
|
849
|
+
skippedCount++;
|
|
850
|
+
continue;
|
|
851
|
+
}
|
|
852
|
+
withType = healResult.data;
|
|
853
|
+
if (healResult.healed) {
|
|
854
|
+
healedCount++;
|
|
855
|
+
// Persist heal back to IndexedDB (fire-and-forget)
|
|
856
|
+
if (this.database) {
|
|
857
|
+
const id = healResult.data.id as string;
|
|
858
|
+
const healedData = healResult.data;
|
|
859
|
+
// void is safe: the handler's body is fully try/catch'd.
|
|
860
|
+
void Promise.resolve().then(async () => {
|
|
861
|
+
try {
|
|
862
|
+
await this.database.putRecord(modelType, id, healedData);
|
|
863
|
+
} catch {
|
|
864
|
+
// Non-critical — will heal again next time
|
|
865
|
+
}
|
|
866
|
+
});
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// Register ID before createFromData — prevents ghost removal
|
|
871
|
+
// if createFromData fails for a record that exists in IDB
|
|
872
|
+
const recordId = (withType as Record<string, unknown>).id as string | undefined;
|
|
873
|
+
if (recordId) {
|
|
874
|
+
idsForType.add(recordId);
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
try {
|
|
878
|
+
const model = this.objectPool.createFromData(withType);
|
|
879
|
+
if (model) {
|
|
880
|
+
allModels.push(model);
|
|
881
|
+
}
|
|
882
|
+
} catch (error) {
|
|
883
|
+
this.runtime.observability.breadcrumb(
|
|
884
|
+
'Model creation failed during rehydration',
|
|
885
|
+
'sync.bootstrap',
|
|
886
|
+
'warning',
|
|
887
|
+
{
|
|
888
|
+
modelType,
|
|
889
|
+
modelId: recordId?.slice(0, 8) ?? 'unknown',
|
|
890
|
+
error: error instanceof Error ? error.message : String(error),
|
|
891
|
+
}
|
|
892
|
+
);
|
|
893
|
+
skippedCount++;
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
} catch (error) {
|
|
897
|
+
this.runtime.observability.captureBootstrapFailure(error, { type: `rehydrate-${modelType}` });
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
// ── Phase 2: Upsert batch (single MobX action) ──
|
|
902
|
+
// createFromData already calls updateFromData() on existing models,
|
|
903
|
+
// so existing models are up-to-date. Upsert adds the new ones and
|
|
904
|
+
// updates scope for any that changed.
|
|
905
|
+
const beforeSize = this.objectPool.size;
|
|
906
|
+
this.objectPool.upsertBatch(allModels, ModelScope.live);
|
|
907
|
+
const addedCount = this.objectPool.size - beforeSize;
|
|
908
|
+
const updatedCount = allModels.length - addedCount;
|
|
909
|
+
|
|
910
|
+
// ── Phase 3: Reconcile ghost deletions (single MobX action) ──
|
|
911
|
+
// Only reconcile types that were rehydrated — never touch lazy-loaded types.
|
|
912
|
+
const ghostIds: string[] = [];
|
|
913
|
+
|
|
914
|
+
for (const modelType of modelTypes) {
|
|
915
|
+
const idbIds = idbIdsByType.get(modelType);
|
|
916
|
+
if (!idbIds) continue; // Type had an error during fetch — don't reconcile
|
|
917
|
+
|
|
918
|
+
const poolIds = this.objectPool.getIdsByModelType(modelType);
|
|
919
|
+
if (!poolIds) continue;
|
|
920
|
+
|
|
921
|
+
for (const poolId of poolIds) {
|
|
922
|
+
if (!idbIds.has(poolId)) {
|
|
923
|
+
ghostIds.push(poolId);
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
const removedCount = this.objectPool.removeBatch(ghostIds);
|
|
929
|
+
|
|
930
|
+
// ── Phase 4: Stats & logging ──
|
|
931
|
+
const totalEnd = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
932
|
+
const elapsedMs = Math.round(totalEnd - totalStart);
|
|
933
|
+
|
|
934
|
+
const stats: RehydrationStats = {
|
|
935
|
+
added: addedCount,
|
|
936
|
+
updated: updatedCount,
|
|
937
|
+
removed: removedCount,
|
|
938
|
+
skipped: skippedCount,
|
|
939
|
+
healed: healedCount,
|
|
940
|
+
elapsedMs,
|
|
941
|
+
};
|
|
942
|
+
|
|
943
|
+
this.runtime.logger.info('[SyncClient.rehydrate] Complete', {
|
|
944
|
+
...stats,
|
|
945
|
+
poolSize: this.objectPool.size,
|
|
946
|
+
ghostIds: ghostIds.length > 0 ? ghostIds.slice(0, 5).map((id) => id.slice(0, 8)) : [],
|
|
947
|
+
});
|
|
948
|
+
|
|
949
|
+
this.runtime.observability.breadcrumb('Rehydration complete', 'sync.bootstrap', 'info', {
|
|
950
|
+
added: stats.added,
|
|
951
|
+
updated: stats.updated,
|
|
952
|
+
removed: stats.removed,
|
|
953
|
+
elapsedMs: stats.elapsedMs,
|
|
954
|
+
});
|
|
955
|
+
|
|
956
|
+
return stats;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
/**
|
|
960
|
+
* Apply a mutation to a model optimistically and queue it for server sync.
|
|
961
|
+
* IndexedDB is updated only once the server confirms the change with a delta
|
|
962
|
+
* packet.
|
|
963
|
+
*
|
|
964
|
+
* A model's changes are captured before the pool action runs, because a pool
|
|
965
|
+
* operation such as an upsert can clear the model's local change set;
|
|
966
|
+
* capturing first ensures those changes are never lost. The captured set is
|
|
967
|
+
* frozen and handed to {@link queueMutation}.
|
|
968
|
+
*/
|
|
969
|
+
private mutate(
|
|
970
|
+
type: 'create' | 'update' | 'delete' | 'archive',
|
|
971
|
+
model: Model,
|
|
972
|
+
poolAction: () => void,
|
|
973
|
+
writeOptions?: WriteOptions,
|
|
974
|
+
): void {
|
|
975
|
+
// No-op UPDATE guard (O(1)). An update with no dirty fields would travel
|
|
976
|
+
// to the server, get dropped by `coalesceOperations` Rule 4 (empty input),
|
|
977
|
+
// and — if it was the only op — come back as `lastSyncId: 0`. That trips
|
|
978
|
+
// `captureCommitZeroSyncId` (false-positive Sentry anomaly) AND parks the
|
|
979
|
+
// tx in `awaiting_delta` for a 30s reconciliation timeout on a write that
|
|
980
|
+
// changed nothing. `Model.hasChanges` reads `modifiedProperties.size`, so
|
|
981
|
+
// this costs O(1) with no allocation (vs. O(N) materializing getChanges()).
|
|
982
|
+
//
|
|
983
|
+
// Strict `=== false` is deliberate: `rowAsModel` only casts, so a non-Model
|
|
984
|
+
// object can reach here with `hasChanges === undefined`. `undefined === false`
|
|
985
|
+
// is false → we fall through to the normal path rather than risk dropping a
|
|
986
|
+
// real write. Only a genuine Model with an empty dirty-set is skipped.
|
|
987
|
+
const hasChanges: unknown = model.hasChanges;
|
|
988
|
+
if (type === 'update' && hasChanges === false) {
|
|
989
|
+
return;
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
// Capture changes before the pool action runs. Pool operations —
|
|
993
|
+
// upsert in particular — can clear the model's local changes, so
|
|
994
|
+
// capturing first ensures they are never lost.
|
|
995
|
+
const capturedChanges =
|
|
996
|
+
type === 'update' || type === 'create' ? this.captureModelChanges(model) : undefined;
|
|
997
|
+
|
|
998
|
+
poolAction();
|
|
999
|
+
this.stageMutation(type, model, capturedChanges, writeOptions);
|
|
1000
|
+
this.notifyObservers({
|
|
1001
|
+
type,
|
|
1002
|
+
modelType: model.getModelName(),
|
|
1003
|
+
model: type !== 'delete' ? model : undefined,
|
|
1004
|
+
modelId: model.id,
|
|
1005
|
+
});
|
|
1006
|
+
|
|
1007
|
+
// QueryProcessor uses `models:changed` to invalidate caches. Coalesce
|
|
1008
|
+
// to one event per microtask: a paste of 100 rows should re-run
|
|
1009
|
+
// affected queries ONCE, not 100×.
|
|
1010
|
+
this.markModelChanged(model.getModelName());
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
private pendingChangedTypes: Set<string> | null = null;
|
|
1014
|
+
|
|
1015
|
+
private markModelChanged(modelType: string): void {
|
|
1016
|
+
if (!this.pendingChangedTypes) {
|
|
1017
|
+
this.pendingChangedTypes = new Set();
|
|
1018
|
+
const schedule =
|
|
1019
|
+
typeof queueMicrotask === 'function'
|
|
1020
|
+
? queueMicrotask
|
|
1021
|
+
: (cb: () => void) => Promise.resolve().then(cb);
|
|
1022
|
+
schedule(() => {
|
|
1023
|
+
const types = this.pendingChangedTypes;
|
|
1024
|
+
this.pendingChangedTypes = null;
|
|
1025
|
+
if (types && types.size > 0) this.emit('models:changed', types);
|
|
1026
|
+
});
|
|
1027
|
+
}
|
|
1028
|
+
this.pendingChangedTypes.add(modelType);
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
/**
|
|
1032
|
+
* Capture model changes immutably BEFORE any pool operations
|
|
1033
|
+
* This prevents the fragile pattern of reading changes after state modification
|
|
1034
|
+
*/
|
|
1035
|
+
private captureModelChanges(model: Model): Record<string, unknown> | undefined {
|
|
1036
|
+
if (typeof model.getChanges !== 'function') return undefined;
|
|
1037
|
+
const changes = model.getChanges();
|
|
1038
|
+
// Return a frozen copy to prevent accidental modification
|
|
1039
|
+
return Object.keys(changes).length > 0 ? Object.freeze({ ...changes }) : undefined;
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
/** Add new model (CREATE) - works offline */
|
|
1043
|
+
add(model: Model, options?: WriteOptions): void {
|
|
1044
|
+
this.mutate('create', model, () => { this.objectPool.add(model, ModelScope.live); }, options);
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
/** Update existing model (UPDATE) - works offline */
|
|
1048
|
+
update(model: Model, options?: WriteOptions): void {
|
|
1049
|
+
this.mutate('update', model, () => { this.objectPool.upsert(model, ModelScope.live); }, options);
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
/**
|
|
1053
|
+
* Update existing model with pre-computed changes.
|
|
1054
|
+
* Used by saveManyOptimized when incoming models have empty change-tracking
|
|
1055
|
+
* (e.g. freshly constructed cell models from a bulk document decomposition).
|
|
1056
|
+
*/
|
|
1057
|
+
updateWithChanges(model: Model, changes?: Record<string, unknown>): void {
|
|
1058
|
+
this.runtime.logger.debug(`SyncClient.updateWithChanges`, {
|
|
1059
|
+
modelId: model.id,
|
|
1060
|
+
modelType: model.getModelName(),
|
|
1061
|
+
});
|
|
1062
|
+
|
|
1063
|
+
// Use pre-computed changes if provided, otherwise fall back to model.getChanges()
|
|
1064
|
+
const capturedChanges =
|
|
1065
|
+
changes && Object.keys(changes).length > 0
|
|
1066
|
+
? Object.freeze({ ...changes })
|
|
1067
|
+
: this.captureModelChanges(model);
|
|
1068
|
+
|
|
1069
|
+
// No-op UPDATE guard: neither an explicit change set nor model dirty-fields.
|
|
1070
|
+
// `captureModelChanges` already returns undefined for an empty dirty-set, so
|
|
1071
|
+
// an undefined here means there is genuinely nothing to send — skip rather
|
|
1072
|
+
// than emit an empty-input update that the server coalesces to lastSyncId 0
|
|
1073
|
+
// (see the same guard in `mutate`).
|
|
1074
|
+
if (capturedChanges === undefined) return;
|
|
1075
|
+
|
|
1076
|
+
this.objectPool.upsert(model, ModelScope.live);
|
|
1077
|
+
this.stageMutation('update', model, capturedChanges);
|
|
1078
|
+
this.notifyObservers({
|
|
1079
|
+
type: 'update',
|
|
1080
|
+
modelType: model.getModelName(),
|
|
1081
|
+
model,
|
|
1082
|
+
modelId: model.id,
|
|
1083
|
+
});
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
/** Expose the GraphQL client for atomic mutations (e.g., createSectionWithBlocks).
|
|
1087
|
+
* Used by SyncedStore for operations that bypass the transaction queue
|
|
1088
|
+
* but still need optimistic pool updates at the sync layer. */
|
|
1089
|
+
get gql() {
|
|
1090
|
+
return this.mutationExecutor;
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
/** Delete model (DELETE) - works offline */
|
|
1094
|
+
delete(model: Model, options?: WriteOptions): void {
|
|
1095
|
+
// Clear pending mutations first to prevent "not found" errors on fast delete
|
|
1096
|
+
this.mutationQueue.cancelTransactionsForModel(model.id);
|
|
1097
|
+
this.mutate('delete', model, () => this.objectPool.remove(model.id), options);
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
/**
|
|
1101
|
+
* Upload a file and create its attachment record. The upload runs through
|
|
1102
|
+
* the {@link MutationQueue}, and a model is built from the server's
|
|
1103
|
+
* response and added to the pool.
|
|
1104
|
+
*/
|
|
1105
|
+
async uploadFile(
|
|
1106
|
+
file: File,
|
|
1107
|
+
options: {
|
|
1108
|
+
id: string;
|
|
1109
|
+
attachableType: string;
|
|
1110
|
+
attachableId: string;
|
|
1111
|
+
metadata?: Record<string, unknown>;
|
|
1112
|
+
}
|
|
1113
|
+
): Promise<Model | null> {
|
|
1114
|
+
if (!this.userId || !this.organizationId) {
|
|
1115
|
+
throw new AbloAuthenticationError('Authentication required for file uploads', {
|
|
1116
|
+
code: 'file_upload_auth_required',
|
|
1117
|
+
});
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
try {
|
|
1121
|
+
// Use MutationQueue to handle the upload mutation
|
|
1122
|
+
const result = await this.mutationQueue.uploadAttachment(
|
|
1123
|
+
file,
|
|
1124
|
+
{
|
|
1125
|
+
id: options.id,
|
|
1126
|
+
attachableType: options.attachableType,
|
|
1127
|
+
attachableId: options.attachableId,
|
|
1128
|
+
metadata: options.metadata,
|
|
1129
|
+
},
|
|
1130
|
+
{
|
|
1131
|
+
userId: this.userId,
|
|
1132
|
+
organizationId: this.organizationId,
|
|
1133
|
+
}
|
|
1134
|
+
);
|
|
1135
|
+
|
|
1136
|
+
if (result) {
|
|
1137
|
+
// Create model from response using ModelRegistry (generic — no concrete class import)
|
|
1138
|
+
const model = this.objectPool.createFromData({
|
|
1139
|
+
id: options.id,
|
|
1140
|
+
...result,
|
|
1141
|
+
});
|
|
1142
|
+
|
|
1143
|
+
if (model) {
|
|
1144
|
+
this.objectPool.add(model, ModelScope.live);
|
|
1145
|
+
this.notifyObservers({
|
|
1146
|
+
type: 'create',
|
|
1147
|
+
modelType: model.getModelName(),
|
|
1148
|
+
model,
|
|
1149
|
+
});
|
|
1150
|
+
return model;
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
return null;
|
|
1155
|
+
} catch (error) {
|
|
1156
|
+
this.runtime.observability.captureMutationFailure({
|
|
1157
|
+
context: 'file-upload',
|
|
1158
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
1159
|
+
});
|
|
1160
|
+
throw error;
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
/**
|
|
1165
|
+
* Batch upload files — single GraphQL call + parallel S3 PUTs.
|
|
1166
|
+
*
|
|
1167
|
+
* Returns the raw `Model[]` built by the object pool (typename is
|
|
1168
|
+
* determined by the payload the server returns — currently always
|
|
1169
|
+
* `Attachment`). The SDK has no knowledge of app-specific model classes,
|
|
1170
|
+
* so it cannot honestly claim a narrower return type; consumers that
|
|
1171
|
+
* need an `Attachment[]` project through their own typed accessor
|
|
1172
|
+
* (e.g. `store.query.attachments.findMany({ where: { id: IN ids } })`)
|
|
1173
|
+
* after the upload resolves.
|
|
1174
|
+
*/
|
|
1175
|
+
async batchUploadFiles(
|
|
1176
|
+
files: File[],
|
|
1177
|
+
options: {
|
|
1178
|
+
ids: string[];
|
|
1179
|
+
attachableType: string;
|
|
1180
|
+
attachableId: string;
|
|
1181
|
+
metadata?: Record<string, unknown>;
|
|
1182
|
+
}
|
|
1183
|
+
): Promise<Model[]> {
|
|
1184
|
+
if (!this.userId || !this.organizationId) {
|
|
1185
|
+
throw new AbloAuthenticationError('Authentication required for file uploads', {
|
|
1186
|
+
code: 'file_upload_auth_required',
|
|
1187
|
+
});
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
const items = options.ids.map((id) => ({
|
|
1191
|
+
id,
|
|
1192
|
+
attachableType: options.attachableType,
|
|
1193
|
+
attachableId: options.attachableId,
|
|
1194
|
+
metadata: options.metadata,
|
|
1195
|
+
}));
|
|
1196
|
+
|
|
1197
|
+
const results = await this.mutationQueue.batchUploadAttachments(files, items, {
|
|
1198
|
+
userId: this.userId,
|
|
1199
|
+
organizationId: this.organizationId,
|
|
1200
|
+
});
|
|
1201
|
+
|
|
1202
|
+
const models: Model[] = [];
|
|
1203
|
+
for (const result of results) {
|
|
1204
|
+
const model = this.objectPool.createFromData({ ...result });
|
|
1205
|
+
if (model) {
|
|
1206
|
+
this.objectPool.add(model, ModelScope.live);
|
|
1207
|
+
this.notifyObservers({
|
|
1208
|
+
type: 'create',
|
|
1209
|
+
modelType: model.getModelName(),
|
|
1210
|
+
model,
|
|
1211
|
+
});
|
|
1212
|
+
models.push(model);
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
return models;
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
/** Archive model (ARCHIVE) - works offline */
|
|
1220
|
+
archive(model: Model): void {
|
|
1221
|
+
this.mutate('archive', model, () => { this.objectPool.updateScope(model.id, ModelScope.archived); });
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
/**
|
|
1225
|
+
* Append a mutation to the pending queue and schedule its sync work.
|
|
1226
|
+
*
|
|
1227
|
+
* IndexedDB persistence and the server push are deferred to a microtask, so
|
|
1228
|
+
* many pushes within the same tick collapse into a single serialization and
|
|
1229
|
+
* a single process call. Without the deferral, queueing a hundred mutations
|
|
1230
|
+
* at once — a large paste, a document import, bulk row creation — would
|
|
1231
|
+
* reserialize the whole growing queue a hundred times, an O(N²) cost in
|
|
1232
|
+
* `model.toJSON()`.
|
|
1233
|
+
*
|
|
1234
|
+
* @param mutation.capturedChanges - Pre-captured, frozen changes, used to
|
|
1235
|
+
* avoid re-reading a model after pool operations that might clear them.
|
|
1236
|
+
*/
|
|
1237
|
+
/** Stage one mutation through the queue, which owns durability and execution. */
|
|
1238
|
+
private stageMutation(
|
|
1239
|
+
type: 'create' | 'update' | 'delete' | 'archive',
|
|
1240
|
+
model: Model,
|
|
1241
|
+
capturedChanges?: Record<string, unknown>,
|
|
1242
|
+
writeOptions?: WriteOptions,
|
|
1243
|
+
): void {
|
|
1244
|
+
if (this.isDisposed) return;
|
|
1245
|
+
if (!this.userId || !this.organizationId) {
|
|
1246
|
+
this.mutationQueue.deferMutation(type, model, capturedChanges, writeOptions);
|
|
1247
|
+
return;
|
|
1248
|
+
}
|
|
1249
|
+
const context = { userId: this.userId, organizationId: this.organizationId };
|
|
1250
|
+
const staging = this.mutationQueue.enqueueModelMutation(
|
|
1251
|
+
type,
|
|
1252
|
+
model,
|
|
1253
|
+
context,
|
|
1254
|
+
capturedChanges,
|
|
1255
|
+
writeOptions,
|
|
1256
|
+
);
|
|
1257
|
+
const pending = staging.then(() => undefined).catch((error: Error) => {
|
|
1258
|
+
this.runtime.observability.captureMutationFailure({
|
|
1259
|
+
context: `stage-mutation-${type}`,
|
|
1260
|
+
modelName: model.getModelName(),
|
|
1261
|
+
modelId: model.id,
|
|
1262
|
+
error,
|
|
1263
|
+
});
|
|
1264
|
+
});
|
|
1265
|
+
this.pendingStages.add(pending);
|
|
1266
|
+
void pending.finally(() => this.pendingStages.delete(pending));
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
private scheduleSync(): void {
|
|
1270
|
+
if (!this.runtime.onlineStatus.isOnline() || this.isDisposed) return;
|
|
1271
|
+
void this.processPendingMutations().catch((error: Error) => {
|
|
1272
|
+
this.runtime.observability.captureMutationFailure({
|
|
1273
|
+
context: 'background-sync',
|
|
1274
|
+
error,
|
|
1275
|
+
});
|
|
1276
|
+
});
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
async processPendingMutations(): Promise<void> {
|
|
1280
|
+
if (this.pendingStages.size > 0) {
|
|
1281
|
+
await Promise.all([...this.pendingStages]);
|
|
1282
|
+
}
|
|
1283
|
+
await this.drainPendingSettlements();
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
/**
|
|
1287
|
+
* Resolve a conflict between the local model and incoming server data,
|
|
1288
|
+
* called while processing deltas from the WebSocket. Certain server states,
|
|
1289
|
+
* such as deletions and deactivations, always take precedence even when the
|
|
1290
|
+
* local model has unsynced changes, so the two sides stay consistent.
|
|
1291
|
+
*/
|
|
1292
|
+
resolveConflicts(localModel: Model, serverData: Record<string, unknown>): Model {
|
|
1293
|
+
const hasLocalChanges = localModel.hasChanges;
|
|
1294
|
+
// Safely get timestamp, handling both Date objects and strings
|
|
1295
|
+
const localUpdatedAt = localModel.updatedAt
|
|
1296
|
+
? localModel.updatedAt instanceof Date
|
|
1297
|
+
? localModel.updatedAt.getTime()
|
|
1298
|
+
: new Date(localModel.updatedAt).getTime()
|
|
1299
|
+
: 0;
|
|
1300
|
+
const serverUpdatedAt = toEpochMs(serverData.updatedAt);
|
|
1301
|
+
|
|
1302
|
+
this.runtime.logger.debug('Conflict resolution', {
|
|
1303
|
+
modelId: localModel.id,
|
|
1304
|
+
modelType: localModel.getModelName(),
|
|
1305
|
+
hasLocalChanges,
|
|
1306
|
+
localUpdatedAt: localModel.updatedAt?.toString(),
|
|
1307
|
+
serverUpdatedAt: serverData.updatedAt,
|
|
1308
|
+
localChanges: localModel.getChanges(),
|
|
1309
|
+
serverState: this.extractCriticalState(serverData),
|
|
1310
|
+
});
|
|
1311
|
+
|
|
1312
|
+
// PRIORITY 1: Check for critical server states that must be respected
|
|
1313
|
+
// These states override any local changes to maintain data consistency
|
|
1314
|
+
const criticalServerStates = this.extractCriticalState(serverData);
|
|
1315
|
+
const shouldForceAcceptServer = this.hasCriticalStateChange(criticalServerStates);
|
|
1316
|
+
|
|
1317
|
+
if (shouldForceAcceptServer) {
|
|
1318
|
+
this.runtime.logger.debug('Accepting server update - critical state change detected', {
|
|
1319
|
+
modelId: localModel.id,
|
|
1320
|
+
criticalStates: criticalServerStates,
|
|
1321
|
+
});
|
|
1322
|
+
|
|
1323
|
+
// Force accept server state for critical changes
|
|
1324
|
+
localModel.updateFromData(serverData);
|
|
1325
|
+
localModel.clearChanges();
|
|
1326
|
+
localModel.markAsSynced();
|
|
1327
|
+
return localModel;
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
// Local-first: if we have local dirty fields, merge by field.
|
|
1331
|
+
// Keep locally changed fields; apply server for the rest.
|
|
1332
|
+
if (hasLocalChanges) {
|
|
1333
|
+
const localChanges = localModel.getChanges();
|
|
1334
|
+
this.runtime.logger.debug('Merging server update with local dirty fields', {
|
|
1335
|
+
modelId: localModel.id,
|
|
1336
|
+
keptFields: Object.keys(localChanges || {}),
|
|
1337
|
+
});
|
|
1338
|
+
|
|
1339
|
+
// Merge: server baseline + local dirty fields win
|
|
1340
|
+
const merged: ModelData = { ...serverData, ...(localChanges || {}) };
|
|
1341
|
+
|
|
1342
|
+
// Preserve the most recent updatedAt without clearing dirty flags
|
|
1343
|
+
if (serverData.updatedAt || localModel.updatedAt) {
|
|
1344
|
+
const mergedUpdatedAt = new Date(Math.max(localUpdatedAt, serverUpdatedAt));
|
|
1345
|
+
// updateFromData accepts Date or ISO string for dates
|
|
1346
|
+
merged.updatedAt = mergedUpdatedAt;
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
localModel.updateFromData(merged);
|
|
1350
|
+
// Intentionally DO NOT clearChanges here; pending tx will confirm and clear
|
|
1351
|
+
return localModel;
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
// No local changes: fall back to LWW to converge
|
|
1355
|
+
// Accept server regardless of timestamp equality to stay in sync
|
|
1356
|
+
const acceptReason = serverUpdatedAt > localUpdatedAt ? 'server is newer' : 'no local changes';
|
|
1357
|
+
this.runtime.logger.debug(`Accepting server update - ${acceptReason}`);
|
|
1358
|
+
localModel.updateFromData(serverData);
|
|
1359
|
+
localModel.clearChanges();
|
|
1360
|
+
localModel.markAsSynced();
|
|
1361
|
+
return localModel;
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
/**
|
|
1365
|
+
* Extract the critical state fields from server data. These are the states
|
|
1366
|
+
* that must be honored even when the local model has unsynced changes. The
|
|
1367
|
+
* conflict resolver reads exactly these fields and no others.
|
|
1368
|
+
*/
|
|
1369
|
+
private extractCriticalState(serverData: Record<string, unknown>): Record<string, unknown> {
|
|
1370
|
+
const critical: Record<string, unknown> = {};
|
|
1371
|
+
|
|
1372
|
+
if (!serverData || typeof serverData !== 'object') {
|
|
1373
|
+
return critical;
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
// Deletion/archival states - always critical
|
|
1377
|
+
if (serverData.deletedAt !== undefined) {
|
|
1378
|
+
critical.deletedAt = serverData.deletedAt;
|
|
1379
|
+
}
|
|
1380
|
+
if (serverData.archivedAt !== undefined) {
|
|
1381
|
+
critical.archivedAt = serverData.archivedAt;
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
// Deactivation states - critical for assignments and similar entities
|
|
1385
|
+
if (serverData.isActive !== undefined && serverData.isActive === false) {
|
|
1386
|
+
critical.isActive = false;
|
|
1387
|
+
}
|
|
1388
|
+
if (serverData.unassignedAt !== undefined) {
|
|
1389
|
+
critical.unassignedAt = serverData.unassignedAt;
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
return critical;
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
/**
|
|
1396
|
+
* Check if critical state changes exist that require forcing server state
|
|
1397
|
+
*/
|
|
1398
|
+
private hasCriticalStateChange(criticalStates: Record<string, unknown>): boolean {
|
|
1399
|
+
// Any critical state present means we should force accept server
|
|
1400
|
+
return (
|
|
1401
|
+
Object.keys(criticalStates).length > 0 &&
|
|
1402
|
+
Object.values(criticalStates).some((v) => v !== null && v !== undefined)
|
|
1403
|
+
);
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
/**
|
|
1407
|
+
* Handle network reconnection
|
|
1408
|
+
*/
|
|
1409
|
+
private async handleReconnection(): Promise<void> {
|
|
1410
|
+
this.runtime.observability.breadcrumb('Network reconnected', 'sync.offline');
|
|
1411
|
+
this.emit('sync:reconnecting');
|
|
1412
|
+
|
|
1413
|
+
try {
|
|
1414
|
+
// MutationQueue owns the durable flush and commit lanes.
|
|
1415
|
+
await this.processPendingMutations();
|
|
1416
|
+
|
|
1417
|
+
this.setConnectionState('connected');
|
|
1418
|
+
this.emit('sync:reconnected');
|
|
1419
|
+
} catch (error) {
|
|
1420
|
+
this.runtime.observability.captureMutationFailure({
|
|
1421
|
+
context: 'reconnection-sync',
|
|
1422
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
1423
|
+
});
|
|
1424
|
+
this.emit('sync:error', error);
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
/**
|
|
1429
|
+
* Handle network disconnection
|
|
1430
|
+
*/
|
|
1431
|
+
private async handleDisconnection(): Promise<void> {
|
|
1432
|
+
this.runtime.observability.breadcrumb('Network disconnected', 'sync.offline');
|
|
1433
|
+
this.setConnectionState('disconnected');
|
|
1434
|
+
this.emit('sync:offline');
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
/**
|
|
1438
|
+
* Get current sync state
|
|
1439
|
+
*/
|
|
1440
|
+
getState(): SyncState {
|
|
1441
|
+
return {
|
|
1442
|
+
connectionState: this.connectionState,
|
|
1443
|
+
pendingMutations: this.mutationQueue.getOutstandingTransactionCount(),
|
|
1444
|
+
lastSyncAt: new Date(),
|
|
1445
|
+
error: undefined,
|
|
1446
|
+
};
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
/**
|
|
1450
|
+
* Set connection state
|
|
1451
|
+
*/
|
|
1452
|
+
private setConnectionState(state: 'connected' | 'disconnected' | 'connecting'): void {
|
|
1453
|
+
const oldState = this.connectionState;
|
|
1454
|
+
this.connectionState = state;
|
|
1455
|
+
|
|
1456
|
+
if (oldState !== state) {
|
|
1457
|
+
this.runtime.observability.setConnectionState(state);
|
|
1458
|
+
this.runtime.observability.breadcrumb(`Connection: ${oldState} → ${state}`, 'sync.websocket');
|
|
1459
|
+
if (state === 'connected') {
|
|
1460
|
+
this.emit('connection:established');
|
|
1461
|
+
this.mutationQueue.setConnectionState('connected');
|
|
1462
|
+
} else if (state === 'disconnected') {
|
|
1463
|
+
this.emit('connection:disconnected');
|
|
1464
|
+
this.mutationQueue.setConnectionState('disconnected');
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
/**
|
|
1470
|
+
* Subscribe to events with disposer pattern
|
|
1471
|
+
*/
|
|
1472
|
+
subscribe(event: string, handler: (data?: unknown) => void): () => void {
|
|
1473
|
+
super.on(event, handler);
|
|
1474
|
+
|
|
1475
|
+
// Return disposer function
|
|
1476
|
+
return () => {
|
|
1477
|
+
this.off(event, handler);
|
|
1478
|
+
};
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
/**
|
|
1482
|
+
* Add observer for sync events
|
|
1483
|
+
*/
|
|
1484
|
+
addObserver(observer: SyncObserver): void {
|
|
1485
|
+
this.observers.add(observer);
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
/**
|
|
1489
|
+
* Remove observer
|
|
1490
|
+
*/
|
|
1491
|
+
removeObserver(observer: SyncObserver): void {
|
|
1492
|
+
this.observers.delete(observer);
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
/**
|
|
1496
|
+
* Notify all observers
|
|
1497
|
+
*/
|
|
1498
|
+
private notifyObservers(event: SyncEvent): void {
|
|
1499
|
+
for (const observer of this.observers) {
|
|
1500
|
+
if (observer.onSync) {
|
|
1501
|
+
try {
|
|
1502
|
+
observer.onSync(event);
|
|
1503
|
+
} catch (error) {
|
|
1504
|
+
this.runtime.observability.breadcrumb('Observer error', 'sync.transaction', 'error', {
|
|
1505
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1506
|
+
});
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
/**
|
|
1513
|
+
* Disconnect from sync
|
|
1514
|
+
*/
|
|
1515
|
+
disconnect(): void {
|
|
1516
|
+
this.setConnectionState('disconnected');
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
/**
|
|
1520
|
+
* Mark the sync client as connected
|
|
1521
|
+
* Called when WebSocket successfully connects (can happen independently of browser online/offline)
|
|
1522
|
+
*/
|
|
1523
|
+
markConnected(): void {
|
|
1524
|
+
this.setConnectionState('connected');
|
|
1525
|
+
// Browser online state may have marked the client connected before the
|
|
1526
|
+
// WebSocket itself was ready. Always kick both durable lanes on the real
|
|
1527
|
+
// socket event, even when the high-level state did not change.
|
|
1528
|
+
void this.drainPendingSettlements().catch((error: unknown) => {
|
|
1529
|
+
this.runtime.observability.captureMutationFailure({
|
|
1530
|
+
context: 'restore-commit-outbox',
|
|
1531
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
1532
|
+
});
|
|
1533
|
+
});
|
|
1534
|
+
void this.processPendingMutations();
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
private drainPendingSettlements(): Promise<void> {
|
|
1538
|
+
return this.reconnectDrain.drain(() => this.mutationQueue.drainPending());
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
/**
|
|
1542
|
+
* Dispose and cleanup
|
|
1543
|
+
*/
|
|
1544
|
+
dispose(): void {
|
|
1545
|
+
this.isDisposed = true;
|
|
1546
|
+
this.disconnect();
|
|
1547
|
+
this.networkMonitor.dispose();
|
|
1548
|
+
this.observers.clear();
|
|
1549
|
+
this.pendingStages.clear();
|
|
1550
|
+
this.removeAllListeners();
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
/**
|
|
1554
|
+
* Notify the {@link MutationQueue} of an incoming delta so it can confirm
|
|
1555
|
+
* hosted writes by sync-id threshold and queued forwards by their echoed
|
|
1556
|
+
* source-batch correlation id.
|
|
1557
|
+
* @param syncId - The sync id of the received delta.
|
|
1558
|
+
* @param transactionId - Optional server echo of the originating local write.
|
|
1559
|
+
* @param correlationId - Opaque batch identity decoded from a source WAL echo.
|
|
1560
|
+
*/
|
|
1561
|
+
onDeltaReceived(
|
|
1562
|
+
syncId: number,
|
|
1563
|
+
transactionId?: string,
|
|
1564
|
+
correlationId?: string,
|
|
1565
|
+
): void {
|
|
1566
|
+
try {
|
|
1567
|
+
this.mutationQueue.onDeltaReceived(
|
|
1568
|
+
syncId,
|
|
1569
|
+
transactionId,
|
|
1570
|
+
correlationId,
|
|
1571
|
+
);
|
|
1572
|
+
} catch (e) {
|
|
1573
|
+
this.runtime.observability.breadcrumb(
|
|
1574
|
+
'Failed to notify delta received',
|
|
1575
|
+
'sync.transaction',
|
|
1576
|
+
'warning',
|
|
1577
|
+
{
|
|
1578
|
+
syncId,
|
|
1579
|
+
transactionId,
|
|
1580
|
+
correlationId,
|
|
1581
|
+
}
|
|
1582
|
+
);
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
/**
|
|
1587
|
+
* Cancel pending transactions for child entities orphaned by a parent's
|
|
1588
|
+
* deletion. The store calls this when a delete delta arrives for a parent,
|
|
1589
|
+
* cancelling any queued writes on children that reference it.
|
|
1590
|
+
*
|
|
1591
|
+
* @param childModelName - The child model type (for example, `Block`).
|
|
1592
|
+
* @param foreignKey - The foreign-key property name (for example, `sectionId`).
|
|
1593
|
+
* @param parentId - The id of the deleted parent.
|
|
1594
|
+
* @returns The number of transactions cancelled.
|
|
1595
|
+
*/
|
|
1596
|
+
cancelTransactionsByForeignKey(
|
|
1597
|
+
childModelName: string,
|
|
1598
|
+
foreignKey: string,
|
|
1599
|
+
parentId: string
|
|
1600
|
+
): number {
|
|
1601
|
+
return this.mutationQueue.cancelTransactionsByForeignKey(
|
|
1602
|
+
childModelName,
|
|
1603
|
+
foreignKey,
|
|
1604
|
+
parentId
|
|
1605
|
+
);
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
/**
|
|
1609
|
+
* Wait for a transaction to be confirmed by its delta echo. Delegates to the
|
|
1610
|
+
* {@link MutationQueue}, which handles the confirmation timeout.
|
|
1611
|
+
*/
|
|
1612
|
+
waitForDeltaConfirmation(transactionId: string): Promise<void> {
|
|
1613
|
+
return this.mutationQueue.waitForConfirmation(transactionId);
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
/**
|
|
1617
|
+
* Force sync now - process pending mutations
|
|
1618
|
+
*/
|
|
1619
|
+
async syncNow(): Promise<void> {
|
|
1620
|
+
await this.processPendingMutations();
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
/**
|
|
1624
|
+
* Get sync statistics. Return type is inferred from the literal so
|
|
1625
|
+
* the call site sees the actual shape — `connectionState` narrowed
|
|
1626
|
+
* to its three states, `objectPoolStats` typed by `InstanceCache.getStats`.
|
|
1627
|
+
*/
|
|
1628
|
+
getSyncStats(): {
|
|
1629
|
+
connectionState: 'connected' | 'disconnected' | 'connecting';
|
|
1630
|
+
pendingMutations: number;
|
|
1631
|
+
objectPoolStats: ReturnType<InstanceCache['getStats']>;
|
|
1632
|
+
} {
|
|
1633
|
+
return {
|
|
1634
|
+
connectionState: this.connectionState,
|
|
1635
|
+
pendingMutations: this.mutationQueue.getOutstandingTransactionCount(),
|
|
1636
|
+
objectPoolStats: this.objectPool.getStats(),
|
|
1637
|
+
};
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
/**
|
|
1641
|
+
* Get pending transaction count from MutationQueue
|
|
1642
|
+
* Used by SyncedStore to compute hasUnsyncedChanges
|
|
1643
|
+
*/
|
|
1644
|
+
getPendingTransactionCount(): number {
|
|
1645
|
+
const stats = this.mutationQueue.getStats();
|
|
1646
|
+
// Include pending and executing as "unsynced"
|
|
1647
|
+
// awaiting_delta transactions are included in 'executing' until confirmed
|
|
1648
|
+
// Completed and failed are "synced" (either done or gave up)
|
|
1649
|
+
return stats.pending + stats.executing;
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
/**
|
|
1653
|
+
* Subscribe to transaction events for sync status tracking
|
|
1654
|
+
* Returns unsubscribe function
|
|
1655
|
+
*/
|
|
1656
|
+
onTransactionEvent(event: 'created' | 'completed' | 'failed', callback: () => void): () => void {
|
|
1657
|
+
const eventName = `transaction:${event}`;
|
|
1658
|
+
this.mutationQueue.on(eventName, callback);
|
|
1659
|
+
return () => this.mutationQueue.off(eventName, callback);
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
/**
|
|
1663
|
+
* Subscribe to mutation failures with the full payload. Mirrors the
|
|
1664
|
+
* underlying MutationQueue 'transaction:failed' shape so consumers
|
|
1665
|
+
* can render typed UI (toast keyed by `AbloError.type`, route-level
|
|
1666
|
+
* "this entity reverted" boundaries, telemetry).
|
|
1667
|
+
*
|
|
1668
|
+
* Distinct from `onTransactionEvent('failed', cb)`, which serves the
|
|
1669
|
+
* parameterless `pendingChanges` counter and intentionally drops the
|
|
1670
|
+
* payload. The two coexist: the counter callback stays lightweight, while
|
|
1671
|
+
* this typed listener drives user-visible surfaces.
|
|
1672
|
+
*/
|
|
1673
|
+
onMutationFailure(
|
|
1674
|
+
listener: (payload: {
|
|
1675
|
+
transaction: QueuedMutation;
|
|
1676
|
+
error: Error;
|
|
1677
|
+
permanent?: boolean;
|
|
1678
|
+
}) => void,
|
|
1679
|
+
): () => void {
|
|
1680
|
+
this.mutationQueue.on('transaction:failed', listener);
|
|
1681
|
+
return () => this.mutationQueue.off('transaction:failed', listener);
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
/**
|
|
1685
|
+
* Subscribe to commit round-trip latency, split into the local seal and the
|
|
1686
|
+
* remote acknowledgement. Fires once per completed commit.
|
|
1687
|
+
*
|
|
1688
|
+
* Taps the {@link MutationQueue} emitter for the same reason
|
|
1689
|
+
* {@link onMutationFailure} does: the commit lifecycle events originate
|
|
1690
|
+
* there and the SyncClient's own emitter never rebroadcasts them.
|
|
1691
|
+
*/
|
|
1692
|
+
onCommitLatency(listener: (sample: CommitLatencySample) => void): () => void {
|
|
1693
|
+
return observeCommitLatency(this.mutationQueue, listener);
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
/**
|
|
1697
|
+
* Subscribe to local transaction creation with the full {@link QueuedMutation}
|
|
1698
|
+
* payload (`type`, `modelName`, `modelId`, `data`, `previousData`). This is
|
|
1699
|
+
* the feed the store's local-mutation subscription taps for undo recording.
|
|
1700
|
+
*
|
|
1701
|
+
* It subscribes to the {@link MutationQueue}'s emitter directly, since
|
|
1702
|
+
* that is the only emitter that fires `transaction:created`. The SyncClient's
|
|
1703
|
+
* own emitter (reached through {@link subscribe}) never rebroadcasts that
|
|
1704
|
+
* event, so routing undo through `subscribe('transaction:created')` would
|
|
1705
|
+
* record nothing. {@link onMutationFailure} taps the queue for the same
|
|
1706
|
+
* reason.
|
|
1707
|
+
*/
|
|
1708
|
+
onLocalTransaction(
|
|
1709
|
+
listener: (tx: QueuedMutation) => void,
|
|
1710
|
+
): () => void {
|
|
1711
|
+
this.mutationQueue.on('transaction:created', listener);
|
|
1712
|
+
interface CommitEventOperation {
|
|
1713
|
+
type: string;
|
|
1714
|
+
model: string;
|
|
1715
|
+
id: string;
|
|
1716
|
+
input?: Record<string, unknown>;
|
|
1717
|
+
}
|
|
1718
|
+
const snapshotsByCommit = new Map<
|
|
1719
|
+
string,
|
|
1720
|
+
readonly (Record<string, unknown> | undefined)[]
|
|
1721
|
+
>();
|
|
1722
|
+
const onCommitStaging = (payload: {
|
|
1723
|
+
clientTxId: string;
|
|
1724
|
+
operations: readonly CommitEventOperation[];
|
|
1725
|
+
}): void => {
|
|
1726
|
+
snapshotsByCommit.set(
|
|
1727
|
+
payload.clientTxId,
|
|
1728
|
+
payload.operations.map((operation) => {
|
|
1729
|
+
if (operation.type === 'CREATE') return undefined;
|
|
1730
|
+
const resident = this.objectPool.get(operation.id);
|
|
1731
|
+
return resident?.toJSON() as Record<string, unknown> | undefined;
|
|
1732
|
+
}),
|
|
1733
|
+
);
|
|
1734
|
+
};
|
|
1735
|
+
const onCommitSealFailed = (payload: { clientTxId: string }): void => {
|
|
1736
|
+
snapshotsByCommit.delete(payload.clientTxId);
|
|
1737
|
+
};
|
|
1738
|
+
// Commit-lane writes (`ablo.commits.create` — the agent/atomic door) ride
|
|
1739
|
+
// their own `commit:created` event: they have no optimistic pool apply,
|
|
1740
|
+
// so they must not feed the echo tracker's `transaction:created` path.
|
|
1741
|
+
// Enrich each operation with previous state captured from the pool HERE
|
|
1742
|
+
// (the queue is pool-free) and hand the synthesized transaction to the
|
|
1743
|
+
// same listener, so undo observes every write door — one stream.
|
|
1744
|
+
const onCommitCreated = (payload: {
|
|
1745
|
+
clientTxId: string;
|
|
1746
|
+
operations: readonly CommitEventOperation[];
|
|
1747
|
+
}): void => {
|
|
1748
|
+
const stagedSnapshots = snapshotsByCommit.get(payload.clientTxId);
|
|
1749
|
+
snapshotsByCommit.delete(payload.clientTxId);
|
|
1750
|
+
const TYPE_BY_WIRE: Record<
|
|
1751
|
+
string,
|
|
1752
|
+
QueuedMutation['type']
|
|
1753
|
+
> = {
|
|
1754
|
+
CREATE: 'create',
|
|
1755
|
+
UPDATE: 'update',
|
|
1756
|
+
DELETE: 'delete',
|
|
1757
|
+
ARCHIVE: 'archive',
|
|
1758
|
+
UNARCHIVE: 'unarchive',
|
|
1759
|
+
};
|
|
1760
|
+
payload.operations.forEach((op, index) => {
|
|
1761
|
+
const type = TYPE_BY_WIRE[op.type];
|
|
1762
|
+
if (!type || !op.id) return;
|
|
1763
|
+
const snapshot =
|
|
1764
|
+
type === 'create'
|
|
1765
|
+
? undefined
|
|
1766
|
+
: stagedSnapshots
|
|
1767
|
+
? stagedSnapshots[index]
|
|
1768
|
+
: (this.objectPool.get(op.id)?.toJSON() as
|
|
1769
|
+
| Record<string, unknown>
|
|
1770
|
+
| undefined);
|
|
1771
|
+
// A DELETE of a row the local graph never saw is not invertible —
|
|
1772
|
+
// recording it would make undo "restore" an empty husk. Skip it.
|
|
1773
|
+
if (type === 'delete' && !snapshot) return;
|
|
1774
|
+
// UPDATE inverse must only revert the fields this op actually wrote;
|
|
1775
|
+
// handing undo the FULL row would clobber concurrent edits to
|
|
1776
|
+
// unrelated fields on revert.
|
|
1777
|
+
const previousData =
|
|
1778
|
+
type === 'update' && snapshot && op.input
|
|
1779
|
+
? Object.fromEntries(
|
|
1780
|
+
Object.keys(op.input).map((key) => [key, snapshot[key]]),
|
|
1781
|
+
)
|
|
1782
|
+
: snapshot ?? null;
|
|
1783
|
+
listener({
|
|
1784
|
+
id: `${payload.clientTxId}_op${index}`,
|
|
1785
|
+
type,
|
|
1786
|
+
modelName: op.model,
|
|
1787
|
+
modelId: op.id,
|
|
1788
|
+
modelKey: op.model,
|
|
1789
|
+
data: op.input ?? undefined,
|
|
1790
|
+
previousData,
|
|
1791
|
+
context: {
|
|
1792
|
+
userId: this.userId ?? '',
|
|
1793
|
+
organizationId: this.organizationId ?? '',
|
|
1794
|
+
},
|
|
1795
|
+
status: 'pending',
|
|
1796
|
+
createdAt: Date.now(),
|
|
1797
|
+
attempts: 0,
|
|
1798
|
+
priority: 'normal',
|
|
1799
|
+
priorityScore: 0,
|
|
1800
|
+
});
|
|
1801
|
+
});
|
|
1802
|
+
};
|
|
1803
|
+
this.mutationQueue.on('commit:staging', onCommitStaging);
|
|
1804
|
+
this.mutationQueue.on('commit:seal_failed', onCommitSealFailed);
|
|
1805
|
+
this.mutationQueue.on('commit:created', onCommitCreated);
|
|
1806
|
+
return () => {
|
|
1807
|
+
this.mutationQueue.off('transaction:created', listener);
|
|
1808
|
+
this.mutationQueue.off('commit:staging', onCommitStaging);
|
|
1809
|
+
this.mutationQueue.off('commit:seal_failed', onCommitSealFailed);
|
|
1810
|
+
this.mutationQueue.off('commit:created', onCommitCreated);
|
|
1811
|
+
snapshotsByCommit.clear();
|
|
1812
|
+
};
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
/**
|
|
1816
|
+
* Wait for the latest in-flight transaction for (modelName, modelId)
|
|
1817
|
+
* to be confirmed by the server, or reject if it's rolled back.
|
|
1818
|
+
* Resolves immediately when no transaction is in flight — see
|
|
1819
|
+
* `MutationQueue.confirmationFor` for the lookup contract.
|
|
1820
|
+
*
|
|
1821
|
+
* Distinct from `waitForDeltaConfirmation(transactionId)` which keys
|
|
1822
|
+
* off a known tx id; this variant is for call sites that hold a
|
|
1823
|
+
* Model reference but never see the underlying transaction.
|
|
1824
|
+
*/
|
|
1825
|
+
waitForConfirmation(modelName: string, modelId: string): Promise<void> {
|
|
1826
|
+
return this.mutationQueue.confirmationFor(modelName, modelId);
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
/**
|
|
1830
|
+
* Get detailed debug info for the sync debug page
|
|
1831
|
+
*/
|
|
1832
|
+
getDebugInfo() {
|
|
1833
|
+
return {
|
|
1834
|
+
connectionState: this.connectionState,
|
|
1835
|
+
pendingMutationsCount: this.mutationQueue.getOutstandingTransactionCount(),
|
|
1836
|
+
mutationQueue: this.mutationQueue.getDebugInfo(),
|
|
1837
|
+
};
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
// --- Best-practice assignment ops ---
|
|
1841
|
+
async unassignEntity(entityType: string, entityId: string): Promise<void> {
|
|
1842
|
+
// Call server-side unassign to avoid per-id races
|
|
1843
|
+
await this.mutationExecutor.executeDelete('Assignment', entityId);
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
async reassignEntity(
|
|
1847
|
+
entityType: string,
|
|
1848
|
+
entityId: string,
|
|
1849
|
+
assigneeType: string,
|
|
1850
|
+
assigneeId: string,
|
|
1851
|
+
id?: string
|
|
1852
|
+
): Promise<void> {
|
|
1853
|
+
await this.mutationExecutor.executeCreate('Assignment', id || '', {
|
|
1854
|
+
entityType,
|
|
1855
|
+
entityId,
|
|
1856
|
+
assigneeType,
|
|
1857
|
+
assigneeId,
|
|
1858
|
+
});
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1861
|
+
// ── Delta + Bootstrap application (owns InstanceCache writes) ──────────────
|
|
1862
|
+
|
|
1863
|
+
/**
|
|
1864
|
+
* Apply a batch of delta results from Database to the InstanceCache.
|
|
1865
|
+
* Owns: model creation, upsert, remove, archive, conflict resolution.
|
|
1866
|
+
* Returns: nothing — InstanceCache is updated in place.
|
|
1867
|
+
*/
|
|
1868
|
+
/**
|
|
1869
|
+
* Mark a local transaction as optimistically applied. The matching
|
|
1870
|
+
* server delta (when it arrives with the same `transactionId`) will
|
|
1871
|
+
* be recognized as an echo and skip the pool mutation. Called
|
|
1872
|
+
* automatically by `MutationQueue` when a transaction is staged;
|
|
1873
|
+
* exposed publicly so tests can drive the API directly.
|
|
1874
|
+
*/
|
|
1875
|
+
markTransactionPending(transactionId: string): void {
|
|
1876
|
+
this.echoTracker.markPending(transactionId);
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
/**
|
|
1880
|
+
* Read echo-detection counters: hits, rollbacks, evictions, and the
|
|
1881
|
+
* current pending-set size. Surfaced for production observability
|
|
1882
|
+
* — a sustained `evictions > 0` rate or `rollbacks` spike is a
|
|
1883
|
+
* health signal worth alerting on.
|
|
1884
|
+
*/
|
|
1885
|
+
getEchoMetrics(): Readonly<UnconfirmedWritesMetrics> {
|
|
1886
|
+
return this.echoTracker.getMetrics();
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
/**
|
|
1890
|
+
* Package-internal accessor for the {@link MutationQueue}. Used by
|
|
1891
|
+
* `Ablo.commits.create()` to route raw multi-operation envelopes through the
|
|
1892
|
+
* same retry-on-reconnect lane as the model proxy path, and by tests to
|
|
1893
|
+
* exercise the queue's interaction with {@link markTransactionPending} on the
|
|
1894
|
+
* real instance the SyncClient subscribes to. It is not re-exported to SDK
|
|
1895
|
+
* consumers; `Ablo` is the public surface.
|
|
1896
|
+
*/
|
|
1897
|
+
getMutationQueue(): MutationQueue {
|
|
1898
|
+
return this.mutationQueue;
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
applyDeltaBatchToPool(
|
|
1902
|
+
dbResults: readonly AppliedChange[],
|
|
1903
|
+
enrichRelations: (modelName: string, data: Record<string, unknown>) => Record<string, unknown>,
|
|
1904
|
+
): void {
|
|
1905
|
+
const modelsToAdd: Model[] = [];
|
|
1906
|
+
const modelsToUpsert: Model[] = [];
|
|
1907
|
+
const idsToRemove: string[] = [];
|
|
1908
|
+
const idsToArchive: string[] = [];
|
|
1909
|
+
|
|
1910
|
+
// Pre-pass: collect every id slated for `remove` in this batch. The
|
|
1911
|
+
// parent-delete flicker came from this exact pattern: a peer (or the
|
|
1912
|
+
// user themself) deletes a parent with N children; the commit produces
|
|
1913
|
+
// BOTH residual `update` deltas (from the optimistic edits that
|
|
1914
|
+
// happened just before the delete) AND `remove` deltas. The
|
|
1915
|
+
// `update` branch below would `createFromData` the row back into
|
|
1916
|
+
// the pool when `existing` was already gone (optimistic remove
|
|
1917
|
+
// happened), and the next loop iteration's `remove` would strip
|
|
1918
|
+
// it again — net effect: pool transitions live → gone → live →
|
|
1919
|
+
// gone in one tick, which the renderer catches mid-frame as a
|
|
1920
|
+
// flicker. Filter ops on doomed ids before they touch the pool.
|
|
1921
|
+
const idsBeingRemoved = new Set<string>();
|
|
1922
|
+
for (const r of dbResults) {
|
|
1923
|
+
if (r.action === 'remove') idsBeingRemoved.add(r.modelId);
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
for (const result of dbResults) {
|
|
1927
|
+
const { modelName, modelId, action, transactionId } = result;
|
|
1928
|
+
|
|
1929
|
+
// Echo detection: if this delta carries a transaction id that matches
|
|
1930
|
+
// one already applied optimistically, the pool already reflects the
|
|
1931
|
+
// mutation, so the pool operation is skipped. The IndexedDB write in
|
|
1932
|
+
// Database.processDeltaBatch still runs; only the in-memory pool update
|
|
1933
|
+
// is suppressed. This prevents a resurrection flicker: a server-confirmed
|
|
1934
|
+
// create arriving after the user has optimistically deleted the row would
|
|
1935
|
+
// otherwise re-add it for the brief window before the matching delete
|
|
1936
|
+
// confirmation lands.
|
|
1937
|
+
if (this.echoTracker.consumeEcho(transactionId)) {
|
|
1938
|
+
continue;
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
// If a later op in this batch will remove this id, skip earlier
|
|
1942
|
+
// add/update ops on it. Server FK ordering can produce
|
|
1943
|
+
// U(child)+D(child) when an optimistic edit and a delete both
|
|
1944
|
+
// commit in the same window; only the final state matters.
|
|
1945
|
+
if ((action === 'add' || action === 'update') && idsBeingRemoved.has(modelId)) {
|
|
1946
|
+
continue;
|
|
1947
|
+
}
|
|
1948
|
+
|
|
1949
|
+
switch (action) {
|
|
1950
|
+
case 'add': {
|
|
1951
|
+
const existing = this.objectPool.get(modelId);
|
|
1952
|
+
if (existing) {
|
|
1953
|
+
existing.markAsSynced();
|
|
1954
|
+
} else if (result.data) {
|
|
1955
|
+
const data = enrichRelations(modelName, { ...result.data, __typename: modelName });
|
|
1956
|
+
const model = this.objectPool.createFromData(data, undefined, {
|
|
1957
|
+
deferObservability: true,
|
|
1958
|
+
});
|
|
1959
|
+
if (model) modelsToAdd.push(model);
|
|
1960
|
+
}
|
|
1961
|
+
break;
|
|
1962
|
+
}
|
|
1963
|
+
case 'update': {
|
|
1964
|
+
const existing = this.objectPool.get(modelId);
|
|
1965
|
+
if (existing && !existing.disposed && result.data) {
|
|
1966
|
+
enrichRelations(modelName, result.data);
|
|
1967
|
+
const resolved = this.resolveConflicts(existing, result.data);
|
|
1968
|
+
modelsToUpsert.push(resolved);
|
|
1969
|
+
}
|
|
1970
|
+
// Resurrection drop: if `existing` is gone (optimistic delete
|
|
1971
|
+
// discarded it; the matching D delta is in-flight) we used
|
|
1972
|
+
// to call `createFromData` here, which reintroduced the row
|
|
1973
|
+
// for a frame before the D delta stripped it again — the
|
|
1974
|
+
// chart-delete flicker. Trust the local state. If the server
|
|
1975
|
+
// still considers the row alive, a subsequent bootstrap or
|
|
1976
|
+
// resync will reconcile.
|
|
1977
|
+
break;
|
|
1978
|
+
}
|
|
1979
|
+
case 'remove':
|
|
1980
|
+
idsToRemove.push(modelId);
|
|
1981
|
+
break;
|
|
1982
|
+
case 'archive':
|
|
1983
|
+
idsToArchive.push(modelId);
|
|
1984
|
+
break;
|
|
1985
|
+
case 'verify':
|
|
1986
|
+
// `verify` is `Database.processDeltaBatch`'s signal for a delta
|
|
1987
|
+
// whose IDB store transaction FAILED. Pool isn't updated for
|
|
1988
|
+
// this delta — by design, since the persisted view doesn't
|
|
1989
|
+
// reflect it either — and the persistence-gated cursor in
|
|
1990
|
+
// `BaseSyncedStore.flushPendingDeltas` will NOT ack past it,
|
|
1991
|
+
// so the next 30s catch-up poll (or reconnect handshake) will
|
|
1992
|
+
// re-fetch and re-apply. Logged here so silent IDB failures
|
|
1993
|
+
// are observable instead of disappearing into a default switch
|
|
1994
|
+
// fall-through.
|
|
1995
|
+
// Self-healing: the next catch-up poll / reconnect re-fetches and
|
|
1996
|
+
// re-applies this delta, so it's forensic, not consumer-actionable → debug.
|
|
1997
|
+
this.runtime.logger.debug('[SyncClient.applyDeltaBatchToPool] skipping pool op for unpersisted delta', {
|
|
1998
|
+
modelName,
|
|
1999
|
+
modelId: modelId.slice(0, 12),
|
|
2000
|
+
});
|
|
2001
|
+
break;
|
|
2002
|
+
}
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
// Reveal the whole frame in a single MobX action. `addBatch`,
|
|
2006
|
+
// `upsertBatch`, `removeBatch`, and `updateScope` are each individually
|
|
2007
|
+
// wrapped in an action, so calling them in sequence flushes reactions at
|
|
2008
|
+
// every action boundary — a catch-up frame that adds, updates, and removes
|
|
2009
|
+
// would fire every dependent reaction several times in a row, re-rendering
|
|
2010
|
+
// and re-sorting on each. Wrapping them in one outer `runInAction` defers
|
|
2011
|
+
// all reaction flushes to a single boundary, so dependents recompute
|
|
2012
|
+
// exactly once regardless of how many models or operation kinds the frame
|
|
2013
|
+
// touched. The app therefore never observes a partially applied frame.
|
|
2014
|
+
runInAction(() => {
|
|
2015
|
+
if (modelsToAdd.length > 0) this.objectPool.addBatch(modelsToAdd, ModelScope.live);
|
|
2016
|
+
if (modelsToUpsert.length > 0) this.objectPool.upsertBatch(modelsToUpsert, ModelScope.live);
|
|
2017
|
+
if (idsToRemove.length > 0) this.objectPool.removeBatch(idsToRemove);
|
|
2018
|
+
for (const id of idsToArchive) this.objectPool.updateScope(id, ModelScope.archived);
|
|
2019
|
+
|
|
2020
|
+
// Emit changed model types so QueryProcessor can auto-invalidate.
|
|
2021
|
+
// Kept inside the action so any observable query-cache state it
|
|
2022
|
+
// flips is part of the same atomic reveal.
|
|
2023
|
+
const changedTypes = new Set(dbResults.map(r => r.modelName));
|
|
2024
|
+
if (changedTypes.size > 0) this.emit('models:changed', changedTypes);
|
|
2025
|
+
});
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
/**
|
|
2029
|
+
* Apply bootstrap data to the InstanceCache with ghost removal.
|
|
2030
|
+
* Owns: model creation, batch upsert, ghost detection + removal.
|
|
2031
|
+
*/
|
|
2032
|
+
applyBootstrapDataToPool(
|
|
2033
|
+
bootstrapData: { models?: Record<string, unknown[]>; failedModels?: string[] },
|
|
2034
|
+
protectedIds?: ReadonlySet<string>,
|
|
2035
|
+
options?: {
|
|
2036
|
+
/**
|
|
2037
|
+
* Scoped backfill for the hydrate-on-enter path: the snapshot covers only
|
|
2038
|
+
* the groups just entered, not the whole model type. Two behaviors change
|
|
2039
|
+
* so the subset cannot corrupt the pool. First, the upsert is
|
|
2040
|
+
* version-guarded ({@link InstanceCache.upsertIfNewer}) so a concurrent live
|
|
2041
|
+
* delta is not clobbered back to the snapshot version. Second, ghost
|
|
2042
|
+
* removal is skipped, because a subset snapshot must never evict rows of
|
|
2043
|
+
* the same type that belong to other, unhydrated groups.
|
|
2044
|
+
*/
|
|
2045
|
+
scoped?: boolean;
|
|
2046
|
+
},
|
|
2047
|
+
): { added: number; updated: number; removed: number; skipped: number; healed: number } {
|
|
2048
|
+
if (!bootstrapData.models) {
|
|
2049
|
+
return { added: 0, updated: 0, removed: 0, skipped: 0, healed: 0 };
|
|
2050
|
+
}
|
|
2051
|
+
|
|
2052
|
+
const allModels: Model[] = [];
|
|
2053
|
+
const serverIdsByType = new Map<string, Set<string>>();
|
|
2054
|
+
let healedCount = 0;
|
|
2055
|
+
let skippedCount = 0;
|
|
2056
|
+
|
|
2057
|
+
const failedTypes = new Set(bootstrapData.failedModels ?? []);
|
|
2058
|
+
|
|
2059
|
+
for (const [modelType, records] of Object.entries(bootstrapData.models)) {
|
|
2060
|
+
if (failedTypes.has(modelType)) continue;
|
|
2061
|
+
|
|
2062
|
+
const idsForType = new Set<string>();
|
|
2063
|
+
serverIdsByType.set(modelType, idsForType);
|
|
2064
|
+
|
|
2065
|
+
if (!Array.isArray(records) || records.length === 0) continue;
|
|
2066
|
+
|
|
2067
|
+
for (const rawRecord of records) {
|
|
2068
|
+
if (!rawRecord || typeof rawRecord !== 'object') { skippedCount++; continue; }
|
|
2069
|
+
|
|
2070
|
+
let data = rawRecord as Record<string, unknown>;
|
|
2071
|
+
if (!data.__typename) data = { __typename: modelType, ...data };
|
|
2072
|
+
|
|
2073
|
+
const healResult = this.healModelRecord(modelType, data);
|
|
2074
|
+
if (healResult === null) { skippedCount++; continue; }
|
|
2075
|
+
data = healResult.data;
|
|
2076
|
+
if (healResult.healed) healedCount++;
|
|
2077
|
+
|
|
2078
|
+
const recordId = data.id as string | undefined;
|
|
2079
|
+
if (recordId) idsForType.add(recordId);
|
|
2080
|
+
|
|
2081
|
+
// Scoped backfill for the hydrate-on-enter path: a subset snapshot is
|
|
2082
|
+
// taken at a server watermark. If a concurrent live delta already
|
|
2083
|
+
// advanced this row past the snapshot, skip it. `createFromData`
|
|
2084
|
+
// mutates the pooled model in place to keep instances alive, so this
|
|
2085
|
+
// version guard has to run before it; a guard at the upsert layer would
|
|
2086
|
+
// be too late, because the row would already be clobbered.
|
|
2087
|
+
if (options?.scoped && recordId) {
|
|
2088
|
+
const existing = this.objectPool.get(recordId);
|
|
2089
|
+
if (existing && !rawRecordIsNewer(data, existing)) { skippedCount++; continue; }
|
|
2090
|
+
}
|
|
2091
|
+
|
|
2092
|
+
try {
|
|
2093
|
+
const model = this.objectPool.createFromData(data);
|
|
2094
|
+
if (model) allModels.push(model);
|
|
2095
|
+
} catch {
|
|
2096
|
+
skippedCount++;
|
|
2097
|
+
}
|
|
2098
|
+
}
|
|
2099
|
+
}
|
|
2100
|
+
|
|
2101
|
+
// Upsert. The scoped stale-skip above already guarded the version, so a
|
|
2102
|
+
// plain upsert is correct here for both paths.
|
|
2103
|
+
const beforeSize = this.objectPool.size;
|
|
2104
|
+
this.objectPool.upsertBatch(allModels, ModelScope.live);
|
|
2105
|
+
const addedCount = this.objectPool.size - beforeSize;
|
|
2106
|
+
const updatedCount = allModels.length - addedCount;
|
|
2107
|
+
|
|
2108
|
+
// Ghost removal: drop pool entities absent from the server snapshot. This
|
|
2109
|
+
// is valid only for a full bootstrap, where the snapshot is authoritative
|
|
2110
|
+
// for each returned type. A scoped subset snapshot must not remove rows of
|
|
2111
|
+
// the same type that belong to other, unhydrated groups.
|
|
2112
|
+
let removedCount = 0;
|
|
2113
|
+
if (!options?.scoped) {
|
|
2114
|
+
const ghostIds: string[] = [];
|
|
2115
|
+
for (const [modelType, serverIds] of serverIdsByType) {
|
|
2116
|
+
const poolIds = this.objectPool.getIdsByModelType(modelType);
|
|
2117
|
+
if (!poolIds) continue;
|
|
2118
|
+
for (const poolId of poolIds) {
|
|
2119
|
+
if (!serverIds.has(poolId) && !protectedIds?.has(poolId)) ghostIds.push(poolId);
|
|
2120
|
+
}
|
|
2121
|
+
}
|
|
2122
|
+
removedCount = this.objectPool.removeBatch(ghostIds);
|
|
2123
|
+
}
|
|
2124
|
+
|
|
2125
|
+
// Emit changed model types so QueryProcessor can auto-invalidate
|
|
2126
|
+
const changedTypes = new Set(Object.keys(bootstrapData.models));
|
|
2127
|
+
if (changedTypes.size > 0) this.emit('models:changed', changedTypes);
|
|
2128
|
+
|
|
2129
|
+
return { added: addedCount, updated: updatedCount, removed: removedCount, skipped: skippedCount, healed: healedCount };
|
|
2130
|
+
}
|
|
2131
|
+
}
|