@abloatai/ablo 0.36.0 → 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/AGENTS.md +2 -2
- package/CHANGELOG.md +71 -2013
- package/NOTICE +2 -2
- package/README.md +25 -71
- package/assets/banner.png +0 -0
- package/dist/auth.d.ts +2 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +2 -0
- package/dist/auth.js.map +1 -0
- package/dist/client.d.ts +3 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +3 -0
- package/dist/client.js.map +1 -0
- package/dist/coordination.d.ts +2 -0
- package/dist/coordination.d.ts.map +1 -0
- package/dist/coordination.js +2 -0
- package/dist/coordination.js.map +1 -0
- package/dist/index.d.ts +3 -114
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -163
- package/dist/index.js.map +1 -0
- package/dist/react.d.ts +4 -0
- package/dist/react.d.ts.map +1 -0
- package/dist/react.js +3 -0
- package/dist/react.js.map +1 -0
- package/dist/schema.d.ts +2 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +2 -0
- package/dist/schema.js.map +1 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +2 -0
- package/dist/server.js.map +1 -0
- package/dist/source-conformance.d.ts +2 -0
- package/dist/source-conformance.d.ts.map +1 -0
- package/dist/source-conformance.js +2 -0
- package/dist/source-conformance.js.map +1 -0
- package/dist/source-drizzle.d.ts +2 -0
- package/dist/source-drizzle.d.ts.map +1 -0
- package/dist/source-drizzle.js +2 -0
- package/dist/source-drizzle.js.map +1 -0
- package/dist/source-kysely.d.ts +2 -0
- package/dist/source-kysely.d.ts.map +1 -0
- package/dist/source-kysely.js +2 -0
- package/dist/source-kysely.js.map +1 -0
- package/dist/source-next.d.ts +2 -0
- package/dist/source-next.d.ts.map +1 -0
- package/dist/source-next.js +2 -0
- package/dist/source-next.js.map +1 -0
- package/dist/source.d.ts +2 -0
- package/dist/source.d.ts.map +1 -0
- package/dist/source.js +2 -0
- package/dist/source.js.map +1 -0
- package/dist/wire.d.ts +2 -0
- package/dist/wire.d.ts.map +1 -0
- package/dist/wire.js +2 -0
- package/dist/wire.js.map +1 -0
- package/docs/agents.md +1 -1
- package/docs/api-keys.md +7 -6
- package/docs/api.md +10 -10
- package/docs/client-behavior.md +5 -5
- package/docs/coordination.md +52 -62
- package/docs/data-sources.md +1 -1
- package/docs/examples/agent-human.md +4 -4
- package/docs/examples/ai-sdk-tool.md +1 -1
- package/docs/examples/existing-python-backend.md +15 -4
- package/docs/examples/nextjs.md +27 -6
- package/docs/examples/server-agent.md +2 -2
- package/docs/how-it-works.md +4 -4
- package/docs/identity.md +2 -1
- package/docs/integration-guide.md +24 -13
- package/docs/internal/README.md +18 -0
- package/docs/internal/agent-fleet-coordination-design.md +171 -0
- package/docs/internal/agent-orchestration.md +58 -0
- package/docs/internal/commit-identifiers.md +91 -0
- package/docs/internal/concurrency-open-decisions.md +37 -0
- package/docs/internal/data-source-reverse-channel.md +150 -0
- package/docs/internal/per-field-conflict-detection.md +165 -0
- package/docs/internal/postgres-replication.md +64 -0
- package/docs/internal/serializable-schema.md +119 -0
- package/docs/internal/structure.md +32 -0
- package/docs/mcp.md +2 -2
- package/docs/migration.md +3 -3
- package/docs/quickstart.md +2 -2
- package/docs/react.md +5 -5
- package/docs/schema-contract.md +3 -3
- package/docs/sessions.md +91 -37
- package/examples/README.md +2 -2
- package/examples/data-source/README.md +1 -1
- package/examples/data-source/ablo-driver.ts +1 -1
- package/examples/data-source/customer-server.ts +1 -1
- package/examples/data-source/run.ts +1 -1
- package/examples/data-source/schema.ts +1 -1
- package/examples/quickstart.ts +2 -2
- package/llms.txt +8 -8
- package/package.json +63 -166
- package/bin/ablo.cjs +0 -39
- package/dist/BaseSyncedStore.d.ts +0 -843
- package/dist/BaseSyncedStore.js +0 -1971
- package/dist/Database.d.ts +0 -323
- package/dist/Database.js +0 -1502
- package/dist/InstanceCache.d.ts +0 -237
- package/dist/InstanceCache.js +0 -1166
- package/dist/LazyReferenceCollection.d.ts +0 -177
- package/dist/LazyReferenceCollection.js +0 -461
- package/dist/Model.d.ts +0 -454
- package/dist/Model.js +0 -919
- package/dist/ModelRegistry.d.ts +0 -225
- package/dist/ModelRegistry.js +0 -539
- package/dist/NetworkMonitor.d.ts +0 -28
- package/dist/NetworkMonitor.js +0 -79
- package/dist/RuntimeContext.d.ts +0 -52
- package/dist/RuntimeContext.js +0 -80
- package/dist/SyncClient.d.ts +0 -541
- package/dist/SyncClient.js +0 -2202
- package/dist/adapters/alwaysOnline.d.ts +0 -14
- package/dist/adapters/alwaysOnline.js +0 -17
- package/dist/adapters/inMemoryStorage.d.ts +0 -31
- package/dist/adapters/inMemoryStorage.js +0 -110
- package/dist/ai-sdk/coordinatedTool.d.ts +0 -120
- package/dist/ai-sdk/coordinatedTool.js +0 -134
- package/dist/ai-sdk/coordinationContext.d.ts +0 -46
- package/dist/ai-sdk/coordinationContext.js +0 -106
- package/dist/ai-sdk/index.d.ts +0 -121
- package/dist/ai-sdk/index.js +0 -121
- package/dist/ai-sdk/wrap.d.ts +0 -65
- package/dist/ai-sdk/wrap.js +0 -39
- package/dist/auth/index.d.ts +0 -1
- package/dist/auth/index.js +0 -8
- package/dist/batching/index.d.ts +0 -55
- package/dist/batching/index.js +0 -147
- package/dist/client/Ablo.d.ts +0 -231
- package/dist/client/Ablo.js +0 -160
- package/dist/client/abloClient.d.ts +0 -309
- package/dist/client/abloClient.js +0 -13
- package/dist/client/clientPrelude.d.ts +0 -52
- package/dist/client/clientPrelude.js +0 -60
- package/dist/client/consoleLogger.d.ts +0 -35
- package/dist/client/consoleLogger.js +0 -44
- package/dist/client/coreClient.d.ts +0 -60
- package/dist/client/coreClient.js +0 -118
- package/dist/client/createInternalComponents.d.ts +0 -50
- package/dist/client/createInternalComponents.js +0 -98
- package/dist/client/createModelProxy.d.ts +0 -248
- package/dist/client/createModelProxy.js +0 -884
- package/dist/client/humans.d.ts +0 -69
- package/dist/client/humans.js +0 -78
- package/dist/client/modelRegistration.d.ts +0 -10
- package/dist/client/modelRegistration.js +0 -312
- package/dist/client/options.d.ts +0 -461
- package/dist/client/options.js +0 -7
- package/dist/client/reactiveEngine.d.ts +0 -53
- package/dist/client/reactiveEngine.js +0 -688
- package/dist/client/resourceTypes.d.ts +0 -12
- package/dist/client/resourceTypes.js +0 -10
- package/dist/client/schemaConfig.d.ts +0 -44
- package/dist/client/schemaConfig.js +0 -185
- package/dist/client/storeCluster.d.ts +0 -47
- package/dist/client/storeCluster.js +0 -118
- package/dist/client/storeLifecycle.d.ts +0 -61
- package/dist/client/storeLifecycle.js +0 -231
- package/dist/client/validateAbloOptions.d.ts +0 -42
- package/dist/client/validateAbloOptions.js +0 -43
- package/dist/client/wsMutationExecutor.d.ts +0 -27
- package/dist/client/wsMutationExecutor.js +0 -72
- package/dist/context.d.ts +0 -42
- package/dist/context.js +0 -81
- package/dist/coordination/ClaimLog.d.ts +0 -26
- package/dist/coordination/ClaimLog.js +0 -32
- package/dist/coordination/index.d.ts +0 -1
- package/dist/coordination/index.js +0 -8
- package/dist/core/index.d.ts +0 -33
- package/dist/core/index.js +0 -48
- package/dist/docs/catalog.d.ts +0 -72
- package/dist/docs/catalog.js +0 -230
- package/dist/docs/index.d.ts +0 -10
- package/dist/docs/index.js +0 -10
- package/dist/environment.d.ts +0 -1
- package/dist/environment.js +0 -8
- package/dist/interfaces/index.d.ts +0 -311
- package/dist/interfaces/index.js +0 -9
- package/dist/keys/index.d.ts +0 -1
- package/dist/keys/index.js +0 -8
- package/dist/mutators/RecordingMutation.d.ts +0 -36
- package/dist/mutators/RecordingMutation.js +0 -182
- package/dist/mutators/Transaction.d.ts +0 -40
- package/dist/mutators/Transaction.js +0 -58
- package/dist/mutators/UndoManager.d.ts +0 -258
- package/dist/mutators/UndoManager.js +0 -658
- package/dist/mutators/defineMutators.d.ts +0 -60
- package/dist/mutators/defineMutators.js +0 -18
- package/dist/mutators/inverseOp.d.ts +0 -126
- package/dist/mutators/inverseOp.js +0 -71
- package/dist/mutators/mutateActions.d.ts +0 -45
- package/dist/mutators/mutateActions.js +0 -105
- package/dist/mutators/readerActions.d.ts +0 -33
- package/dist/mutators/readerActions.js +0 -57
- package/dist/mutators/undoApply.d.ts +0 -51
- package/dist/mutators/undoApply.js +0 -117
- package/dist/policy/index.d.ts +0 -21
- package/dist/policy/index.js +0 -20
- package/dist/query/QueryProcessor.d.ts +0 -75
- package/dist/query/QueryProcessor.js +0 -255
- package/dist/query/client.d.ts +0 -64
- package/dist/query/client.js +0 -138
- package/dist/query/types.d.ts +0 -85
- package/dist/query/types.js +0 -16
- package/dist/react/AbloProvider.d.ts +0 -242
- package/dist/react/AbloProvider.js +0 -458
- package/dist/react/ClientSideSuspense.d.ts +0 -36
- package/dist/react/ClientSideSuspense.js +0 -17
- package/dist/react/DefaultFallback.d.ts +0 -24
- package/dist/react/DefaultFallback.js +0 -43
- package/dist/react/context.d.ts +0 -55
- package/dist/react/context.js +0 -29
- package/dist/react/createAbloReact.d.ts +0 -56
- package/dist/react/createAbloReact.js +0 -51
- package/dist/react/index.d.ts +0 -62
- package/dist/react/index.js +0 -69
- package/dist/react/internalContext.d.ts +0 -33
- package/dist/react/internalContext.js +0 -3
- package/dist/react/useAblo.d.ts +0 -82
- package/dist/react/useAblo.js +0 -120
- package/dist/react/useCurrentUserId.d.ts +0 -22
- package/dist/react/useCurrentUserId.js +0 -34
- package/dist/react/useErrorListener.d.ts +0 -20
- package/dist/react/useErrorListener.js +0 -38
- package/dist/react/useMutationFailureListener.d.ts +0 -26
- package/dist/react/useMutationFailureListener.js +0 -38
- package/dist/react/useMutators.d.ts +0 -56
- package/dist/react/useMutators.js +0 -84
- package/dist/react/useReactive.d.ts +0 -35
- package/dist/react/useReactive.js +0 -123
- package/dist/react/useSyncStatus.d.ts +0 -59
- package/dist/react/useSyncStatus.js +0 -76
- package/dist/react/useUndoScope.d.ts +0 -34
- package/dist/react/useUndoScope.js +0 -81
- package/dist/schema/coordination.d.ts +0 -112
- package/dist/schema/coordination.js +0 -133
- package/dist/schema/ddl.d.ts +0 -97
- package/dist/schema/ddl.js +0 -491
- package/dist/schema/ddlLock.d.ts +0 -35
- package/dist/schema/ddlLock.js +0 -46
- package/dist/schema/diff.d.ts +0 -225
- package/dist/schema/diff.js +0 -289
- package/dist/schema/generate.d.ts +0 -19
- package/dist/schema/generate.js +0 -86
- package/dist/schema/index.d.ts +0 -42
- package/dist/schema/index.js +0 -80
- package/dist/schema/queries.d.ts +0 -201
- package/dist/schema/queries.js +0 -144
- package/dist/schema/select.d.ts +0 -40
- package/dist/schema/select.js +0 -90
- package/dist/schema/serialize.d.ts +0 -115
- package/dist/schema/serialize.js +0 -265
- package/dist/schema/sugar.d.ts +0 -109
- package/dist/schema/sugar.js +0 -83
- package/dist/schema/syncDeltaRow.d.ts +0 -6
- package/dist/schema/syncDeltaRow.js +0 -6
- package/dist/server/adapter.d.ts +0 -173
- package/dist/server/adapter.js +0 -18
- package/dist/server/commit.d.ts +0 -107
- package/dist/server/commit.js +0 -1
- package/dist/server/index.d.ts +0 -14
- package/dist/server/index.js +0 -2
- package/dist/server/readConfig.d.ts +0 -80
- package/dist/server/readConfig.js +0 -8
- package/dist/server/storageMode.d.ts +0 -23
- package/dist/server/storageMode.js +0 -17
- package/dist/source/adapter.d.ts +0 -83
- package/dist/source/adapter.js +0 -24
- package/dist/source/adapters/drizzle.d.ts +0 -48
- package/dist/source/adapters/drizzle.js +0 -219
- package/dist/source/adapters/kysely.d.ts +0 -42
- package/dist/source/adapters/kysely.js +0 -205
- package/dist/source/adapters/kyselyMutationCore.d.ts +0 -76
- package/dist/source/adapters/kyselyMutationCore.js +0 -125
- package/dist/source/adapters/memory.d.ts +0 -13
- package/dist/source/adapters/memory.js +0 -130
- package/dist/source/adapters/prisma.d.ts +0 -63
- package/dist/source/adapters/prisma.js +0 -202
- package/dist/source/conformance.d.ts +0 -37
- package/dist/source/conformance.js +0 -215
- package/dist/source/connector.d.ts +0 -95
- package/dist/source/connector.js +0 -266
- package/dist/source/connectorProtocol.d.ts +0 -154
- package/dist/source/connectorProtocol.js +0 -163
- package/dist/source/contract.d.ts +0 -195
- package/dist/source/contract.js +0 -164
- package/dist/source/factory.d.ts +0 -92
- package/dist/source/factory.js +0 -286
- package/dist/source/idempotency.d.ts +0 -61
- package/dist/source/idempotency.js +0 -144
- package/dist/source/index.d.ts +0 -23
- package/dist/source/index.js +0 -30
- package/dist/source/migrations.d.ts +0 -21
- package/dist/source/migrations.js +0 -103
- package/dist/source/next.d.ts +0 -32
- package/dist/source/next.js +0 -25
- package/dist/source/pushQueue.d.ts +0 -134
- package/dist/source/pushQueue.js +0 -256
- package/dist/source/signing.d.ts +0 -92
- package/dist/source/signing.js +0 -162
- package/dist/source/types.d.ts +0 -401
- package/dist/source/types.js +0 -59
- package/dist/storeContract.d.ts +0 -145
- package/dist/storeContract.js +0 -12
- package/dist/stores/DatabaseManager.d.ts +0 -107
- package/dist/stores/DatabaseManager.js +0 -388
- package/dist/stores/ObjectStore.d.ts +0 -115
- package/dist/stores/ObjectStore.js +0 -393
- package/dist/stores/ObjectStoreContract.d.ts +0 -38
- package/dist/stores/ObjectStoreContract.js +0 -1
- package/dist/stores/StoreManager.d.ts +0 -114
- package/dist/stores/StoreManager.js +0 -304
- package/dist/stores/SyncActionStore.d.ts +0 -99
- package/dist/stores/SyncActionStore.js +0 -506
- package/dist/stores/openIDBWithTimeout.d.ts +0 -65
- package/dist/stores/openIDBWithTimeout.js +0 -153
- package/dist/stores/syncAction.d.ts +0 -26
- package/dist/stores/syncAction.js +0 -16
- package/dist/surface.d.ts +0 -36
- package/dist/surface.js +0 -75
- package/dist/sync/BootstrapFetcher.d.ts +0 -284
- package/dist/sync/BootstrapFetcher.js +0 -964
- package/dist/sync/ConnectionManager.d.ts +0 -8
- package/dist/sync/ConnectionManager.js +0 -8
- package/dist/sync/OnDemandLoader.d.ts +0 -231
- package/dist/sync/OnDemandLoader.js +0 -743
- package/dist/sync/SubscriptionManager.d.ts +0 -159
- package/dist/sync/SubscriptionManager.js +0 -243
- package/dist/sync/SyncWebSocket.d.ts +0 -173
- package/dist/sync/SyncWebSocket.js +0 -438
- package/dist/sync/awaitClaimGrant.d.ts +0 -6
- package/dist/sync/awaitClaimGrant.js +0 -6
- package/dist/sync/bootstrapApply.d.ts +0 -73
- package/dist/sync/bootstrapApply.js +0 -73
- package/dist/sync/commitFrames.d.ts +0 -8
- package/dist/sync/commitFrames.js +0 -8
- package/dist/sync/contextPorts.d.ts +0 -18
- package/dist/sync/contextPorts.js +0 -31
- package/dist/sync/createClaimStream.d.ts +0 -7
- package/dist/sync/createClaimStream.js +0 -7
- package/dist/sync/createPresenceStream.d.ts +0 -69
- package/dist/sync/createPresenceStream.js +0 -200
- package/dist/sync/createSnapshot.d.ts +0 -29
- package/dist/sync/createSnapshot.js +0 -118
- package/dist/sync/credentialLifecycle.d.ts +0 -7
- package/dist/sync/credentialLifecycle.js +0 -7
- package/dist/sync/deltaPipeline.d.ts +0 -114
- package/dist/sync/deltaPipeline.js +0 -278
- package/dist/sync/groupChange.d.ts +0 -116
- package/dist/sync/groupChange.js +0 -244
- package/dist/sync/participants.d.ts +0 -132
- package/dist/sync/participants.js +0 -346
- package/dist/sync/persistedPrefix.d.ts +0 -12
- package/dist/sync/persistedPrefix.js +0 -22
- package/dist/sync/schemaDrift.d.ts +0 -55
- package/dist/sync/schemaDrift.js +0 -53
- package/dist/sync/schemas.d.ts +0 -71
- package/dist/sync/schemas.js +0 -94
- package/dist/sync/syncCursor.d.ts +0 -40
- package/dist/sync/syncCursor.js +0 -55
- package/dist/sync/syncPlan.d.ts +0 -54
- package/dist/sync/syncPlan.js +0 -50
- package/dist/sync/wsFrameHandlers.d.ts +0 -8
- package/dist/sync/wsFrameHandlers.js +0 -8
- package/dist/syncLog/contract.d.ts +0 -20
- package/dist/syncLog/contract.js +0 -19
- package/dist/syncLog/index.d.ts +0 -1
- package/dist/syncLog/index.js +0 -1
- package/dist/transaction/ablo.d.ts +0 -88
- package/dist/transaction/ablo.js +0 -33
- package/dist/transaction/auth/apiKey.d.ts +0 -152
- package/dist/transaction/auth/apiKey.js +0 -419
- package/dist/transaction/auth/bootstrapScope.d.ts +0 -15
- package/dist/transaction/auth/bootstrapScope.js +0 -1
- package/dist/transaction/auth/capability.d.ts +0 -212
- package/dist/transaction/auth/capability.js +0 -224
- package/dist/transaction/auth/credentialEndpoint.d.ts +0 -61
- package/dist/transaction/auth/credentialEndpoint.js +0 -86
- package/dist/transaction/auth/credentialPolicy.d.ts +0 -148
- package/dist/transaction/auth/credentialPolicy.js +0 -125
- package/dist/transaction/auth/credentialSource.d.ts +0 -30
- package/dist/transaction/auth/credentialSource.js +0 -55
- package/dist/transaction/auth/hostedEndpoints.d.ts +0 -21
- package/dist/transaction/auth/hostedEndpoints.js +0 -21
- package/dist/transaction/auth/identity.d.ts +0 -55
- package/dist/transaction/auth/identity.js +0 -210
- package/dist/transaction/auth/index.d.ts +0 -162
- package/dist/transaction/auth/index.js +0 -304
- package/dist/transaction/auth/schemas.d.ts +0 -59
- package/dist/transaction/auth/schemas.js +0 -85
- package/dist/transaction/auth/sessionMint.d.ts +0 -28
- package/dist/transaction/auth/sessionMint.js +0 -85
- package/dist/transaction/coordination/awaitClaimGrant.d.ts +0 -56
- package/dist/transaction/coordination/awaitClaimGrant.js +0 -124
- package/dist/transaction/coordination/claimHeartbeatLoop.d.ts +0 -84
- package/dist/transaction/coordination/claimHeartbeatLoop.js +0 -108
- package/dist/transaction/coordination/claimMeta.d.ts +0 -49
- package/dist/transaction/coordination/claimMeta.js +0 -52
- package/dist/transaction/coordination/createClaimStream.d.ts +0 -64
- package/dist/transaction/coordination/createClaimStream.js +0 -475
- package/dist/transaction/coordination/events.d.ts +0 -74
- package/dist/transaction/coordination/events.js +0 -7
- package/dist/transaction/coordination/index.d.ts +0 -19
- package/dist/transaction/coordination/index.js +0 -45
- package/dist/transaction/coordination/locator.d.ts +0 -104
- package/dist/transaction/coordination/locator.js +0 -102
- package/dist/transaction/coordination/schema.d.ts +0 -1536
- package/dist/transaction/coordination/schema.js +0 -1177
- package/dist/transaction/coordination/targetConflict.d.ts +0 -2
- package/dist/transaction/coordination/targetConflict.js +0 -107
- package/dist/transaction/coordination/trace.d.ts +0 -78
- package/dist/transaction/coordination/trace.js +0 -138
- package/dist/transaction/durableWrites.d.ts +0 -62
- package/dist/transaction/durableWrites.js +0 -71
- package/dist/transaction/environment.d.ts +0 -105
- package/dist/transaction/environment.js +0 -108
- package/dist/transaction/errorCodes.d.ts +0 -403
- package/dist/transaction/errorCodes.js +0 -484
- package/dist/transaction/errors.d.ts +0 -428
- package/dist/transaction/errors.js +0 -686
- package/dist/transaction/footprint.d.ts +0 -111
- package/dist/transaction/footprint.js +0 -0
- package/dist/transaction/index.d.ts +0 -20
- package/dist/transaction/index.js +0 -20
- package/dist/transaction/keys/index.d.ts +0 -87
- package/dist/transaction/keys/index.js +0 -207
- package/dist/transaction/log/syncDeltaRow.d.ts +0 -158
- package/dist/transaction/log/syncDeltaRow.js +0 -95
- package/dist/transaction/logPosition.d.ts +0 -97
- package/dist/transaction/logPosition.js +0 -125
- package/dist/transaction/logger.d.ts +0 -16
- package/dist/transaction/logger.js +0 -7
- package/dist/transaction/observability.d.ts +0 -53
- package/dist/transaction/observability.js +0 -19
- package/dist/transaction/persistence.d.ts +0 -12
- package/dist/transaction/persistence.js +0 -11
- package/dist/transaction/plugin.d.ts +0 -285
- package/dist/transaction/plugin.js +0 -106
- package/dist/transaction/policy/types.d.ts +0 -217
- package/dist/transaction/policy/types.js +0 -126
- package/dist/transaction/resources/functionalUpdate.d.ts +0 -79
- package/dist/transaction/resources/functionalUpdate.js +0 -87
- package/dist/transaction/resources/httpResources.d.ts +0 -321
- package/dist/transaction/resources/httpResources.js +0 -7
- package/dist/transaction/resources/modelOperations.d.ts +0 -427
- package/dist/transaction/resources/modelOperations.js +0 -12
- package/dist/transaction/resources/mutationOptions.d.ts +0 -66
- package/dist/transaction/resources/mutationOptions.js +0 -9
- package/dist/transaction/resources/where.d.ts +0 -101
- package/dist/transaction/resources/where.js +0 -115
- package/dist/transaction/resources/writeOptionsSchema.d.ts +0 -47
- package/dist/transaction/resources/writeOptionsSchema.js +0 -73
- package/dist/transaction/schema/field.d.ts +0 -120
- package/dist/transaction/schema/field.js +0 -265
- package/dist/transaction/schema/fieldRef.d.ts +0 -38
- package/dist/transaction/schema/fieldRef.js +0 -11
- package/dist/transaction/schema/loadStrategy.d.ts +0 -45
- package/dist/transaction/schema/loadStrategy.js +0 -46
- package/dist/transaction/schema/model.d.ts +0 -379
- package/dist/transaction/schema/model.js +0 -123
- package/dist/transaction/schema/openapi.d.ts +0 -58
- package/dist/transaction/schema/openapi.js +0 -501
- package/dist/transaction/schema/relation.d.ts +0 -204
- package/dist/transaction/schema/relation.js +0 -104
- package/dist/transaction/schema/residency.d.ts +0 -29
- package/dist/transaction/schema/residency.js +0 -25
- package/dist/transaction/schema/roles.d.ts +0 -249
- package/dist/transaction/schema/roles.js +0 -230
- package/dist/transaction/schema/schema.d.ts +0 -351
- package/dist/transaction/schema/schema.js +0 -325
- package/dist/transaction/schema/tenancy.d.ts +0 -139
- package/dist/transaction/schema/tenancy.js +0 -190
- package/dist/transaction/transactionLayer.d.ts +0 -82
- package/dist/transaction/transactionLayer.js +0 -24
- package/dist/transaction/transactions/settlement/commitEnvelope.d.ts +0 -143
- package/dist/transaction/transactions/settlement/commitEnvelope.js +0 -161
- package/dist/transaction/transactions/settlement/httpCommitEnvelope.d.ts +0 -53
- package/dist/transaction/transactions/settlement/httpCommitEnvelope.js +0 -207
- package/dist/transaction/transactions/settlement/idempotencyKey.d.ts +0 -10
- package/dist/transaction/transactions/settlement/idempotencyKey.js +0 -9
- package/dist/transaction/transactions/settlement/pendingWrite.d.ts +0 -112
- package/dist/transaction/transactions/settlement/pendingWrite.js +0 -20
- package/dist/transaction/transport/commitFrames.d.ts +0 -90
- package/dist/transaction/transport/commitFrames.js +0 -134
- package/dist/transaction/transport/connectionManager.d.ts +0 -215
- package/dist/transaction/transport/connectionManager.js +0 -673
- package/dist/transaction/transport/credentialLifecycle.d.ts +0 -177
- package/dist/transaction/transport/credentialLifecycle.js +0 -324
- package/dist/transaction/transport/heartbeat.d.ts +0 -65
- package/dist/transaction/transport/heartbeat.js +0 -93
- package/dist/transaction/transport/httpClient.d.ts +0 -131
- package/dist/transaction/transport/httpClient.js +0 -146
- package/dist/transaction/transport/httpOptions.d.ts +0 -33
- package/dist/transaction/transport/httpOptions.js +0 -12
- package/dist/transaction/transport/httpTransport.d.ts +0 -8
- package/dist/transaction/transport/httpTransport.js +0 -1388
- package/dist/transaction/transport/networkProbe.d.ts +0 -84
- package/dist/transaction/transport/networkProbe.js +0 -207
- package/dist/transaction/transport/wsFrameHandlers.d.ts +0 -128
- package/dist/transaction/transport/wsFrameHandlers.js +0 -429
- package/dist/transaction/transport/wsTransport.d.ts +0 -574
- package/dist/transaction/transport/wsTransport.js +0 -1023
- package/dist/transaction/types/assertExact.d.ts +0 -17
- package/dist/transaction/types/assertExact.js +0 -1
- package/dist/transaction/types/global.d.ts +0 -107
- package/dist/transaction/types/global.js +0 -40
- package/dist/transaction/types/index.d.ts +0 -205
- package/dist/transaction/types/index.js +0 -56
- package/dist/transaction/types/modelData.d.ts +0 -10
- package/dist/transaction/types/modelData.js +0 -9
- package/dist/transaction/types/participant.d.ts +0 -20
- package/dist/transaction/types/participant.js +0 -10
- package/dist/transaction/types/streams.d.ts +0 -550
- package/dist/transaction/types/streams.js +0 -11
- package/dist/transaction/utils/asyncIterator.d.ts +0 -34
- package/dist/transaction/utils/asyncIterator.js +0 -135
- package/dist/transaction/utils/duration.d.ts +0 -50
- package/dist/transaction/utils/duration.js +0 -77
- package/dist/transaction/utils/json.d.ts +0 -57
- package/dist/transaction/utils/json.js +0 -276
- package/dist/transaction/wire/accountResponses.d.ts +0 -420
- package/dist/transaction/wire/accountResponses.js +0 -290
- package/dist/transaction/wire/auth.d.ts +0 -56
- package/dist/transaction/wire/auth.js +0 -63
- package/dist/transaction/wire/bootstrapReason.d.ts +0 -9
- package/dist/transaction/wire/bootstrapReason.js +0 -8
- package/dist/transaction/wire/claimEvent.d.ts +0 -76
- package/dist/transaction/wire/claimEvent.js +0 -73
- package/dist/transaction/wire/claims.d.ts +0 -530
- package/dist/transaction/wire/claims.js +0 -327
- package/dist/transaction/wire/commit.d.ts +0 -603
- package/dist/transaction/wire/commit.js +0 -321
- package/dist/transaction/wire/delta.d.ts +0 -250
- package/dist/transaction/wire/delta.js +0 -147
- package/dist/transaction/wire/errorEnvelope.d.ts +0 -72
- package/dist/transaction/wire/errorEnvelope.js +0 -123
- package/dist/transaction/wire/feedCursor.d.ts +0 -60
- package/dist/transaction/wire/feedCursor.js +0 -82
- package/dist/transaction/wire/feedEvent.d.ts +0 -204
- package/dist/transaction/wire/feedEvent.js +0 -65
- package/dist/transaction/wire/frames.d.ts +0 -194
- package/dist/transaction/wire/frames.js +0 -50
- package/dist/transaction/wire/inboundFrames.d.ts +0 -562
- package/dist/transaction/wire/inboundFrames.js +0 -116
- package/dist/transaction/wire/index.d.ts +0 -54
- package/dist/transaction/wire/index.js +0 -83
- package/dist/transaction/wire/listEnvelope.d.ts +0 -37
- package/dist/transaction/wire/listEnvelope.js +0 -42
- package/dist/transaction/wire/modelMutations.d.ts +0 -31
- package/dist/transaction/wire/modelMutations.js +0 -52
- package/dist/transaction/wire/modelResponses.d.ts +0 -85
- package/dist/transaction/wire/modelResponses.js +0 -43
- package/dist/transaction/wire/modelShape.d.ts +0 -78
- package/dist/transaction/wire/modelShape.js +0 -74
- package/dist/transaction/wire/protocol.d.ts +0 -38
- package/dist/transaction/wire/protocol.js +0 -38
- package/dist/transaction/wire/protocolVersion.d.ts +0 -73
- package/dist/transaction/wire/protocolVersion.js +0 -83
- package/dist/transactions/mutations/MutationQueue.d.ts +0 -661
- package/dist/transactions/mutations/MutationQueue.js +0 -2807
- package/dist/transactions/mutations/MutationStore.d.ts +0 -20
- package/dist/transactions/mutations/MutationStore.js +0 -53
- package/dist/transactions/mutations/UnconfirmedWrites.d.ts +0 -82
- package/dist/transactions/mutations/UnconfirmedWrites.js +0 -104
- package/dist/transactions/mutations/coalesceRules.d.ts +0 -58
- package/dist/transactions/mutations/coalesceRules.js +0 -140
- package/dist/transactions/mutations/commitLatency.d.ts +0 -52
- package/dist/transactions/mutations/commitLatency.js +0 -130
- package/dist/transactions/mutations/commitOutboxStore.d.ts +0 -28
- package/dist/transactions/mutations/commitOutboxStore.js +0 -26
- package/dist/transactions/mutations/commitPayload.d.ts +0 -165
- package/dist/transactions/mutations/commitPayload.js +0 -152
- package/dist/transactions/mutations/deltaConfirmation.d.ts +0 -63
- package/dist/transactions/mutations/deltaConfirmation.js +0 -235
- package/dist/transactions/mutations/durableWriteStore.d.ts +0 -14
- package/dist/transactions/mutations/durableWriteStore.js +0 -12
- package/dist/transactions/mutations/optimisticApply.d.ts +0 -49
- package/dist/transactions/mutations/optimisticApply.js +0 -65
- package/dist/transactions/mutations/replayValidation.d.ts +0 -187
- package/dist/transactions/mutations/replayValidation.js +0 -164
- package/dist/utils/mobxSetup.d.ts +0 -53
- package/dist/utils/mobxSetup.js +0 -330
- package/dist/views/QueryView.d.ts +0 -79
- package/dist/views/QueryView.js +0 -218
- package/dist/views/ViewRegistry.d.ts +0 -20
- package/dist/views/ViewRegistry.js +0 -55
- package/dist/views/incrementalView.d.ts +0 -45
- package/dist/views/incrementalView.js +0 -69
- package/dist/webhooks/events.d.ts +0 -43
- package/dist/webhooks/events.js +0 -42
- package/dist/webhooks/index.d.ts +0 -8
- package/dist/webhooks/index.js +0 -8
- package/dist/wire/index.d.ts +0 -1
- package/dist/wire/index.js +0 -8
package/dist/Database.js
DELETED
|
@@ -1,1502 +0,0 @@
|
|
|
1
|
-
/**
|
|
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.
|
|
7
|
-
*/
|
|
8
|
-
import { DatabaseManager } from './stores/DatabaseManager.js';
|
|
9
|
-
import { StoreManager } from './stores/StoreManager.js';
|
|
10
|
-
import { ModelRegistry } from './ModelRegistry.js';
|
|
11
|
-
import { LoadStrategy } from './transaction/types/index.js';
|
|
12
|
-
import { globalRuntime } from './context.js';
|
|
13
|
-
import { AbloConnectionError, AbloValidationError } from './transaction/errors.js';
|
|
14
|
-
import { InMemoryObjectStore } from './adapters/inMemoryStorage.js';
|
|
15
|
-
import { logPositionSchema } from './transaction/logPosition.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
|
-
// HTTP outbox rows written before protocol versioning are v1. Normalize
|
|
32
|
-
// them so a same-request re-seal remains idempotent after an upgrade.
|
|
33
|
-
protocolVersion: record.protocolVersion ?? 1,
|
|
34
|
-
request: record.request,
|
|
35
|
-
scopeNamespace: record.scopeNamespace,
|
|
36
|
-
});
|
|
37
|
-
if (existing.correlationId !== undefined &&
|
|
38
|
-
candidate.correlationId !== undefined &&
|
|
39
|
-
existing.correlationId !== candidate.correlationId) {
|
|
40
|
-
return false;
|
|
41
|
-
}
|
|
42
|
-
return JSON.stringify(identity(existing)) === JSON.stringify(identity(candidate));
|
|
43
|
-
}
|
|
44
|
-
if (existing.type === 'commit_envelope' &&
|
|
45
|
-
candidate.type === 'commit_envelope') {
|
|
46
|
-
const identity = (record) => ({
|
|
47
|
-
id: record.id,
|
|
48
|
-
type: record.type,
|
|
49
|
-
storageVersion: record.storageVersion,
|
|
50
|
-
origin: record.origin,
|
|
51
|
-
idempotencyKey: record.idempotencyKey,
|
|
52
|
-
operations: record.operations,
|
|
53
|
-
sourceMutationIds: record.sourceMutationIds,
|
|
54
|
-
commitOptions: record.commitOptions,
|
|
55
|
-
scope: record.scope,
|
|
56
|
-
});
|
|
57
|
-
if (existing.correlationId !== undefined &&
|
|
58
|
-
candidate.correlationId !== undefined &&
|
|
59
|
-
existing.correlationId !== candidate.correlationId) {
|
|
60
|
-
return false;
|
|
61
|
-
}
|
|
62
|
-
return JSON.stringify(identity(existing)) === JSON.stringify(identity(candidate));
|
|
63
|
-
}
|
|
64
|
-
return JSON.stringify(existing) === JSON.stringify(candidate);
|
|
65
|
-
}
|
|
66
|
-
function isAcceptedOutboxPromotion(existing, candidate) {
|
|
67
|
-
return (existing !== undefined &&
|
|
68
|
-
(existing.type === 'commit_envelope' ||
|
|
69
|
-
existing.type === 'http_commit_envelope') &&
|
|
70
|
-
existing.type === candidate.type &&
|
|
71
|
-
existing.acceptedAt === undefined &&
|
|
72
|
-
candidate.acceptedAt !== undefined);
|
|
73
|
-
}
|
|
74
|
-
export class Database {
|
|
75
|
-
// Core database components
|
|
76
|
-
databaseManager;
|
|
77
|
-
storeManager;
|
|
78
|
-
// Injected dependencies
|
|
79
|
-
modelRegistry;
|
|
80
|
-
bootstrapHelper;
|
|
81
|
-
/** The pre-configured query helper for lazy-loading data from the sync server. */
|
|
82
|
-
get helper() {
|
|
83
|
-
return this.bootstrapHelper;
|
|
84
|
-
}
|
|
85
|
-
/**
|
|
86
|
-
* Fetch the current rows of the given sync groups as a side-effect-free
|
|
87
|
-
* snapshot, used to hydrate a scope as the user enters it. Unlike
|
|
88
|
-
* {@link bootstrapFromServer}, it does not persist to IndexedDB and does not
|
|
89
|
-
* change the connection's subscribed sync groups. The caller applies the
|
|
90
|
-
* result to the pool through the scoped apply path.
|
|
91
|
-
*/
|
|
92
|
-
async fetchScopedBootstrapData(syncGroups) {
|
|
93
|
-
// No lastSyncId → a full snapshot of exactly these groups.
|
|
94
|
-
return this.bootstrapHelper.fetchBootstrap(undefined, syncGroups);
|
|
95
|
-
}
|
|
96
|
-
// Database state
|
|
97
|
-
currentDbInfo = null;
|
|
98
|
-
workspaceDb = null;
|
|
99
|
-
/**
|
|
100
|
-
* Flag to track if database is closing/closed.
|
|
101
|
-
* Used for graceful degradation when operations are attempted during shutdown.
|
|
102
|
-
*/
|
|
103
|
-
isClosing = false;
|
|
104
|
-
/**
|
|
105
|
-
* When set, forces the next requiredBootstrap() call to return 'full' even if offline.
|
|
106
|
-
* Used when a sync group change delta is received — we must re-bootstrap to purge
|
|
107
|
-
* revoked data, even if the device is currently offline (it will bootstrap when online).
|
|
108
|
-
*/
|
|
109
|
-
_forceFullBootstrap = false;
|
|
110
|
-
/** Essential fields that must be preserved during partial UPDATE merges.
|
|
111
|
-
* Sourced from SyncEngineConfig.essentialFields — consumers define their own. */
|
|
112
|
-
get essentialFields() {
|
|
113
|
-
return this.runtime.config.essentialFields;
|
|
114
|
-
}
|
|
115
|
-
/**
|
|
116
|
-
* When true, all IndexedDB operations are replaced with in-memory Maps.
|
|
117
|
-
* Enables the SDK to run headlessly in Node.js / agent workers / tests
|
|
118
|
-
* without requiring a browser environment.
|
|
119
|
-
*
|
|
120
|
-
* Set via createSyncEngine({ storage: inMemoryStorage() }) or directly:
|
|
121
|
-
* new Database(registry, bootstrap, { inMemory: true })
|
|
122
|
-
*/
|
|
123
|
-
inMemory;
|
|
124
|
-
runtime;
|
|
125
|
-
/** In-memory stores used when inMemory=true. Keyed by model name. */
|
|
126
|
-
inMemoryStores = new Map();
|
|
127
|
-
/** In-memory workspace metadata when inMemory=true. */
|
|
128
|
-
inMemoryMetadata = null;
|
|
129
|
-
constructor(modelRegistry, bootstrapHelper, options) {
|
|
130
|
-
this.runtime = options?.runtime ?? globalRuntime;
|
|
131
|
-
this.databaseManager = new DatabaseManager(this.runtime);
|
|
132
|
-
this.storeManager = new StoreManager(modelRegistry, this.runtime);
|
|
133
|
-
this.modelRegistry = modelRegistry;
|
|
134
|
-
this.bootstrapHelper = bootstrapHelper;
|
|
135
|
-
this.inMemory = options?.inMemory ?? false;
|
|
136
|
-
}
|
|
137
|
-
/**
|
|
138
|
-
* Get store for a model, or `undefined` if no store exists.
|
|
139
|
-
*
|
|
140
|
-
* Routes to `inMemoryStores` in inMemory mode and `storeManager`
|
|
141
|
-
* otherwise. Both implementations satisfy `ObjectStoreContract`, so
|
|
142
|
-
* callers don't branch on which one they got back.
|
|
143
|
-
*
|
|
144
|
-
* Pass `context` to emit an observability breadcrumb when the store
|
|
145
|
-
* is missing — useful for hot paths (bootstrap, delta apply, hydrate)
|
|
146
|
-
* where a missing store points to silent data loss. Callers that
|
|
147
|
-
* already expect optional behavior (e.g. lazy lookups) can omit it.
|
|
148
|
-
*/
|
|
149
|
-
getStore(modelName, context) {
|
|
150
|
-
const store = this.inMemory
|
|
151
|
-
? this.inMemoryStores.get(modelName)
|
|
152
|
-
: this.storeManager.getStore(modelName);
|
|
153
|
-
if (!store && context) {
|
|
154
|
-
this.runtime.observability.breadcrumb(`Store not found for model: ${modelName}`, 'sync.database', 'warning', { context });
|
|
155
|
-
}
|
|
156
|
-
return store;
|
|
157
|
-
}
|
|
158
|
-
/** Get store or throw if not found (for operations that require the store). */
|
|
159
|
-
getRequiredStore(modelName) {
|
|
160
|
-
const store = this.getStore(modelName);
|
|
161
|
-
if (!store) {
|
|
162
|
-
throw new AbloValidationError(`Store not found: ${modelName}`, {
|
|
163
|
-
code: 'db_store_not_found',
|
|
164
|
-
});
|
|
165
|
-
}
|
|
166
|
-
return store; // TypeScript narrows to non-undefined after the throw
|
|
167
|
-
}
|
|
168
|
-
/** Log preserved fields during partial UPDATE merge (debug helper) */
|
|
169
|
-
logPreservedFields(modelName, modelId, existing, delta) {
|
|
170
|
-
if (modelName === 'Activity')
|
|
171
|
-
return;
|
|
172
|
-
const requiredFields = this.essentialFields[modelName] ?? [];
|
|
173
|
-
const preserved = requiredFields.filter((field) => existing[field] !== undefined && delta[field] === undefined);
|
|
174
|
-
if (preserved.length > 0) {
|
|
175
|
-
this.runtime.logger.debug('[Database] UPDATE merged - preserved fields', {
|
|
176
|
-
modelName,
|
|
177
|
-
modelId: modelId.slice(0, 12),
|
|
178
|
-
deltaFields: Object.keys(delta),
|
|
179
|
-
preservedFields: preserved,
|
|
180
|
-
});
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
async open(userId, organizationId, version = 1) {
|
|
184
|
-
// Reset closing flag when opening (in case of reopen)
|
|
185
|
-
this.isClosing = false;
|
|
186
|
-
if (this.workspaceDb && this.currentDbInfo) {
|
|
187
|
-
return;
|
|
188
|
-
}
|
|
189
|
-
// ── In-memory mode: skip IndexedDB entirely ──────────────────
|
|
190
|
-
// Creates InMemoryObjectStore instances for all registered models.
|
|
191
|
-
// Bootstrap via HTTP still works; only local persistence is skipped.
|
|
192
|
-
if (this.inMemory) {
|
|
193
|
-
this.runtime.logger.debug('Opening in-memory database (headless mode)');
|
|
194
|
-
const allModels = this.modelRegistry.getRegisteredModelNames();
|
|
195
|
-
for (const modelName of allModels) {
|
|
196
|
-
const storeName = `store_${modelName.toLowerCase()}`;
|
|
197
|
-
this.inMemoryStores.set(modelName, new InMemoryObjectStore(modelName, storeName));
|
|
198
|
-
}
|
|
199
|
-
// Create a __transactions store for the offline queue
|
|
200
|
-
this.inMemoryStores.set('__transactions', new InMemoryObjectStore('__transactions', '__transactions'));
|
|
201
|
-
this.runtime.logger.info(`In-memory database opened: ${this.inMemoryStores.size} stores`);
|
|
202
|
-
return;
|
|
203
|
-
}
|
|
204
|
-
// ── Browser mode: IndexedDB (existing behavior, unchanged) ───
|
|
205
|
-
this.runtime.logger.debug('Opening IndexedDB database');
|
|
206
|
-
// Initialize meta database
|
|
207
|
-
await this.databaseManager.initializeMetaDatabase();
|
|
208
|
-
// Calculate database info
|
|
209
|
-
this.currentDbInfo = await this.databaseManager.calculateDatabaseInfo(userId, organizationId, version);
|
|
210
|
-
// Register database
|
|
211
|
-
await this.databaseManager.registerDatabase(this.currentDbInfo);
|
|
212
|
-
// Open workspace database
|
|
213
|
-
this.workspaceDb = await this.databaseManager.openWorkspaceDatabase(this.currentDbInfo, async (db) => {
|
|
214
|
-
await this.storeManager.createStores(db);
|
|
215
|
-
});
|
|
216
|
-
// Initialize stores
|
|
217
|
-
await this.storeManager.initializeStores(this.workspaceDb);
|
|
218
|
-
const readiness = await this.storeManager.checkReadinessOfStores();
|
|
219
|
-
this.runtime.logger.info(`Database opened: ${this.currentDbInfo.name} (${readiness.readyStores.length}/${readiness.totalStores} stores ready)`);
|
|
220
|
-
}
|
|
221
|
-
/**
|
|
222
|
-
* Shrink a record before persisting it. Drops `undefined` fields, empty
|
|
223
|
-
* arrays, empty objects, and the redundant markers `__typename`, `__class`,
|
|
224
|
-
* `clientId`, and `syncStatus`. Explicit `null` values are preserved, since
|
|
225
|
-
* a null is a meaningful "clear this field" in a nullable column.
|
|
226
|
-
*
|
|
227
|
-
* By design this receives plain objects, never live observables: WebSocket
|
|
228
|
-
* deltas arrive already parsed, optimistic updates come through `toJSON()`,
|
|
229
|
-
* and bootstrap data is plain JSON from the server.
|
|
230
|
-
*/
|
|
231
|
-
compactRecord(_modelName, data) {
|
|
232
|
-
if (!data || typeof data !== 'object')
|
|
233
|
-
return data;
|
|
234
|
-
const out = {};
|
|
235
|
-
for (const [key, value] of Object.entries(data)) {
|
|
236
|
-
// Drop redundant or ephemeral markers
|
|
237
|
-
if (key === '__typename' || key === '__class' || key === 'clientId' || key === 'syncStatus') {
|
|
238
|
-
continue;
|
|
239
|
-
}
|
|
240
|
-
// Skip only `undefined`; preserve explicit `null`, which is a
|
|
241
|
-
// meaningful value for a nullable column.
|
|
242
|
-
if (value === undefined) {
|
|
243
|
-
continue;
|
|
244
|
-
}
|
|
245
|
-
if (Array.isArray(value)) {
|
|
246
|
-
if (value.length === 0)
|
|
247
|
-
continue;
|
|
248
|
-
out[key] = value;
|
|
249
|
-
continue;
|
|
250
|
-
}
|
|
251
|
-
if (typeof value === 'object') {
|
|
252
|
-
// Preserve explicit null values
|
|
253
|
-
if (value === null) {
|
|
254
|
-
out[key] = null;
|
|
255
|
-
continue;
|
|
256
|
-
}
|
|
257
|
-
// Preserve Date objects (IndexedDB can clone these)
|
|
258
|
-
if (value instanceof Date) {
|
|
259
|
-
out[key] = value;
|
|
260
|
-
continue;
|
|
261
|
-
}
|
|
262
|
-
// For plain objects, drop if empty
|
|
263
|
-
if (Object.keys(value).length === 0)
|
|
264
|
-
continue;
|
|
265
|
-
out[key] = value;
|
|
266
|
-
continue;
|
|
267
|
-
}
|
|
268
|
-
out[key] = value;
|
|
269
|
-
}
|
|
270
|
-
// Always ensure id is present
|
|
271
|
-
if (!out.id && data.id)
|
|
272
|
-
out.id = data.id;
|
|
273
|
-
return out;
|
|
274
|
-
}
|
|
275
|
-
/**
|
|
276
|
-
* Mark that the next bootstrap must be a full bootstrap.
|
|
277
|
-
* Called when a sync group change ("G" delta) is received — the client must
|
|
278
|
-
* re-fetch all data from the server to purge models from revoked sync groups.
|
|
279
|
-
*/
|
|
280
|
-
markRequiresFullBootstrap() {
|
|
281
|
-
this._forceFullBootstrap = true;
|
|
282
|
-
this.runtime.logger.info('[Database] Marked for forced full bootstrap (sync group change)');
|
|
283
|
-
}
|
|
284
|
-
/**
|
|
285
|
-
* Smart bootstrap requirements based on data freshness
|
|
286
|
-
*/
|
|
287
|
-
async requiredBootstrap() {
|
|
288
|
-
// In-memory mode (server-side agents, headless workers): there's
|
|
289
|
-
// no `workspaceDb` by design — `open()` returns early after
|
|
290
|
-
// initializing `inMemoryStores`. Persistent data never exists
|
|
291
|
-
// across sessions, so the right answer is always a full bootstrap
|
|
292
|
-
// from the server. Mirrors the `inMemory` short-circuit in
|
|
293
|
-
// `setModelPersisted` / `isModelPersisted` / `getMetadata`.
|
|
294
|
-
if (this.inMemory) {
|
|
295
|
-
const instantModels = this.modelRegistry.getModelsByLoadStrategy(LoadStrategy.instant);
|
|
296
|
-
const lazyModels = this.modelRegistry.getModelsByLoadStrategy(LoadStrategy.lazy);
|
|
297
|
-
return {
|
|
298
|
-
type: 'full',
|
|
299
|
-
modelsToLoad: [...instantModels, ...lazyModels],
|
|
300
|
-
lastSyncId: 0,
|
|
301
|
-
syncGroups: [],
|
|
302
|
-
};
|
|
303
|
-
}
|
|
304
|
-
if (!this.workspaceDb) {
|
|
305
|
-
throw new AbloConnectionError('Database not opened', {
|
|
306
|
-
code: 'db_not_opened',
|
|
307
|
-
});
|
|
308
|
-
}
|
|
309
|
-
// Sync group change requires full re-bootstrap to purge revoked data
|
|
310
|
-
if (this._forceFullBootstrap) {
|
|
311
|
-
this._forceFullBootstrap = false;
|
|
312
|
-
const instantModels = this.modelRegistry.getModelsByLoadStrategy(LoadStrategy.instant);
|
|
313
|
-
const lazyModels = this.modelRegistry.getModelsByLoadStrategy(LoadStrategy.lazy);
|
|
314
|
-
this.runtime.logger.info('[Database.requiredBootstrap] Forced FULL bootstrap (sync group change)');
|
|
315
|
-
return {
|
|
316
|
-
type: 'full',
|
|
317
|
-
modelsToLoad: [...instantModels, ...lazyModels],
|
|
318
|
-
lastSyncId: 0,
|
|
319
|
-
syncGroups: [],
|
|
320
|
-
};
|
|
321
|
-
}
|
|
322
|
-
const readiness = await this.storeManager.checkReadinessOfStores();
|
|
323
|
-
const metadata = await this.databaseManager.getWorkspaceMetadata(this.workspaceDb);
|
|
324
|
-
// Get models from registry
|
|
325
|
-
const instantModels = this.modelRegistry.getModelsByLoadStrategy(LoadStrategy.instant);
|
|
326
|
-
const lazyModels = this.modelRegistry.getModelsByLoadStrategy(LoadStrategy.lazy);
|
|
327
|
-
const modelsToLoad = [...instantModels, ...lazyModels];
|
|
328
|
-
// Gate the PERSISTED cursor through the sync-position schema field —
|
|
329
|
-
// the one trust boundary for resume state. IDB can hand back anything
|
|
330
|
-
// (a corrupted negative/float cursor would previously pass `|| 0`,
|
|
331
|
-
// which only catches falsy, and get sent to the server as the resume
|
|
332
|
-
// point). Invalid → 0 → full bootstrap, the safe degradation.
|
|
333
|
-
const metadataLastSyncId = logPositionSchema.shape.persisted.safeParse(metadata?.lastSyncId).data ?? 0;
|
|
334
|
-
const dataAge = metadata?.updatedAt ? Date.now() - metadata.updatedAt.getTime() : Infinity;
|
|
335
|
-
// ── Cache-validity check ─────────────────────────────────────
|
|
336
|
-
//
|
|
337
|
-
// The cursor (lastSyncId) is only valid if the data it refers to
|
|
338
|
-
// actually exists in the stores. If the local store was cleared (or
|
|
339
|
-
// this is a fresh in-memory session), the metadata's lastSyncId is
|
|
340
|
-
// stale — sending it to the server would trigger a partial bootstrap
|
|
341
|
-
// that returns zero deltas because the gap is 0, leaving the client
|
|
342
|
-
// with an empty InstanceCache.
|
|
343
|
-
//
|
|
344
|
-
// The fix is to sample the actual stores: if they hold no rows, the
|
|
345
|
-
// cursor is meaningless regardless of what the metadata claims.
|
|
346
|
-
const dataExists = this.inMemory
|
|
347
|
-
? false // In-memory mode: no persistent data across sessions
|
|
348
|
-
: await this.storeManager.hasAnyData();
|
|
349
|
-
// The effective lastSyncId: only trust the metadata cursor when
|
|
350
|
-
// we've confirmed the data it refers to actually exists in the stores.
|
|
351
|
-
const lastSyncId = dataExists ? metadataLastSyncId : 0;
|
|
352
|
-
// Log the resolved database state for diagnostics.
|
|
353
|
-
this.runtime.logger.debug('[Database.requiredBootstrap] State check', {
|
|
354
|
-
readinessReady: readiness.ready,
|
|
355
|
-
hasMetadata: !!metadata,
|
|
356
|
-
metadataLastSyncId,
|
|
357
|
-
effectiveLastSyncId: lastSyncId,
|
|
358
|
-
dataExists,
|
|
359
|
-
dataAge: metadata?.updatedAt ? Math.round(dataAge / 1000) + 's' : 'N/A',
|
|
360
|
-
navigatorOnline: typeof navigator !== 'undefined' ? navigator.onLine : 'N/A',
|
|
361
|
-
});
|
|
362
|
-
// Determine bootstrap type based on connectivity and data state
|
|
363
|
-
const offline = typeof navigator !== 'undefined' && navigator && navigator.onLine === false;
|
|
364
|
-
let type;
|
|
365
|
-
// hasLocalData: stores actually have records AND we have a valid cursor
|
|
366
|
-
const hasLocalData = readiness.ready && dataExists && lastSyncId > 0;
|
|
367
|
-
if (offline && hasLocalData) {
|
|
368
|
-
// Offline with data - use local bootstrap (only option when offline)
|
|
369
|
-
type = 'local';
|
|
370
|
-
this.runtime.logger.info('Offline detected with local data - using local bootstrap');
|
|
371
|
-
}
|
|
372
|
-
else {
|
|
373
|
-
// The server is the source of truth: always use a full bootstrap
|
|
374
|
-
// when online.
|
|
375
|
-
type = 'full';
|
|
376
|
-
this.runtime.logger.info('Full bootstrap - server is source of truth', {
|
|
377
|
-
reason: offline ? 'offline_no_data' : 'server_authoritative',
|
|
378
|
-
hasLocalData,
|
|
379
|
-
lastSyncId,
|
|
380
|
-
dataExists,
|
|
381
|
-
});
|
|
382
|
-
}
|
|
383
|
-
return {
|
|
384
|
-
type,
|
|
385
|
-
modelsToLoad,
|
|
386
|
-
lastSyncId,
|
|
387
|
-
syncGroups: metadata?.syncGroups ?? [],
|
|
388
|
-
};
|
|
389
|
-
}
|
|
390
|
-
/**
|
|
391
|
-
* Fetch a bootstrap snapshot (or delta batch) from the sync server and load
|
|
392
|
-
* it into the local store, then return a {@link BootstrapResult} the caller
|
|
393
|
-
* applies to the {@link InstanceCache}.
|
|
394
|
-
*/
|
|
395
|
-
async bootstrapFromServer(requirements,
|
|
396
|
-
/** Full sync-group subscription list — what the WS subscribes to
|
|
397
|
-
* AND what gets persisted as `subscribedSyncGroups` for the
|
|
398
|
-
* shrinkage check. Caller supplies the complete list, not just
|
|
399
|
-
* team-derived groups. */
|
|
400
|
-
syncGroups, onProgress) {
|
|
401
|
-
this.runtime.logger.debug('Starting bootstrap fetch', {
|
|
402
|
-
type: requirements.type,
|
|
403
|
-
lastSyncId: requirements.lastSyncId,
|
|
404
|
-
modelsToLoad: requirements.modelsToLoad,
|
|
405
|
-
});
|
|
406
|
-
this.runtime.logger.info('Database: Starting bootstrap from Go server', {
|
|
407
|
-
type: requirements.type,
|
|
408
|
-
syncGroups,
|
|
409
|
-
modelsToLoad: requirements.modelsToLoad,
|
|
410
|
-
});
|
|
411
|
-
try {
|
|
412
|
-
// Fetch before any destructive operation, so a failed network
|
|
413
|
-
// request can't leave the local store empty.
|
|
414
|
-
const startTime = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
415
|
-
this.runtime.logger.info('Fetching bootstrap data from server (before clearing local data)', {
|
|
416
|
-
type: requirements.type,
|
|
417
|
-
lastSyncId: requirements.lastSyncId,
|
|
418
|
-
});
|
|
419
|
-
const bootstrapData = await this.bootstrapHelper.fetchBootstrap(requirements.lastSyncId);
|
|
420
|
-
this.runtime.logger.debug('Received bootstrap response', {
|
|
421
|
-
type: bootstrapData.type,
|
|
422
|
-
lastSyncId: bootstrapData.lastSyncId,
|
|
423
|
-
hasModels: !!bootstrapData.models,
|
|
424
|
-
hasDeltas: !!bootstrapData.deltas,
|
|
425
|
-
deltaCount: bootstrapData.deltaCount ?? 0,
|
|
426
|
-
});
|
|
427
|
-
// Clear only after a successful fetch, for transactional safety.
|
|
428
|
-
// Clear when the server says the response is a full snapshot,
|
|
429
|
-
// regardless of what type was requested.
|
|
430
|
-
if (bootstrapData.type === 'full') {
|
|
431
|
-
await this.clear();
|
|
432
|
-
}
|
|
433
|
-
// Handle partial bootstrap (delta batch)
|
|
434
|
-
if (bootstrapData.type === 'partial') {
|
|
435
|
-
const deltas = bootstrapData.deltas ?? [];
|
|
436
|
-
this.runtime.logger.info('Processing partial bootstrap with delta batch', {
|
|
437
|
-
deltaCount: deltas.length,
|
|
438
|
-
fromSyncId: requirements.lastSyncId,
|
|
439
|
-
toSyncId: bootstrapData.lastSyncId,
|
|
440
|
-
});
|
|
441
|
-
// Apply deltas to IndexedDB using processDeltaBatch for better performance.
|
|
442
|
-
// Capture the return value so the pool can be updated by the caller —
|
|
443
|
-
// without this, partial-bootstrap DELETEs persist to IDB but don't
|
|
444
|
-
// evict entities from the in-memory InstanceCache, leaving ghost rows
|
|
445
|
-
// visible on the canvas until a full reload rebuilds the pool.
|
|
446
|
-
let deltasApplied = 0;
|
|
447
|
-
let deltaResults;
|
|
448
|
-
if (deltas.length > 0) {
|
|
449
|
-
// Narrow the wire delta to what processDelta reads. The field names
|
|
450
|
-
// are the wire's own — the only change is `id` becoming `syncId`.
|
|
451
|
-
// A group-change frame carries its payload as a JSON string, decoded
|
|
452
|
-
// here exactly as the live delta path does in BaseSyncedStore.
|
|
453
|
-
const formattedDeltas = deltas.map((delta) => ({
|
|
454
|
-
syncId: delta.id,
|
|
455
|
-
actionType: delta.actionType,
|
|
456
|
-
modelName: delta.modelName,
|
|
457
|
-
modelId: delta.modelId,
|
|
458
|
-
data: typeof delta.data === 'string'
|
|
459
|
-
? JSON.parse(delta.data)
|
|
460
|
-
: delta.data,
|
|
461
|
-
}));
|
|
462
|
-
// Use batch processing for better performance
|
|
463
|
-
const batch = await this.processDeltaBatch(formattedDeltas);
|
|
464
|
-
deltaResults = batch.results;
|
|
465
|
-
deltasApplied = formattedDeltas.length;
|
|
466
|
-
onProgress?.(deltasApplied);
|
|
467
|
-
}
|
|
468
|
-
// Update workspace metadata with new lastSyncId (critical even when 0 deltas)
|
|
469
|
-
await this.updateWorkspaceMetadata({
|
|
470
|
-
lastSyncId: bootstrapData.lastSyncId,
|
|
471
|
-
schemaHash: this.modelRegistry.getSchemaHash(),
|
|
472
|
-
syncGroups: [...syncGroups],
|
|
473
|
-
updatedAt: new Date(),
|
|
474
|
-
});
|
|
475
|
-
const elapsed = (typeof performance !== 'undefined' ? performance.now() : Date.now()) - startTime;
|
|
476
|
-
this.runtime.logger.info(`Partial bootstrap complete in ${elapsed.toFixed(2)}ms`, {
|
|
477
|
-
deltasApplied,
|
|
478
|
-
lastSyncId: bootstrapData.lastSyncId,
|
|
479
|
-
});
|
|
480
|
-
return { modelsLoaded: 0, modelsStored: deltasApplied, bootstrapData, deltaResults };
|
|
481
|
-
}
|
|
482
|
-
// Full bootstrap: Process model data
|
|
483
|
-
if (!bootstrapData.models) {
|
|
484
|
-
throw new AbloValidationError('Full bootstrap response missing models data', {
|
|
485
|
-
code: 'bootstrap_response_invalid',
|
|
486
|
-
});
|
|
487
|
-
}
|
|
488
|
-
let modelsLoaded = 0;
|
|
489
|
-
let modelsStored = 0;
|
|
490
|
-
for (const [modelName, modelData] of Object.entries(bootstrapData.models)) {
|
|
491
|
-
// Handle null, undefined, or non-array data
|
|
492
|
-
if (!modelData) {
|
|
493
|
-
this.runtime.observability.breadcrumb(`No data received for ${modelName}`, 'sync.bootstrap', 'warning');
|
|
494
|
-
continue;
|
|
495
|
-
}
|
|
496
|
-
if (!Array.isArray(modelData)) {
|
|
497
|
-
this.runtime.observability.breadcrumb(`Skipping non-array data for ${modelName}`, 'sync.bootstrap', 'warning');
|
|
498
|
-
continue;
|
|
499
|
-
}
|
|
500
|
-
// Skip empty arrays silently (expected for some models)
|
|
501
|
-
if (modelData.length === 0) {
|
|
502
|
-
this.runtime.logger.debug(`No ${modelName} items to store (empty array)`);
|
|
503
|
-
continue;
|
|
504
|
-
}
|
|
505
|
-
const store = this.getStore(modelName, 'bootstrap');
|
|
506
|
-
if (!store) {
|
|
507
|
-
this.runtime.logger.debug(`[Bootstrap] NO IDB STORE for ${modelName} — ${modelData.length} items DROPPED`);
|
|
508
|
-
continue;
|
|
509
|
-
}
|
|
510
|
-
let writeErrors = 0;
|
|
511
|
-
// Store all items to IndexedDB (compacted)
|
|
512
|
-
for (const item of modelData) {
|
|
513
|
-
try {
|
|
514
|
-
const compacted = this.compactRecord(modelName, item);
|
|
515
|
-
await store.put(compacted);
|
|
516
|
-
modelsStored++;
|
|
517
|
-
modelsLoaded++;
|
|
518
|
-
// Report progress every 10 items
|
|
519
|
-
if (modelsLoaded % 10 === 0) {
|
|
520
|
-
onProgress?.(modelsLoaded);
|
|
521
|
-
}
|
|
522
|
-
}
|
|
523
|
-
catch (error) {
|
|
524
|
-
writeErrors++;
|
|
525
|
-
this.runtime.observability.breadcrumb(`Failed to store ${modelName} item`, 'sync.database', 'error', {
|
|
526
|
-
error: error instanceof Error ? error.message : String(error),
|
|
527
|
-
});
|
|
528
|
-
}
|
|
529
|
-
}
|
|
530
|
-
// The model is marked persisted below whether or not every item landed,
|
|
531
|
-
// because a partial store is still what the next sync reconciles
|
|
532
|
-
// against. Counted and surfaced here so a partial does not read as a
|
|
533
|
-
// clean bootstrap.
|
|
534
|
-
if (writeErrors > 0) {
|
|
535
|
-
this.runtime.observability.breadcrumb(`Stored ${modelName} with ${writeErrors} of ${modelData.length} items dropped`, 'sync.database', 'warning');
|
|
536
|
-
}
|
|
537
|
-
// Mark model as persisted after successful write
|
|
538
|
-
try {
|
|
539
|
-
await this.setModelPersisted(modelName, true);
|
|
540
|
-
}
|
|
541
|
-
catch { }
|
|
542
|
-
}
|
|
543
|
-
// Update workspace metadata with bootstrap snapshot's lastSyncId
|
|
544
|
-
// Note: This method is only called for 'full' bootstrap (not 'local')
|
|
545
|
-
// For 'partial' bootstrap (future): would need intelligent merge logic here
|
|
546
|
-
await this.updateWorkspaceMetadata({
|
|
547
|
-
lastSyncId: bootstrapData.lastSyncId,
|
|
548
|
-
schemaHash: this.modelRegistry.getSchemaHash(),
|
|
549
|
-
syncGroups: [...syncGroups],
|
|
550
|
-
updatedAt: new Date(),
|
|
551
|
-
});
|
|
552
|
-
const elapsed = (typeof performance !== 'undefined' ? performance.now() : Date.now()) - startTime;
|
|
553
|
-
this.runtime.logger.info(`Bootstrap complete: ${modelsLoaded} items loaded, ${modelsStored} stored to IndexedDB in ${elapsed.toFixed(2)}ms`);
|
|
554
|
-
this.runtime.analytics?.capture('bootstrap_success', {
|
|
555
|
-
responseTime: elapsed,
|
|
556
|
-
modelsLoaded,
|
|
557
|
-
});
|
|
558
|
-
return { modelsLoaded, modelsStored, bootstrapData };
|
|
559
|
-
}
|
|
560
|
-
catch (error) {
|
|
561
|
-
// Comprehensive error logging for bootstrap failures
|
|
562
|
-
this.runtime.observability.captureBootstrapFailure(error, {
|
|
563
|
-
type: requirements.type,
|
|
564
|
-
navigatorOnline: typeof navigator !== 'undefined' ? navigator.onLine : undefined,
|
|
565
|
-
});
|
|
566
|
-
// Track bootstrap failure telemetry
|
|
567
|
-
this.runtime.analytics?.capture('bootstrap_failed', {
|
|
568
|
-
bootstrapType: requirements.type,
|
|
569
|
-
lastSyncId: requirements.lastSyncId,
|
|
570
|
-
errorMessage: error instanceof Error ? error.message : String(error),
|
|
571
|
-
errorName: error instanceof Error ? error.name : 'UnknownError',
|
|
572
|
-
});
|
|
573
|
-
throw error;
|
|
574
|
-
}
|
|
575
|
-
}
|
|
576
|
-
// bootstrapSpecificModels removed per request
|
|
577
|
-
/**
|
|
578
|
-
* Apply a single inbound delta from the WebSocket to the local store.
|
|
579
|
-
*
|
|
580
|
-
* This handles one delta at a time. To apply several, prefer
|
|
581
|
-
* {@link processDeltaBatch}, which commits them in one IndexedDB transaction
|
|
582
|
-
* rather than two transactions per delta.
|
|
583
|
-
*
|
|
584
|
-
* Update deltas carry only the changed fields, so they are merged onto the
|
|
585
|
-
* existing record rather than replacing it. That preserves fields the delta
|
|
586
|
-
* omits (such as reportId or title), and an explicit null is kept as a value,
|
|
587
|
-
* clearing that field.
|
|
588
|
-
*/
|
|
589
|
-
async processDelta(delta) {
|
|
590
|
-
const { actionType, modelName, modelId, data, syncId } = delta;
|
|
591
|
-
const store = this.getStore(modelName, 'processDelta');
|
|
592
|
-
if (!store) {
|
|
593
|
-
return { action: 'verify', modelName, modelId };
|
|
594
|
-
}
|
|
595
|
-
// Idempotency gate: ignore already-applied deltas by comparing with the persisted lastSyncId
|
|
596
|
-
try {
|
|
597
|
-
const lastApplied = await this.getLastSyncId();
|
|
598
|
-
const incomingId = typeof syncId === 'number' ? syncId : undefined;
|
|
599
|
-
if (typeof incomingId === 'number' && incomingId <= lastApplied) {
|
|
600
|
-
return { action: 'verify', modelName, modelId };
|
|
601
|
-
}
|
|
602
|
-
}
|
|
603
|
-
catch { }
|
|
604
|
-
// Compact data before persistence; do not store redundant type markers.
|
|
605
|
-
// Inject `id` from the envelope — server deltas frequently strip it
|
|
606
|
-
// from the `data` payload, but IDB object stores use keyPath='id'
|
|
607
|
-
// and require it on the record itself. See `processDeltaBatch` for
|
|
608
|
-
// the same rationale on the batch path.
|
|
609
|
-
const dataWithId = data && typeof data === 'object'
|
|
610
|
-
? { id: modelId, ...data }
|
|
611
|
-
: data;
|
|
612
|
-
const compacted = dataWithId && typeof dataWithId === 'object'
|
|
613
|
-
? this.compactRecord(modelName, dataWithId)
|
|
614
|
-
: dataWithId;
|
|
615
|
-
switch (actionType) {
|
|
616
|
-
// 'C' (Covering) — client gained permission to see an existing entity.
|
|
617
|
-
// End state in the local store is identical to an insert: the row is
|
|
618
|
-
// present. The semantic difference is purely observability — it wasn't
|
|
619
|
-
// newly created, it was newly visible. We fall through to the 'I' case
|
|
620
|
-
// after a debug trace so the two can be disambiguated in logs.
|
|
621
|
-
case 'C':
|
|
622
|
-
this.runtime.observability.breadcrumb('Applying covering delta (gained permission)', 'sync.database', 'info', { modelName, modelId: modelId.slice(0, 12) });
|
|
623
|
-
// falls through
|
|
624
|
-
case 'I': {
|
|
625
|
-
// Skip when the delta payload was empty/null. IDB rejects
|
|
626
|
-
// non-record `put` arguments at runtime; the previous `any`
|
|
627
|
-
// typing on `ObjectStore.put` was silently letting that
|
|
628
|
-
// through. Real I-deltas always carry a row body.
|
|
629
|
-
if (!compacted || typeof compacted !== 'object') {
|
|
630
|
-
return { action: 'add', modelName, modelId, data: null };
|
|
631
|
-
}
|
|
632
|
-
// Insert synchronously for durable ack-after-apply semantics
|
|
633
|
-
try {
|
|
634
|
-
await store.put(compacted);
|
|
635
|
-
if (typeof syncId === 'number') {
|
|
636
|
-
await this.updateWorkspaceMetadata({ lastSyncId: syncId });
|
|
637
|
-
}
|
|
638
|
-
}
|
|
639
|
-
catch (err) {
|
|
640
|
-
this.runtime.observability.breadcrumb(`IndexedDB put failed for ${modelName}:${modelId}`, 'sync.database', 'error', {
|
|
641
|
-
error: err instanceof Error ? err.message : String(err),
|
|
642
|
-
});
|
|
643
|
-
throw err; // Re-throw to see the actual error
|
|
644
|
-
}
|
|
645
|
-
return { action: 'add', modelName, modelId, data: compacted };
|
|
646
|
-
}
|
|
647
|
-
case 'U': {
|
|
648
|
-
// Update: merge onto the existing record (partial-delta pattern).
|
|
649
|
-
// Read the existing record first.
|
|
650
|
-
const existing = await store.get(modelId);
|
|
651
|
-
// Skip the update when there's no existing record to merge with:
|
|
652
|
-
// building a record from partial update data would corrupt it
|
|
653
|
-
// (missing reportId, and so on).
|
|
654
|
-
if (!existing) {
|
|
655
|
-
this.runtime.observability.breadcrumb('Skipping UPDATE delta - no existing record to merge with', 'sync.database', 'warning', {
|
|
656
|
-
modelName,
|
|
657
|
-
modelId: modelId.slice(0, 12),
|
|
658
|
-
});
|
|
659
|
-
// Return verify action to signal no changes were made
|
|
660
|
-
return { action: 'verify', modelName, modelId, data: null };
|
|
661
|
-
}
|
|
662
|
-
// Shallow merge: delta overrides existing fields (safe - existing is guaranteed)
|
|
663
|
-
const merged = { ...existing, ...compacted };
|
|
664
|
-
// Log preserved fields for debugging partial updates
|
|
665
|
-
if (existing && compacted) {
|
|
666
|
-
this.logPreservedFields(modelName, modelId, existing, compacted);
|
|
667
|
-
}
|
|
668
|
-
// Persist merged record
|
|
669
|
-
try {
|
|
670
|
-
await store.put(merged);
|
|
671
|
-
if (typeof syncId === 'number') {
|
|
672
|
-
await this.updateWorkspaceMetadata({ lastSyncId: syncId });
|
|
673
|
-
}
|
|
674
|
-
}
|
|
675
|
-
catch (err) {
|
|
676
|
-
this.runtime.observability.breadcrumb(`IndexedDB put failed for ${modelName}:${modelId}`, 'sync.database', 'error', {
|
|
677
|
-
error: err instanceof Error ? err.message : String(err),
|
|
678
|
-
});
|
|
679
|
-
throw err;
|
|
680
|
-
}
|
|
681
|
-
// Return merged data (not just delta) to preserve essential fields like organizationId
|
|
682
|
-
return { action: 'update', modelName, modelId, data: merged };
|
|
683
|
-
}
|
|
684
|
-
case 'D': {
|
|
685
|
-
// Delete synchronously
|
|
686
|
-
try {
|
|
687
|
-
await store.delete(modelId);
|
|
688
|
-
if (typeof syncId === 'number') {
|
|
689
|
-
await this.updateWorkspaceMetadata({ lastSyncId: syncId });
|
|
690
|
-
}
|
|
691
|
-
}
|
|
692
|
-
catch (err) {
|
|
693
|
-
this.runtime.observability.breadcrumb(`IndexedDB delete failed for ${modelName}:${modelId}`, 'sync.database', 'error', {
|
|
694
|
-
error: err instanceof Error ? err.message : String(err),
|
|
695
|
-
});
|
|
696
|
-
// Surface failure so caller does not mutate InstanceCache inconsistently
|
|
697
|
-
throw err;
|
|
698
|
-
}
|
|
699
|
-
return { action: 'remove', modelName, modelId };
|
|
700
|
-
}
|
|
701
|
-
case 'A': {
|
|
702
|
-
// Archive
|
|
703
|
-
const archivedData = this.compactRecord(modelName, { ...data, archivedAt: new Date() });
|
|
704
|
-
try {
|
|
705
|
-
await store.put(archivedData);
|
|
706
|
-
if (typeof syncId === 'number') {
|
|
707
|
-
await this.updateWorkspaceMetadata({ lastSyncId: syncId });
|
|
708
|
-
}
|
|
709
|
-
}
|
|
710
|
-
catch (err) {
|
|
711
|
-
this.runtime.observability.breadcrumb(`IndexedDB archive put failed for ${modelName}:${modelId}`, 'sync.database', 'error', {
|
|
712
|
-
error: err instanceof Error ? err.message : String(err),
|
|
713
|
-
});
|
|
714
|
-
throw err;
|
|
715
|
-
}
|
|
716
|
-
return { action: 'archive', modelName, modelId, data: archivedData };
|
|
717
|
-
}
|
|
718
|
-
case 'V': // Verify
|
|
719
|
-
return { action: 'verify', modelName, modelId, data };
|
|
720
|
-
// 'G' (GroupAdded) and 'S' (GroupRemoved) are sync-group membership
|
|
721
|
-
// signals, not entity mutations. They are routed upstream in
|
|
722
|
-
// BaseSyncedStore.processDeltaWithBatching and should never reach
|
|
723
|
-
// processDelta. If one slips through (e.g. replayed from the bootstrap
|
|
724
|
-
// queue), we return a no-op verify rather than crashing the engine.
|
|
725
|
-
case 'G':
|
|
726
|
-
case 'S':
|
|
727
|
-
this.runtime.observability.breadcrumb(`Group membership delta (${actionType}) reached processDelta — should be handled upstream`, 'sync.database', 'warning', { modelName, modelId: modelId.slice(0, 12), actionType });
|
|
728
|
-
return { action: 'verify', modelName, modelId, data: null };
|
|
729
|
-
default: {
|
|
730
|
-
// The switch above is exhaustive over the declared action types, so
|
|
731
|
-
// this branch is only reachable when a value escapes the type — hence
|
|
732
|
-
// stringifying whatever actually arrived rather than the `never`.
|
|
733
|
-
const _exhaustive = actionType;
|
|
734
|
-
void _exhaustive;
|
|
735
|
-
throw new AbloValidationError(`Unknown action type: ${JSON.stringify(actionType)}`, { code: 'db_unknown_action_type' });
|
|
736
|
-
}
|
|
737
|
-
}
|
|
738
|
-
}
|
|
739
|
-
/**
|
|
740
|
-
* Apply many deltas to the local store in as few IndexedDB transactions as
|
|
741
|
-
* possible. Deltas are grouped by store, and each store's writes commit in a
|
|
742
|
-
* single transaction, so a batch of 186 deltas becomes roughly one
|
|
743
|
-
* transaction per store instead of two per delta.
|
|
744
|
-
*
|
|
745
|
-
* The method reads the existing records for update deltas up front, then
|
|
746
|
-
* merges each update onto its existing record so fields the delta omits are
|
|
747
|
-
* preserved and an explicit null still clears its field. It advances the
|
|
748
|
-
* persisted sync cursor once, to the highest committed sync id.
|
|
749
|
-
*
|
|
750
|
-
* Conflict resolution follows a delete-wins rule: it first indexes the
|
|
751
|
-
* delete deltas by entity, then skips any insert or update whose sync id is
|
|
752
|
-
* at or below a delete for the same entity. This avoids resurrecting a
|
|
753
|
-
* deleted entity and avoids fetching one that no longer exists.
|
|
754
|
-
*/
|
|
755
|
-
async processDeltaBatch(deltas) {
|
|
756
|
-
if ((!this.workspaceDb && !this.inMemory) || this.isClosing || deltas.length === 0) {
|
|
757
|
-
return { results: [], persistedSyncId: 0 };
|
|
758
|
-
}
|
|
759
|
-
// ── inMemory short-circuit ───────────────────────────────────────
|
|
760
|
-
//
|
|
761
|
-
// The batched IDB transaction path below assumes `this.storeManager`
|
|
762
|
-
// and `workspaceDb`. In inMemory mode (agent-worker, tests) those
|
|
763
|
-
// don't exist. Without this branch, every live delta arriving over
|
|
764
|
-
// the WebSocket is silently dropped — the local pool never updates,
|
|
765
|
-
// `subscribe()` autoruns never re-fire, lazy-model dispatchers
|
|
766
|
-
// never claim incoming work.
|
|
767
|
-
//
|
|
768
|
-
// Fall through to the single-delta path (`processDelta`), which
|
|
769
|
-
// uses `getStore` and is inMemory-compatible. Same return
|
|
770
|
-
// shape, sequential apply per delta — fine since inMemory mode
|
|
771
|
-
// doesn't need IDB transaction batching for performance.
|
|
772
|
-
if (this.inMemory) {
|
|
773
|
-
const inMemResults = [];
|
|
774
|
-
let inMemPersistedSyncId = 0;
|
|
775
|
-
for (const delta of deltas) {
|
|
776
|
-
const single = await this.processDelta({
|
|
777
|
-
syncId: delta.syncId,
|
|
778
|
-
actionType: delta.actionType,
|
|
779
|
-
modelName: delta.modelName,
|
|
780
|
-
modelId: delta.modelId,
|
|
781
|
-
data: delta.data,
|
|
782
|
-
});
|
|
783
|
-
inMemResults.push({ ...single, transactionId: delta.transactionId });
|
|
784
|
-
// inMemory has no IDB tx that can fail — every non-'verify'
|
|
785
|
-
// single result is durable in the in-memory store. Advance the
|
|
786
|
-
// persisted-cursor watermark to the input delta's syncId so the
|
|
787
|
-
// ack path can move forward.
|
|
788
|
-
if (single.action !== 'verify' && typeof delta.syncId === 'number' && delta.syncId > inMemPersistedSyncId) {
|
|
789
|
-
inMemPersistedSyncId = delta.syncId;
|
|
790
|
-
}
|
|
791
|
-
}
|
|
792
|
-
return { results: inMemResults, persistedSyncId: inMemPersistedSyncId };
|
|
793
|
-
}
|
|
794
|
-
// Prepare results aligned with input order
|
|
795
|
-
const results = new Array(deltas.length);
|
|
796
|
-
// Build a delete index for conflict resolution. When a delete has a sync
|
|
797
|
-
// id at or above a later insert or update for the same entity, that entity
|
|
798
|
-
// is not (re)created — which drops stale updates for cascade-deleted
|
|
799
|
-
// entities.
|
|
800
|
-
const deleteSyncIds = new Map(); // key: "ModelName:modelId" -> delete syncId
|
|
801
|
-
for (const delta of deltas) {
|
|
802
|
-
if (delta.actionType === 'D' && delta.syncId) {
|
|
803
|
-
const key = `${delta.modelName}:${delta.modelId}`;
|
|
804
|
-
const existing = deleteSyncIds.get(key);
|
|
805
|
-
// Normalize to number — postgres sends bigint as string on the wire.
|
|
806
|
-
const n = typeof delta.syncId === 'string' ? Number(delta.syncId) : delta.syncId;
|
|
807
|
-
if (typeof n === 'number' && !isNaN(n) && (!existing || n > existing)) {
|
|
808
|
-
deleteSyncIds.set(key, n);
|
|
809
|
-
}
|
|
810
|
-
}
|
|
811
|
-
}
|
|
812
|
-
if (deleteSyncIds.size > 0) {
|
|
813
|
-
this.runtime.logger.debug('[Database.processDeltaBatch] Built DELETE index for conflict resolution', {
|
|
814
|
-
deleteCount: deleteSyncIds.size,
|
|
815
|
-
totalDeltas: deltas.length,
|
|
816
|
-
});
|
|
817
|
-
}
|
|
818
|
-
// Group deltas by store for efficient transaction management.
|
|
819
|
-
//
|
|
820
|
-
// The method tracks the total range seen plus the exact input indexes whose
|
|
821
|
-
// store transaction committed. The cursor is derived from their ordered
|
|
822
|
-
// prefix after every store finishes; a maximum alone is unsafe because a
|
|
823
|
-
// later store can succeed after an earlier store failed.
|
|
824
|
-
//
|
|
825
|
-
// Without this split, a single store-level failure (a compacted record
|
|
826
|
-
// missing a required field, a validation abort) would advance the cursor
|
|
827
|
-
// past deltas that never wrote to IndexedDB. The next partial bootstrap
|
|
828
|
-
// would ask "what's new since {advanced cursor}?", the skipped rows would
|
|
829
|
-
// fall into the already-seen range forever, and the local store would stay
|
|
830
|
-
// permanently behind the server with no way to recover on reload.
|
|
831
|
-
const deltasByStore = new Map();
|
|
832
|
-
let highestSyncId = 0;
|
|
833
|
-
const persistedIndexes = new Set();
|
|
834
|
-
let skippedDueToConflict = 0;
|
|
835
|
-
deltas.forEach((delta, idx) => {
|
|
836
|
-
// Normalize to number — postgres sends bigint syncIds as strings.
|
|
837
|
-
const deltaSyncIdNum = typeof delta.syncId === 'string'
|
|
838
|
-
? Number(delta.syncId)
|
|
839
|
-
: delta.syncId;
|
|
840
|
-
if (typeof deltaSyncIdNum === 'number' && !isNaN(deltaSyncIdNum) && deltaSyncIdNum > highestSyncId) {
|
|
841
|
-
highestSyncId = deltaSyncIdNum;
|
|
842
|
-
}
|
|
843
|
-
// Conflict check: skip an insert or update when a delete for the same
|
|
844
|
-
// entity has an equal or higher sync id.
|
|
845
|
-
if (delta.actionType === 'U' ||
|
|
846
|
-
delta.actionType === 'I' ||
|
|
847
|
-
delta.actionType === 'C') {
|
|
848
|
-
const key = `${delta.modelName}:${delta.modelId}`;
|
|
849
|
-
const deleteSyncId = deleteSyncIds.get(key);
|
|
850
|
-
if (deleteSyncId !== undefined) {
|
|
851
|
-
// DELETE exists for this entity
|
|
852
|
-
const deltaSyncId = delta.syncId ?? 0;
|
|
853
|
-
if (deleteSyncId >= deltaSyncId) {
|
|
854
|
-
// DELETE has equal or higher syncId - skip this UPDATE/INSERT
|
|
855
|
-
this.runtime.logger.debug('[Database.processDeltaBatch] Skipping stale delta (DELETE wins)', {
|
|
856
|
-
modelName: delta.modelName,
|
|
857
|
-
modelId: delta.modelId.slice(0, 12),
|
|
858
|
-
actionType: delta.actionType,
|
|
859
|
-
deltaSyncId,
|
|
860
|
-
deleteSyncId,
|
|
861
|
-
});
|
|
862
|
-
results[idx] = { action: 'verify', modelName: delta.modelName, modelId: delta.modelId };
|
|
863
|
-
// The later delete in this same ordered frame supersedes this
|
|
864
|
-
// value, so the stale predecessor requires no separate write.
|
|
865
|
-
persistedIndexes.add(idx);
|
|
866
|
-
skippedDueToConflict++;
|
|
867
|
-
return; // Skip this delta
|
|
868
|
-
}
|
|
869
|
-
}
|
|
870
|
-
}
|
|
871
|
-
const store = this.getStore(delta.modelName, 'processDeltaBatch');
|
|
872
|
-
if (!store) {
|
|
873
|
-
results[idx] = { action: 'verify', modelName: delta.modelName, modelId: delta.modelId };
|
|
874
|
-
return;
|
|
875
|
-
}
|
|
876
|
-
if (!deltasByStore.has(delta.modelName)) {
|
|
877
|
-
deltasByStore.set(delta.modelName, []);
|
|
878
|
-
}
|
|
879
|
-
deltasByStore.get(delta.modelName).push({ idx, delta });
|
|
880
|
-
});
|
|
881
|
-
if (skippedDueToConflict > 0) {
|
|
882
|
-
this.runtime.logger.info('[Database.processDeltaBatch] Conflict resolution summary', {
|
|
883
|
-
skippedDueToConflict,
|
|
884
|
-
totalDeltas: deltas.length,
|
|
885
|
-
deleteCount: deleteSyncIds.size,
|
|
886
|
-
});
|
|
887
|
-
}
|
|
888
|
-
// Process each store's deltas in a single transaction
|
|
889
|
-
for (const [modelName, storeDeltas] of deltasByStore.entries()) {
|
|
890
|
-
const store = this.storeManager.getStore(modelName);
|
|
891
|
-
if (!store)
|
|
892
|
-
continue;
|
|
893
|
-
try {
|
|
894
|
-
// Batch read-modify-write.
|
|
895
|
-
// Step 1: Identify which deltas need existing data (updates)
|
|
896
|
-
const updateDeltas = storeDeltas.filter(({ delta }) => delta.actionType === 'U');
|
|
897
|
-
const updateIds = updateDeltas.map(({ delta }) => delta.modelId);
|
|
898
|
-
// Step 2: Batch read all existing records in a SINGLE IDB transaction
|
|
899
|
-
// This replaces N sequential get() calls with 1 transaction containing N gets
|
|
900
|
-
let existingRecords = new Map();
|
|
901
|
-
const missingIds = new Set();
|
|
902
|
-
if (updateIds.length > 0) {
|
|
903
|
-
try {
|
|
904
|
-
existingRecords = await store.getMany(updateIds);
|
|
905
|
-
// Identify missing IDs for self-healing
|
|
906
|
-
for (const id of updateIds) {
|
|
907
|
-
if (!existingRecords.has(id)) {
|
|
908
|
-
missingIds.add(id);
|
|
909
|
-
}
|
|
910
|
-
}
|
|
911
|
-
}
|
|
912
|
-
catch {
|
|
913
|
-
this.runtime.observability.breadcrumb(`Batch read failed for ${modelName}, falling back to individual reads`, 'sync.database', 'warning');
|
|
914
|
-
// Fallback: mark all as missing for self-healing
|
|
915
|
-
for (const id of updateIds) {
|
|
916
|
-
missingIds.add(id);
|
|
917
|
-
}
|
|
918
|
-
}
|
|
919
|
-
}
|
|
920
|
-
// Self-heal by fetching missing records for update deltas.
|
|
921
|
-
// Track ids that failed to fetch (a 404 means the entity was deleted,
|
|
922
|
-
// so its delta is skipped).
|
|
923
|
-
const failedToFetch = new Set();
|
|
924
|
-
if (missingIds.size > 0) {
|
|
925
|
-
this.runtime.logger.info(`[Database.processDeltaBatch] Found ${missingIds.size} missing records for ${modelName}, fetching from server...`);
|
|
926
|
-
// Fetch sequentially to avoid overwhelming server
|
|
927
|
-
for (const id of missingIds) {
|
|
928
|
-
try {
|
|
929
|
-
const fetchedRecord = await this.bootstrapHelper.fetchEntity(modelName, id);
|
|
930
|
-
if (fetchedRecord) {
|
|
931
|
-
const compacted = this.compactRecord(modelName, fetchedRecord);
|
|
932
|
-
existingRecords.set(id, compacted);
|
|
933
|
-
this.runtime.logger.debug(`[Database.processDeltaBatch] Successfully fetched missing record: ${modelName}:${id}`);
|
|
934
|
-
}
|
|
935
|
-
else {
|
|
936
|
-
// fetchEntity returns null for 404 — entity was deleted, skip the delta
|
|
937
|
-
failedToFetch.add(id);
|
|
938
|
-
this.runtime.logger.debug(`[Database.processDeltaBatch] Entity not found (deleted): ${modelName}:${id}`);
|
|
939
|
-
}
|
|
940
|
-
}
|
|
941
|
-
catch (error) {
|
|
942
|
-
// Unexpected error (5xx, network failure) — mark for skipping and report
|
|
943
|
-
failedToFetch.add(id);
|
|
944
|
-
this.runtime.observability.breadcrumb(`Failed to fetch missing record ${modelName}:${id}`, 'sync.database', 'warning', {
|
|
945
|
-
error: error instanceof Error ? error.message : String(error),
|
|
946
|
-
});
|
|
947
|
-
}
|
|
948
|
-
}
|
|
949
|
-
if (failedToFetch.size > 0) {
|
|
950
|
-
this.runtime.logger.info(`[Database.processDeltaBatch] Skipping ${failedToFetch.size} stale UPDATE deltas for deleted entities`, {
|
|
951
|
-
modelName,
|
|
952
|
-
failedCount: failedToFetch.size,
|
|
953
|
-
totalMissing: missingIds.size,
|
|
954
|
-
});
|
|
955
|
-
}
|
|
956
|
-
}
|
|
957
|
-
// Re-check after entity fetch loop: close() may have run during network I/O
|
|
958
|
-
if (!this.workspaceDb || this.isClosing) {
|
|
959
|
-
for (const { idx, delta } of storeDeltas) {
|
|
960
|
-
results[idx] = { action: 'verify', modelName, modelId: delta.modelId };
|
|
961
|
-
}
|
|
962
|
-
continue;
|
|
963
|
-
}
|
|
964
|
-
// Step 3: Start a single readwrite transaction for this store
|
|
965
|
-
const tx = this.workspaceDb.transaction([modelName], 'readwrite');
|
|
966
|
-
const objectStore = tx.objectStore(modelName);
|
|
967
|
-
// Stage results for this store; only commit to global results when tx completes successfully
|
|
968
|
-
const stagedResults = [];
|
|
969
|
-
// Step 4: Process all deltas synchronously within transaction (no await!)
|
|
970
|
-
for (const { idx, delta } of storeDeltas) {
|
|
971
|
-
const { actionType, modelId, data } = delta;
|
|
972
|
-
// Server deltas carry `id` in the envelope (modelId) but often
|
|
973
|
-
// strip it from the `data` payload as redundant. IDB object
|
|
974
|
-
// stores use keyPath='id' on the record itself, so the record
|
|
975
|
-
// MUST have `id` set. Inject it before `compactRecord` so the
|
|
976
|
-
// record is self-describing.
|
|
977
|
-
const dataWithId = data && typeof data === 'object'
|
|
978
|
-
? { id: modelId, ...data }
|
|
979
|
-
: data;
|
|
980
|
-
const compacted = dataWithId && typeof dataWithId === 'object'
|
|
981
|
-
? this.compactRecord(modelName, dataWithId)
|
|
982
|
-
: dataWithId;
|
|
983
|
-
switch (actionType) {
|
|
984
|
-
case 'C': // Create
|
|
985
|
-
case 'I': // Insert
|
|
986
|
-
objectStore.put(compacted);
|
|
987
|
-
stagedResults.push({
|
|
988
|
-
action: 'add',
|
|
989
|
-
modelName,
|
|
990
|
-
modelId,
|
|
991
|
-
data: compacted,
|
|
992
|
-
idx,
|
|
993
|
-
});
|
|
994
|
-
break;
|
|
995
|
-
case 'U': {
|
|
996
|
-
// Update: merge the delta onto the existing record (already fetched).
|
|
997
|
-
const existing = existingRecords.get(modelId);
|
|
998
|
-
// Skip a stale update: if the entity is neither in the local
|
|
999
|
-
// store nor fetchable from the server (a 404), it was deleted,
|
|
1000
|
-
// so skip it rather than create an incomplete record.
|
|
1001
|
-
if (!existing && failedToFetch.has(modelId)) {
|
|
1002
|
-
this.runtime.logger.debug('[Database.processDeltaBatch] Skipping UPDATE for deleted entity', {
|
|
1003
|
-
modelName,
|
|
1004
|
-
modelId: modelId.slice(0, 12),
|
|
1005
|
-
});
|
|
1006
|
-
stagedResults.push({ action: 'verify', modelName, modelId, idx });
|
|
1007
|
-
break; // Skip this delta
|
|
1008
|
-
}
|
|
1009
|
-
// Skip the update when there's no existing record to merge with:
|
|
1010
|
-
// building a record from partial update data would corrupt it
|
|
1011
|
-
// (missing reportId, and so on).
|
|
1012
|
-
if (!existing) {
|
|
1013
|
-
this.runtime.observability.breadcrumb('Batch: Skipping UPDATE delta - no existing record', 'sync.database', 'warning', {
|
|
1014
|
-
modelName,
|
|
1015
|
-
modelId: modelId.slice(0, 12),
|
|
1016
|
-
});
|
|
1017
|
-
stagedResults.push({ action: 'verify', modelName, modelId, idx });
|
|
1018
|
-
break; // Skip this delta
|
|
1019
|
-
}
|
|
1020
|
-
// Safe to merge - existing record is guaranteed
|
|
1021
|
-
const merged = { ...existing, ...compacted };
|
|
1022
|
-
// Log preserved fields for debugging partial updates
|
|
1023
|
-
if (existing && compacted) {
|
|
1024
|
-
this.logPreservedFields(modelName, modelId, existing, compacted);
|
|
1025
|
-
}
|
|
1026
|
-
objectStore.put(merged);
|
|
1027
|
-
stagedResults.push({
|
|
1028
|
-
action: 'update',
|
|
1029
|
-
modelName,
|
|
1030
|
-
modelId,
|
|
1031
|
-
data: merged, // Return merged data, not just delta
|
|
1032
|
-
idx,
|
|
1033
|
-
});
|
|
1034
|
-
break;
|
|
1035
|
-
}
|
|
1036
|
-
case 'D': // Delete
|
|
1037
|
-
objectStore.delete(modelId);
|
|
1038
|
-
stagedResults.push({ action: 'remove', modelName, modelId, idx });
|
|
1039
|
-
break;
|
|
1040
|
-
case 'A': // Archive
|
|
1041
|
-
const archivedData = this.compactRecord(modelName, {
|
|
1042
|
-
...data,
|
|
1043
|
-
archivedAt: new Date(),
|
|
1044
|
-
});
|
|
1045
|
-
objectStore.put(archivedData);
|
|
1046
|
-
stagedResults.push({
|
|
1047
|
-
action: 'archive',
|
|
1048
|
-
modelName,
|
|
1049
|
-
modelId,
|
|
1050
|
-
data: archivedData,
|
|
1051
|
-
idx,
|
|
1052
|
-
});
|
|
1053
|
-
break;
|
|
1054
|
-
case 'V': // Verify
|
|
1055
|
-
stagedResults.push({ action: 'verify', modelName, modelId, data, idx });
|
|
1056
|
-
break;
|
|
1057
|
-
}
|
|
1058
|
-
}
|
|
1059
|
-
// Wait for transaction to complete
|
|
1060
|
-
await new Promise((resolve, reject) => {
|
|
1061
|
-
tx.oncomplete = () => { resolve(); };
|
|
1062
|
-
tx.onerror = () => { reject(tx.error); };
|
|
1063
|
-
});
|
|
1064
|
-
// Only commit staged results to the global results if the transaction
|
|
1065
|
-
// succeeded. Record input indexes rather than a maximum sync id; the
|
|
1066
|
-
// durable cursor is the prefix through these indexes.
|
|
1067
|
-
for (const r of stagedResults) {
|
|
1068
|
-
// Resolve the originating delta so we can carry its
|
|
1069
|
-
// transactionId through to the result. Echo detection in
|
|
1070
|
-
// `SyncClient.applyDeltaBatchToPool` reads it.
|
|
1071
|
-
const sourceDelta = deltas[r.idx];
|
|
1072
|
-
results[r.idx] = {
|
|
1073
|
-
action: r.action,
|
|
1074
|
-
modelName: r.modelName,
|
|
1075
|
-
modelId: r.modelId,
|
|
1076
|
-
data: r.data,
|
|
1077
|
-
transactionId: sourceDelta?.transactionId,
|
|
1078
|
-
};
|
|
1079
|
-
persistedIndexes.add(r.idx);
|
|
1080
|
-
}
|
|
1081
|
-
}
|
|
1082
|
-
catch (err) {
|
|
1083
|
-
// Surface the IDB error directly — `captureMutationFailure`
|
|
1084
|
-
// routes to Sentry, but during interactive debugging the console
|
|
1085
|
-
// needs to show the specific failure (e.g. `ConstraintError`,
|
|
1086
|
-
// `DataError`, `AbortError`) so we can find what's wrong with
|
|
1087
|
-
// the `compacted` payload shape or store schema.
|
|
1088
|
-
const idbErr = err instanceof Error ? err : new Error(String(err));
|
|
1089
|
-
this.runtime.logger.debug('[Database.processDeltaBatch] store tx FAILED', {
|
|
1090
|
-
modelName,
|
|
1091
|
-
storeDeltasCount: storeDeltas.length,
|
|
1092
|
-
errorName: idbErr.name,
|
|
1093
|
-
message: idbErr.message,
|
|
1094
|
-
sampleDeltas: storeDeltas.slice(0, 3).map(({ delta }) => ({
|
|
1095
|
-
action: delta.actionType,
|
|
1096
|
-
id: delta.modelId.slice(0, 12),
|
|
1097
|
-
dataKeys: delta.data && typeof delta.data === 'object'
|
|
1098
|
-
? Object.keys(delta.data).slice(0, 8)
|
|
1099
|
-
: typeof delta.data,
|
|
1100
|
-
})),
|
|
1101
|
-
});
|
|
1102
|
-
this.runtime.observability.captureMutationFailure({
|
|
1103
|
-
context: 'batch-indexeddb-operation',
|
|
1104
|
-
modelName,
|
|
1105
|
-
error: idbErr,
|
|
1106
|
-
});
|
|
1107
|
-
// Mark all store deltas as verify in their original positions
|
|
1108
|
-
for (const { idx, delta } of storeDeltas) {
|
|
1109
|
-
results[idx] = { action: 'verify', modelName, modelId: delta.modelId };
|
|
1110
|
-
}
|
|
1111
|
-
}
|
|
1112
|
-
}
|
|
1113
|
-
// Advance only through the durable INPUT PREFIX. IDs need not be
|
|
1114
|
-
// numerically contiguous because other tenants and filtered sync groups
|
|
1115
|
-
// occupy gaps; the server-delivered order is the relevant sequence.
|
|
1116
|
-
const highestPersistedSyncId = highestPersistedPrefixSyncId(deltas, persistedIndexes);
|
|
1117
|
-
// Using `highestSyncId` (the range-seen max) would advance past an earlier
|
|
1118
|
-
// failed store transaction and permanently skip its delta.
|
|
1119
|
-
//
|
|
1120
|
-
// If `highestPersistedSyncId === 0` (every store tx failed), we leave
|
|
1121
|
-
// the metadata alone. Next partial bootstrap will re-deliver the
|
|
1122
|
-
// deltas at the original cursor position.
|
|
1123
|
-
if (highestPersistedSyncId > 0) {
|
|
1124
|
-
try {
|
|
1125
|
-
await this.updateWorkspaceMetadata({ lastSyncId: highestPersistedSyncId });
|
|
1126
|
-
}
|
|
1127
|
-
catch (err) {
|
|
1128
|
-
this.runtime.observability.breadcrumb('Failed to update metadata after batch', 'sync.database', 'error', {
|
|
1129
|
-
error: err instanceof Error ? err.message : String(err),
|
|
1130
|
-
});
|
|
1131
|
-
}
|
|
1132
|
-
}
|
|
1133
|
-
if (highestPersistedSyncId < highestSyncId) {
|
|
1134
|
-
// Staging-visibility probe: makes the "some deltas seen but not
|
|
1135
|
-
// persisted" signal loud when it actually happens. If this fires
|
|
1136
|
-
// repeatedly on the same sync IDs, a specific row is un-writable
|
|
1137
|
-
// (validation? compact issue?) and needs fixing at that layer.
|
|
1138
|
-
this.runtime.logger.debug('[Database.processDeltaBatch] cursor withheld due to failed store tx', {
|
|
1139
|
-
seen: highestSyncId,
|
|
1140
|
-
persisted: highestPersistedSyncId,
|
|
1141
|
-
gap: highestSyncId - highestPersistedSyncId,
|
|
1142
|
-
});
|
|
1143
|
-
}
|
|
1144
|
-
return { results, persistedSyncId: highestPersistedSyncId };
|
|
1145
|
-
}
|
|
1146
|
-
/** Get raw data for hydration */
|
|
1147
|
-
async hydrateModels(modelName) {
|
|
1148
|
-
const store = this.getStore(modelName, 'hydrate');
|
|
1149
|
-
if (!store) {
|
|
1150
|
-
return [];
|
|
1151
|
-
}
|
|
1152
|
-
return store.getAll();
|
|
1153
|
-
}
|
|
1154
|
-
/** Put a single record to IndexedDB (for self-healing corrupted records) */
|
|
1155
|
-
async putRecord(modelName, id, data) {
|
|
1156
|
-
const store = this.getStore(modelName, 'putRecord');
|
|
1157
|
-
if (!store) {
|
|
1158
|
-
this.runtime.observability.breadcrumb(`Store not found for putRecord: ${modelName}`, 'sync.database', 'warning');
|
|
1159
|
-
return;
|
|
1160
|
-
}
|
|
1161
|
-
const compacted = this.compactRecord(modelName, data);
|
|
1162
|
-
await store.put(compacted);
|
|
1163
|
-
}
|
|
1164
|
-
/** Get data by index. `value` is an IDB key — string, number, Date,
|
|
1165
|
-
* BufferSource, or array thereof. */
|
|
1166
|
-
async getDataByIndex(modelName, indexName, value) {
|
|
1167
|
-
const store = this.getRequiredStore(modelName);
|
|
1168
|
-
return await store.getAllFromIndex(indexName, value);
|
|
1169
|
-
}
|
|
1170
|
-
/** Read workspace metadata from IndexedDB. Returns null when the database is not open. */
|
|
1171
|
-
async getWorkspaceMetadata() {
|
|
1172
|
-
if (this.inMemory)
|
|
1173
|
-
return this.inMemoryMetadata;
|
|
1174
|
-
if (!this.workspaceDb)
|
|
1175
|
-
return null;
|
|
1176
|
-
return this.databaseManager.getWorkspaceMetadata(this.workspaceDb);
|
|
1177
|
-
}
|
|
1178
|
-
async getLastSyncId() {
|
|
1179
|
-
if (!this.workspaceDb) {
|
|
1180
|
-
return 0;
|
|
1181
|
-
}
|
|
1182
|
-
const metadata = await this.databaseManager.getWorkspaceMetadata(this.workspaceDb);
|
|
1183
|
-
return metadata?.lastSyncId ?? 0;
|
|
1184
|
-
}
|
|
1185
|
-
async updateWorkspaceMetadata(metadata) {
|
|
1186
|
-
// In-memory mode: store in local variable
|
|
1187
|
-
if (this.inMemory) {
|
|
1188
|
-
this.inMemoryMetadata = {
|
|
1189
|
-
...(this.inMemoryMetadata ?? {
|
|
1190
|
-
lastSyncId: 0, firstSyncId: 0, backendDatabaseVersion: 0,
|
|
1191
|
-
subscribedSyncGroups: [], updatedAt: new Date(),
|
|
1192
|
-
}),
|
|
1193
|
-
...metadata,
|
|
1194
|
-
updatedAt: new Date(),
|
|
1195
|
-
};
|
|
1196
|
-
return;
|
|
1197
|
-
}
|
|
1198
|
-
// Graceful degradation: skip if database is closing or not open
|
|
1199
|
-
// This prevents "Database not opened" errors during React Strict Mode cleanup
|
|
1200
|
-
if (!this.workspaceDb || this.isClosing) {
|
|
1201
|
-
this.runtime.observability.breadcrumb('updateWorkspaceMetadata: Database not open or closing', 'sync.database', 'warning', {
|
|
1202
|
-
hasDb: !!this.workspaceDb,
|
|
1203
|
-
isClosing: this.isClosing,
|
|
1204
|
-
});
|
|
1205
|
-
return;
|
|
1206
|
-
}
|
|
1207
|
-
const current = await this.databaseManager.getWorkspaceMetadata(this.workspaceDb);
|
|
1208
|
-
// Re-check after await: close() may have been called during getWorkspaceMetadata,
|
|
1209
|
-
// or the browser may have closed the IDB connection (tab background, navigation).
|
|
1210
|
-
// Without this, setWorkspaceMetadata would hit "The database connection is closing".
|
|
1211
|
-
if (!this.workspaceDb || this.isClosing) {
|
|
1212
|
-
return;
|
|
1213
|
-
}
|
|
1214
|
-
const updated = {
|
|
1215
|
-
...current,
|
|
1216
|
-
...metadata,
|
|
1217
|
-
updatedAt: new Date(),
|
|
1218
|
-
};
|
|
1219
|
-
await this.databaseManager.setWorkspaceMetadata(this.workspaceDb, updated);
|
|
1220
|
-
}
|
|
1221
|
-
/** Transaction persistence for offline/retry support.
|
|
1222
|
-
* Returns either the IDB-backed ObjectStore or its in-memory twin
|
|
1223
|
-
* (`InMemoryObjectStore`) — both expose the same async put/get/
|
|
1224
|
-
* delete/getAll/getAllFromIndex surface, so callers don't need to
|
|
1225
|
-
* branch on which one they got back. */
|
|
1226
|
-
get transactionStore() {
|
|
1227
|
-
return this.getRequiredStore('__transactions');
|
|
1228
|
-
}
|
|
1229
|
-
async saveTransaction(transaction) {
|
|
1230
|
-
await this.transactionStore.put(transaction);
|
|
1231
|
-
}
|
|
1232
|
-
/** Persist one burst of journal rows in a single strict durability group. */
|
|
1233
|
-
async saveTransactions(transactions) {
|
|
1234
|
-
if (transactions.length === 0)
|
|
1235
|
-
return;
|
|
1236
|
-
if (this.inMemory) {
|
|
1237
|
-
await Promise.all(transactions.map((transaction) => this.transactionStore.put(transaction)));
|
|
1238
|
-
return;
|
|
1239
|
-
}
|
|
1240
|
-
const db = this.workspaceDb;
|
|
1241
|
-
if (!db || this.isClosing) {
|
|
1242
|
-
throw new AbloConnectionError('Database not opened for mutation journal', {
|
|
1243
|
-
code: 'db_not_opened',
|
|
1244
|
-
});
|
|
1245
|
-
}
|
|
1246
|
-
await new Promise((resolve, reject) => {
|
|
1247
|
-
try {
|
|
1248
|
-
const tx = db.transaction(['__transactions'], 'readwrite', {
|
|
1249
|
-
durability: 'strict',
|
|
1250
|
-
});
|
|
1251
|
-
const store = tx.objectStore('__transactions');
|
|
1252
|
-
for (const transaction of transactions)
|
|
1253
|
-
store.put(transaction);
|
|
1254
|
-
tx.oncomplete = () => { resolve(); };
|
|
1255
|
-
tx.onabort = () => {
|
|
1256
|
-
reject(tx.error ?? new Error('Mutation journal transaction aborted'));
|
|
1257
|
-
};
|
|
1258
|
-
tx.onerror = () => {
|
|
1259
|
-
// onabort owns rejection.
|
|
1260
|
-
};
|
|
1261
|
-
}
|
|
1262
|
-
catch (error) {
|
|
1263
|
-
reject(error instanceof Error ? error : new Error(String(error)));
|
|
1264
|
-
}
|
|
1265
|
-
});
|
|
1266
|
-
}
|
|
1267
|
-
async removeTransaction(id) {
|
|
1268
|
-
await this.transactionStore.delete(id);
|
|
1269
|
-
}
|
|
1270
|
-
async getPersistedTransactions() {
|
|
1271
|
-
const rows = await this.transactionStore.getAll();
|
|
1272
|
-
// Storage layer returns the centralized `Record<string, unknown>`
|
|
1273
|
-
// shape from `ObjectStoreContract`. PersistedTransaction adds an
|
|
1274
|
-
// index signature so each row already structurally satisfies the
|
|
1275
|
-
// narrower type — runtime invariant: only saveTransaction writes
|
|
1276
|
-
// here, and it only accepts PersistedTransaction.
|
|
1277
|
-
return rows;
|
|
1278
|
-
}
|
|
1279
|
-
async getPersistedTransaction(id) {
|
|
1280
|
-
return (await this.transactionStore.get(id));
|
|
1281
|
-
}
|
|
1282
|
-
/**
|
|
1283
|
-
* Atomically seal one exact commit request and consume the staged mutation
|
|
1284
|
-
* records it replaces. The read, optional add, and deletes share one strict
|
|
1285
|
-
* IndexedDB transaction, so a crash can expose the staged records or the
|
|
1286
|
-
* sealed envelope, never a missing handoff. Returns the pre-existing record
|
|
1287
|
-
* when the envelope id was already sealed (retry/re-entrant call).
|
|
1288
|
-
*/
|
|
1289
|
-
async sealTransactionRecord(record, consumedRecordIds) {
|
|
1290
|
-
const recordId = record.id;
|
|
1291
|
-
if (!recordId) {
|
|
1292
|
-
throw new AbloValidationError('A sealed transaction record must carry an id', {
|
|
1293
|
-
code: 'invalid_body',
|
|
1294
|
-
});
|
|
1295
|
-
}
|
|
1296
|
-
if (this.inMemory) {
|
|
1297
|
-
const store = this.transactionStore;
|
|
1298
|
-
const existing = (await store.get(recordId));
|
|
1299
|
-
if (existing && !isSameOutboxRecord(existing, record)) {
|
|
1300
|
-
throw new AbloValidationError('Pending-write key already identifies a different request', {
|
|
1301
|
-
code: 'idempotency_conflict',
|
|
1302
|
-
});
|
|
1303
|
-
}
|
|
1304
|
-
if (isAcceptedOutboxPromotion(existing, record)) {
|
|
1305
|
-
await store.put(record);
|
|
1306
|
-
}
|
|
1307
|
-
if (!existing) {
|
|
1308
|
-
const sources = await Promise.all(consumedRecordIds.map((id) => store.get(id)));
|
|
1309
|
-
if (sources.some((source) => source === undefined)) {
|
|
1310
|
-
throw new AbloValidationError('Pending-write source mutations were already claimed by another write', { code: 'idempotency_conflict' });
|
|
1311
|
-
}
|
|
1312
|
-
await store.add(record);
|
|
1313
|
-
}
|
|
1314
|
-
for (const id of consumedRecordIds) {
|
|
1315
|
-
if (id !== recordId)
|
|
1316
|
-
await store.delete(id);
|
|
1317
|
-
}
|
|
1318
|
-
return existing;
|
|
1319
|
-
}
|
|
1320
|
-
const db = this.workspaceDb;
|
|
1321
|
-
if (!db || this.isClosing) {
|
|
1322
|
-
throw new AbloConnectionError('Database not opened for durable writes', {
|
|
1323
|
-
code: 'db_not_opened',
|
|
1324
|
-
});
|
|
1325
|
-
}
|
|
1326
|
-
return new Promise((resolve, reject) => {
|
|
1327
|
-
try {
|
|
1328
|
-
const tx = db.transaction(['__transactions'], 'readwrite', {
|
|
1329
|
-
durability: 'strict',
|
|
1330
|
-
});
|
|
1331
|
-
const store = tx.objectStore('__transactions');
|
|
1332
|
-
const getRequest = store.get(recordId);
|
|
1333
|
-
const sourceIds = [...new Set(consumedRecordIds)].filter((id) => id !== recordId);
|
|
1334
|
-
const sourceRequests = sourceIds.map((id) => store.get(id));
|
|
1335
|
-
const sourceExists = new Array(sourceRequests.length).fill(false);
|
|
1336
|
-
let existing;
|
|
1337
|
-
let collisionError;
|
|
1338
|
-
let envelopeRead = false;
|
|
1339
|
-
let sourcesRead = 0;
|
|
1340
|
-
let promotionStarted = false;
|
|
1341
|
-
const promote = () => {
|
|
1342
|
-
if (promotionStarted ||
|
|
1343
|
-
!envelopeRead ||
|
|
1344
|
-
sourcesRead !== sourceRequests.length)
|
|
1345
|
-
return;
|
|
1346
|
-
promotionStarted = true;
|
|
1347
|
-
if (existing && !isSameOutboxRecord(existing, record)) {
|
|
1348
|
-
collisionError = new AbloValidationError('Pending-write key already identifies a different request', { code: 'idempotency_conflict' });
|
|
1349
|
-
tx.abort();
|
|
1350
|
-
return;
|
|
1351
|
-
}
|
|
1352
|
-
// A new envelope owns promotion only while every source row still
|
|
1353
|
-
// exists. This is the fleet/tab execution claim: a second tab that
|
|
1354
|
-
// restored the same journal entries under another key loses here and
|
|
1355
|
-
// cannot dispatch. An identical existing envelope is an idempotent
|
|
1356
|
-
// retry, so its already-consumed sources may be absent.
|
|
1357
|
-
if (!existing && sourceExists.some((exists) => !exists)) {
|
|
1358
|
-
collisionError = new AbloValidationError('Pending-write source mutations were already claimed by another write', { code: 'idempotency_conflict' });
|
|
1359
|
-
tx.abort();
|
|
1360
|
-
return;
|
|
1361
|
-
}
|
|
1362
|
-
if (!existing) {
|
|
1363
|
-
store.add(record);
|
|
1364
|
-
}
|
|
1365
|
-
else if (isAcceptedOutboxPromotion(existing, record)) {
|
|
1366
|
-
store.put(record);
|
|
1367
|
-
}
|
|
1368
|
-
for (const id of sourceIds)
|
|
1369
|
-
store.delete(id);
|
|
1370
|
-
};
|
|
1371
|
-
getRequest.onsuccess = () => {
|
|
1372
|
-
existing = getRequest.result;
|
|
1373
|
-
envelopeRead = true;
|
|
1374
|
-
promote();
|
|
1375
|
-
};
|
|
1376
|
-
getRequest.onerror = () => {
|
|
1377
|
-
tx.abort();
|
|
1378
|
-
};
|
|
1379
|
-
sourceRequests.forEach((request, index) => {
|
|
1380
|
-
request.onsuccess = () => {
|
|
1381
|
-
sourceExists[index] = request.result !== undefined;
|
|
1382
|
-
sourcesRead += 1;
|
|
1383
|
-
promote();
|
|
1384
|
-
};
|
|
1385
|
-
request.onerror = () => {
|
|
1386
|
-
tx.abort();
|
|
1387
|
-
};
|
|
1388
|
-
});
|
|
1389
|
-
tx.oncomplete = () => { resolve(existing); };
|
|
1390
|
-
tx.onabort = () => {
|
|
1391
|
-
reject(collisionError ??
|
|
1392
|
-
tx.error ??
|
|
1393
|
-
getRequest.error ??
|
|
1394
|
-
new Error('Durable-write transaction aborted'));
|
|
1395
|
-
};
|
|
1396
|
-
tx.onerror = () => {
|
|
1397
|
-
// onabort owns rejection so the promise settles exactly once.
|
|
1398
|
-
};
|
|
1399
|
-
}
|
|
1400
|
-
catch (error) {
|
|
1401
|
-
reject(error instanceof Error ? error : new Error(String(error)));
|
|
1402
|
-
}
|
|
1403
|
-
});
|
|
1404
|
-
}
|
|
1405
|
-
async cleanupOldTransactions(maxAge) {
|
|
1406
|
-
const store = this.transactionStore;
|
|
1407
|
-
const rows = (await store.getAll());
|
|
1408
|
-
const cutoff = Date.now() - maxAge;
|
|
1409
|
-
let cleaned = 0;
|
|
1410
|
-
for (const tx of rows) {
|
|
1411
|
-
// Live write intent has no safe age-based expiry. In particular, server
|
|
1412
|
-
// idempotency retention may already have elapsed, so silently deleting or
|
|
1413
|
-
// blindly replaying an old envelope would both be unsafe. Restoration
|
|
1414
|
-
// owns quarantine/reconciliation for these records.
|
|
1415
|
-
if (tx.type === 'commit_envelope' ||
|
|
1416
|
-
tx.type === 'http_commit_envelope' ||
|
|
1417
|
-
tx.type === 'pending_mutation') {
|
|
1418
|
-
continue;
|
|
1419
|
-
}
|
|
1420
|
-
if (typeof tx.timestamp === 'number' && tx.timestamp < cutoff) {
|
|
1421
|
-
await store.delete(tx.id);
|
|
1422
|
-
cleaned++;
|
|
1423
|
-
}
|
|
1424
|
-
}
|
|
1425
|
-
return cleaned;
|
|
1426
|
-
}
|
|
1427
|
-
/**
|
|
1428
|
-
* Store management
|
|
1429
|
-
*
|
|
1430
|
-
* `getStore(modelName, context?)` is defined near the top of this
|
|
1431
|
-
* class — single accessor for both inMemory and IDB modes.
|
|
1432
|
-
*/
|
|
1433
|
-
getAllStores() {
|
|
1434
|
-
if (this.inMemory) {
|
|
1435
|
-
return this.inMemoryStores;
|
|
1436
|
-
}
|
|
1437
|
-
return this.storeManager.getAllStores();
|
|
1438
|
-
}
|
|
1439
|
-
/**
|
|
1440
|
-
* Model persistence tracking
|
|
1441
|
-
*/
|
|
1442
|
-
async setModelPersisted(modelName, persisted) {
|
|
1443
|
-
if (this.inMemory)
|
|
1444
|
-
return; // No persistence tracking in memory mode
|
|
1445
|
-
if (!this.workspaceDb) {
|
|
1446
|
-
throw new AbloConnectionError('Database not opened', {
|
|
1447
|
-
code: 'db_not_opened',
|
|
1448
|
-
});
|
|
1449
|
-
}
|
|
1450
|
-
await this.databaseManager.setModelPersisted(this.workspaceDb, modelName, persisted);
|
|
1451
|
-
}
|
|
1452
|
-
async isModelPersisted(modelName) {
|
|
1453
|
-
if (this.inMemory)
|
|
1454
|
-
return false; // In-memory = nothing persisted
|
|
1455
|
-
if (!this.workspaceDb) {
|
|
1456
|
-
throw new AbloConnectionError('Database not opened', {
|
|
1457
|
-
code: 'db_not_opened',
|
|
1458
|
-
});
|
|
1459
|
-
}
|
|
1460
|
-
return await this.databaseManager.isModelPersisted(this.workspaceDb, modelName);
|
|
1461
|
-
}
|
|
1462
|
-
/**
|
|
1463
|
-
* Statistics
|
|
1464
|
-
*/
|
|
1465
|
-
async getStats() {
|
|
1466
|
-
const storeStats = await this.storeManager.getComprehensiveStats();
|
|
1467
|
-
return {
|
|
1468
|
-
database: this.currentDbInfo,
|
|
1469
|
-
stores: storeStats,
|
|
1470
|
-
metadata: this.workspaceDb
|
|
1471
|
-
? await this.databaseManager.getWorkspaceMetadata(this.workspaceDb)
|
|
1472
|
-
: null,
|
|
1473
|
-
};
|
|
1474
|
-
}
|
|
1475
|
-
/**
|
|
1476
|
-
* Lifecycle
|
|
1477
|
-
*/
|
|
1478
|
-
isOpen() {
|
|
1479
|
-
return this.workspaceDb !== null;
|
|
1480
|
-
}
|
|
1481
|
-
async close() {
|
|
1482
|
-
// Mark database as closing FIRST to enable graceful degradation
|
|
1483
|
-
// This allows in-flight operations to bail out gracefully
|
|
1484
|
-
this.isClosing = true;
|
|
1485
|
-
// Mark all stores as closing to prevent new operations
|
|
1486
|
-
this.storeManager.markAllStoresAsClosing();
|
|
1487
|
-
if (this.workspaceDb) {
|
|
1488
|
-
this.workspaceDb.close();
|
|
1489
|
-
this.workspaceDb = null;
|
|
1490
|
-
}
|
|
1491
|
-
await this.databaseManager.close();
|
|
1492
|
-
this.currentDbInfo = null;
|
|
1493
|
-
this.runtime.logger.debug('Database closed');
|
|
1494
|
-
}
|
|
1495
|
-
async clear(options = {}) {
|
|
1496
|
-
await this.storeManager.clearAllStores();
|
|
1497
|
-
if (options.includeWriteJournal) {
|
|
1498
|
-
await this.transactionStore.clear();
|
|
1499
|
-
}
|
|
1500
|
-
this.runtime.logger.info('All stores cleared');
|
|
1501
|
-
}
|
|
1502
|
-
}
|