@abloatai/ablo 0.3.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.
Files changed (278) hide show
  1. package/CHANGELOG.md +208 -0
  2. package/LICENSE +201 -0
  3. package/NOTICE +12 -0
  4. package/README.md +230 -0
  5. package/dist/BaseSyncedStore.d.ts +709 -0
  6. package/dist/BaseSyncedStore.js +1843 -0
  7. package/dist/Database.d.ts +344 -0
  8. package/dist/Database.js +1259 -0
  9. package/dist/LazyReferenceCollection.d.ts +181 -0
  10. package/dist/LazyReferenceCollection.js +460 -0
  11. package/dist/Model.d.ts +339 -0
  12. package/dist/Model.js +715 -0
  13. package/dist/ModelRegistry.d.ts +200 -0
  14. package/dist/ModelRegistry.js +535 -0
  15. package/dist/NetworkMonitor.d.ts +27 -0
  16. package/dist/NetworkMonitor.js +73 -0
  17. package/dist/ObjectPool.d.ts +202 -0
  18. package/dist/ObjectPool.js +1106 -0
  19. package/dist/SyncClient.d.ts +489 -0
  20. package/dist/SyncClient.js +1555 -0
  21. package/dist/SyncEngineContext.d.ts +46 -0
  22. package/dist/SyncEngineContext.js +74 -0
  23. package/dist/adapters/alwaysOnline.d.ts +16 -0
  24. package/dist/adapters/alwaysOnline.js +19 -0
  25. package/dist/adapters/inMemoryStorage.d.ts +30 -0
  26. package/dist/adapters/inMemoryStorage.js +94 -0
  27. package/dist/agent/Agent.d.ts +358 -0
  28. package/dist/agent/Agent.js +500 -0
  29. package/dist/agent/index.d.ts +115 -0
  30. package/dist/agent/index.js +128 -0
  31. package/dist/agent/session.d.ts +90 -0
  32. package/dist/agent/session.js +156 -0
  33. package/dist/agent/types.d.ts +73 -0
  34. package/dist/agent/types.js +10 -0
  35. package/dist/ai-sdk/coordination-context.d.ts +51 -0
  36. package/dist/ai-sdk/coordination-context.js +107 -0
  37. package/dist/ai-sdk/index.d.ts +68 -0
  38. package/dist/ai-sdk/index.js +68 -0
  39. package/dist/ai-sdk/intent-broadcast.d.ts +77 -0
  40. package/dist/ai-sdk/intent-broadcast.js +72 -0
  41. package/dist/ai-sdk/wrap.d.ts +67 -0
  42. package/dist/ai-sdk/wrap.js +45 -0
  43. package/dist/api/index.d.ts +10 -0
  44. package/dist/api/index.js +9 -0
  45. package/dist/auth/index.d.ts +137 -0
  46. package/dist/auth/index.js +246 -0
  47. package/dist/client/Ablo.d.ts +835 -0
  48. package/dist/client/Ablo.js +1440 -0
  49. package/dist/client/ApiClient.d.ts +200 -0
  50. package/dist/client/ApiClient.js +659 -0
  51. package/dist/client/auth.d.ts +79 -0
  52. package/dist/client/auth.js +81 -0
  53. package/dist/client/createInternalComponents.d.ts +44 -0
  54. package/dist/client/createInternalComponents.js +88 -0
  55. package/dist/client/createModelProxy.d.ts +152 -0
  56. package/dist/client/createModelProxy.js +199 -0
  57. package/dist/client/identity.d.ts +63 -0
  58. package/dist/client/identity.js +156 -0
  59. package/dist/client/index.d.ts +36 -0
  60. package/dist/client/index.js +33 -0
  61. package/dist/client/persistence.d.ts +7 -0
  62. package/dist/client/persistence.js +11 -0
  63. package/dist/client/validateAbloOptions.d.ts +42 -0
  64. package/dist/client/validateAbloOptions.js +43 -0
  65. package/dist/config/index.d.ts +10 -0
  66. package/dist/config/index.js +12 -0
  67. package/dist/context.d.ts +27 -0
  68. package/dist/context.js +58 -0
  69. package/dist/core/DatabaseManager.d.ts +108 -0
  70. package/dist/core/DatabaseManager.js +361 -0
  71. package/dist/core/QueryProcessor.d.ts +77 -0
  72. package/dist/core/QueryProcessor.js +262 -0
  73. package/dist/core/QueryView.d.ts +64 -0
  74. package/dist/core/QueryView.js +219 -0
  75. package/dist/core/StoreManager.d.ts +131 -0
  76. package/dist/core/StoreManager.js +334 -0
  77. package/dist/core/ViewRegistry.d.ts +20 -0
  78. package/dist/core/ViewRegistry.js +55 -0
  79. package/dist/core/index.d.ts +34 -0
  80. package/dist/core/index.js +59 -0
  81. package/dist/core/openIDBWithTimeout.d.ts +27 -0
  82. package/dist/core/openIDBWithTimeout.js +63 -0
  83. package/dist/core/query-utils.d.ts +37 -0
  84. package/dist/core/query-utils.js +60 -0
  85. package/dist/errors.d.ts +235 -0
  86. package/dist/errors.js +243 -0
  87. package/dist/index.d.ts +41 -0
  88. package/dist/index.js +82 -0
  89. package/dist/interfaces/headless.d.ts +95 -0
  90. package/dist/interfaces/headless.js +41 -0
  91. package/dist/interfaces/index.d.ts +321 -0
  92. package/dist/interfaces/index.js +8 -0
  93. package/dist/mutators/RecordingTransaction.d.ts +36 -0
  94. package/dist/mutators/RecordingTransaction.js +216 -0
  95. package/dist/mutators/Transaction.d.ts +48 -0
  96. package/dist/mutators/Transaction.js +64 -0
  97. package/dist/mutators/UndoManager.d.ts +114 -0
  98. package/dist/mutators/UndoManager.js +143 -0
  99. package/dist/mutators/defineMutators.d.ts +55 -0
  100. package/dist/mutators/defineMutators.js +28 -0
  101. package/dist/policy/index.d.ts +19 -0
  102. package/dist/policy/index.js +18 -0
  103. package/dist/policy/types.d.ts +74 -0
  104. package/dist/policy/types.js +17 -0
  105. package/dist/principal.d.ts +44 -0
  106. package/dist/principal.js +49 -0
  107. package/dist/query/client.d.ts +43 -0
  108. package/dist/query/client.js +84 -0
  109. package/dist/query/index.d.ts +6 -0
  110. package/dist/query/index.js +5 -0
  111. package/dist/query/types.d.ts +143 -0
  112. package/dist/query/types.js +36 -0
  113. package/dist/react/AbloProvider.d.ts +205 -0
  114. package/dist/react/AbloProvider.js +398 -0
  115. package/dist/react/ClientSideSuspense.d.ts +36 -0
  116. package/dist/react/ClientSideSuspense.js +17 -0
  117. package/dist/react/DefaultFallback.d.ts +24 -0
  118. package/dist/react/DefaultFallback.js +43 -0
  119. package/dist/react/SyncGroupProvider.d.ts +19 -0
  120. package/dist/react/SyncGroupProvider.js +44 -0
  121. package/dist/react/context.d.ts +161 -0
  122. package/dist/react/context.js +35 -0
  123. package/dist/react/index.d.ts +64 -0
  124. package/dist/react/index.js +73 -0
  125. package/dist/react/internalContext.d.ts +35 -0
  126. package/dist/react/internalContext.js +3 -0
  127. package/dist/react/useAblo.d.ts +72 -0
  128. package/dist/react/useAblo.js +63 -0
  129. package/dist/react/useCurrentUserId.d.ts +21 -0
  130. package/dist/react/useCurrentUserId.js +33 -0
  131. package/dist/react/useErrorListener.d.ts +20 -0
  132. package/dist/react/useErrorListener.js +39 -0
  133. package/dist/react/useIntent.d.ts +29 -0
  134. package/dist/react/useIntent.js +42 -0
  135. package/dist/react/useMutate.d.ts +83 -0
  136. package/dist/react/useMutate.js +122 -0
  137. package/dist/react/useMutationFailureListener.d.ts +26 -0
  138. package/dist/react/useMutationFailureListener.js +38 -0
  139. package/dist/react/useMutators.d.ts +56 -0
  140. package/dist/react/useMutators.js +66 -0
  141. package/dist/react/usePresence.d.ts +32 -0
  142. package/dist/react/usePresence.js +41 -0
  143. package/dist/react/useQuery.d.ts +123 -0
  144. package/dist/react/useQuery.js +145 -0
  145. package/dist/react/useReactive.d.ts +35 -0
  146. package/dist/react/useReactive.js +111 -0
  147. package/dist/react/useReader.d.ts +69 -0
  148. package/dist/react/useReader.js +73 -0
  149. package/dist/react/useSyncStatus.d.ts +61 -0
  150. package/dist/react/useSyncStatus.js +76 -0
  151. package/dist/react/useUndoScope.d.ts +36 -0
  152. package/dist/react/useUndoScope.js +73 -0
  153. package/dist/realtime/index.d.ts +10 -0
  154. package/dist/realtime/index.js +9 -0
  155. package/dist/schema/field.d.ts +134 -0
  156. package/dist/schema/field.js +264 -0
  157. package/dist/schema/index.d.ts +29 -0
  158. package/dist/schema/index.js +38 -0
  159. package/dist/schema/model.d.ts +326 -0
  160. package/dist/schema/model.js +89 -0
  161. package/dist/schema/queries.d.ts +203 -0
  162. package/dist/schema/queries.js +145 -0
  163. package/dist/schema/relation.d.ts +172 -0
  164. package/dist/schema/relation.js +104 -0
  165. package/dist/schema/schema.d.ts +259 -0
  166. package/dist/schema/schema.js +188 -0
  167. package/dist/schema/sugar.d.ts +129 -0
  168. package/dist/schema/sugar.js +94 -0
  169. package/dist/source/index.d.ts +423 -0
  170. package/dist/source/index.js +320 -0
  171. package/dist/source/pushQueue.d.ts +112 -0
  172. package/dist/source/pushQueue.js +249 -0
  173. package/dist/stores/ObjectStore.d.ts +103 -0
  174. package/dist/stores/ObjectStore.js +371 -0
  175. package/dist/stores/ObjectStoreContract.d.ts +39 -0
  176. package/dist/stores/ObjectStoreContract.js +1 -0
  177. package/dist/stores/SyncActionStore.d.ts +101 -0
  178. package/dist/stores/SyncActionStore.js +481 -0
  179. package/dist/sync/BootstrapHelper.d.ts +127 -0
  180. package/dist/sync/BootstrapHelper.js +434 -0
  181. package/dist/sync/ConnectionManager.d.ts +136 -0
  182. package/dist/sync/ConnectionManager.js +465 -0
  183. package/dist/sync/HydrationCoordinator.d.ts +137 -0
  184. package/dist/sync/HydrationCoordinator.js +468 -0
  185. package/dist/sync/NetworkProbe.d.ts +43 -0
  186. package/dist/sync/NetworkProbe.js +113 -0
  187. package/dist/sync/OfflineFlush.d.ts +9 -0
  188. package/dist/sync/OfflineFlush.js +22 -0
  189. package/dist/sync/OfflineTransactionStore.d.ts +37 -0
  190. package/dist/sync/OfflineTransactionStore.js +263 -0
  191. package/dist/sync/SyncWebSocket.d.ts +663 -0
  192. package/dist/sync/SyncWebSocket.js +1336 -0
  193. package/dist/sync/createIntentStream.d.ts +33 -0
  194. package/dist/sync/createIntentStream.js +243 -0
  195. package/dist/sync/createPresenceStream.d.ts +46 -0
  196. package/dist/sync/createPresenceStream.js +192 -0
  197. package/dist/sync/createSnapshot.d.ts +33 -0
  198. package/dist/sync/createSnapshot.js +124 -0
  199. package/dist/sync/participants.d.ts +114 -0
  200. package/dist/sync/participants.js +336 -0
  201. package/dist/sync/schemas.d.ts +79 -0
  202. package/dist/sync/schemas.js +78 -0
  203. package/dist/testing/fixtures/bootstrap.d.ts +45 -0
  204. package/dist/testing/fixtures/bootstrap.js +53 -0
  205. package/dist/testing/fixtures/deltas.d.ts +86 -0
  206. package/dist/testing/fixtures/deltas.js +139 -0
  207. package/dist/testing/fixtures/models.d.ts +82 -0
  208. package/dist/testing/fixtures/models.js +270 -0
  209. package/dist/testing/helpers/react-wrapper.d.ts +66 -0
  210. package/dist/testing/helpers/react-wrapper.js +64 -0
  211. package/dist/testing/helpers/sync-engine-harness.d.ts +55 -0
  212. package/dist/testing/helpers/sync-engine-harness.js +70 -0
  213. package/dist/testing/helpers/wait.d.ts +25 -0
  214. package/dist/testing/helpers/wait.js +44 -0
  215. package/dist/testing/index.d.ts +21 -0
  216. package/dist/testing/index.js +32 -0
  217. package/dist/testing/mocks/MockMutationExecutor.d.ts +65 -0
  218. package/dist/testing/mocks/MockMutationExecutor.js +139 -0
  219. package/dist/testing/mocks/MockNetworkMonitor.d.ts +20 -0
  220. package/dist/testing/mocks/MockNetworkMonitor.js +46 -0
  221. package/dist/testing/mocks/MockSyncContext.d.ts +64 -0
  222. package/dist/testing/mocks/MockSyncContext.js +100 -0
  223. package/dist/testing/mocks/MockSyncStore.d.ts +88 -0
  224. package/dist/testing/mocks/MockSyncStore.js +171 -0
  225. package/dist/testing/mocks/MockWebSocket.d.ts +66 -0
  226. package/dist/testing/mocks/MockWebSocket.js +117 -0
  227. package/dist/transactions/OptimisticEchoTracker.d.ts +82 -0
  228. package/dist/transactions/OptimisticEchoTracker.js +104 -0
  229. package/dist/transactions/TransactionQueue.d.ts +499 -0
  230. package/dist/transactions/TransactionQueue.js +1895 -0
  231. package/dist/transactions/index.d.ts +16 -0
  232. package/dist/transactions/index.js +7 -0
  233. package/dist/transactions/mutation-error-handler.d.ts +5 -0
  234. package/dist/transactions/mutation-error-handler.js +39 -0
  235. package/dist/types/global.d.ts +107 -0
  236. package/dist/types/global.js +38 -0
  237. package/dist/types/index.d.ts +241 -0
  238. package/dist/types/index.js +70 -0
  239. package/dist/types/streams.d.ts +495 -0
  240. package/dist/types/streams.js +11 -0
  241. package/dist/utils/asyncIterator.d.ts +41 -0
  242. package/dist/utils/asyncIterator.js +142 -0
  243. package/dist/utils/duration.d.ts +28 -0
  244. package/dist/utils/duration.js +47 -0
  245. package/dist/utils/mobx-setup.d.ts +42 -0
  246. package/dist/utils/mobx-setup.js +381 -0
  247. package/docs/api-keys.md +24 -0
  248. package/docs/api.md +230 -0
  249. package/docs/audit.md +81 -0
  250. package/docs/capabilities.md +163 -0
  251. package/docs/client-behavior.md +202 -0
  252. package/docs/data-sources.md +214 -0
  253. package/docs/examples/agent-human.md +84 -0
  254. package/docs/examples/ai-sdk-tool.md +92 -0
  255. package/docs/examples/existing-python-backend.md +249 -0
  256. package/docs/examples/nextjs.md +88 -0
  257. package/docs/examples/server-agent.md +86 -0
  258. package/docs/guarantees.md +148 -0
  259. package/docs/index.md +97 -0
  260. package/docs/integration-guide.md +493 -0
  261. package/docs/interaction-model.md +140 -0
  262. package/docs/mcp/claude-code.md +43 -0
  263. package/docs/mcp/cursor.md +53 -0
  264. package/docs/mcp/windsurf.md +46 -0
  265. package/docs/mcp.md +59 -0
  266. package/docs/quickstart.md +152 -0
  267. package/docs/react.md +115 -0
  268. package/docs/roadmap.md +45 -0
  269. package/examples/README.md +54 -0
  270. package/examples/data-source/README.md +102 -0
  271. package/examples/data-source/ablo-driver.ts +89 -0
  272. package/examples/data-source/customer-server.ts +208 -0
  273. package/examples/data-source/run.ts +101 -0
  274. package/examples/data-source/schema.ts +25 -0
  275. package/examples/quickstart.ts +54 -0
  276. package/examples/tsconfig.json +16 -0
  277. package/llms.txt +143 -0
  278. package/package.json +147 -0
@@ -0,0 +1,1259 @@
1
+ /**
2
+ * Database - Simplified persistence layer
3
+ * Fixed bootstrap triggering and data flow
4
+ */
5
+ import { DatabaseManager } from './core/DatabaseManager.js';
6
+ import { StoreManager } from './core/StoreManager.js';
7
+ import { LoadStrategy } from './types/index.js';
8
+ import { getContext } from './context.js';
9
+ import { AbloConnectionError, AbloValidationError } from './errors.js';
10
+ import { InMemoryObjectStore } from './adapters/inMemoryStorage.js';
11
+ export class Database {
12
+ // Core database components
13
+ databaseManager;
14
+ storeManager;
15
+ // Injected dependencies
16
+ modelRegistry;
17
+ bootstrapHelper;
18
+ /** The pre-configured query helper for lazy-loading data from the sync server. */
19
+ get helper() {
20
+ return this.bootstrapHelper;
21
+ }
22
+ // Database state
23
+ currentDbInfo = null;
24
+ workspaceDb = null;
25
+ /**
26
+ * Flag to track if database is closing/closed.
27
+ * Used for graceful degradation when operations are attempted during shutdown.
28
+ */
29
+ isClosing = false;
30
+ /**
31
+ * When set, forces the next requiredBootstrap() call to return 'full' even if offline.
32
+ * Used when a sync group change delta is received — we must re-bootstrap to purge
33
+ * revoked data, even if the device is currently offline (it will bootstrap when online).
34
+ */
35
+ _forceFullBootstrap = false;
36
+ /** Essential fields that must be preserved during partial UPDATE merges.
37
+ * Sourced from SyncEngineConfig.essentialFields — consumers define their own. */
38
+ get essentialFields() {
39
+ return getContext().config.essentialFields;
40
+ }
41
+ /**
42
+ * When true, all IndexedDB operations are replaced with in-memory Maps.
43
+ * Enables the SDK to run headlessly in Node.js / agent workers / tests
44
+ * without requiring a browser environment.
45
+ *
46
+ * Set via createSyncEngine({ storage: inMemoryStorage() }) or directly:
47
+ * new Database(registry, bootstrap, { inMemory: true })
48
+ */
49
+ inMemory;
50
+ /** In-memory stores used when inMemory=true. Keyed by model name. */
51
+ inMemoryStores = new Map();
52
+ /** In-memory workspace metadata when inMemory=true. */
53
+ inMemoryMetadata = null;
54
+ constructor(modelRegistry, bootstrapHelper, options) {
55
+ this.databaseManager = new DatabaseManager();
56
+ this.storeManager = new StoreManager(modelRegistry);
57
+ this.modelRegistry = modelRegistry;
58
+ this.bootstrapHelper = bootstrapHelper;
59
+ this.inMemory = options?.inMemory ?? false;
60
+ }
61
+ /**
62
+ * Get store for a model, or `undefined` if no store exists.
63
+ *
64
+ * Routes to `inMemoryStores` in inMemory mode and `storeManager`
65
+ * otherwise. Both implementations satisfy `ObjectStoreContract`, so
66
+ * callers don't branch on which one they got back.
67
+ *
68
+ * Pass `context` to emit an observability breadcrumb when the store
69
+ * is missing — useful for hot paths (bootstrap, delta apply, hydrate)
70
+ * where a missing store points to silent data loss. Callers that
71
+ * already expect optional behavior (e.g. lazy lookups) can omit it.
72
+ */
73
+ getStore(modelName, context) {
74
+ const store = this.inMemory
75
+ ? this.inMemoryStores.get(modelName)
76
+ : this.storeManager.getStore(modelName);
77
+ if (!store && context) {
78
+ getContext().observability.breadcrumb(`Store not found for model: ${modelName}`, 'sync.database', 'warning', { context });
79
+ }
80
+ return store;
81
+ }
82
+ /** Get store or throw if not found (for operations that require the store). */
83
+ getRequiredStore(modelName) {
84
+ const store = this.getStore(modelName);
85
+ if (!store) {
86
+ throw new AbloValidationError(`Store not found: ${modelName}`, {
87
+ code: 'db_store_not_found',
88
+ });
89
+ }
90
+ return store; // TypeScript narrows to non-undefined after the throw
91
+ }
92
+ /** Log preserved fields during partial UPDATE merge (debug helper) */
93
+ logPreservedFields(modelName, modelId, existing, delta) {
94
+ if (modelName === 'Activity')
95
+ return;
96
+ const requiredFields = this.essentialFields[modelName] || [];
97
+ const preserved = requiredFields.filter((field) => existing[field] !== undefined && delta[field] === undefined);
98
+ if (preserved.length > 0) {
99
+ getContext().logger.debug('[Database] UPDATE merged - preserved fields', {
100
+ modelName,
101
+ modelId: modelId.slice(0, 12),
102
+ deltaFields: Object.keys(delta),
103
+ preservedFields: preserved,
104
+ });
105
+ }
106
+ }
107
+ async open(userId, organizationId, version = 1) {
108
+ // Reset closing flag when opening (in case of reopen)
109
+ this.isClosing = false;
110
+ if (this.workspaceDb && this.currentDbInfo) {
111
+ return;
112
+ }
113
+ // ── In-memory mode: skip IndexedDB entirely ──────────────────
114
+ // Creates InMemoryObjectStore instances for all registered models.
115
+ // Bootstrap via HTTP still works; only local persistence is skipped.
116
+ if (this.inMemory) {
117
+ getContext().logger.debug('Opening in-memory database (headless mode)');
118
+ const allModels = this.modelRegistry.getRegisteredModelNames();
119
+ for (const modelName of allModels) {
120
+ const storeName = `store_${modelName.toLowerCase()}`;
121
+ this.inMemoryStores.set(modelName, new InMemoryObjectStore(modelName, storeName));
122
+ }
123
+ // Create a __transactions store for the offline queue
124
+ this.inMemoryStores.set('__transactions', new InMemoryObjectStore('__transactions', '__transactions'));
125
+ getContext().logger.info(`In-memory database opened: ${this.inMemoryStores.size} stores`);
126
+ return;
127
+ }
128
+ // ── Browser mode: IndexedDB (existing behavior, unchanged) ───
129
+ getContext().logger.debug('Opening IndexedDB database');
130
+ // Initialize meta database
131
+ await this.databaseManager.initializeMetaDatabase();
132
+ // Calculate database info
133
+ this.currentDbInfo = await this.databaseManager.calculateDatabaseInfo(userId, organizationId, version);
134
+ // Register database
135
+ await this.databaseManager.registerDatabase(this.currentDbInfo);
136
+ // Open workspace database
137
+ this.workspaceDb = await this.databaseManager.openWorkspaceDatabase(this.currentDbInfo, async (db, tx) => {
138
+ await this.storeManager.createStores(db, tx);
139
+ });
140
+ // Initialize stores
141
+ await this.storeManager.initializeStores(this.workspaceDb);
142
+ const readiness = await this.storeManager.checkReadinessOfStores();
143
+ getContext().logger.info(`Database opened: ${this.currentDbInfo.name} (${readiness.readyStores.length}/${readiness.totalStores} stores ready)`);
144
+ }
145
+ /**
146
+ * Compact a record before persisting to IndexedDB
147
+ * - Removes null/undefined fields
148
+ * - Removes empty arrays and empty objects
149
+ * - Drops redundant fields: __typename, __class, clientId, syncStatus
150
+ *
151
+ * ARCHITECTURE: By design, this method receives plain objects, not MobX observables:
152
+ * - WebSocket deltas: Already JSON-parsed (SyncedStore.ts:889)
153
+ * - Optimistic updates: Models call toJSON() which uses toJS() (SlideLayer.ts:224)
154
+ * - Bootstrap data: Plain JSON from server
155
+ *
156
+ * Note: We do NOT drop required defaults; server provides them.
157
+ */
158
+ compactRecord(_modelName, data) {
159
+ if (!data || typeof data !== 'object')
160
+ return data;
161
+ const out = {};
162
+ for (const [key, value] of Object.entries(data)) {
163
+ // Drop redundant or ephemeral markers
164
+ if (key === '__typename' || key === '__class' || key === 'clientId' || key === 'syncStatus') {
165
+ continue;
166
+ }
167
+ // FIXED: Only skip undefined, preserve explicit null values
168
+ // Null is semantically meaningful in Prisma schemas (nullable fields)
169
+ if (value === undefined) {
170
+ continue;
171
+ }
172
+ if (Array.isArray(value)) {
173
+ if (value.length === 0)
174
+ continue;
175
+ out[key] = value;
176
+ continue;
177
+ }
178
+ if (typeof value === 'object') {
179
+ // Preserve explicit null values
180
+ if (value === null) {
181
+ out[key] = null;
182
+ continue;
183
+ }
184
+ // Preserve Date objects (IndexedDB can clone these)
185
+ if (value instanceof Date) {
186
+ out[key] = value;
187
+ continue;
188
+ }
189
+ // For plain objects, drop if empty
190
+ if (Object.keys(value).length === 0)
191
+ continue;
192
+ out[key] = value;
193
+ continue;
194
+ }
195
+ out[key] = value;
196
+ }
197
+ // Always ensure id is present
198
+ if (!out.id && data.id)
199
+ out.id = data.id;
200
+ return out;
201
+ }
202
+ /**
203
+ * Mark that the next bootstrap must be a full bootstrap.
204
+ * Called when a sync group change ("G" delta) is received — the client must
205
+ * re-fetch all data from the server to purge models from revoked sync groups.
206
+ */
207
+ markRequiresFullBootstrap() {
208
+ this._forceFullBootstrap = true;
209
+ getContext().logger.info('[Database] Marked for forced full bootstrap (sync group change)');
210
+ }
211
+ /**
212
+ * Smart bootstrap requirements based on data freshness
213
+ */
214
+ async requiredBootstrap() {
215
+ // In-memory mode (server-side agents, headless workers): there's
216
+ // no `workspaceDb` by design — `open()` returns early after
217
+ // initializing `inMemoryStores`. Persistent data never exists
218
+ // across sessions, so the right answer is always a full bootstrap
219
+ // from the server. Mirrors the `inMemory` short-circuit in
220
+ // `setModelPersisted` / `isModelPersisted` / `getMetadata`.
221
+ if (this.inMemory) {
222
+ const instantModels = this.modelRegistry.getModelsByLoadStrategy(LoadStrategy.instant);
223
+ const lazyModels = this.modelRegistry.getModelsByLoadStrategy(LoadStrategy.lazy);
224
+ return {
225
+ type: 'full',
226
+ modelsToLoad: [...instantModels, ...lazyModels],
227
+ lastSyncId: 0,
228
+ syncGroups: [],
229
+ };
230
+ }
231
+ if (!this.workspaceDb) {
232
+ throw new AbloConnectionError('Database not opened', {
233
+ code: 'db_not_opened',
234
+ });
235
+ }
236
+ // Sync group change requires full re-bootstrap to purge revoked data
237
+ if (this._forceFullBootstrap) {
238
+ this._forceFullBootstrap = false;
239
+ const instantModels = this.modelRegistry.getModelsByLoadStrategy(LoadStrategy.instant);
240
+ const lazyModels = this.modelRegistry.getModelsByLoadStrategy(LoadStrategy.lazy);
241
+ getContext().logger.info('[Database.requiredBootstrap] Forced FULL bootstrap (sync group change)');
242
+ return {
243
+ type: 'full',
244
+ modelsToLoad: [...instantModels, ...lazyModels],
245
+ lastSyncId: 0,
246
+ syncGroups: [],
247
+ };
248
+ }
249
+ const readiness = await this.storeManager.checkReadinessOfStores();
250
+ const metadata = await this.databaseManager.getWorkspaceMetadata(this.workspaceDb);
251
+ // Get models from registry
252
+ const instantModels = this.modelRegistry.getModelsByLoadStrategy(LoadStrategy.instant);
253
+ const lazyModels = this.modelRegistry.getModelsByLoadStrategy(LoadStrategy.lazy);
254
+ const modelsToLoad = [...instantModels, ...lazyModels];
255
+ const metadataLastSyncId = metadata?.lastSyncId || 0;
256
+ const dataAge = metadata?.updatedAt ? Date.now() - metadata.updatedAt.getTime() : Infinity;
257
+ // ── Zero-style cache-validity check ──────────────────────────
258
+ //
259
+ // The cursor (lastSyncId) is only valid if the data it refers to
260
+ // actually exists in the stores. If IDB was cleared (or this is a
261
+ // fresh in-memory session), the metadata's lastSyncId is stale —
262
+ // sending it to the server would trigger a partial bootstrap that
263
+ // returns zero deltas because the gap is 0, leaving the client
264
+ // with an empty ObjectPool.
265
+ //
266
+ // Zero solves this by co-locating the cursor with the cached data:
267
+ // if the data is gone, the cursor is gone. We achieve the same
268
+ // property by sampling the actual stores — if they're empty, the
269
+ // cursor is meaningless regardless of what metadata claims.
270
+ const dataExists = this.inMemory
271
+ ? false // In-memory mode: no persistent data across sessions
272
+ : await this.storeManager.hasAnyData();
273
+ // The effective lastSyncId: only trust the metadata cursor when
274
+ // we've confirmed the data it refers to actually exists in the stores.
275
+ const lastSyncId = dataExists ? metadataLastSyncId : 0;
276
+ // 🔍 DIAGNOSTIC: Log database state
277
+ getContext().logger.debug('[Database.requiredBootstrap] State check', {
278
+ readinessReady: readiness.ready,
279
+ hasMetadata: !!metadata,
280
+ metadataLastSyncId,
281
+ effectiveLastSyncId: lastSyncId,
282
+ dataExists,
283
+ dataAge: metadata?.updatedAt ? Math.round(dataAge / 1000) + 's' : 'N/A',
284
+ navigatorOnline: typeof navigator !== 'undefined' ? navigator.onLine : 'N/A',
285
+ });
286
+ // Determine bootstrap type based on connectivity and data state
287
+ const offline = typeof navigator !== 'undefined' && navigator && navigator.onLine === false;
288
+ let type;
289
+ // hasLocalData: stores actually have records AND we have a valid cursor
290
+ const hasLocalData = readiness.ready && dataExists && lastSyncId > 0;
291
+ if (offline && hasLocalData) {
292
+ // Offline with data - use local bootstrap (only option when offline)
293
+ type = 'local';
294
+ getContext().logger.info('Offline detected with local data - using local bootstrap');
295
+ }
296
+ else {
297
+ // SERVER-AUTHORITATIVE: Always use full bootstrap when online.
298
+ type = 'full';
299
+ getContext().logger.info('Full bootstrap - server is source of truth', {
300
+ reason: offline ? 'offline_no_data' : 'server_authoritative',
301
+ hasLocalData,
302
+ lastSyncId,
303
+ dataExists,
304
+ });
305
+ }
306
+ return {
307
+ type,
308
+ modelsToLoad,
309
+ lastSyncId,
310
+ syncGroups: metadata?.syncGroups || [],
311
+ };
312
+ }
313
+ /**
314
+ * Bootstrap database with data from Go server
315
+ */
316
+ async bootstrapFromServer(requirements,
317
+ /** Full sync-group subscription list — what the WS subscribes to
318
+ * AND what gets persisted as `subscribedSyncGroups` for the
319
+ * shrinkage check. Caller supplies the complete list, not just
320
+ * team-derived groups. */
321
+ syncGroups, onProgress) {
322
+ getContext().logger.debug('Starting bootstrap fetch', {
323
+ type: requirements.type,
324
+ lastSyncId: requirements.lastSyncId,
325
+ modelsToLoad: requirements.modelsToLoad,
326
+ });
327
+ getContext().logger.info('Database: Starting bootstrap from Go server', {
328
+ type: requirements.type,
329
+ syncGroups,
330
+ modelsToLoad: requirements.modelsToLoad,
331
+ });
332
+ try {
333
+ // ✅ FETCH FIRST (before any destructive operations)
334
+ // This prevents data loss if the network request fails
335
+ const startTime = typeof performance !== 'undefined' ? performance.now() : Date.now();
336
+ getContext().logger.info('Fetching bootstrap data from server (before clearing local data)', {
337
+ type: requirements.type,
338
+ lastSyncId: requirements.lastSyncId,
339
+ });
340
+ const bootstrapData = await this.bootstrapHelper.fetchBootstrap(requirements.lastSyncId);
341
+ getContext().logger.debug('Received bootstrap response', {
342
+ type: bootstrapData.type,
343
+ lastSyncId: bootstrapData.lastSyncId,
344
+ hasModels: !!bootstrapData.models,
345
+ hasDeltas: !!bootstrapData.deltas,
346
+ deltaCount: bootstrapData.deltaCount || 0,
347
+ });
348
+ // ✅ Only clear AFTER successful fetch (transactional safety)
349
+ // IMPORTANT: Clear if the SERVER says it's a full snapshot, regardless of what we asked.
350
+ if (bootstrapData.type === 'full') {
351
+ await this.clear();
352
+ }
353
+ // Handle partial bootstrap (delta batch)
354
+ if (bootstrapData.type === 'partial') {
355
+ const deltas = bootstrapData.deltas || [];
356
+ getContext().logger.info('Processing partial bootstrap with delta batch', {
357
+ deltaCount: deltas.length,
358
+ fromSyncId: requirements.lastSyncId,
359
+ toSyncId: bootstrapData.lastSyncId,
360
+ });
361
+ // Apply deltas to IndexedDB using processDeltaBatch for better performance.
362
+ // Capture the return value so the pool can be updated by the caller —
363
+ // without this, partial-bootstrap DELETEs persist to IDB but don't
364
+ // evict entities from the in-memory ObjectPool, leaving ghost rows
365
+ // visible on the canvas until a full reload rebuilds the pool.
366
+ let deltasApplied = 0;
367
+ let deltaResults;
368
+ if (deltas.length > 0) {
369
+ // Convert server delta format to processDelta format
370
+ const formattedDeltas = deltas.map((delta) => ({
371
+ syncId: delta.id,
372
+ actionType: delta.operation,
373
+ modelName: delta.modelName,
374
+ modelId: delta.entityId,
375
+ data: delta.data,
376
+ }));
377
+ // Use batch processing for better performance
378
+ const batch = await this.processDeltaBatch(formattedDeltas);
379
+ deltaResults = batch.results;
380
+ deltasApplied = formattedDeltas.length;
381
+ onProgress?.(deltasApplied);
382
+ }
383
+ // Update workspace metadata with new lastSyncId (critical even when 0 deltas)
384
+ await this.updateWorkspaceMetadata({
385
+ lastSyncId: bootstrapData.lastSyncId,
386
+ schemaHash: this.modelRegistry.getSchemaHash(),
387
+ syncGroups: [...syncGroups],
388
+ updatedAt: new Date(),
389
+ });
390
+ const elapsed = (typeof performance !== 'undefined' ? performance.now() : Date.now()) - startTime;
391
+ getContext().logger.info(`Partial bootstrap complete in ${elapsed.toFixed(2)}ms`, {
392
+ deltasApplied,
393
+ lastSyncId: bootstrapData.lastSyncId,
394
+ });
395
+ return { modelsLoaded: 0, modelsStored: deltasApplied, bootstrapData, deltaResults };
396
+ }
397
+ // Full bootstrap: Process model data
398
+ if (!bootstrapData.models) {
399
+ throw new AbloValidationError('Full bootstrap response missing models data', {
400
+ code: 'bootstrap_response_invalid',
401
+ });
402
+ }
403
+ let modelsLoaded = 0;
404
+ let modelsStored = 0;
405
+ for (const [modelName, modelData] of Object.entries(bootstrapData.models)) {
406
+ // Handle null, undefined, or non-array data
407
+ if (!modelData) {
408
+ getContext().observability.breadcrumb(`No data received for ${modelName}`, 'sync.bootstrap', 'warning');
409
+ continue;
410
+ }
411
+ if (!Array.isArray(modelData)) {
412
+ getContext().observability.breadcrumb(`Skipping non-array data for ${modelName}`, 'sync.bootstrap', 'warning');
413
+ continue;
414
+ }
415
+ // Skip empty arrays silently (expected for some models)
416
+ if (modelData.length === 0) {
417
+ getContext().logger.debug(`No ${modelName} items to store (empty array)`);
418
+ continue;
419
+ }
420
+ const store = this.getStore(modelName, 'bootstrap');
421
+ if (!store) {
422
+ getContext().logger.warn(`[Bootstrap] NO IDB STORE for ${modelName} — ${modelData.length} items DROPPED`);
423
+ continue;
424
+ }
425
+ let writeErrors = 0;
426
+ // Store all items to IndexedDB (compacted)
427
+ for (const item of modelData) {
428
+ try {
429
+ const compacted = this.compactRecord(modelName, item);
430
+ await store.put(compacted);
431
+ modelsStored++;
432
+ modelsLoaded++;
433
+ // Report progress every 10 items
434
+ if (modelsLoaded % 10 === 0) {
435
+ onProgress?.(modelsLoaded);
436
+ }
437
+ }
438
+ catch (error) {
439
+ writeErrors++;
440
+ getContext().observability.breadcrumb(`Failed to store ${modelName} item`, 'sync.database', 'error', {
441
+ error: error instanceof Error ? error.message : String(error),
442
+ });
443
+ }
444
+ }
445
+ // Mark model as persisted after successful write
446
+ try {
447
+ await this.setModelPersisted(modelName, true);
448
+ }
449
+ catch { }
450
+ }
451
+ // Update workspace metadata with bootstrap snapshot's lastSyncId
452
+ // Note: This method is only called for 'full' bootstrap (not 'local')
453
+ // For 'partial' bootstrap (future): would need intelligent merge logic here
454
+ await this.updateWorkspaceMetadata({
455
+ lastSyncId: bootstrapData.lastSyncId,
456
+ schemaHash: this.modelRegistry.getSchemaHash(),
457
+ syncGroups: [...syncGroups],
458
+ updatedAt: new Date(),
459
+ });
460
+ const elapsed = (typeof performance !== 'undefined' ? performance.now() : Date.now()) - startTime;
461
+ getContext().logger.info(`Bootstrap complete: ${modelsLoaded} items loaded, ${modelsStored} stored to IndexedDB in ${elapsed.toFixed(2)}ms`);
462
+ getContext().analytics?.capture('bootstrap_success', {
463
+ responseTime: elapsed,
464
+ modelsLoaded,
465
+ });
466
+ return { modelsLoaded, modelsStored, bootstrapData };
467
+ }
468
+ catch (error) {
469
+ // Comprehensive error logging for bootstrap failures
470
+ getContext().observability.captureBootstrapFailure(error, {
471
+ type: requirements.type,
472
+ navigatorOnline: typeof navigator !== 'undefined' ? navigator.onLine : undefined,
473
+ });
474
+ // Track bootstrap failure telemetry
475
+ getContext().analytics?.capture('bootstrap_failed', {
476
+ bootstrapType: requirements.type,
477
+ lastSyncId: requirements.lastSyncId,
478
+ errorMessage: error instanceof Error ? error.message : String(error),
479
+ errorName: error instanceof Error ? error.name : 'UnknownError',
480
+ });
481
+ throw error;
482
+ }
483
+ }
484
+ // bootstrapSpecificModels removed per request
485
+ /**
486
+ * Process incoming delta from WebSocket - simplified
487
+ *
488
+ * ⚠️ PERFORMANCE NOTE: This method is called for each individual delta.
489
+ * For batch processing, use processDeltaBatch() instead to avoid
490
+ * transaction overhead (2x transactions per delta = major bottleneck).
491
+ *
492
+ * 📝 PARTIAL DELTA PATTERN:
493
+ * - Server sends only changed fields: {id, position: {...}, updatedAt}
494
+ * - UPDATE deltas are MERGED with existing records: {...existing, ...delta}
495
+ * - This preserves fields not included in the delta (e.g., deckId, title)
496
+ * - Explicit null values ARE preserved: {position: null} clears the field
497
+ */
498
+ async processDelta(delta) {
499
+ const { actionType, modelName, modelId, data, syncId } = delta;
500
+ const store = this.getStore(modelName, 'processDelta');
501
+ if (!store) {
502
+ return { action: 'verify', modelName, modelId };
503
+ }
504
+ // Best-practice gating: ignore already-applied deltas by comparing with persisted lastSyncId
505
+ try {
506
+ const lastApplied = await this.getLastSyncId();
507
+ const incomingId = typeof syncId === 'number' ? syncId : undefined;
508
+ if (typeof incomingId === 'number' && incomingId <= lastApplied) {
509
+ return { action: 'verify', modelName, modelId };
510
+ }
511
+ }
512
+ catch { }
513
+ // Compact data before persistence; do not store redundant type markers.
514
+ // Inject `id` from the envelope — server deltas frequently strip it
515
+ // from the `data` payload, but IDB object stores use keyPath='id'
516
+ // and require it on the record itself. See `processDeltaBatch` for
517
+ // the same rationale on the batch path.
518
+ const dataWithId = data && typeof data === 'object'
519
+ ? { id: modelId, ...data }
520
+ : data;
521
+ const compacted = dataWithId && typeof dataWithId === 'object'
522
+ ? this.compactRecord(modelName, dataWithId)
523
+ : dataWithId;
524
+ switch (actionType) {
525
+ // 'C' (Covering) — client gained permission to see an existing entity.
526
+ // End state in the local store is identical to an insert: the row is
527
+ // present. The semantic difference is purely observability — it wasn't
528
+ // newly created, it was newly visible. We fall through to the 'I' case
529
+ // after a debug trace so the two can be disambiguated in logs.
530
+ case 'C':
531
+ getContext().observability.breadcrumb('Applying covering delta (gained permission)', 'sync.database', 'info', { modelName, modelId: modelId.slice(0, 12) });
532
+ // falls through
533
+ case 'I': {
534
+ // Skip when the delta payload was empty/null. IDB rejects
535
+ // non-record `put` arguments at runtime; the previous `any`
536
+ // typing on `ObjectStore.put` was silently letting that
537
+ // through. Real I-deltas always carry a row body.
538
+ if (!compacted || typeof compacted !== 'object') {
539
+ return { action: 'add', modelName, modelId, data: null };
540
+ }
541
+ // Insert synchronously for durable ack-after-apply semantics
542
+ try {
543
+ await store.put(compacted);
544
+ if (typeof syncId === 'number') {
545
+ await this.updateWorkspaceMetadata({ lastSyncId: syncId });
546
+ }
547
+ }
548
+ catch (err) {
549
+ getContext().observability.breadcrumb(`IndexedDB put failed for ${modelName}:${modelId}`, 'sync.database', 'error', {
550
+ error: err instanceof Error ? err.message : String(err),
551
+ });
552
+ throw err; // Re-throw to see the actual error
553
+ }
554
+ return { action: 'add', modelName, modelId, data: compacted };
555
+ }
556
+ case 'U': {
557
+ // ✅ UPDATE: MUST merge with existing record (partial delta pattern)
558
+ // Read existing record first
559
+ const existing = await store.get(modelId);
560
+ // CRITICAL FIX: Skip UPDATE if there's no existing record to merge with
561
+ // Creating a record from partial UPDATE data causes corruption (missing deckId, etc.)
562
+ if (!existing) {
563
+ getContext().observability.breadcrumb('Skipping UPDATE delta - no existing record to merge with', 'sync.database', 'warning', {
564
+ modelName,
565
+ modelId: modelId.slice(0, 12),
566
+ });
567
+ // Return verify action to signal no changes were made
568
+ return { action: 'verify', modelName, modelId, data: null };
569
+ }
570
+ // Shallow merge: delta overrides existing fields (safe - existing is guaranteed)
571
+ const merged = { ...existing, ...compacted };
572
+ // Log preserved fields for debugging partial updates
573
+ if (existing && compacted) {
574
+ this.logPreservedFields(modelName, modelId, existing, compacted);
575
+ }
576
+ // Persist merged record
577
+ try {
578
+ await store.put(merged);
579
+ if (typeof syncId === 'number') {
580
+ await this.updateWorkspaceMetadata({ lastSyncId: syncId });
581
+ }
582
+ }
583
+ catch (err) {
584
+ getContext().observability.breadcrumb(`IndexedDB put failed for ${modelName}:${modelId}`, 'sync.database', 'error', {
585
+ error: err instanceof Error ? err.message : String(err),
586
+ });
587
+ throw err;
588
+ }
589
+ // Return merged data (not just delta) to preserve essential fields like organizationId
590
+ return { action: 'update', modelName, modelId, data: merged };
591
+ }
592
+ case 'D': {
593
+ // Delete synchronously
594
+ try {
595
+ await store.delete(modelId);
596
+ if (typeof syncId === 'number') {
597
+ await this.updateWorkspaceMetadata({ lastSyncId: syncId });
598
+ }
599
+ }
600
+ catch (err) {
601
+ getContext().observability.breadcrumb(`IndexedDB delete failed for ${modelName}:${modelId}`, 'sync.database', 'error', {
602
+ error: err instanceof Error ? err.message : String(err),
603
+ });
604
+ // Surface failure so caller does not mutate ObjectPool inconsistently
605
+ throw err;
606
+ }
607
+ return { action: 'remove', modelName, modelId };
608
+ }
609
+ case 'A': {
610
+ // Archive
611
+ const archivedData = this.compactRecord(modelName, { ...data, archivedAt: new Date() });
612
+ try {
613
+ await store.put(archivedData);
614
+ if (typeof syncId === 'number') {
615
+ await this.updateWorkspaceMetadata({ lastSyncId: syncId });
616
+ }
617
+ }
618
+ catch (err) {
619
+ getContext().observability.breadcrumb(`IndexedDB archive put failed for ${modelName}:${modelId}`, 'sync.database', 'error', {
620
+ error: err instanceof Error ? err.message : String(err),
621
+ });
622
+ throw err;
623
+ }
624
+ return { action: 'archive', modelName, modelId, data: archivedData };
625
+ }
626
+ case 'V': // Verify
627
+ return { action: 'verify', modelName, modelId, data };
628
+ // 'G' (GroupAdded) and 'S' (GroupRemoved) are sync-group membership
629
+ // signals, not entity mutations. They are routed upstream in
630
+ // BaseSyncedStore.processDeltaWithBatching and should never reach
631
+ // processDelta. If one slips through (e.g. replayed from the bootstrap
632
+ // queue), we return a no-op verify rather than crashing the engine.
633
+ case 'G':
634
+ case 'S':
635
+ getContext().observability.breadcrumb(`Group membership delta (${actionType}) reached processDelta — should be handled upstream`, 'sync.database', 'warning', { modelName, modelId: modelId.slice(0, 12), actionType });
636
+ return { action: 'verify', modelName, modelId, data: null };
637
+ default:
638
+ throw new AbloValidationError(`Unknown action type: ${actionType}`, {
639
+ code: 'db_unknown_action_type',
640
+ });
641
+ }
642
+ }
643
+ /**
644
+ * ✅ PERFORMANCE FIX: Process multiple deltas in a single IndexedDB transaction
645
+ *
646
+ * This method dramatically improves sync performance by:
647
+ * 1. Batch-reading all existing records for UPDATEs (outside transaction for speed)
648
+ * 2. Opening a single transaction per store for all writes
649
+ * 3. Merging UPDATE deltas with existing data to preserve unmodified fields
650
+ * 4. Updating metadata only once at the end with highest syncId
651
+ *
652
+ * Performance impact: 186 deltas goes from ~372 transactions to just 1 transaction
653
+ *
654
+ * 📝 PARTIAL DELTA MERGE PATTERN:
655
+ * - UPDATE deltas contain only changed fields
656
+ * - We merge with existing: {...existing, ...delta}
657
+ * - Preserves deckId, title, settings etc. when updating just position
658
+ * - Handles explicit null: {field: null} clears the field correctly
659
+ *
660
+ * 🔄 LINEAR-STYLE CONFLICT RESOLUTION:
661
+ * - Builds a map of DELETE deltas with their syncIds
662
+ * - Before processing UPDATE/INSERT, checks for DELETE with higher syncId
663
+ * - Skips stale updates for entities that will be/were deleted
664
+ * - Prevents 404 errors from fetching already-deleted entities
665
+ */
666
+ async processDeltaBatch(deltas) {
667
+ if ((!this.workspaceDb && !this.inMemory) || this.isClosing || deltas.length === 0) {
668
+ return { results: [], persistedSyncId: 0 };
669
+ }
670
+ // ── inMemory short-circuit ───────────────────────────────────────
671
+ //
672
+ // The batched IDB transaction path below assumes `this.storeManager`
673
+ // and `workspaceDb`. In inMemory mode (agent-worker, tests) those
674
+ // don't exist. Without this branch, every live delta arriving over
675
+ // the WebSocket is silently dropped — the local pool never updates,
676
+ // `subscribe()` autoruns never re-fire, lazy-model dispatchers
677
+ // never claim incoming work.
678
+ //
679
+ // Fall through to the single-delta path (`processDelta`), which
680
+ // uses `getStore` and is inMemory-compatible. Same return
681
+ // shape, sequential apply per delta — fine since inMemory mode
682
+ // doesn't need IDB transaction batching for performance.
683
+ if (this.inMemory) {
684
+ const inMemResults = [];
685
+ let inMemPersistedSyncId = 0;
686
+ for (const delta of deltas) {
687
+ const single = await this.processDelta({
688
+ syncId: delta.syncId,
689
+ actionType: delta.actionType,
690
+ modelName: delta.modelName,
691
+ modelId: delta.modelId,
692
+ data: delta.data,
693
+ });
694
+ inMemResults.push({ ...single, transactionId: delta.transactionId });
695
+ // inMemory has no IDB tx that can fail — every non-'verify'
696
+ // single result is durable in the in-memory store. Advance the
697
+ // persisted-cursor watermark to the input delta's syncId so the
698
+ // ack path can move forward.
699
+ if (single.action !== 'verify' && typeof delta.syncId === 'number' && delta.syncId > inMemPersistedSyncId) {
700
+ inMemPersistedSyncId = delta.syncId;
701
+ }
702
+ }
703
+ return { results: inMemResults, persistedSyncId: inMemPersistedSyncId };
704
+ }
705
+ // Prepare results aligned with input order
706
+ const results = new Array(deltas.length);
707
+ // ========================================================================
708
+ // LINEAR-STYLE CONFLICT RESOLUTION: Build DELETE syncId index
709
+ // ========================================================================
710
+ // Per Linear's architecture: "If the syncId of the deleting action is larger,
711
+ // the model will not be created." This prevents processing stale UPDATE deltas
712
+ // for entities that have been cascade-deleted (where DELETE delta exists).
713
+ // ========================================================================
714
+ const deleteSyncIds = new Map(); // key: "ModelName:modelId" -> DELETE syncId
715
+ for (const delta of deltas) {
716
+ if (delta.actionType === 'D' && delta.syncId) {
717
+ const key = `${delta.modelName}:${delta.modelId}`;
718
+ const existing = deleteSyncIds.get(key);
719
+ // Normalize to number — postgres sends bigint as string on the wire.
720
+ const n = typeof delta.syncId === 'string' ? Number(delta.syncId) : delta.syncId;
721
+ if (typeof n === 'number' && !isNaN(n) && (!existing || n > existing)) {
722
+ deleteSyncIds.set(key, n);
723
+ }
724
+ }
725
+ }
726
+ if (deleteSyncIds.size > 0) {
727
+ getContext().logger.debug('[Database.processDeltaBatch] Built DELETE index for conflict resolution', {
728
+ deleteCount: deleteSyncIds.size,
729
+ totalDeltas: deltas.length,
730
+ });
731
+ }
732
+ // Group deltas by store for efficient transaction management.
733
+ //
734
+ // We intentionally track TWO highwater marks: `highestSyncId` for the
735
+ // total range seen, and `highestPersistedSyncId` accumulated only from
736
+ // deltas whose store transaction actually succeeded. The cursor
737
+ // advance (at `updateWorkspaceMetadata`) uses ONLY the persisted one.
738
+ //
739
+ // Without this split, a single store-level IDB failure (e.g. compact
740
+ // record missing required field, validation abort) silently advances
741
+ // the cursor past deltas that never wrote to IDB. Next partial
742
+ // bootstrap asks "what's new since {advanced cursor}?" and the
743
+ // skipped rows fall into the already-seen range forever — the
744
+ // observed "postgres has the deck, IDB doesn't, full reload can't
745
+ // recover it" failure mode.
746
+ const deltasByStore = new Map();
747
+ let highestSyncId = 0;
748
+ let highestPersistedSyncId = 0;
749
+ let skippedDueToConflict = 0;
750
+ deltas.forEach((delta, idx) => {
751
+ // Normalize to number — postgres sends bigint syncIds as strings.
752
+ const deltaSyncIdNum = typeof delta.syncId === 'string'
753
+ ? Number(delta.syncId)
754
+ : delta.syncId;
755
+ if (typeof deltaSyncIdNum === 'number' && !isNaN(deltaSyncIdNum) && deltaSyncIdNum > highestSyncId) {
756
+ highestSyncId = deltaSyncIdNum;
757
+ }
758
+ // ========================================================================
759
+ // CONFLICT CHECK: Skip UPDATE/INSERT if DELETE exists with higher syncId
760
+ // ========================================================================
761
+ if (delta.actionType === 'U' || delta.actionType === 'I' || delta.actionType === 'C') {
762
+ const key = `${delta.modelName}:${delta.modelId}`;
763
+ const deleteSyncId = deleteSyncIds.get(key);
764
+ if (deleteSyncId !== undefined) {
765
+ // DELETE exists for this entity
766
+ const deltaSyncId = delta.syncId || 0;
767
+ if (deleteSyncId >= deltaSyncId) {
768
+ // DELETE has equal or higher syncId - skip this UPDATE/INSERT
769
+ getContext().logger.debug('[Database.processDeltaBatch] Skipping stale delta (DELETE wins)', {
770
+ modelName: delta.modelName,
771
+ modelId: delta.modelId.slice(0, 12),
772
+ actionType: delta.actionType,
773
+ deltaSyncId,
774
+ deleteSyncId,
775
+ });
776
+ results[idx] = { action: 'verify', modelName: delta.modelName, modelId: delta.modelId };
777
+ skippedDueToConflict++;
778
+ return; // Skip this delta
779
+ }
780
+ }
781
+ }
782
+ const store = this.getStore(delta.modelName, 'processDeltaBatch');
783
+ if (!store) {
784
+ results[idx] = { action: 'verify', modelName: delta.modelName, modelId: delta.modelId };
785
+ return;
786
+ }
787
+ if (!deltasByStore.has(delta.modelName)) {
788
+ deltasByStore.set(delta.modelName, []);
789
+ }
790
+ deltasByStore.get(delta.modelName).push({ idx, delta });
791
+ });
792
+ if (skippedDueToConflict > 0) {
793
+ getContext().logger.info('[Database.processDeltaBatch] Conflict resolution summary', {
794
+ skippedDueToConflict,
795
+ totalDeltas: deltas.length,
796
+ deleteCount: deleteSyncIds.size,
797
+ });
798
+ }
799
+ // Process each store's deltas in a single transaction
800
+ for (const [modelName, storeDeltas] of deltasByStore.entries()) {
801
+ const store = this.storeManager.getStore(modelName);
802
+ if (!store)
803
+ continue;
804
+ try {
805
+ // ✅ BEST PRACTICE: Batch read-modify-write pattern
806
+ // Step 1: Identify which deltas need existing data (UPDATEs)
807
+ const updateDeltas = storeDeltas.filter(({ delta }) => delta.actionType === 'U');
808
+ const updateIds = updateDeltas.map(({ delta }) => delta.modelId);
809
+ // Step 2: Batch read all existing records in a SINGLE IDB transaction
810
+ // This replaces N sequential get() calls with 1 transaction containing N gets
811
+ let existingRecords = new Map();
812
+ const missingIds = new Set();
813
+ if (updateIds.length > 0) {
814
+ try {
815
+ existingRecords = await store.getMany(updateIds);
816
+ // Identify missing IDs for self-healing
817
+ for (const id of updateIds) {
818
+ if (!existingRecords.has(id)) {
819
+ missingIds.add(id);
820
+ }
821
+ }
822
+ }
823
+ catch (error) {
824
+ getContext().observability.breadcrumb(`Batch read failed for ${modelName}, falling back to individual reads`, 'sync.database', 'warning');
825
+ // Fallback: mark all as missing for self-healing
826
+ for (const id of updateIds) {
827
+ missingIds.add(id);
828
+ }
829
+ }
830
+ }
831
+ // ✅ SELF-HEALING: Fetch missing records for UPDATE deltas
832
+ // Track IDs that failed to fetch (404 = entity deleted, skip the delta)
833
+ const failedToFetch = new Set();
834
+ if (missingIds.size > 0) {
835
+ getContext().logger.info(`[Database.processDeltaBatch] Found ${missingIds.size} missing records for ${modelName}, fetching from server...`);
836
+ // Fetch sequentially to avoid overwhelming server
837
+ for (const id of missingIds) {
838
+ try {
839
+ const fetchedRecord = await this.bootstrapHelper.fetchEntity(modelName, id);
840
+ if (fetchedRecord) {
841
+ const compacted = this.compactRecord(modelName, fetchedRecord);
842
+ existingRecords.set(id, compacted);
843
+ getContext().logger.debug(`[Database.processDeltaBatch] Successfully fetched missing record: ${modelName}:${id}`);
844
+ }
845
+ else {
846
+ // fetchEntity returns null for 404 — entity was deleted, skip the delta
847
+ failedToFetch.add(id);
848
+ getContext().logger.debug(`[Database.processDeltaBatch] Entity not found (deleted): ${modelName}:${id}`);
849
+ }
850
+ }
851
+ catch (error) {
852
+ // Unexpected error (5xx, network failure) — mark for skipping and report
853
+ failedToFetch.add(id);
854
+ getContext().observability.breadcrumb(`Failed to fetch missing record ${modelName}:${id}`, 'sync.database', 'warning', {
855
+ error: error instanceof Error ? error.message : String(error),
856
+ });
857
+ }
858
+ }
859
+ if (failedToFetch.size > 0) {
860
+ getContext().logger.info(`[Database.processDeltaBatch] Skipping ${failedToFetch.size} stale UPDATE deltas for deleted entities`, {
861
+ modelName,
862
+ failedCount: failedToFetch.size,
863
+ totalMissing: missingIds.size,
864
+ });
865
+ }
866
+ }
867
+ // Re-check after entity fetch loop: close() may have run during network I/O
868
+ if (!this.workspaceDb || this.isClosing) {
869
+ for (const { idx, delta } of storeDeltas) {
870
+ results[idx] = { action: 'verify', modelName, modelId: delta.modelId };
871
+ }
872
+ continue;
873
+ }
874
+ // Step 3: Start a single readwrite transaction for this store
875
+ const tx = this.workspaceDb.transaction([modelName], 'readwrite');
876
+ const objectStore = tx.objectStore(modelName);
877
+ // Stage results for this store; only commit to global results when tx completes successfully
878
+ const stagedResults = [];
879
+ // Step 4: Process all deltas synchronously within transaction (no await!)
880
+ for (const { idx, delta } of storeDeltas) {
881
+ const { actionType, modelId, data } = delta;
882
+ // Server deltas carry `id` in the envelope (modelId) but often
883
+ // strip it from the `data` payload as redundant. IDB object
884
+ // stores use keyPath='id' on the record itself, so the record
885
+ // MUST have `id` set. Inject it before `compactRecord` so the
886
+ // record is self-describing.
887
+ const dataWithId = data && typeof data === 'object'
888
+ ? { id: modelId, ...data }
889
+ : data;
890
+ const compacted = dataWithId && typeof dataWithId === 'object'
891
+ ? this.compactRecord(modelName, dataWithId)
892
+ : dataWithId;
893
+ switch (actionType) {
894
+ case 'C': // Create
895
+ case 'I': // Insert
896
+ objectStore.put(compacted);
897
+ stagedResults.push({
898
+ action: 'add',
899
+ modelName,
900
+ modelId,
901
+ data: compacted,
902
+ idx,
903
+ });
904
+ break;
905
+ case 'U': {
906
+ // ✅ UPDATE: Merge delta with existing record (already fetched)
907
+ const existing = existingRecords.get(modelId);
908
+ // ========================================================================
909
+ // SKIP STALE DELTAS: If entity doesn't exist locally AND failed to fetch
910
+ // from server (404), this is a stale UPDATE for a deleted entity.
911
+ // Per Linear's architecture, skip it instead of creating incomplete data.
912
+ // ========================================================================
913
+ if (!existing && failedToFetch.has(modelId)) {
914
+ getContext().logger.debug('[Database.processDeltaBatch] Skipping UPDATE for deleted entity', {
915
+ modelName,
916
+ modelId: modelId.slice(0, 12),
917
+ });
918
+ stagedResults.push({ action: 'verify', modelName, modelId, idx });
919
+ break; // Skip this delta
920
+ }
921
+ // CRITICAL FIX: Skip UPDATE if there's no existing record to merge with
922
+ // Creating a record from partial UPDATE data causes corruption (missing deckId, etc.)
923
+ if (!existing) {
924
+ getContext().observability.breadcrumb('Batch: Skipping UPDATE delta - no existing record', 'sync.database', 'warning', {
925
+ modelName,
926
+ modelId: modelId.slice(0, 12),
927
+ });
928
+ stagedResults.push({ action: 'verify', modelName, modelId, idx });
929
+ break; // Skip this delta
930
+ }
931
+ // Safe to merge - existing record is guaranteed
932
+ const merged = { ...existing, ...compacted };
933
+ // Log preserved fields for debugging partial updates
934
+ if (existing && compacted) {
935
+ this.logPreservedFields(modelName, modelId, existing, compacted);
936
+ }
937
+ objectStore.put(merged);
938
+ stagedResults.push({
939
+ action: 'update',
940
+ modelName,
941
+ modelId,
942
+ data: merged, // Return merged data, not just delta
943
+ idx,
944
+ });
945
+ break;
946
+ }
947
+ case 'D': // Delete
948
+ objectStore.delete(modelId);
949
+ stagedResults.push({ action: 'remove', modelName, modelId, idx });
950
+ break;
951
+ case 'A': // Archive
952
+ const archivedData = this.compactRecord(modelName, {
953
+ ...data,
954
+ archivedAt: new Date(),
955
+ });
956
+ objectStore.put(archivedData);
957
+ stagedResults.push({
958
+ action: 'archive',
959
+ modelName,
960
+ modelId,
961
+ data: archivedData,
962
+ idx,
963
+ });
964
+ break;
965
+ case 'V': // Verify
966
+ stagedResults.push({ action: 'verify', modelName, modelId, data, idx });
967
+ break;
968
+ }
969
+ }
970
+ // Wait for transaction to complete
971
+ await new Promise((resolve, reject) => {
972
+ tx.oncomplete = () => resolve();
973
+ tx.onerror = () => reject(tx.error);
974
+ });
975
+ // Only commit staged results to the global results if the transaction succeeded.
976
+ // Also advance `highestPersistedSyncId` ONLY for deltas in this successful tx
977
+ // — so the cursor can't advance past rows that never wrote to IDB.
978
+ for (const r of stagedResults) {
979
+ // Resolve the originating delta so we can carry its
980
+ // transactionId through to the result. Echo detection in
981
+ // `SyncClient.applyDeltaBatchToPool` reads it.
982
+ const sourceDelta = storeDeltas.find(({ idx }) => idx === r.idx)?.delta;
983
+ results[r.idx] = {
984
+ action: r.action,
985
+ modelName: r.modelName,
986
+ modelId: r.modelId,
987
+ data: r.data,
988
+ transactionId: sourceDelta?.transactionId,
989
+ };
990
+ const rawSyncId = storeDeltas[storeDeltas.findIndex(({ idx }) => idx === r.idx)]?.delta.syncId;
991
+ // SyncDelta.syncId is typed as number but postgres serializes
992
+ // bigint to string on the wire — coerce before compare.
993
+ const syncId = typeof rawSyncId === 'string' ? Number(rawSyncId) : rawSyncId;
994
+ if (typeof syncId === 'number' && !isNaN(syncId) && syncId > highestPersistedSyncId) {
995
+ highestPersistedSyncId = syncId;
996
+ }
997
+ }
998
+ }
999
+ catch (err) {
1000
+ // Surface the IDB error directly — `captureTransactionFailure`
1001
+ // routes to Sentry, but during interactive debugging the console
1002
+ // needs to show the specific failure (e.g. `ConstraintError`,
1003
+ // `DataError`, `AbortError`) so we can find what's wrong with
1004
+ // the `compacted` payload shape or store schema.
1005
+ const idbErr = err instanceof Error ? err : new Error(String(err));
1006
+ getContext().logger.warn('[Database.processDeltaBatch] store tx FAILED', {
1007
+ modelName,
1008
+ storeDeltasCount: storeDeltas.length,
1009
+ errorName: idbErr.name,
1010
+ message: idbErr.message,
1011
+ sampleDeltas: storeDeltas.slice(0, 3).map(({ delta }) => ({
1012
+ action: delta.actionType,
1013
+ id: delta.modelId.slice(0, 12),
1014
+ dataKeys: delta.data && typeof delta.data === 'object'
1015
+ ? Object.keys(delta.data).slice(0, 8)
1016
+ : typeof delta.data,
1017
+ })),
1018
+ });
1019
+ getContext().observability.captureTransactionFailure({
1020
+ context: 'batch-indexeddb-operation',
1021
+ modelName,
1022
+ error: idbErr,
1023
+ });
1024
+ // Mark all store deltas as verify in their original positions
1025
+ for (const { idx, delta } of storeDeltas) {
1026
+ results[idx] = { action: 'verify', modelName, modelId: delta.modelId };
1027
+ }
1028
+ }
1029
+ }
1030
+ // Update metadata only to the highest syncId whose store transaction
1031
+ // actually committed. Using `highestSyncId` (the range-seen max) would
1032
+ // advance the cursor past deltas that failed to persist — the "cursor
1033
+ // ahead of IDB" divergence that makes subsequent partial bootstraps
1034
+ // skip the missing rows forever.
1035
+ //
1036
+ // If `highestPersistedSyncId === 0` (every store tx failed), we leave
1037
+ // the metadata alone. Next partial bootstrap will re-deliver the
1038
+ // deltas at the original cursor position.
1039
+ if (highestPersistedSyncId > 0) {
1040
+ try {
1041
+ await this.updateWorkspaceMetadata({ lastSyncId: highestPersistedSyncId });
1042
+ }
1043
+ catch (err) {
1044
+ getContext().observability.breadcrumb('Failed to update metadata after batch', 'sync.database', 'error', {
1045
+ error: err instanceof Error ? err.message : String(err),
1046
+ });
1047
+ }
1048
+ }
1049
+ if (highestPersistedSyncId < highestSyncId) {
1050
+ // Staging-visibility probe: makes the "some deltas seen but not
1051
+ // persisted" signal loud when it actually happens. If this fires
1052
+ // repeatedly on the same sync IDs, a specific row is un-writable
1053
+ // (validation? compact issue?) and needs fixing at that layer.
1054
+ getContext().logger.warn('[Database.processDeltaBatch] cursor withheld due to failed store tx', {
1055
+ seen: highestSyncId,
1056
+ persisted: highestPersistedSyncId,
1057
+ gap: highestSyncId - highestPersistedSyncId,
1058
+ });
1059
+ }
1060
+ return { results, persistedSyncId: highestPersistedSyncId };
1061
+ }
1062
+ /** Get raw data for hydration */
1063
+ async hydrateModels(modelName) {
1064
+ const store = this.getStore(modelName, 'hydrate');
1065
+ if (!store) {
1066
+ return [];
1067
+ }
1068
+ return store.getAll();
1069
+ }
1070
+ /** Put a single record to IndexedDB (for self-healing corrupted records) */
1071
+ async putRecord(modelName, id, data) {
1072
+ const store = this.getStore(modelName, 'putRecord');
1073
+ if (!store) {
1074
+ getContext().observability.breadcrumb(`Store not found for putRecord: ${modelName}`, 'sync.database', 'warning');
1075
+ return;
1076
+ }
1077
+ const compacted = this.compactRecord(modelName, data);
1078
+ await store.put(compacted);
1079
+ }
1080
+ /** Get data by index. `value` is an IDB key — string, number, Date,
1081
+ * BufferSource, or array thereof. */
1082
+ async getDataByIndex(modelName, indexName, value) {
1083
+ const store = this.getRequiredStore(modelName);
1084
+ return await store.getAllFromIndex(indexName, value);
1085
+ }
1086
+ /**
1087
+ * Update workspace metadata
1088
+ */
1089
+ /**
1090
+ * Get the last sync ID from workspace metadata
1091
+ */
1092
+ /** Read workspace metadata from IDB (returns null if db not open). */
1093
+ async getWorkspaceMetadata() {
1094
+ if (this.inMemory)
1095
+ return this.inMemoryMetadata;
1096
+ if (!this.workspaceDb)
1097
+ return null;
1098
+ return this.databaseManager.getWorkspaceMetadata(this.workspaceDb);
1099
+ }
1100
+ async getLastSyncId() {
1101
+ if (!this.workspaceDb) {
1102
+ return 0;
1103
+ }
1104
+ const metadata = await this.databaseManager.getWorkspaceMetadata(this.workspaceDb);
1105
+ return metadata?.lastSyncId || 0;
1106
+ }
1107
+ async getVersionVector() {
1108
+ if (!this.workspaceDb)
1109
+ return null;
1110
+ const metadata = await this.databaseManager.getWorkspaceMetadata(this.workspaceDb);
1111
+ return (metadata?.versions || null);
1112
+ }
1113
+ async updateWorkspaceMetadata(metadata) {
1114
+ // In-memory mode: store in local variable
1115
+ if (this.inMemory) {
1116
+ this.inMemoryMetadata = {
1117
+ ...(this.inMemoryMetadata ?? {
1118
+ lastSyncId: 0, firstSyncId: 0, backendDatabaseVersion: 0,
1119
+ subscribedSyncGroups: [], updatedAt: new Date(),
1120
+ }),
1121
+ ...metadata,
1122
+ updatedAt: new Date(),
1123
+ };
1124
+ return;
1125
+ }
1126
+ // Graceful degradation: skip if database is closing or not open
1127
+ // This prevents "Database not opened" errors during React Strict Mode cleanup
1128
+ if (!this.workspaceDb || this.isClosing) {
1129
+ getContext().observability.breadcrumb('updateWorkspaceMetadata: Database not open or closing', 'sync.database', 'warning', {
1130
+ hasDb: !!this.workspaceDb,
1131
+ isClosing: this.isClosing,
1132
+ });
1133
+ return;
1134
+ }
1135
+ const current = await this.databaseManager.getWorkspaceMetadata(this.workspaceDb);
1136
+ // Re-check after await: close() may have been called during getWorkspaceMetadata,
1137
+ // or the browser may have closed the IDB connection (tab background, navigation).
1138
+ // Without this, setWorkspaceMetadata would hit "The database connection is closing".
1139
+ if (!this.workspaceDb || this.isClosing) {
1140
+ return;
1141
+ }
1142
+ const updated = {
1143
+ ...current,
1144
+ ...metadata,
1145
+ updatedAt: new Date(),
1146
+ };
1147
+ await this.databaseManager.setWorkspaceMetadata(this.workspaceDb, updated);
1148
+ }
1149
+ /** Transaction persistence for offline/retry support.
1150
+ * Returns either the IDB-backed ObjectStore or its in-memory twin
1151
+ * (`InMemoryObjectStore`) — both expose the same async put/get/
1152
+ * delete/getAll/getAllFromIndex surface, so callers don't need to
1153
+ * branch on which one they got back. */
1154
+ get transactionStore() {
1155
+ return this.getStore('__transactions');
1156
+ }
1157
+ async saveTransaction(transaction) {
1158
+ await this.transactionStore?.put(transaction);
1159
+ }
1160
+ async removeTransaction(id) {
1161
+ await this.transactionStore?.delete(id);
1162
+ }
1163
+ async getPersistedTransactions() {
1164
+ const rows = (await this.transactionStore?.getAll()) ?? [];
1165
+ // Storage layer returns the centralized `Record<string, unknown>`
1166
+ // shape from `ObjectStoreContract`. PersistedTransaction adds an
1167
+ // index signature so each row already structurally satisfies the
1168
+ // narrower type — runtime invariant: only saveTransaction writes
1169
+ // here, and it only accepts PersistedTransaction.
1170
+ return rows;
1171
+ }
1172
+ async cleanupOldTransactions(maxAge) {
1173
+ const store = this.transactionStore;
1174
+ if (!store)
1175
+ return 0;
1176
+ const rows = (await store.getAll());
1177
+ const cutoff = Date.now() - maxAge;
1178
+ let cleaned = 0;
1179
+ for (const tx of rows) {
1180
+ if (typeof tx.timestamp === 'number' && tx.timestamp < cutoff) {
1181
+ await store.delete(tx.id);
1182
+ cleaned++;
1183
+ }
1184
+ }
1185
+ return cleaned;
1186
+ }
1187
+ /**
1188
+ * Store management
1189
+ *
1190
+ * `getStore(modelName, context?)` is defined near the top of this
1191
+ * class — single accessor for both inMemory and IDB modes.
1192
+ */
1193
+ getAllStores() {
1194
+ if (this.inMemory) {
1195
+ return this.inMemoryStores;
1196
+ }
1197
+ return this.storeManager.getAllStores();
1198
+ }
1199
+ /**
1200
+ * Model persistence tracking
1201
+ */
1202
+ async setModelPersisted(modelName, persisted) {
1203
+ if (this.inMemory)
1204
+ return; // No persistence tracking in memory mode
1205
+ if (!this.workspaceDb) {
1206
+ throw new AbloConnectionError('Database not opened', {
1207
+ code: 'db_not_opened',
1208
+ });
1209
+ }
1210
+ await this.databaseManager.setModelPersisted(this.workspaceDb, modelName, persisted);
1211
+ }
1212
+ async isModelPersisted(modelName) {
1213
+ if (this.inMemory)
1214
+ return false; // In-memory = nothing persisted
1215
+ if (!this.workspaceDb) {
1216
+ throw new AbloConnectionError('Database not opened', {
1217
+ code: 'db_not_opened',
1218
+ });
1219
+ }
1220
+ return await this.databaseManager.isModelPersisted(this.workspaceDb, modelName);
1221
+ }
1222
+ /**
1223
+ * Statistics
1224
+ */
1225
+ async getStats() {
1226
+ const storeStats = await this.storeManager.getComprehensiveStats();
1227
+ return {
1228
+ database: this.currentDbInfo,
1229
+ stores: storeStats,
1230
+ metadata: this.workspaceDb
1231
+ ? await this.databaseManager.getWorkspaceMetadata(this.workspaceDb)
1232
+ : null,
1233
+ };
1234
+ }
1235
+ /**
1236
+ * Lifecycle
1237
+ */
1238
+ isOpen() {
1239
+ return this.workspaceDb !== null;
1240
+ }
1241
+ async close() {
1242
+ // Mark database as closing FIRST to enable graceful degradation
1243
+ // This allows in-flight operations to bail out gracefully
1244
+ this.isClosing = true;
1245
+ // Mark all stores as closing to prevent new operations
1246
+ this.storeManager.markAllStoresAsClosing();
1247
+ if (this.workspaceDb) {
1248
+ this.workspaceDb.close();
1249
+ this.workspaceDb = null;
1250
+ }
1251
+ await this.databaseManager.close();
1252
+ this.currentDbInfo = null;
1253
+ getContext().logger.debug('Database closed');
1254
+ }
1255
+ async clear() {
1256
+ await this.storeManager.clearAllStores();
1257
+ getContext().logger.info('All stores cleared');
1258
+ }
1259
+ }