@abloatai/ablo 0.26.0 → 0.28.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/CHANGELOG.md +42 -2
- package/README.md +102 -86
- package/dist/BaseSyncedStore.d.ts +85 -88
- package/dist/BaseSyncedStore.js +134 -151
- package/dist/Database.d.ts +68 -69
- package/dist/Database.js +316 -135
- package/dist/{ObjectPool.d.ts → InstanceCache.d.ts} +10 -13
- package/dist/{ObjectPool.js → InstanceCache.js} +85 -83
- package/dist/LazyReferenceCollection.d.ts +11 -15
- package/dist/LazyReferenceCollection.js +12 -16
- package/dist/Model.d.ts +54 -52
- package/dist/Model.js +78 -62
- package/dist/ModelRegistry.d.ts +21 -19
- package/dist/ModelRegistry.js +23 -27
- package/dist/NetworkMonitor.d.ts +5 -6
- package/dist/NetworkMonitor.js +5 -6
- package/dist/SyncClient.d.ts +122 -118
- package/dist/SyncClient.js +541 -245
- package/dist/adapters/alwaysOnline.d.ts +6 -8
- package/dist/adapters/alwaysOnline.js +6 -8
- package/dist/adapters/inMemoryStorage.d.ts +10 -9
- package/dist/adapters/inMemoryStorage.js +21 -9
- package/dist/agent/Agent.d.ts +27 -32
- package/dist/agent/Agent.js +18 -19
- package/dist/agent/index.d.ts +4 -4
- package/dist/agent/index.js +5 -5
- package/dist/agent/session.d.ts +47 -44
- package/dist/agent/session.js +37 -48
- package/dist/agent/types.d.ts +26 -31
- package/dist/agent/types.js +6 -7
- package/dist/ai-sdk/coordinatedTool.d.ts +108 -0
- package/dist/ai-sdk/{coordinated-tool.js → coordinatedTool.js} +44 -38
- package/dist/ai-sdk/coordinationContext.d.ts +46 -0
- package/dist/ai-sdk/{coordination-context.js → coordinationContext.js} +26 -33
- package/dist/ai-sdk/index.d.ts +25 -22
- package/dist/ai-sdk/index.js +25 -22
- package/dist/ai-sdk/wrap.d.ts +6 -7
- package/dist/ai-sdk/wrap.js +1 -1
- package/dist/auth/credentialPolicy.d.ts +69 -74
- package/dist/auth/credentialPolicy.js +51 -56
- package/dist/auth/credentialSource.d.ts +6 -5
- package/dist/auth/credentialSource.js +9 -10
- package/dist/auth/index.d.ts +59 -58
- package/dist/auth/index.js +31 -37
- package/dist/auth/schemas.d.ts +5 -4
- package/dist/auth/schemas.js +5 -4
- package/dist/batching/index.d.ts +19 -21
- package/dist/batching/index.js +14 -17
- package/dist/cli.cjs +173 -121
- package/dist/client/Ablo.d.ts +97 -74
- package/dist/client/Ablo.js +129 -163
- package/dist/client/ApiClient.d.ts +30 -19
- package/dist/client/ApiClient.js +442 -81
- package/dist/client/auth.d.ts +47 -47
- package/dist/client/auth.js +108 -117
- package/dist/client/claimHeartbeatLoop.d.ts +50 -0
- package/dist/client/claimHeartbeatLoop.js +88 -0
- package/dist/client/consoleLogger.d.ts +5 -6
- package/dist/client/consoleLogger.js +5 -6
- package/dist/client/createInternalComponents.d.ts +16 -17
- package/dist/client/createInternalComponents.js +26 -31
- package/dist/client/createModelProxy.d.ts +130 -120
- package/dist/client/createModelProxy.js +152 -122
- package/dist/client/credentialEndpoint.d.ts +40 -42
- package/dist/client/credentialEndpoint.js +35 -36
- package/dist/client/functionalUpdate.d.ts +29 -27
- package/dist/client/functionalUpdate.js +21 -21
- package/dist/client/hostedEndpoints.d.ts +9 -12
- package/dist/client/hostedEndpoints.js +9 -12
- package/dist/client/httpClient.d.ts +59 -53
- package/dist/client/httpClient.js +29 -31
- package/dist/client/identity.d.ts +15 -20
- package/dist/client/identity.js +47 -58
- package/dist/client/modelRegistration.d.ts +5 -9
- package/dist/client/modelRegistration.js +78 -87
- package/dist/client/options.d.ts +157 -157
- package/dist/client/options.js +3 -7
- package/dist/client/registerDataSource.d.ts +9 -9
- package/dist/client/registerDataSource.js +15 -16
- package/dist/client/resourceTypes.d.ts +64 -75
- package/dist/client/resourceTypes.js +4 -10
- package/dist/client/schemaConfig.d.ts +31 -43
- package/dist/client/schemaConfig.js +38 -50
- package/dist/client/sessionMint.d.ts +16 -12
- package/dist/client/sessionMint.js +26 -31
- package/dist/client/validateAbloOptions.d.ts +12 -14
- package/dist/client/validateAbloOptions.js +8 -9
- package/dist/client/writeOptionsSchema.d.ts +18 -16
- package/dist/client/writeOptionsSchema.js +23 -20
- package/dist/client/wsMutationExecutor.d.ts +16 -20
- package/dist/client/wsMutationExecutor.js +18 -23
- package/dist/commit/contract.d.ts +493 -0
- package/dist/commit/contract.js +187 -0
- package/dist/commit/index.d.ts +6 -0
- package/dist/commit/index.js +5 -0
- package/dist/context.d.ts +6 -4
- package/dist/context.js +6 -4
- package/dist/coordination/index.d.ts +10 -8
- package/dist/coordination/index.js +14 -12
- package/dist/coordination/schema.d.ts +176 -128
- package/dist/coordination/schema.js +197 -133
- package/dist/coordination/trace.d.ts +9 -10
- package/dist/coordination/trace.js +13 -14
- package/dist/core/DatabaseManager.d.ts +5 -7
- package/dist/core/DatabaseManager.js +15 -19
- package/dist/core/QueryProcessor.d.ts +7 -9
- package/dist/core/QueryProcessor.js +22 -28
- package/dist/core/QueryView.d.ts +8 -8
- package/dist/core/QueryView.js +2 -2
- package/dist/core/StoreManager.d.ts +14 -14
- package/dist/core/StoreManager.js +33 -24
- package/dist/core/ViewRegistry.d.ts +5 -5
- package/dist/core/ViewRegistry.js +4 -4
- package/dist/core/index.d.ts +17 -12
- package/dist/core/index.js +32 -26
- package/dist/core/openIDBWithTimeout.d.ts +38 -36
- package/dist/core/openIDBWithTimeout.js +42 -43
- package/dist/core/queryUtils.d.ts +45 -0
- package/dist/core/queryUtils.js +69 -0
- package/dist/core/storeContract.d.ts +63 -61
- package/dist/core/storeContract.js +8 -12
- package/dist/environment.d.ts +28 -0
- package/dist/environment.js +21 -0
- package/dist/errorCodes.d.ts +107 -99
- package/dist/errorCodes.js +137 -134
- package/dist/errors.d.ts +160 -166
- package/dist/errors.js +155 -158
- package/dist/index.d.ts +36 -27
- package/dist/index.js +91 -86
- package/dist/interfaces/index.d.ts +102 -113
- package/dist/interfaces/index.js +5 -4
- package/dist/keys/index.d.ts +27 -29
- package/dist/keys/index.js +41 -40
- package/dist/mutators/RecordingTransaction.d.ts +16 -16
- package/dist/mutators/RecordingTransaction.js +31 -37
- package/dist/mutators/Transaction.d.ts +18 -26
- package/dist/mutators/Transaction.js +14 -20
- package/dist/mutators/UndoManager.d.ts +124 -131
- package/dist/mutators/UndoManager.js +177 -156
- package/dist/mutators/defineMutators.d.ts +23 -34
- package/dist/mutators/defineMutators.js +14 -20
- package/dist/mutators/inverseOp.d.ts +12 -15
- package/dist/mutators/inverseOp.js +12 -15
- package/dist/mutators/mutateActions.d.ts +10 -9
- package/dist/mutators/mutateActions.js +1 -1
- package/dist/mutators/readerActions.d.ts +9 -8
- package/dist/mutators/readerActions.js +2 -2
- package/dist/mutators/undoApply.d.ts +31 -27
- package/dist/mutators/undoApply.js +26 -24
- package/dist/policy/index.d.ts +5 -3
- package/dist/policy/index.js +5 -3
- package/dist/policy/types.d.ts +104 -100
- package/dist/policy/types.js +67 -66
- package/dist/query/client.d.ts +28 -23
- package/dist/query/client.js +45 -43
- package/dist/query/types.d.ts +37 -60
- package/dist/query/types.js +13 -33
- package/dist/react/AbloProvider.d.ts +1 -1
- package/dist/react/AbloProvider.js +2 -2
- package/dist/react/context.d.ts +25 -28
- package/dist/react/context.js +9 -10
- package/dist/react/index.d.ts +41 -42
- package/dist/react/index.js +37 -38
- package/dist/react/internalContext.d.ts +17 -19
- package/dist/react/useAblo.d.ts +28 -25
- package/dist/react/useAblo.js +41 -17
- package/dist/react/useCurrentUserId.d.ts +8 -7
- package/dist/react/useCurrentUserId.js +8 -7
- package/dist/react/useErrorListener.d.ts +7 -7
- package/dist/react/useErrorListener.js +10 -11
- package/dist/react/useMutationFailureListener.d.ts +8 -8
- package/dist/react/useMutationFailureListener.js +8 -8
- package/dist/react/useMutators.d.ts +11 -11
- package/dist/react/useMutators.js +3 -3
- package/dist/react/useReactive.js +2 -2
- package/dist/react/useSyncStatus.d.ts +4 -6
- package/dist/react/useUndoScope.d.ts +7 -9
- package/dist/react/useUndoScope.js +1 -1
- package/dist/schema/coordination.d.ts +21 -25
- package/dist/schema/coordination.js +21 -25
- package/dist/schema/ddl.d.ts +43 -39
- package/dist/schema/ddl.js +75 -68
- package/dist/schema/ddlLock.d.ts +20 -24
- package/dist/schema/ddlLock.js +18 -23
- package/dist/schema/diff.d.ts +99 -61
- package/dist/schema/diff.js +43 -34
- package/dist/schema/field.d.ts +37 -42
- package/dist/schema/field.js +35 -48
- package/dist/schema/generate.d.ts +12 -12
- package/dist/schema/generate.js +12 -12
- package/dist/schema/index.d.ts +3 -3
- package/dist/schema/index.js +21 -23
- package/dist/schema/model.d.ts +118 -143
- package/dist/schema/model.js +22 -33
- package/dist/schema/openapi.d.ts +10 -9
- package/dist/schema/openapi.js +5 -3
- package/dist/schema/queries.d.ts +29 -31
- package/dist/schema/queries.js +23 -25
- package/dist/schema/relation.d.ts +89 -99
- package/dist/schema/relation.js +13 -13
- package/dist/schema/residency.d.ts +16 -13
- package/dist/schema/residency.js +16 -13
- package/dist/schema/roles.d.ts +36 -43
- package/dist/schema/roles.js +31 -37
- package/dist/schema/schema.d.ts +64 -43
- package/dist/schema/schema.js +31 -32
- package/dist/schema/select.d.ts +13 -13
- package/dist/schema/select.js +13 -13
- package/dist/schema/serialize.d.ts +28 -31
- package/dist/schema/serialize.js +27 -31
- package/dist/schema/sugar.d.ts +17 -32
- package/dist/schema/sugar.js +14 -29
- package/dist/schema/{sync-delta-row.d.ts → syncDeltaRow.d.ts} +26 -49
- package/dist/schema/syncDeltaRow.js +89 -0
- package/dist/schema/tenancy.d.ts +44 -46
- package/dist/schema/tenancy.js +46 -48
- package/dist/server/adapter.d.ts +58 -58
- package/dist/server/adapter.js +13 -14
- package/dist/server/commit.d.ts +60 -64
- package/dist/server/index.d.ts +9 -10
- package/dist/server/index.js +1 -1
- package/dist/server/readConfig.d.ts +70 -0
- package/dist/server/readConfig.js +8 -0
- package/dist/server/storageMode.d.ts +23 -0
- package/dist/server/storageMode.js +17 -0
- package/dist/source/adapter.d.ts +30 -25
- package/dist/source/adapter.js +10 -10
- package/dist/source/adapters/drizzle.d.ts +28 -23
- package/dist/source/adapters/drizzle.js +30 -25
- package/dist/source/adapters/kysely.d.ts +27 -25
- package/dist/source/adapters/kysely.js +24 -23
- package/dist/source/adapters/memory.d.ts +8 -7
- package/dist/source/adapters/memory.js +9 -8
- package/dist/source/adapters/prisma.d.ts +13 -12
- package/dist/source/adapters/prisma.js +22 -25
- package/dist/source/conformance.d.ts +18 -11
- package/dist/source/conformance.js +17 -11
- package/dist/source/connector.d.ts +31 -32
- package/dist/source/connector.js +28 -28
- package/dist/source/connectorProtocol.d.ts +160 -0
- package/dist/source/connectorProtocol.js +162 -0
- package/dist/source/contract.d.ts +26 -27
- package/dist/source/contract.js +28 -29
- package/dist/source/factory.d.ts +46 -58
- package/dist/source/factory.js +22 -27
- package/dist/source/index.d.ts +7 -9
- package/dist/source/index.js +12 -14
- package/dist/source/migrations.d.ts +9 -9
- package/dist/source/migrations.js +9 -9
- package/dist/source/next.d.ts +9 -10
- package/dist/source/next.js +6 -7
- package/dist/source/pushQueue.d.ts +69 -47
- package/dist/source/pushQueue.js +32 -28
- package/dist/source/signing.d.ts +46 -17
- package/dist/source/signing.js +28 -11
- package/dist/source/types.d.ts +121 -104
- package/dist/source/types.js +13 -14
- package/dist/stores/ObjectStore.d.ts +24 -12
- package/dist/stores/ObjectStore.js +38 -16
- package/dist/stores/ObjectStoreContract.d.ts +14 -15
- package/dist/stores/SyncActionStore.d.ts +7 -11
- package/dist/stores/SyncActionStore.js +13 -17
- package/dist/surface.d.ts +28 -21
- package/dist/surface.js +29 -20
- package/dist/sync/{BootstrapHelper.d.ts → BootstrapFetcher.d.ts} +36 -42
- package/dist/sync/{BootstrapHelper.js → BootstrapFetcher.js} +76 -76
- package/dist/sync/ConnectionManager.d.ts +39 -50
- package/dist/sync/ConnectionManager.js +55 -66
- package/dist/sync/NetworkProbe.d.ts +24 -29
- package/dist/sync/NetworkProbe.js +63 -69
- package/dist/sync/{HydrationCoordinator.d.ts → OnDemandLoader.d.ts} +42 -41
- package/dist/sync/{HydrationCoordinator.js → OnDemandLoader.js} +59 -54
- package/dist/sync/{AreaOfInterestManager.d.ts → SubscriptionManager.d.ts} +43 -57
- package/dist/sync/{AreaOfInterestManager.js → SubscriptionManager.js} +43 -51
- package/dist/sync/SyncWebSocket.d.ts +141 -166
- package/dist/sync/SyncWebSocket.js +191 -223
- package/dist/sync/awaitClaimGrant.d.ts +18 -18
- package/dist/sync/awaitClaimGrant.js +11 -11
- package/dist/sync/bootstrapApply.d.ts +34 -24
- package/dist/sync/bootstrapApply.js +27 -19
- package/dist/sync/commitFrames.d.ts +21 -20
- package/dist/sync/commitFrames.js +18 -18
- package/dist/sync/createClaimStream.d.ts +23 -22
- package/dist/sync/createClaimStream.js +105 -23
- package/dist/sync/createPresenceStream.d.ts +19 -18
- package/dist/sync/createPresenceStream.js +25 -26
- package/dist/sync/createSnapshot.d.ts +12 -14
- package/dist/sync/createSnapshot.js +20 -26
- package/dist/sync/credentialLifecycle.d.ts +104 -104
- package/dist/sync/credentialLifecycle.js +140 -147
- package/dist/sync/deltaPipeline.d.ts +36 -34
- package/dist/sync/deltaPipeline.js +64 -65
- package/dist/sync/groupChange.d.ts +63 -61
- package/dist/sync/groupChange.js +74 -78
- package/dist/sync/heartbeat.d.ts +34 -33
- package/dist/sync/heartbeat.js +31 -31
- package/dist/sync/participants.d.ts +19 -19
- package/dist/sync/persistedPrefix.d.ts +12 -0
- package/dist/sync/persistedPrefix.js +22 -0
- package/dist/sync/schemas.d.ts +3 -2
- package/dist/sync/schemas.js +14 -10
- package/dist/sync/syncCursor.d.ts +17 -21
- package/dist/sync/syncCursor.js +17 -21
- package/dist/sync/syncPlan.d.ts +28 -36
- package/dist/sync/syncPlan.js +18 -19
- package/dist/sync/syncPosition.d.ts +54 -49
- package/dist/sync/syncPosition.js +57 -52
- package/dist/sync/wsFrameHandlers.d.ts +35 -36
- package/dist/sync/wsFrameHandlers.js +63 -67
- package/dist/testing/fixtures/bootstrap.d.ts +12 -6
- package/dist/testing/fixtures/bootstrap.js +12 -6
- package/dist/testing/fixtures/deltas.d.ts +30 -33
- package/dist/testing/fixtures/deltas.js +30 -33
- package/dist/testing/fixtures/models.d.ts +11 -10
- package/dist/testing/fixtures/models.js +11 -10
- package/dist/testing/helpers/{react-wrapper.d.ts → reactWrapper.d.ts} +13 -10
- package/dist/testing/helpers/{react-wrapper.js → reactWrapper.js} +15 -12
- package/dist/testing/helpers/{sync-engine-harness.d.ts → syncEngineHarness.d.ts} +17 -15
- package/dist/testing/helpers/{sync-engine-harness.js → syncEngineHarness.js} +12 -10
- package/dist/testing/helpers/wait.d.ts +13 -8
- package/dist/testing/helpers/wait.js +13 -8
- package/dist/testing/index.d.ts +5 -3
- package/dist/testing/index.js +3 -2
- package/dist/testing/mocks/FakeDatabase.d.ts +18 -0
- package/dist/testing/mocks/FakeDatabase.js +10 -0
- package/dist/testing/mocks/MockMutationExecutor.d.ts +18 -17
- package/dist/testing/mocks/MockMutationExecutor.js +15 -14
- package/dist/testing/mocks/MockNetworkMonitor.d.ts +8 -8
- package/dist/testing/mocks/MockNetworkMonitor.js +8 -8
- package/dist/testing/mocks/MockSyncContext.d.ts +20 -17
- package/dist/testing/mocks/MockSyncContext.js +15 -13
- package/dist/testing/mocks/MockSyncStore.d.ts +10 -10
- package/dist/testing/mocks/MockSyncStore.js +11 -11
- package/dist/testing/mocks/MockWebSocket.d.ts +28 -23
- package/dist/testing/mocks/MockWebSocket.js +22 -21
- package/dist/transactions/TransactionQueue.d.ts +244 -181
- package/dist/transactions/TransactionQueue.js +929 -423
- package/dist/transactions/TransactionStore.d.ts +6 -4
- package/dist/transactions/TransactionStore.js +6 -4
- package/dist/transactions/UnconfirmedWrites.d.ts +82 -0
- package/dist/transactions/UnconfirmedWrites.js +104 -0
- package/dist/transactions/coalesceRules.d.ts +41 -17
- package/dist/transactions/coalesceRules.js +40 -17
- package/dist/transactions/commitEnvelope.d.ts +132 -0
- package/dist/transactions/commitEnvelope.js +139 -0
- package/dist/transactions/commitOutboxStore.d.ts +32 -0
- package/dist/transactions/commitOutboxStore.js +26 -0
- package/dist/transactions/commitPayload.d.ts +63 -52
- package/dist/transactions/commitPayload.js +54 -57
- package/dist/transactions/deltaConfirmation.d.ts +20 -22
- package/dist/transactions/deltaConfirmation.js +37 -45
- package/dist/transactions/httpCommitEnvelope.d.ts +43 -0
- package/dist/transactions/httpCommitEnvelope.js +179 -0
- package/dist/transactions/optimisticApply.d.ts +49 -0
- package/dist/transactions/optimisticApply.js +65 -0
- package/dist/transactions/replayValidation.d.ts +182 -0
- package/dist/transactions/replayValidation.js +156 -0
- package/dist/types/global.d.ts +46 -41
- package/dist/types/global.js +20 -19
- package/dist/types/index.d.ts +71 -77
- package/dist/types/index.js +22 -22
- package/dist/types/modelData.d.ts +6 -8
- package/dist/types/modelData.js +5 -7
- package/dist/types/participant.d.ts +10 -11
- package/dist/types/participant.js +6 -8
- package/dist/types/streams.d.ts +208 -195
- package/dist/types/streams.js +7 -7
- package/dist/utils/asyncIterator.d.ts +25 -32
- package/dist/utils/asyncIterator.js +25 -32
- package/dist/utils/duration.d.ts +12 -15
- package/dist/utils/duration.js +12 -15
- package/dist/utils/mobxSetup.d.ts +53 -0
- package/dist/utils/{mobx-setup.js → mobxSetup.js} +42 -98
- package/dist/webhooks/events.d.ts +21 -16
- package/dist/webhooks/events.js +10 -8
- package/dist/webhooks/index.d.ts +5 -7
- package/dist/webhooks/index.js +5 -7
- package/dist/wire/bootstrapReason.d.ts +9 -0
- package/dist/wire/bootstrapReason.js +8 -0
- package/dist/{schema/sync-delta-wire.d.ts → wire/delta.d.ts} +58 -41
- package/dist/wire/delta.js +114 -0
- package/dist/wire/errorEnvelope.d.ts +30 -31
- package/dist/wire/errorEnvelope.js +34 -40
- package/dist/wire/frames.d.ts +315 -86
- package/dist/wire/frames.js +47 -33
- package/dist/wire/index.d.ts +18 -14
- package/dist/wire/index.js +32 -27
- package/dist/wire/listEnvelope.d.ts +16 -23
- package/dist/wire/listEnvelope.js +7 -6
- package/dist/wire/protocol.d.ts +25 -32
- package/dist/wire/protocol.js +25 -32
- package/dist/wire/protocolVersion.d.ts +44 -40
- package/dist/wire/protocolVersion.js +44 -40
- package/docs/api.md +10 -10
- package/docs/coordination.md +59 -0
- package/docs/mcp.md +1 -1
- package/package.json +17 -11
- package/dist/ai-sdk/coordinated-tool.d.ts +0 -101
- package/dist/ai-sdk/coordination-context.d.ts +0 -52
- package/dist/core/query-utils.d.ts +0 -34
- package/dist/core/query-utils.js +0 -59
- package/dist/schema/sync-delta-row.js +0 -103
- package/dist/schema/sync-delta-wire.js +0 -102
- package/dist/server/read-config.d.ts +0 -67
- package/dist/server/read-config.js +0 -8
- package/dist/server/storage-mode.d.ts +0 -8
- package/dist/server/storage-mode.js +0 -28
- package/dist/source/connector-protocol.d.ts +0 -159
- package/dist/source/connector-protocol.js +0 -161
- package/dist/transactions/OptimisticEchoTracker.d.ts +0 -82
- package/dist/transactions/OptimisticEchoTracker.js +0 -104
- package/dist/transactions/mutation-error-handler.d.ts +0 -5
- package/dist/transactions/mutation-error-handler.js +0 -39
- package/dist/transactions/optimistic.d.ts +0 -24
- package/dist/transactions/optimistic.js +0 -45
- package/dist/transactions/persistedReplay.d.ts +0 -93
- package/dist/transactions/persistedReplay.js +0 -105
- package/dist/utils/mobx-setup.d.ts +0 -42
package/dist/Database.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* The local persistence layer for synced models. It stores rows in the
|
|
3
|
+
* browser's IndexedDB (or in-memory maps when run headlessly), applies inbound
|
|
4
|
+
* deltas to that store, and fetches the bootstrap snapshot from your sync
|
|
5
|
+
* server. {@link BaseSyncedStore} drives it, and {@link InstanceCache} holds the
|
|
6
|
+
* in-memory mirror of what this class persists.
|
|
4
7
|
*/
|
|
5
8
|
import { DatabaseManager } from './core/DatabaseManager.js';
|
|
6
9
|
import { StoreManager } from './core/StoreManager.js';
|
|
@@ -10,6 +13,43 @@ import { getContext } from './context.js';
|
|
|
10
13
|
import { AbloConnectionError, AbloValidationError } from './errors.js';
|
|
11
14
|
import { InMemoryObjectStore } from './adapters/inMemoryStorage.js';
|
|
12
15
|
import { syncPositionSchema } from './sync/syncPosition.js';
|
|
16
|
+
import { highestPersistedPrefixSyncId } from './sync/persistedPrefix.js';
|
|
17
|
+
/**
|
|
18
|
+
* Request identity excludes local timing metadata for re-entrant seals: a
|
|
19
|
+
* retry rebuilds its envelope with a fresh `sequence`/seal clock, so comparing
|
|
20
|
+
* those volatile fields would reject every legitimate same-request re-seal as
|
|
21
|
+
* an idempotency conflict. Only the fields that define the wire request count.
|
|
22
|
+
*/
|
|
23
|
+
function isSameOutboxRecord(existing, candidate) {
|
|
24
|
+
if (existing.type === 'http_commit_envelope' &&
|
|
25
|
+
candidate.type === 'http_commit_envelope') {
|
|
26
|
+
const identity = (record) => ({
|
|
27
|
+
id: record.id,
|
|
28
|
+
type: record.type,
|
|
29
|
+
storageVersion: record.storageVersion,
|
|
30
|
+
idempotencyKey: record.idempotencyKey,
|
|
31
|
+
request: record.request,
|
|
32
|
+
scopeNamespace: record.scopeNamespace,
|
|
33
|
+
});
|
|
34
|
+
return JSON.stringify(identity(existing)) === JSON.stringify(identity(candidate));
|
|
35
|
+
}
|
|
36
|
+
if (existing.type === 'commit_envelope' &&
|
|
37
|
+
candidate.type === 'commit_envelope') {
|
|
38
|
+
const identity = (record) => ({
|
|
39
|
+
id: record.id,
|
|
40
|
+
type: record.type,
|
|
41
|
+
storageVersion: record.storageVersion,
|
|
42
|
+
origin: record.origin,
|
|
43
|
+
idempotencyKey: record.idempotencyKey,
|
|
44
|
+
operations: record.operations,
|
|
45
|
+
sourceMutationIds: record.sourceMutationIds,
|
|
46
|
+
commitOptions: record.commitOptions,
|
|
47
|
+
scope: record.scope,
|
|
48
|
+
});
|
|
49
|
+
return JSON.stringify(identity(existing)) === JSON.stringify(identity(candidate));
|
|
50
|
+
}
|
|
51
|
+
return JSON.stringify(existing) === JSON.stringify(candidate);
|
|
52
|
+
}
|
|
13
53
|
export class Database {
|
|
14
54
|
// Core database components
|
|
15
55
|
databaseManager;
|
|
@@ -22,11 +62,11 @@ export class Database {
|
|
|
22
62
|
return this.bootstrapHelper;
|
|
23
63
|
}
|
|
24
64
|
/**
|
|
25
|
-
*
|
|
26
|
-
*
|
|
65
|
+
* Fetch the current rows of the given sync groups as a side-effect-free
|
|
66
|
+
* snapshot, used to hydrate a scope as the user enters it. Unlike
|
|
27
67
|
* {@link bootstrapFromServer}, it does not persist to IndexedDB and does not
|
|
28
|
-
*
|
|
29
|
-
*
|
|
68
|
+
* change the connection's subscribed sync groups. The caller applies the
|
|
69
|
+
* result to the pool through the scoped apply path.
|
|
30
70
|
*/
|
|
31
71
|
async fetchScopedBootstrapData(syncGroups) {
|
|
32
72
|
// No lastSyncId → a full snapshot of exactly these groups.
|
|
@@ -156,17 +196,14 @@ export class Database {
|
|
|
156
196
|
getContext().logger.info(`Database opened: ${this.currentDbInfo.name} (${readiness.readyStores.length}/${readiness.totalStores} stores ready)`);
|
|
157
197
|
}
|
|
158
198
|
/**
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
* ARCHITECTURE: By design, this method receives plain objects, not MobX observables:
|
|
165
|
-
* - WebSocket deltas: Already JSON-parsed (SyncedStore.ts:889)
|
|
166
|
-
* - Optimistic updates: Models call toJSON() which uses toJS() (SlideLayer.ts:224)
|
|
167
|
-
* - Bootstrap data: Plain JSON from server
|
|
199
|
+
* Shrink a record before persisting it. Drops `undefined` fields, empty
|
|
200
|
+
* arrays, empty objects, and the redundant markers `__typename`, `__class`,
|
|
201
|
+
* `clientId`, and `syncStatus`. Explicit `null` values are preserved, since
|
|
202
|
+
* a null is a meaningful "clear this field" in a nullable column.
|
|
168
203
|
*
|
|
169
|
-
*
|
|
204
|
+
* By design this receives plain objects, never live observables: WebSocket
|
|
205
|
+
* deltas arrive already parsed, optimistic updates come through `toJSON()`,
|
|
206
|
+
* and bootstrap data is plain JSON from the server.
|
|
170
207
|
*/
|
|
171
208
|
compactRecord(_modelName, data) {
|
|
172
209
|
if (!data || typeof data !== 'object')
|
|
@@ -177,8 +214,8 @@ export class Database {
|
|
|
177
214
|
if (key === '__typename' || key === '__class' || key === 'clientId' || key === 'syncStatus') {
|
|
178
215
|
continue;
|
|
179
216
|
}
|
|
180
|
-
//
|
|
181
|
-
//
|
|
217
|
+
// Skip only `undefined`; preserve explicit `null`, which is a
|
|
218
|
+
// meaningful value for a nullable column.
|
|
182
219
|
if (value === undefined) {
|
|
183
220
|
continue;
|
|
184
221
|
}
|
|
@@ -272,26 +309,24 @@ export class Database {
|
|
|
272
309
|
// point). Invalid → 0 → full bootstrap, the safe degradation.
|
|
273
310
|
const metadataLastSyncId = syncPositionSchema.shape.persisted.safeParse(metadata?.lastSyncId).data ?? 0;
|
|
274
311
|
const dataAge = metadata?.updatedAt ? Date.now() - metadata.updatedAt.getTime() : Infinity;
|
|
275
|
-
// ──
|
|
312
|
+
// ── Cache-validity check ─────────────────────────────────────
|
|
276
313
|
//
|
|
277
314
|
// The cursor (lastSyncId) is only valid if the data it refers to
|
|
278
|
-
// actually exists in the stores. If
|
|
279
|
-
// fresh in-memory session), the metadata's lastSyncId is
|
|
280
|
-
// sending it to the server would trigger a partial bootstrap
|
|
281
|
-
// returns zero deltas because the gap is 0, leaving the client
|
|
282
|
-
// with an empty
|
|
315
|
+
// actually exists in the stores. If the local store was cleared (or
|
|
316
|
+
// this is a fresh in-memory session), the metadata's lastSyncId is
|
|
317
|
+
// stale — sending it to the server would trigger a partial bootstrap
|
|
318
|
+
// that returns zero deltas because the gap is 0, leaving the client
|
|
319
|
+
// with an empty InstanceCache.
|
|
283
320
|
//
|
|
284
|
-
//
|
|
285
|
-
//
|
|
286
|
-
// property by sampling the actual stores — if they're empty, the
|
|
287
|
-
// cursor is meaningless regardless of what metadata claims.
|
|
321
|
+
// The fix is to sample the actual stores: if they hold no rows, the
|
|
322
|
+
// cursor is meaningless regardless of what the metadata claims.
|
|
288
323
|
const dataExists = this.inMemory
|
|
289
324
|
? false // In-memory mode: no persistent data across sessions
|
|
290
325
|
: await this.storeManager.hasAnyData();
|
|
291
326
|
// The effective lastSyncId: only trust the metadata cursor when
|
|
292
327
|
// we've confirmed the data it refers to actually exists in the stores.
|
|
293
328
|
const lastSyncId = dataExists ? metadataLastSyncId : 0;
|
|
294
|
-
//
|
|
329
|
+
// Log the resolved database state for diagnostics.
|
|
295
330
|
getContext().logger.debug('[Database.requiredBootstrap] State check', {
|
|
296
331
|
readinessReady: readiness.ready,
|
|
297
332
|
hasMetadata: !!metadata,
|
|
@@ -312,7 +347,8 @@ export class Database {
|
|
|
312
347
|
getContext().logger.info('Offline detected with local data - using local bootstrap');
|
|
313
348
|
}
|
|
314
349
|
else {
|
|
315
|
-
//
|
|
350
|
+
// The server is the source of truth: always use a full bootstrap
|
|
351
|
+
// when online.
|
|
316
352
|
type = 'full';
|
|
317
353
|
getContext().logger.info('Full bootstrap - server is source of truth', {
|
|
318
354
|
reason: offline ? 'offline_no_data' : 'server_authoritative',
|
|
@@ -329,7 +365,9 @@ export class Database {
|
|
|
329
365
|
};
|
|
330
366
|
}
|
|
331
367
|
/**
|
|
332
|
-
*
|
|
368
|
+
* Fetch a bootstrap snapshot (or delta batch) from the sync server and load
|
|
369
|
+
* it into the local store, then return a {@link BootstrapResult} the caller
|
|
370
|
+
* applies to the {@link InstanceCache}.
|
|
333
371
|
*/
|
|
334
372
|
async bootstrapFromServer(requirements,
|
|
335
373
|
/** Full sync-group subscription list — what the WS subscribes to
|
|
@@ -348,8 +386,8 @@ export class Database {
|
|
|
348
386
|
modelsToLoad: requirements.modelsToLoad,
|
|
349
387
|
});
|
|
350
388
|
try {
|
|
351
|
-
//
|
|
352
|
-
//
|
|
389
|
+
// Fetch before any destructive operation, so a failed network
|
|
390
|
+
// request can't leave the local store empty.
|
|
353
391
|
const startTime = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
354
392
|
getContext().logger.info('Fetching bootstrap data from server (before clearing local data)', {
|
|
355
393
|
type: requirements.type,
|
|
@@ -363,8 +401,9 @@ export class Database {
|
|
|
363
401
|
hasDeltas: !!bootstrapData.deltas,
|
|
364
402
|
deltaCount: bootstrapData.deltaCount ?? 0,
|
|
365
403
|
});
|
|
366
|
-
//
|
|
367
|
-
//
|
|
404
|
+
// Clear only after a successful fetch, for transactional safety.
|
|
405
|
+
// Clear when the server says the response is a full snapshot,
|
|
406
|
+
// regardless of what type was requested.
|
|
368
407
|
if (bootstrapData.type === 'full') {
|
|
369
408
|
await this.clear();
|
|
370
409
|
}
|
|
@@ -379,7 +418,7 @@ export class Database {
|
|
|
379
418
|
// Apply deltas to IndexedDB using processDeltaBatch for better performance.
|
|
380
419
|
// Capture the return value so the pool can be updated by the caller —
|
|
381
420
|
// without this, partial-bootstrap DELETEs persist to IDB but don't
|
|
382
|
-
// evict entities from the in-memory
|
|
421
|
+
// evict entities from the in-memory InstanceCache, leaving ghost rows
|
|
383
422
|
// visible on the canvas until a full reload rebuilds the pool.
|
|
384
423
|
let deltasApplied = 0;
|
|
385
424
|
let deltaResults;
|
|
@@ -501,17 +540,16 @@ export class Database {
|
|
|
501
540
|
}
|
|
502
541
|
// bootstrapSpecificModels removed per request
|
|
503
542
|
/**
|
|
504
|
-
*
|
|
543
|
+
* Apply a single inbound delta from the WebSocket to the local store.
|
|
505
544
|
*
|
|
506
|
-
*
|
|
507
|
-
*
|
|
508
|
-
*
|
|
545
|
+
* This handles one delta at a time. To apply several, prefer
|
|
546
|
+
* {@link processDeltaBatch}, which commits them in one IndexedDB transaction
|
|
547
|
+
* rather than two transactions per delta.
|
|
509
548
|
*
|
|
510
|
-
*
|
|
511
|
-
*
|
|
512
|
-
*
|
|
513
|
-
*
|
|
514
|
-
* - Explicit null values ARE preserved: {position: null} clears the field
|
|
549
|
+
* Update deltas carry only the changed fields, so they are merged onto the
|
|
550
|
+
* existing record rather than replacing it. That preserves fields the delta
|
|
551
|
+
* omits (such as deckId or title), and an explicit null is kept as a value,
|
|
552
|
+
* clearing that field.
|
|
515
553
|
*/
|
|
516
554
|
async processDelta(delta) {
|
|
517
555
|
const { actionType, modelName, modelId, data, syncId } = delta;
|
|
@@ -519,7 +557,7 @@ export class Database {
|
|
|
519
557
|
if (!store) {
|
|
520
558
|
return { action: 'verify', modelName, modelId };
|
|
521
559
|
}
|
|
522
|
-
//
|
|
560
|
+
// Idempotency gate: ignore already-applied deltas by comparing with the persisted lastSyncId
|
|
523
561
|
try {
|
|
524
562
|
const lastApplied = await this.getLastSyncId();
|
|
525
563
|
const incomingId = typeof syncId === 'number' ? syncId : undefined;
|
|
@@ -572,11 +610,12 @@ export class Database {
|
|
|
572
610
|
return { action: 'add', modelName, modelId, data: compacted };
|
|
573
611
|
}
|
|
574
612
|
case 'U': {
|
|
575
|
-
//
|
|
576
|
-
// Read existing record first
|
|
613
|
+
// Update: merge onto the existing record (partial-delta pattern).
|
|
614
|
+
// Read the existing record first.
|
|
577
615
|
const existing = await store.get(modelId);
|
|
578
|
-
//
|
|
579
|
-
//
|
|
616
|
+
// Skip the update when there's no existing record to merge with:
|
|
617
|
+
// building a record from partial update data would corrupt it
|
|
618
|
+
// (missing deckId, and so on).
|
|
580
619
|
if (!existing) {
|
|
581
620
|
getContext().observability.breadcrumb('Skipping UPDATE delta - no existing record to merge with', 'sync.database', 'warning', {
|
|
582
621
|
modelName,
|
|
@@ -619,7 +658,7 @@ export class Database {
|
|
|
619
658
|
getContext().observability.breadcrumb(`IndexedDB delete failed for ${modelName}:${modelId}`, 'sync.database', 'error', {
|
|
620
659
|
error: err instanceof Error ? err.message : String(err),
|
|
621
660
|
});
|
|
622
|
-
// Surface failure so caller does not mutate
|
|
661
|
+
// Surface failure so caller does not mutate InstanceCache inconsistently
|
|
623
662
|
throw err;
|
|
624
663
|
}
|
|
625
664
|
return { action: 'remove', modelName, modelId };
|
|
@@ -659,27 +698,20 @@ export class Database {
|
|
|
659
698
|
}
|
|
660
699
|
}
|
|
661
700
|
/**
|
|
662
|
-
*
|
|
701
|
+
* Apply many deltas to the local store in as few IndexedDB transactions as
|
|
702
|
+
* possible. Deltas are grouped by store, and each store's writes commit in a
|
|
703
|
+
* single transaction, so a batch of 186 deltas becomes roughly one
|
|
704
|
+
* transaction per store instead of two per delta.
|
|
663
705
|
*
|
|
664
|
-
*
|
|
665
|
-
*
|
|
666
|
-
*
|
|
667
|
-
*
|
|
668
|
-
* 4. Updating metadata only once at the end with highest syncId
|
|
706
|
+
* The method reads the existing records for update deltas up front, then
|
|
707
|
+
* merges each update onto its existing record so fields the delta omits are
|
|
708
|
+
* preserved and an explicit null still clears its field. It advances the
|
|
709
|
+
* persisted sync cursor once, to the highest committed sync id.
|
|
669
710
|
*
|
|
670
|
-
*
|
|
671
|
-
*
|
|
672
|
-
*
|
|
673
|
-
*
|
|
674
|
-
* - We merge with existing: {...existing, ...delta}
|
|
675
|
-
* - Preserves deckId, title, settings etc. when updating just position
|
|
676
|
-
* - Handles explicit null: {field: null} clears the field correctly
|
|
677
|
-
*
|
|
678
|
-
* 🔄 LINEAR-STYLE CONFLICT RESOLUTION:
|
|
679
|
-
* - Builds a map of DELETE deltas with their syncIds
|
|
680
|
-
* - Before processing UPDATE/INSERT, checks for DELETE with higher syncId
|
|
681
|
-
* - Skips stale updates for entities that will be/were deleted
|
|
682
|
-
* - Prevents 404 errors from fetching already-deleted entities
|
|
711
|
+
* Conflict resolution follows a delete-wins rule: it first indexes the
|
|
712
|
+
* delete deltas by entity, then skips any insert or update whose sync id is
|
|
713
|
+
* at or below a delete for the same entity. This avoids resurrecting a
|
|
714
|
+
* deleted entity and avoids fetching one that no longer exists.
|
|
683
715
|
*/
|
|
684
716
|
async processDeltaBatch(deltas) {
|
|
685
717
|
if ((!this.workspaceDb && !this.inMemory) || this.isClosing || deltas.length === 0) {
|
|
@@ -722,14 +754,11 @@ export class Database {
|
|
|
722
754
|
}
|
|
723
755
|
// Prepare results aligned with input order
|
|
724
756
|
const results = new Array(deltas.length);
|
|
725
|
-
//
|
|
726
|
-
//
|
|
727
|
-
//
|
|
728
|
-
//
|
|
729
|
-
|
|
730
|
-
// for entities that have been cascade-deleted (where DELETE delta exists).
|
|
731
|
-
// ========================================================================
|
|
732
|
-
const deleteSyncIds = new Map(); // key: "ModelName:modelId" -> DELETE syncId
|
|
757
|
+
// Build a delete index for conflict resolution. When a delete has a sync
|
|
758
|
+
// id at or above a later insert or update for the same entity, that entity
|
|
759
|
+
// is not (re)created — which drops stale updates for cascade-deleted
|
|
760
|
+
// entities.
|
|
761
|
+
const deleteSyncIds = new Map(); // key: "ModelName:modelId" -> delete syncId
|
|
733
762
|
for (const delta of deltas) {
|
|
734
763
|
if (delta.actionType === 'D' && delta.syncId) {
|
|
735
764
|
const key = `${delta.modelName}:${delta.modelId}`;
|
|
@@ -749,21 +778,20 @@ export class Database {
|
|
|
749
778
|
}
|
|
750
779
|
// Group deltas by store for efficient transaction management.
|
|
751
780
|
//
|
|
752
|
-
//
|
|
753
|
-
//
|
|
754
|
-
//
|
|
755
|
-
//
|
|
781
|
+
// The method tracks the total range seen plus the exact input indexes whose
|
|
782
|
+
// store transaction committed. The cursor is derived from their ordered
|
|
783
|
+
// prefix after every store finishes; a maximum alone is unsafe because a
|
|
784
|
+
// later store can succeed after an earlier store failed.
|
|
756
785
|
//
|
|
757
|
-
// Without this split, a single store-level
|
|
758
|
-
//
|
|
759
|
-
//
|
|
760
|
-
//
|
|
761
|
-
//
|
|
762
|
-
//
|
|
763
|
-
// recover it" failure mode.
|
|
786
|
+
// Without this split, a single store-level failure (a compacted record
|
|
787
|
+
// missing a required field, a validation abort) would advance the cursor
|
|
788
|
+
// past deltas that never wrote to IndexedDB. The next partial bootstrap
|
|
789
|
+
// would ask "what's new since {advanced cursor}?", the skipped rows would
|
|
790
|
+
// fall into the already-seen range forever, and the local store would stay
|
|
791
|
+
// permanently behind the server with no way to recover on reload.
|
|
764
792
|
const deltasByStore = new Map();
|
|
765
793
|
let highestSyncId = 0;
|
|
766
|
-
|
|
794
|
+
const persistedIndexes = new Set();
|
|
767
795
|
let skippedDueToConflict = 0;
|
|
768
796
|
deltas.forEach((delta, idx) => {
|
|
769
797
|
// Normalize to number — postgres sends bigint syncIds as strings.
|
|
@@ -773,9 +801,8 @@ export class Database {
|
|
|
773
801
|
if (typeof deltaSyncIdNum === 'number' && !isNaN(deltaSyncIdNum) && deltaSyncIdNum > highestSyncId) {
|
|
774
802
|
highestSyncId = deltaSyncIdNum;
|
|
775
803
|
}
|
|
776
|
-
//
|
|
777
|
-
//
|
|
778
|
-
// ========================================================================
|
|
804
|
+
// Conflict check: skip an insert or update when a delete for the same
|
|
805
|
+
// entity has an equal or higher sync id.
|
|
779
806
|
if (delta.actionType === 'U' ||
|
|
780
807
|
delta.actionType === 'I' ||
|
|
781
808
|
delta.actionType === 'C' ||
|
|
@@ -795,6 +822,9 @@ export class Database {
|
|
|
795
822
|
deleteSyncId,
|
|
796
823
|
});
|
|
797
824
|
results[idx] = { action: 'verify', modelName: delta.modelName, modelId: delta.modelId };
|
|
825
|
+
// The later delete in this same ordered frame supersedes this
|
|
826
|
+
// value, so the stale predecessor requires no separate write.
|
|
827
|
+
persistedIndexes.add(idx);
|
|
798
828
|
skippedDueToConflict++;
|
|
799
829
|
return; // Skip this delta
|
|
800
830
|
}
|
|
@@ -823,8 +853,8 @@ export class Database {
|
|
|
823
853
|
if (!store)
|
|
824
854
|
continue;
|
|
825
855
|
try {
|
|
826
|
-
//
|
|
827
|
-
// Step 1: Identify which deltas need existing data (
|
|
856
|
+
// Batch read-modify-write.
|
|
857
|
+
// Step 1: Identify which deltas need existing data (updates)
|
|
828
858
|
const updateDeltas = storeDeltas.filter(({ delta }) => delta.actionType === 'U');
|
|
829
859
|
const updateIds = updateDeltas.map(({ delta }) => delta.modelId);
|
|
830
860
|
// Step 2: Batch read all existing records in a SINGLE IDB transaction
|
|
@@ -849,8 +879,9 @@ export class Database {
|
|
|
849
879
|
}
|
|
850
880
|
}
|
|
851
881
|
}
|
|
852
|
-
//
|
|
853
|
-
// Track
|
|
882
|
+
// Self-heal by fetching missing records for update deltas.
|
|
883
|
+
// Track ids that failed to fetch (a 404 means the entity was deleted,
|
|
884
|
+
// so its delta is skipped).
|
|
854
885
|
const failedToFetch = new Set();
|
|
855
886
|
if (missingIds.size > 0) {
|
|
856
887
|
getContext().logger.info(`[Database.processDeltaBatch] Found ${missingIds.size} missing records for ${modelName}, fetching from server...`);
|
|
@@ -924,13 +955,11 @@ export class Database {
|
|
|
924
955
|
});
|
|
925
956
|
break;
|
|
926
957
|
case 'U': {
|
|
927
|
-
//
|
|
958
|
+
// Update: merge the delta onto the existing record (already fetched).
|
|
928
959
|
const existing = existingRecords.get(modelId);
|
|
929
|
-
//
|
|
930
|
-
//
|
|
931
|
-
//
|
|
932
|
-
// Per Linear's architecture, skip it instead of creating incomplete data.
|
|
933
|
-
// ========================================================================
|
|
960
|
+
// Skip a stale update: if the entity is neither in the local
|
|
961
|
+
// store nor fetchable from the server (a 404), it was deleted,
|
|
962
|
+
// so skip it rather than create an incomplete record.
|
|
934
963
|
if (!existing && failedToFetch.has(modelId)) {
|
|
935
964
|
getContext().logger.debug('[Database.processDeltaBatch] Skipping UPDATE for deleted entity', {
|
|
936
965
|
modelName,
|
|
@@ -939,8 +968,9 @@ export class Database {
|
|
|
939
968
|
stagedResults.push({ action: 'verify', modelName, modelId, idx });
|
|
940
969
|
break; // Skip this delta
|
|
941
970
|
}
|
|
942
|
-
//
|
|
943
|
-
//
|
|
971
|
+
// Skip the update when there's no existing record to merge with:
|
|
972
|
+
// building a record from partial update data would corrupt it
|
|
973
|
+
// (missing deckId, and so on).
|
|
944
974
|
if (!existing) {
|
|
945
975
|
getContext().observability.breadcrumb('Batch: Skipping UPDATE delta - no existing record', 'sync.database', 'warning', {
|
|
946
976
|
modelName,
|
|
@@ -993,14 +1023,14 @@ export class Database {
|
|
|
993
1023
|
tx.oncomplete = () => { resolve(); };
|
|
994
1024
|
tx.onerror = () => { reject(tx.error); };
|
|
995
1025
|
});
|
|
996
|
-
// Only commit staged results to the global results if the transaction
|
|
997
|
-
//
|
|
998
|
-
//
|
|
1026
|
+
// Only commit staged results to the global results if the transaction
|
|
1027
|
+
// succeeded. Record input indexes rather than a maximum sync id; the
|
|
1028
|
+
// durable cursor is the prefix through these indexes.
|
|
999
1029
|
for (const r of stagedResults) {
|
|
1000
1030
|
// Resolve the originating delta so we can carry its
|
|
1001
1031
|
// transactionId through to the result. Echo detection in
|
|
1002
1032
|
// `SyncClient.applyDeltaBatchToPool` reads it.
|
|
1003
|
-
const sourceDelta =
|
|
1033
|
+
const sourceDelta = deltas[r.idx];
|
|
1004
1034
|
results[r.idx] = {
|
|
1005
1035
|
action: r.action,
|
|
1006
1036
|
modelName: r.modelName,
|
|
@@ -1008,13 +1038,7 @@ export class Database {
|
|
|
1008
1038
|
data: r.data,
|
|
1009
1039
|
transactionId: sourceDelta?.transactionId,
|
|
1010
1040
|
};
|
|
1011
|
-
|
|
1012
|
-
// SyncDelta.syncId is typed as number but postgres serializes
|
|
1013
|
-
// bigint to string on the wire — coerce before compare.
|
|
1014
|
-
const syncId = typeof rawSyncId === 'string' ? Number(rawSyncId) : rawSyncId;
|
|
1015
|
-
if (typeof syncId === 'number' && !isNaN(syncId) && syncId > highestPersistedSyncId) {
|
|
1016
|
-
highestPersistedSyncId = syncId;
|
|
1017
|
-
}
|
|
1041
|
+
persistedIndexes.add(r.idx);
|
|
1018
1042
|
}
|
|
1019
1043
|
}
|
|
1020
1044
|
catch (err) {
|
|
@@ -1048,11 +1072,12 @@ export class Database {
|
|
|
1048
1072
|
}
|
|
1049
1073
|
}
|
|
1050
1074
|
}
|
|
1051
|
-
//
|
|
1052
|
-
//
|
|
1053
|
-
//
|
|
1054
|
-
|
|
1055
|
-
//
|
|
1075
|
+
// Advance only through the durable INPUT PREFIX. IDs need not be
|
|
1076
|
+
// numerically contiguous because other tenants and filtered sync groups
|
|
1077
|
+
// occupy gaps; the server-delivered order is the relevant sequence.
|
|
1078
|
+
const highestPersistedSyncId = highestPersistedPrefixSyncId(deltas, persistedIndexes);
|
|
1079
|
+
// Using `highestSyncId` (the range-seen max) would advance past an earlier
|
|
1080
|
+
// failed store transaction and permanently skip its delta.
|
|
1056
1081
|
//
|
|
1057
1082
|
// If `highestPersistedSyncId === 0` (every store tx failed), we leave
|
|
1058
1083
|
// the metadata alone. Next partial bootstrap will re-deliver the
|
|
@@ -1104,13 +1129,7 @@ export class Database {
|
|
|
1104
1129
|
const store = this.getRequiredStore(modelName);
|
|
1105
1130
|
return await store.getAllFromIndex(indexName, value);
|
|
1106
1131
|
}
|
|
1107
|
-
/**
|
|
1108
|
-
* Update workspace metadata
|
|
1109
|
-
*/
|
|
1110
|
-
/**
|
|
1111
|
-
* Get the last sync ID from workspace metadata
|
|
1112
|
-
*/
|
|
1113
|
-
/** Read workspace metadata from IDB (returns null if db not open). */
|
|
1132
|
+
/** Read workspace metadata from IndexedDB. Returns null when the database is not open. */
|
|
1114
1133
|
async getWorkspaceMetadata() {
|
|
1115
1134
|
if (this.inMemory)
|
|
1116
1135
|
return this.inMemoryMetadata;
|
|
@@ -1167,16 +1186,51 @@ export class Database {
|
|
|
1167
1186
|
* delete/getAll/getAllFromIndex surface, so callers don't need to
|
|
1168
1187
|
* branch on which one they got back. */
|
|
1169
1188
|
get transactionStore() {
|
|
1170
|
-
return this.
|
|
1189
|
+
return this.getRequiredStore('__transactions');
|
|
1171
1190
|
}
|
|
1172
1191
|
async saveTransaction(transaction) {
|
|
1173
|
-
await this.transactionStore
|
|
1192
|
+
await this.transactionStore.put(transaction);
|
|
1193
|
+
}
|
|
1194
|
+
/** Persist one burst of journal rows in a single strict durability group. */
|
|
1195
|
+
async saveTransactions(transactions) {
|
|
1196
|
+
if (transactions.length === 0)
|
|
1197
|
+
return;
|
|
1198
|
+
if (this.inMemory) {
|
|
1199
|
+
await Promise.all(transactions.map((transaction) => this.transactionStore.put(transaction)));
|
|
1200
|
+
return;
|
|
1201
|
+
}
|
|
1202
|
+
const db = this.workspaceDb;
|
|
1203
|
+
if (!db || this.isClosing) {
|
|
1204
|
+
throw new AbloConnectionError('Database not opened for mutation journal', {
|
|
1205
|
+
code: 'db_not_opened',
|
|
1206
|
+
});
|
|
1207
|
+
}
|
|
1208
|
+
await new Promise((resolve, reject) => {
|
|
1209
|
+
try {
|
|
1210
|
+
const tx = db.transaction(['__transactions'], 'readwrite', {
|
|
1211
|
+
durability: 'strict',
|
|
1212
|
+
});
|
|
1213
|
+
const store = tx.objectStore('__transactions');
|
|
1214
|
+
for (const transaction of transactions)
|
|
1215
|
+
store.put(transaction);
|
|
1216
|
+
tx.oncomplete = () => { resolve(); };
|
|
1217
|
+
tx.onabort = () => {
|
|
1218
|
+
reject(tx.error ?? new Error('Mutation journal transaction aborted'));
|
|
1219
|
+
};
|
|
1220
|
+
tx.onerror = () => {
|
|
1221
|
+
// onabort owns rejection.
|
|
1222
|
+
};
|
|
1223
|
+
}
|
|
1224
|
+
catch (error) {
|
|
1225
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
1226
|
+
}
|
|
1227
|
+
});
|
|
1174
1228
|
}
|
|
1175
1229
|
async removeTransaction(id) {
|
|
1176
|
-
await this.transactionStore
|
|
1230
|
+
await this.transactionStore.delete(id);
|
|
1177
1231
|
}
|
|
1178
1232
|
async getPersistedTransactions() {
|
|
1179
|
-
const rows =
|
|
1233
|
+
const rows = await this.transactionStore.getAll();
|
|
1180
1234
|
// Storage layer returns the centralized `Record<string, unknown>`
|
|
1181
1235
|
// shape from `ObjectStoreContract`. PersistedTransaction adds an
|
|
1182
1236
|
// index signature so each row already structurally satisfies the
|
|
@@ -1184,14 +1238,138 @@ export class Database {
|
|
|
1184
1238
|
// here, and it only accepts PersistedTransaction.
|
|
1185
1239
|
return rows;
|
|
1186
1240
|
}
|
|
1241
|
+
async getPersistedTransaction(id) {
|
|
1242
|
+
return (await this.transactionStore.get(id));
|
|
1243
|
+
}
|
|
1244
|
+
/**
|
|
1245
|
+
* Atomically seal one exact commit request and consume the staged mutation
|
|
1246
|
+
* records it replaces. The read, optional add, and deletes share one strict
|
|
1247
|
+
* IndexedDB transaction, so a crash can expose the staged records or the
|
|
1248
|
+
* sealed envelope, never a missing handoff. Returns the pre-existing record
|
|
1249
|
+
* when the envelope id was already sealed (retry/re-entrant call).
|
|
1250
|
+
*/
|
|
1251
|
+
async sealTransactionRecord(record, consumedRecordIds) {
|
|
1252
|
+
const recordId = record.id;
|
|
1253
|
+
if (!recordId) {
|
|
1254
|
+
throw new AbloValidationError('A sealed transaction record must carry an id', {
|
|
1255
|
+
code: 'invalid_body',
|
|
1256
|
+
});
|
|
1257
|
+
}
|
|
1258
|
+
if (this.inMemory) {
|
|
1259
|
+
const store = this.transactionStore;
|
|
1260
|
+
const existing = (await store.get(recordId));
|
|
1261
|
+
if (existing && !isSameOutboxRecord(existing, record)) {
|
|
1262
|
+
throw new AbloValidationError('Commit outbox key already identifies a different request', {
|
|
1263
|
+
code: 'idempotency_conflict',
|
|
1264
|
+
});
|
|
1265
|
+
}
|
|
1266
|
+
if (!existing) {
|
|
1267
|
+
const sources = await Promise.all(consumedRecordIds.map((id) => store.get(id)));
|
|
1268
|
+
if (sources.some((source) => source === undefined)) {
|
|
1269
|
+
throw new AbloValidationError('Commit outbox source mutations were already claimed by another envelope', { code: 'idempotency_conflict' });
|
|
1270
|
+
}
|
|
1271
|
+
await store.add(record);
|
|
1272
|
+
}
|
|
1273
|
+
for (const id of consumedRecordIds) {
|
|
1274
|
+
if (id !== recordId)
|
|
1275
|
+
await store.delete(id);
|
|
1276
|
+
}
|
|
1277
|
+
return existing;
|
|
1278
|
+
}
|
|
1279
|
+
const db = this.workspaceDb;
|
|
1280
|
+
if (!db || this.isClosing) {
|
|
1281
|
+
throw new AbloConnectionError('Database not opened for commit outbox', {
|
|
1282
|
+
code: 'db_not_opened',
|
|
1283
|
+
});
|
|
1284
|
+
}
|
|
1285
|
+
return new Promise((resolve, reject) => {
|
|
1286
|
+
try {
|
|
1287
|
+
const tx = db.transaction(['__transactions'], 'readwrite', {
|
|
1288
|
+
durability: 'strict',
|
|
1289
|
+
});
|
|
1290
|
+
const store = tx.objectStore('__transactions');
|
|
1291
|
+
const getRequest = store.get(recordId);
|
|
1292
|
+
const sourceIds = [...new Set(consumedRecordIds)].filter((id) => id !== recordId);
|
|
1293
|
+
const sourceRequests = sourceIds.map((id) => store.get(id));
|
|
1294
|
+
const sourceExists = new Array(sourceRequests.length).fill(false);
|
|
1295
|
+
let existing;
|
|
1296
|
+
let collisionError;
|
|
1297
|
+
let envelopeRead = false;
|
|
1298
|
+
let sourcesRead = 0;
|
|
1299
|
+
let promotionStarted = false;
|
|
1300
|
+
const promote = () => {
|
|
1301
|
+
if (promotionStarted ||
|
|
1302
|
+
!envelopeRead ||
|
|
1303
|
+
sourcesRead !== sourceRequests.length)
|
|
1304
|
+
return;
|
|
1305
|
+
promotionStarted = true;
|
|
1306
|
+
if (existing && !isSameOutboxRecord(existing, record)) {
|
|
1307
|
+
collisionError = new AbloValidationError('Commit outbox key already identifies a different request', { code: 'idempotency_conflict' });
|
|
1308
|
+
tx.abort();
|
|
1309
|
+
return;
|
|
1310
|
+
}
|
|
1311
|
+
// A new envelope owns promotion only while every source row still
|
|
1312
|
+
// exists. This is the fleet/tab execution claim: a second tab that
|
|
1313
|
+
// restored the same journal entries under another key loses here and
|
|
1314
|
+
// cannot dispatch. An identical existing envelope is an idempotent
|
|
1315
|
+
// retry, so its already-consumed sources may be absent.
|
|
1316
|
+
if (!existing && sourceExists.some((exists) => !exists)) {
|
|
1317
|
+
collisionError = new AbloValidationError('Commit outbox source mutations were already claimed by another envelope', { code: 'idempotency_conflict' });
|
|
1318
|
+
tx.abort();
|
|
1319
|
+
return;
|
|
1320
|
+
}
|
|
1321
|
+
if (!existing)
|
|
1322
|
+
store.add(record);
|
|
1323
|
+
for (const id of sourceIds)
|
|
1324
|
+
store.delete(id);
|
|
1325
|
+
};
|
|
1326
|
+
getRequest.onsuccess = () => {
|
|
1327
|
+
existing = getRequest.result;
|
|
1328
|
+
envelopeRead = true;
|
|
1329
|
+
promote();
|
|
1330
|
+
};
|
|
1331
|
+
getRequest.onerror = () => {
|
|
1332
|
+
tx.abort();
|
|
1333
|
+
};
|
|
1334
|
+
sourceRequests.forEach((request, index) => {
|
|
1335
|
+
request.onsuccess = () => {
|
|
1336
|
+
sourceExists[index] = request.result !== undefined;
|
|
1337
|
+
sourcesRead += 1;
|
|
1338
|
+
promote();
|
|
1339
|
+
};
|
|
1340
|
+
request.onerror = () => {
|
|
1341
|
+
tx.abort();
|
|
1342
|
+
};
|
|
1343
|
+
});
|
|
1344
|
+
tx.oncomplete = () => { resolve(existing); };
|
|
1345
|
+
tx.onabort = () => {
|
|
1346
|
+
reject(collisionError ??
|
|
1347
|
+
tx.error ??
|
|
1348
|
+
getRequest.error ??
|
|
1349
|
+
new Error('Commit outbox transaction aborted'));
|
|
1350
|
+
};
|
|
1351
|
+
tx.onerror = () => {
|
|
1352
|
+
// onabort owns rejection so the promise settles exactly once.
|
|
1353
|
+
};
|
|
1354
|
+
}
|
|
1355
|
+
catch (error) {
|
|
1356
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
1357
|
+
}
|
|
1358
|
+
});
|
|
1359
|
+
}
|
|
1187
1360
|
async cleanupOldTransactions(maxAge) {
|
|
1188
1361
|
const store = this.transactionStore;
|
|
1189
|
-
if (!store)
|
|
1190
|
-
return 0;
|
|
1191
1362
|
const rows = (await store.getAll());
|
|
1192
1363
|
const cutoff = Date.now() - maxAge;
|
|
1193
1364
|
let cleaned = 0;
|
|
1194
1365
|
for (const tx of rows) {
|
|
1366
|
+
// Live write intent has no safe age-based expiry. In particular, server
|
|
1367
|
+
// idempotency retention may already have elapsed, so silently deleting or
|
|
1368
|
+
// blindly replaying an old envelope would both be unsafe. Restoration
|
|
1369
|
+
// owns quarantine/reconciliation for these records.
|
|
1370
|
+
if (tx.type === 'commit_envelope' || tx.type === 'pending_mutation') {
|
|
1371
|
+
continue;
|
|
1372
|
+
}
|
|
1195
1373
|
if (typeof tx.timestamp === 'number' && tx.timestamp < cutoff) {
|
|
1196
1374
|
await store.delete(tx.id);
|
|
1197
1375
|
cleaned++;
|
|
@@ -1267,8 +1445,11 @@ export class Database {
|
|
|
1267
1445
|
this.currentDbInfo = null;
|
|
1268
1446
|
getContext().logger.debug('Database closed');
|
|
1269
1447
|
}
|
|
1270
|
-
async clear() {
|
|
1448
|
+
async clear(options = {}) {
|
|
1271
1449
|
await this.storeManager.clearAllStores();
|
|
1450
|
+
if (options.includeWriteJournal) {
|
|
1451
|
+
await this.transactionStore.clear();
|
|
1452
|
+
}
|
|
1272
1453
|
getContext().logger.info('All stores cleared');
|
|
1273
1454
|
}
|
|
1274
1455
|
}
|