@abloatai/humans 0.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (404) hide show
  1. package/README.md +52 -0
  2. package/dist/Ablo.d.ts +208 -0
  3. package/dist/Ablo.js +120 -0
  4. package/dist/client.d.ts +317 -0
  5. package/dist/client.js +13 -0
  6. package/dist/core.d.ts +35 -0
  7. package/dist/core.js +48 -0
  8. package/dist/humans.d.ts +28 -0
  9. package/dist/humans.js +34 -0
  10. package/dist/index.d.ts +10 -0
  11. package/dist/index.js +6 -0
  12. package/dist/local/BaseSyncedStore.d.ts +807 -0
  13. package/dist/local/BaseSyncedStore.js +1516 -0
  14. package/dist/local/Database.d.ts +322 -0
  15. package/dist/local/Database.js +1589 -0
  16. package/dist/local/InstanceCache.d.ts +255 -0
  17. package/dist/local/InstanceCache.js +1263 -0
  18. package/dist/local/LazyReferenceCollection.d.ts +177 -0
  19. package/dist/local/LazyReferenceCollection.js +461 -0
  20. package/dist/local/Model.d.ts +475 -0
  21. package/dist/local/Model.js +950 -0
  22. package/dist/local/ModelRegistry.d.ts +225 -0
  23. package/dist/local/ModelRegistry.js +539 -0
  24. package/dist/local/NetworkMonitor.d.ts +28 -0
  25. package/dist/local/NetworkMonitor.js +79 -0
  26. package/dist/local/RuntimeContext.d.ts +52 -0
  27. package/dist/local/RuntimeContext.js +80 -0
  28. package/dist/local/SyncClient.d.ts +516 -0
  29. package/dist/local/SyncClient.js +1754 -0
  30. package/dist/local/adapters/alwaysOnline.d.ts +14 -0
  31. package/dist/local/adapters/alwaysOnline.js +17 -0
  32. package/dist/local/adapters/inMemoryStorage.d.ts +37 -0
  33. package/dist/local/adapters/inMemoryStorage.js +122 -0
  34. package/dist/local/client/clientPrelude.d.ts +52 -0
  35. package/dist/local/client/clientPrelude.js +60 -0
  36. package/dist/local/client/consoleLogger.d.ts +35 -0
  37. package/dist/local/client/consoleLogger.js +44 -0
  38. package/dist/local/client/createInternalComponents.d.ts +50 -0
  39. package/dist/local/client/createInternalComponents.js +98 -0
  40. package/dist/local/client/createModelProxy.d.ts +248 -0
  41. package/dist/local/client/createModelProxy.js +880 -0
  42. package/dist/local/client/modelRegistration.d.ts +10 -0
  43. package/dist/local/client/modelRegistration.js +316 -0
  44. package/dist/local/client/options.d.ts +440 -0
  45. package/dist/local/client/options.js +7 -0
  46. package/dist/local/client/reactiveEngine.d.ts +53 -0
  47. package/dist/local/client/reactiveEngine.js +705 -0
  48. package/dist/local/client/resourceTypes.d.ts +12 -0
  49. package/dist/local/client/resourceTypes.js +10 -0
  50. package/dist/local/client/schemaConfig.d.ts +44 -0
  51. package/dist/local/client/schemaConfig.js +185 -0
  52. package/dist/local/client/storeCluster.d.ts +46 -0
  53. package/dist/local/client/storeCluster.js +133 -0
  54. package/dist/local/client/storeLifecycle.d.ts +61 -0
  55. package/dist/local/client/storeLifecycle.js +236 -0
  56. package/dist/local/client/validateAbloOptions.d.ts +42 -0
  57. package/dist/local/client/validateAbloOptions.js +43 -0
  58. package/dist/local/client/wsMutationExecutor.d.ts +27 -0
  59. package/dist/local/client/wsMutationExecutor.js +72 -0
  60. package/dist/local/context.d.ts +42 -0
  61. package/dist/local/context.js +81 -0
  62. package/dist/local/coordination/ClaimLog.d.ts +26 -0
  63. package/dist/local/coordination/ClaimLog.js +32 -0
  64. package/dist/local/interfaces/index.d.ts +311 -0
  65. package/dist/local/interfaces/index.js +9 -0
  66. package/dist/local/localModelContract.d.ts +14 -0
  67. package/dist/local/localModelContract.js +1 -0
  68. package/dist/local/logPosition.d.ts +31 -0
  69. package/dist/local/logPosition.js +53 -0
  70. package/dist/local/mutationPersistence.d.ts +6 -0
  71. package/dist/local/mutationPersistence.js +1 -0
  72. package/dist/local/mutators/RecordingMutation.d.ts +36 -0
  73. package/dist/local/mutators/RecordingMutation.js +182 -0
  74. package/dist/local/mutators/Transaction.d.ts +40 -0
  75. package/dist/local/mutators/Transaction.js +58 -0
  76. package/dist/local/mutators/UndoManager.d.ts +258 -0
  77. package/dist/local/mutators/UndoManager.js +665 -0
  78. package/dist/local/mutators/defineMutators.d.ts +60 -0
  79. package/dist/local/mutators/defineMutators.js +18 -0
  80. package/dist/local/mutators/inverseOp.d.ts +126 -0
  81. package/dist/local/mutators/inverseOp.js +71 -0
  82. package/dist/local/mutators/mutateActions.d.ts +45 -0
  83. package/dist/local/mutators/mutateActions.js +105 -0
  84. package/dist/local/mutators/readerActions.d.ts +33 -0
  85. package/dist/local/mutators/readerActions.js +57 -0
  86. package/dist/local/mutators/undoApply.d.ts +51 -0
  87. package/dist/local/mutators/undoApply.js +117 -0
  88. package/dist/local/persistence.d.ts +7 -0
  89. package/dist/local/persistence.js +9 -0
  90. package/dist/local/query/QueryProcessor.d.ts +75 -0
  91. package/dist/local/query/QueryProcessor.js +255 -0
  92. package/dist/local/query/client.d.ts +64 -0
  93. package/dist/local/query/client.js +138 -0
  94. package/dist/local/query/types.d.ts +85 -0
  95. package/dist/local/query/types.js +16 -0
  96. package/dist/local/schema/serialize.d.ts +1 -0
  97. package/dist/local/schema/serialize.js +1 -0
  98. package/dist/local/store/queryApi.d.ts +13 -0
  99. package/dist/local/store/queryApi.js +35 -0
  100. package/dist/local/storeContract.d.ts +145 -0
  101. package/dist/local/storeContract.js +12 -0
  102. package/dist/local/stores/DatabaseManager.d.ts +112 -0
  103. package/dist/local/stores/DatabaseManager.js +400 -0
  104. package/dist/local/stores/ObjectStore.d.ts +115 -0
  105. package/dist/local/stores/ObjectStore.js +393 -0
  106. package/dist/local/stores/ObjectStoreContract.d.ts +38 -0
  107. package/dist/local/stores/ObjectStoreContract.js +1 -0
  108. package/dist/local/stores/StoreManager.d.ts +114 -0
  109. package/dist/local/stores/StoreManager.js +304 -0
  110. package/dist/local/stores/SyncActionStore.d.ts +99 -0
  111. package/dist/local/stores/SyncActionStore.js +506 -0
  112. package/dist/local/stores/openIDBWithTimeout.d.ts +65 -0
  113. package/dist/local/stores/openIDBWithTimeout.js +153 -0
  114. package/dist/local/stores/persistenceCleanup.d.ts +7 -0
  115. package/dist/local/stores/persistenceCleanup.js +26 -0
  116. package/dist/local/stores/persistenceIdentity.d.ts +27 -0
  117. package/dist/local/stores/persistenceIdentity.js +38 -0
  118. package/dist/local/stores/syncAction.d.ts +26 -0
  119. package/dist/local/stores/syncAction.js +16 -0
  120. package/dist/local/stores/v1PersistenceDeletion.d.ts +8 -0
  121. package/dist/local/stores/v1PersistenceDeletion.js +16 -0
  122. package/dist/local/sync/BootstrapFetcher.d.ts +284 -0
  123. package/dist/local/sync/BootstrapFetcher.js +964 -0
  124. package/dist/local/sync/ConnectionManager.d.ts +8 -0
  125. package/dist/local/sync/ConnectionManager.js +8 -0
  126. package/dist/local/sync/OnDemandLoader.d.ts +231 -0
  127. package/dist/local/sync/OnDemandLoader.js +743 -0
  128. package/dist/local/sync/SubscriptionManager.d.ts +159 -0
  129. package/dist/local/sync/SubscriptionManager.js +243 -0
  130. package/dist/local/sync/SyncWebSocket.d.ts +173 -0
  131. package/dist/local/sync/SyncWebSocket.js +438 -0
  132. package/dist/local/sync/bootstrapApply.d.ts +73 -0
  133. package/dist/local/sync/bootstrapApply.js +73 -0
  134. package/dist/local/sync/commitFrames.d.ts +8 -0
  135. package/dist/local/sync/commitFrames.js +8 -0
  136. package/dist/local/sync/connectionManagerLifecycle.d.ts +23 -0
  137. package/dist/local/sync/connectionManagerLifecycle.js +126 -0
  138. package/dist/local/sync/contextPorts.d.ts +18 -0
  139. package/dist/local/sync/contextPorts.js +31 -0
  140. package/dist/local/sync/createClaimStream.d.ts +64 -0
  141. package/dist/local/sync/createClaimStream.js +475 -0
  142. package/dist/local/sync/createSnapshot.d.ts +29 -0
  143. package/dist/local/sync/createSnapshot.js +116 -0
  144. package/dist/local/sync/credentialLifecycle.d.ts +7 -0
  145. package/dist/local/sync/credentialLifecycle.js +7 -0
  146. package/dist/local/sync/deltaPipeline.d.ts +116 -0
  147. package/dist/local/sync/deltaPipeline.js +357 -0
  148. package/dist/local/sync/groupChange.d.ts +116 -0
  149. package/dist/local/sync/groupChange.js +244 -0
  150. package/dist/local/sync/initialize.d.ts +27 -0
  151. package/dist/local/sync/initialize.js +137 -0
  152. package/dist/local/sync/participants.d.ts +132 -0
  153. package/dist/local/sync/participants.js +342 -0
  154. package/dist/local/sync/persistedPrefix.d.ts +12 -0
  155. package/dist/local/sync/persistedPrefix.js +22 -0
  156. package/dist/local/sync/reconnect.d.ts +23 -0
  157. package/dist/local/sync/reconnect.js +55 -0
  158. package/dist/local/sync/schemaDrift.d.ts +55 -0
  159. package/dist/local/sync/schemaDrift.js +53 -0
  160. package/dist/local/sync/schemas.d.ts +71 -0
  161. package/dist/local/sync/schemas.js +94 -0
  162. package/dist/local/sync/socketEventWiring.d.ts +31 -0
  163. package/dist/local/sync/socketEventWiring.js +130 -0
  164. package/dist/local/sync/syncCursor.d.ts +40 -0
  165. package/dist/local/sync/syncCursor.js +55 -0
  166. package/dist/local/sync/syncPlan.d.ts +54 -0
  167. package/dist/local/sync/syncPlan.js +50 -0
  168. package/dist/local/sync/terminalSessionLifecycle.d.ts +20 -0
  169. package/dist/local/sync/terminalSessionLifecycle.js +50 -0
  170. package/dist/local/sync/wsFrameHandlers.d.ts +8 -0
  171. package/dist/local/sync/wsFrameHandlers.js +8 -0
  172. package/dist/local/transactions/databaseCommitOutbox.d.ts +15 -0
  173. package/dist/local/transactions/databaseCommitOutbox.js +16 -0
  174. package/dist/local/transactions/localMutation.d.ts +10 -0
  175. package/dist/local/transactions/localMutation.js +37 -0
  176. package/dist/local/transactions/mutations/MutationQueue.d.ts +511 -0
  177. package/dist/local/transactions/mutations/MutationQueue.js +1498 -0
  178. package/dist/local/transactions/mutations/MutationStore.d.ts +20 -0
  179. package/dist/local/transactions/mutations/MutationStore.js +53 -0
  180. package/dist/local/transactions/mutations/UnconfirmedWrites.d.ts +82 -0
  181. package/dist/local/transactions/mutations/UnconfirmedWrites.js +104 -0
  182. package/dist/local/transactions/mutations/batchProcessing.d.ts +64 -0
  183. package/dist/local/transactions/mutations/batchProcessing.js +349 -0
  184. package/dist/local/transactions/mutations/coalesceRules.d.ts +58 -0
  185. package/dist/local/transactions/mutations/coalesceRules.js +140 -0
  186. package/dist/local/transactions/mutations/commitApi.d.ts +19 -0
  187. package/dist/local/transactions/mutations/commitApi.js +74 -0
  188. package/dist/local/transactions/mutations/commitLane.d.ts +70 -0
  189. package/dist/local/transactions/mutations/commitLane.js +140 -0
  190. package/dist/local/transactions/mutations/commitLatency.d.ts +52 -0
  191. package/dist/local/transactions/mutations/commitLatency.js +130 -0
  192. package/dist/local/transactions/mutations/commitPayload.d.ts +165 -0
  193. package/dist/local/transactions/mutations/commitPayload.js +152 -0
  194. package/dist/local/transactions/mutations/commitTransport.d.ts +36 -0
  195. package/dist/local/transactions/mutations/commitTransport.js +104 -0
  196. package/dist/local/transactions/mutations/deltaConfirmation.d.ts +63 -0
  197. package/dist/local/transactions/mutations/deltaConfirmation.js +235 -0
  198. package/dist/local/transactions/mutations/durableCommitRestore.d.ts +17 -0
  199. package/dist/local/transactions/mutations/durableCommitRestore.js +95 -0
  200. package/dist/local/transactions/mutations/durableWriteStore.d.ts +14 -0
  201. package/dist/local/transactions/mutations/durableWriteStore.js +12 -0
  202. package/dist/local/transactions/mutations/executionSelection.d.ts +6 -0
  203. package/dist/local/transactions/mutations/executionSelection.js +42 -0
  204. package/dist/local/transactions/mutations/failureHandling.d.ts +16 -0
  205. package/dist/local/transactions/mutations/failureHandling.js +130 -0
  206. package/dist/local/transactions/mutations/failurePolicy.d.ts +13 -0
  207. package/dist/local/transactions/mutations/failurePolicy.js +48 -0
  208. package/dist/local/transactions/mutations/localMutation.d.ts +58 -0
  209. package/dist/local/transactions/mutations/localMutation.js +75 -0
  210. package/dist/local/transactions/mutations/modelOperations.d.ts +44 -0
  211. package/dist/local/transactions/mutations/modelOperations.js +144 -0
  212. package/dist/local/transactions/mutations/mutationPersistence.d.ts +25 -0
  213. package/dist/local/transactions/mutations/mutationPersistence.js +188 -0
  214. package/dist/local/transactions/mutations/pendingDrain.d.ts +33 -0
  215. package/dist/local/transactions/mutations/pendingDrain.js +112 -0
  216. package/dist/local/transactions/mutations/processingScheduler.d.ts +14 -0
  217. package/dist/local/transactions/mutations/processingScheduler.js +24 -0
  218. package/dist/local/transactions/mutations/queueCoalescing.d.ts +13 -0
  219. package/dist/local/transactions/mutations/queueCoalescing.js +35 -0
  220. package/dist/local/transactions/mutations/replayValidation.d.ts +187 -0
  221. package/dist/local/transactions/mutations/replayValidation.js +164 -0
  222. package/dist/local/transactions/reconnectDrain.d.ts +11 -0
  223. package/dist/local/transactions/reconnectDrain.js +13 -0
  224. package/dist/local/utils/mobxSetup.d.ts +53 -0
  225. package/dist/local/utils/mobxSetup.js +330 -0
  226. package/dist/local/views/QueryView.d.ts +79 -0
  227. package/dist/local/views/QueryView.js +218 -0
  228. package/dist/local/views/ViewRegistry.d.ts +20 -0
  229. package/dist/local/views/ViewRegistry.js +57 -0
  230. package/dist/local/views/incrementalView.d.ts +45 -0
  231. package/dist/local/views/incrementalView.js +69 -0
  232. package/dist/plugin.d.ts +285 -0
  233. package/dist/plugin.js +106 -0
  234. package/dist/presenceStream.d.ts +69 -0
  235. package/dist/presenceStream.js +200 -0
  236. package/dist/react/AbloProvider.d.ts +242 -0
  237. package/dist/react/AbloProvider.js +456 -0
  238. package/dist/react/ClientSideSuspense.d.ts +36 -0
  239. package/dist/react/ClientSideSuspense.js +17 -0
  240. package/dist/react/DefaultFallback.d.ts +24 -0
  241. package/dist/react/DefaultFallback.js +43 -0
  242. package/dist/react/context.d.ts +55 -0
  243. package/dist/react/context.js +29 -0
  244. package/dist/react/createAbloReact.d.ts +50 -0
  245. package/dist/react/createAbloReact.js +48 -0
  246. package/dist/react/internalContext.d.ts +33 -0
  247. package/dist/react/internalContext.js +3 -0
  248. package/dist/react/useAblo.d.ts +96 -0
  249. package/dist/react/useAblo.js +120 -0
  250. package/dist/react/useCurrentUserId.d.ts +2 -0
  251. package/dist/react/useCurrentUserId.js +12 -0
  252. package/dist/react/useErrorListener.d.ts +2 -0
  253. package/dist/react/useErrorListener.js +14 -0
  254. package/dist/react/useMutationFailureListener.d.ts +8 -0
  255. package/dist/react/useMutationFailureListener.js +19 -0
  256. package/dist/react/useMutators.d.ts +56 -0
  257. package/dist/react/useMutators.js +84 -0
  258. package/dist/react/useSyncStatus.d.ts +19 -0
  259. package/dist/react/useSyncStatus.js +37 -0
  260. package/dist/react/useUndoScope.d.ts +34 -0
  261. package/dist/react/useUndoScope.js +73 -0
  262. package/dist/react.d.ts +18 -0
  263. package/dist/react.js +14 -0
  264. package/dist/reactRuntime.d.ts +4 -0
  265. package/dist/reactRuntime.js +2 -0
  266. package/dist/surface.d.ts +36 -0
  267. package/dist/surface.js +77 -0
  268. package/dist/useReactive.d.ts +6 -0
  269. package/dist/useReactive.js +43 -0
  270. package/package.json +119 -0
  271. package/src/Ablo.ts +456 -0
  272. package/src/client.ts +374 -0
  273. package/src/core.ts +104 -0
  274. package/src/humans.ts +61 -0
  275. package/src/index.ts +40 -0
  276. package/src/local/BaseSyncedStore.ts +1991 -0
  277. package/src/local/Database.ts +2052 -0
  278. package/src/local/InstanceCache.ts +1503 -0
  279. package/src/local/LazyReferenceCollection.ts +563 -0
  280. package/src/local/Model.ts +1124 -0
  281. package/src/local/ModelRegistry.ts +762 -0
  282. package/src/local/NetworkMonitor.ts +88 -0
  283. package/src/local/RuntimeContext.ts +141 -0
  284. package/src/local/SyncClient.ts +2131 -0
  285. package/src/local/adapters/alwaysOnline.ts +20 -0
  286. package/src/local/adapters/inMemoryStorage.ts +141 -0
  287. package/src/local/client/clientPrelude.ts +112 -0
  288. package/src/local/client/consoleLogger.ts +60 -0
  289. package/src/local/client/createInternalComponents.ts +163 -0
  290. package/src/local/client/createModelProxy.ts +1390 -0
  291. package/src/local/client/modelRegistration.ts +350 -0
  292. package/src/local/client/options.ts +526 -0
  293. package/src/local/client/reactiveEngine.ts +935 -0
  294. package/src/local/client/resourceTypes.ts +37 -0
  295. package/src/local/client/schemaConfig.ts +194 -0
  296. package/src/local/client/storeCluster.ts +172 -0
  297. package/src/local/client/storeLifecycle.ts +343 -0
  298. package/src/local/client/validateAbloOptions.ts +95 -0
  299. package/src/local/client/wsMutationExecutor.ts +110 -0
  300. package/src/local/context.ts +96 -0
  301. package/src/local/coordination/ClaimLog.ts +39 -0
  302. package/src/local/interfaces/index.ts +468 -0
  303. package/src/local/localModelContract.ts +15 -0
  304. package/src/local/logPosition.ts +84 -0
  305. package/src/local/mutationPersistence.ts +7 -0
  306. package/src/local/mutators/RecordingMutation.ts +222 -0
  307. package/src/local/mutators/Transaction.ts +97 -0
  308. package/src/local/mutators/UndoManager.ts +741 -0
  309. package/src/local/mutators/defineMutators.ts +76 -0
  310. package/src/local/mutators/inverseOp.ts +83 -0
  311. package/src/local/mutators/mutateActions.ts +167 -0
  312. package/src/local/mutators/readerActions.ts +99 -0
  313. package/src/local/mutators/undoApply.ts +141 -0
  314. package/src/local/persistence.ts +16 -0
  315. package/src/local/query/QueryProcessor.ts +347 -0
  316. package/src/local/query/client.ts +197 -0
  317. package/src/local/query/types.ts +102 -0
  318. package/src/local/schema/serialize.ts +1 -0
  319. package/src/local/store/queryApi.ts +56 -0
  320. package/src/local/storeContract.ts +146 -0
  321. package/src/local/stores/DatabaseManager.ts +507 -0
  322. package/src/local/stores/ObjectStore.ts +449 -0
  323. package/src/local/stores/ObjectStoreContract.ts +48 -0
  324. package/src/local/stores/StoreManager.ts +388 -0
  325. package/src/local/stores/SyncActionStore.ts +579 -0
  326. package/src/local/stores/openIDBWithTimeout.ts +195 -0
  327. package/src/local/stores/persistenceCleanup.ts +43 -0
  328. package/src/local/stores/persistenceIdentity.ts +83 -0
  329. package/src/local/stores/syncAction.ts +21 -0
  330. package/src/local/stores/v1PersistenceDeletion.ts +21 -0
  331. package/src/local/sync/BootstrapFetcher.ts +1224 -0
  332. package/src/local/sync/ConnectionManager.ts +15 -0
  333. package/src/local/sync/OnDemandLoader.ts +927 -0
  334. package/src/local/sync/SubscriptionManager.ts +300 -0
  335. package/src/local/sync/SyncWebSocket.ts +584 -0
  336. package/src/local/sync/bootstrapApply.ts +130 -0
  337. package/src/local/sync/commitFrames.ts +16 -0
  338. package/src/local/sync/connectionManagerLifecycle.ts +158 -0
  339. package/src/local/sync/contextPorts.ts +37 -0
  340. package/src/local/sync/createClaimStream.ts +668 -0
  341. package/src/local/sync/createSnapshot.ts +160 -0
  342. package/src/local/sync/credentialLifecycle.ts +18 -0
  343. package/src/local/sync/deltaPipeline.ts +473 -0
  344. package/src/local/sync/groupChange.ts +343 -0
  345. package/src/local/sync/initialize.ts +205 -0
  346. package/src/local/sync/participants.ts +564 -0
  347. package/src/local/sync/persistedPrefix.ts +27 -0
  348. package/src/local/sync/reconnect.ts +88 -0
  349. package/src/local/sync/schemaDrift.ts +86 -0
  350. package/src/local/sync/schemas.ts +118 -0
  351. package/src/local/sync/socketEventWiring.ts +196 -0
  352. package/src/local/sync/syncCursor.ts +62 -0
  353. package/src/local/sync/syncPlan.ts +89 -0
  354. package/src/local/sync/terminalSessionLifecycle.ts +66 -0
  355. package/src/local/sync/wsFrameHandlers.ts +20 -0
  356. package/src/local/transactions/databaseCommitOutbox.ts +33 -0
  357. package/src/local/transactions/localMutation.ts +58 -0
  358. package/src/local/transactions/mutations/MutationQueue.ts +1998 -0
  359. package/src/local/transactions/mutations/MutationStore.ts +65 -0
  360. package/src/local/transactions/mutations/UnconfirmedWrites.ts +133 -0
  361. package/src/local/transactions/mutations/batchProcessing.ts +469 -0
  362. package/src/local/transactions/mutations/coalesceRules.ts +192 -0
  363. package/src/local/transactions/mutations/commitApi.ts +97 -0
  364. package/src/local/transactions/mutations/commitLane.ts +191 -0
  365. package/src/local/transactions/mutations/commitLatency.ts +164 -0
  366. package/src/local/transactions/mutations/commitPayload.ts +281 -0
  367. package/src/local/transactions/mutations/commitTransport.ts +174 -0
  368. package/src/local/transactions/mutations/deltaConfirmation.ts +298 -0
  369. package/src/local/transactions/mutations/durableCommitRestore.ts +128 -0
  370. package/src/local/transactions/mutations/durableWriteStore.ts +21 -0
  371. package/src/local/transactions/mutations/executionSelection.ts +42 -0
  372. package/src/local/transactions/mutations/failureHandling.ts +154 -0
  373. package/src/local/transactions/mutations/failurePolicy.ts +61 -0
  374. package/src/local/transactions/mutations/localMutation.ts +135 -0
  375. package/src/local/transactions/mutations/modelOperations.ts +210 -0
  376. package/src/local/transactions/mutations/mutationPersistence.ts +231 -0
  377. package/src/local/transactions/mutations/pendingDrain.ts +160 -0
  378. package/src/local/transactions/mutations/processingScheduler.ts +35 -0
  379. package/src/local/transactions/mutations/queueCoalescing.ts +45 -0
  380. package/src/local/transactions/mutations/replayValidation.ts +192 -0
  381. package/src/local/transactions/reconnectDrain.ts +24 -0
  382. package/src/local/utils/mobxSetup.ts +388 -0
  383. package/src/local/views/QueryView.ts +311 -0
  384. package/src/local/views/ViewRegistry.ts +61 -0
  385. package/src/local/views/incrementalView.ts +92 -0
  386. package/src/plugin.ts +396 -0
  387. package/src/presenceStream.ts +279 -0
  388. package/src/react/AbloProvider.tsx +744 -0
  389. package/src/react/ClientSideSuspense.tsx +57 -0
  390. package/src/react/DefaultFallback.tsx +60 -0
  391. package/src/react/context.ts +89 -0
  392. package/src/react/createAbloReact.ts +116 -0
  393. package/src/react/internalContext.ts +38 -0
  394. package/src/react/useAblo.ts +280 -0
  395. package/src/react/useCurrentUserId.ts +17 -0
  396. package/src/react/useErrorListener.ts +22 -0
  397. package/src/react/useMutationFailureListener.ts +34 -0
  398. package/src/react/useMutators.ts +184 -0
  399. package/src/react/useSyncStatus.ts +42 -0
  400. package/src/react/useUndoScope.ts +143 -0
  401. package/src/react.ts +69 -0
  402. package/src/reactRuntime.ts +10 -0
  403. package/src/surface.ts +106 -0
  404. package/src/useReactive.ts +51 -0
@@ -0,0 +1,1263 @@
1
+ /**
2
+ * InstanceCache is the in-memory cache of live model instances, keyed by id and
3
+ * deduplicated so each entity has a single instance. It maintains type and
4
+ * foreign-key indexes for fast lookups, evicts entries under a size cap, and
5
+ * notifies subscribers and query views as models are added, updated, or
6
+ * removed. It holds a {@link ModelRegistry} to map between model names and
7
+ * constructor classes, but performs no persistence of its own.
8
+ */
9
+ import { makeObservable, observable, action, computed, runInAction } from 'mobx';
10
+ import { DEFER_MODEL_OBSERVABILITY, Model } from './Model.js';
11
+ import { ModelRegistry } from './ModelRegistry.js';
12
+ import { globalRuntime } from './context.js';
13
+ import { AbloValidationError } from '@abloatai/transaction/errors';
14
+ import { ModelScope } from '@abloatai/transaction/types';
15
+ import { ViewRegistry } from './views/ViewRegistry.js';
16
+ import { QueryView } from './views/QueryView.js';
17
+ // Re-exported so `import { ModelScope } from './InstanceCache.js'` resolves
18
+ export { ModelScope };
19
+ /**
20
+ * The in-memory cache of model instances, keyed by id and deduplicated so each
21
+ * entity resolves to a single instance.
22
+ */
23
+ export class InstanceCache {
24
+ // Single source of truth for all models (observable for reactivity)
25
+ entries = observable.map();
26
+ typeIndex = observable.map();
27
+ // Non-observable access time tracking — kept outside observable.map so that
28
+ // updating timestamps in get() during React render does NOT trigger MobX
29
+ // reactions (which would cause infinite re-render loops).
30
+ accessTimes = new Map();
31
+ // Deduplication tracking
32
+ recentAdditions = new Map(); // "modelType:modelId" -> timestamp
33
+ deltaHistory = new Map();
34
+ // No intermediate cache layer — getByType() reads typeIndex and entries
35
+ // directly. Both are observable, so the data structures are themselves the
36
+ // reactivity source; there are no computed getters with conditional cache
37
+ // invalidation to get wrong.
38
+ // Foreign key indexes: Map<"ModelType:fieldName", Map<fieldValue, ObservableSet<modelId>>>
39
+ // Enables O(1) lookups like "all Block models where sectionId = X"
40
+ // instead of scanning all models of a type and filtering.
41
+ foreignKeyIndexes = new Map();
42
+ // Registry of which fields to index: Map<modelName, fieldName[]>
43
+ foreignKeyConfig = new Map();
44
+ // Performance tracking
45
+ metrics = {
46
+ hits: 0,
47
+ misses: 0,
48
+ evictions: 0,
49
+ additions: 0,
50
+ duplicatesSkipped: 0,
51
+ };
52
+ // Configuration
53
+ config;
54
+ runtime;
55
+ gcTimer;
56
+ // ModelRegistry instance — single source of truth for model metadata
57
+ registry;
58
+ // ViewRegistry — tracks active QueryViews for incremental view maintenance
59
+ viewRegistry = new ViewRegistry();
60
+ // Subscription registry
61
+ subscriptions = new Map();
62
+ /**
63
+ * Subscribe to updates for a specific model type.
64
+ */
65
+ subscribe(modelClass, callback) {
66
+ const modelName = this.registry.getModelNameFromConstructor(modelClass);
67
+ if (!modelName) {
68
+ throw new AbloValidationError(`Model class not registered: ${modelClass.name}`, {
69
+ code: 'pool_subscribe_unregistered',
70
+ });
71
+ }
72
+ let subs = this.subscriptions.get(modelName);
73
+ if (!subs) {
74
+ subs = new Set();
75
+ this.subscriptions.set(modelName, subs);
76
+ }
77
+ const erased = callback;
78
+ subs.add(erased);
79
+ return () => subs.delete(erased);
80
+ }
81
+ notifySubscribers(model) {
82
+ const modelName = model.getModelName();
83
+ const subs = this.subscriptions.get(modelName);
84
+ if (subs) {
85
+ model.ensureObservable();
86
+ for (const callback of subs) {
87
+ callback(model);
88
+ }
89
+ }
90
+ }
91
+ constructor(config = {}, modelRegistry) {
92
+ this.config = {
93
+ maxSize: config.maxSize ?? 10000,
94
+ // Idle-eviction disabled by default. The 5-minute default used to
95
+ // live here, but with schema-driven dynamic classes not
96
+ // registering `LazyReferenceCollection`s, the
97
+ // `hasObservedCollections()` guard in gc() didn't fire for most
98
+ // actively-rendered models — and they'd evict out from under a
99
+ // user whose tab sat for 10 minutes. Memory pressure relief is
100
+ // handled by the `maxSize` LRU cap (see `evictOldest`), which is
101
+ // the bound that actually matches usage: "keep the most recent N
102
+ // entities, not the entities touched in the last N minutes."
103
+ //
104
+ // Callers who genuinely want time-based eviction can pass an
105
+ // explicit `maxAge`. Leaving the default at Infinity keeps
106
+ // correctness as the default and makes aggressive GC an opt-in.
107
+ maxAge: config.maxAge ?? Number.POSITIVE_INFINITY,
108
+ gcInterval: config.gcInterval ?? 60000, // 1 minute
109
+ useWeakRefs: config.useWeakRefs ?? true,
110
+ };
111
+ this.runtime = config.runtime ?? globalRuntime;
112
+ // Store the model registry reference
113
+ if (!modelRegistry) {
114
+ throw new AbloValidationError('InstanceCache requires ModelRegistry for production-safe model name lookup', { code: 'pool_registry_missing' });
115
+ }
116
+ this.registry = modelRegistry;
117
+ // Type indexes are initialized on first use, so models can be registered
118
+ // after the InstanceCache is created; the first getByType call builds them.
119
+ // No computed cache layer: entries and typeIndex are both observable, and
120
+ // getByType() reads them directly, so MobX always tracks the dependency.
121
+ makeObservable(this, {
122
+ add: action,
123
+ addBatch: action,
124
+ upsertBatch: action,
125
+ removeBatch: action,
126
+ addToArchive: action,
127
+ remove: action,
128
+ removeFromArchive: action,
129
+ clear: action,
130
+ updateScope: action,
131
+ size: computed,
132
+ });
133
+ this.startGC();
134
+ }
135
+ // No computed getters — getByType() reads typeIndex and entries directly.
136
+ // Both are observable, so MobX always tracks the dependency; there is no
137
+ // conditional cache path that could silently drop a dependency.
138
+ // There is no cache layer to manage: typeIndex and entries are observable
139
+ // and read directly by getByType().
140
+ resolveModel(entry, id) {
141
+ if (entry.model)
142
+ return entry.model;
143
+ if (entry.weakRef) {
144
+ const model = entry.weakRef.deref();
145
+ if (model) {
146
+ entry.model = model;
147
+ if (id)
148
+ this.accessTimes.set(id, Date.now());
149
+ return model;
150
+ }
151
+ }
152
+ return undefined;
153
+ }
154
+ // The type parameter appears only in the return position on purpose: this is
155
+ // an ergonomic typed accessor (like Map<K, V>.get) that centralizes what would
156
+ // otherwise be an `as T` cast at every call site.
157
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters
158
+ get(id) {
159
+ const entry = this.entries.get(id);
160
+ if (!entry) {
161
+ runInAction(() => {
162
+ this.metrics.misses++;
163
+ });
164
+ return undefined;
165
+ }
166
+ let model = entry.model;
167
+ if (!model && entry.weakRef) {
168
+ const restoredModel = entry.weakRef.deref();
169
+ if (!restoredModel) {
170
+ runInAction(() => {
171
+ this.entries.delete(id);
172
+ this.removeFromTypeIndex(id, entry.model?.getModelName());
173
+ this.metrics.misses++;
174
+ });
175
+ return undefined;
176
+ }
177
+ model = restoredModel;
178
+ runInAction(() => {
179
+ entry.model = restoredModel;
180
+ });
181
+ }
182
+ // Never return disposed models — they are logically removed and may have
183
+ // torn-down internal state. Callers (e.g. flushPendingDeltas) must not
184
+ // receive a disposed reference that will throw on updateFromData().
185
+ if (model?.disposed) {
186
+ return undefined;
187
+ }
188
+ // Update access time in non-observable map — prevents MobX reactions during render
189
+ this.accessTimes.set(id, Date.now());
190
+ this.metrics.hits++;
191
+ model?.ensureObservable();
192
+ return model ?? undefined;
193
+ }
194
+ /**
195
+ * Look a row up **within one model**.
196
+ *
197
+ * The pool is a single id space: `get(id)` returns whatever row carries that
198
+ * id, whatever model it belongs to. That is the correct storage shape — ids
199
+ * are globally unique, the same premise as Relay's Global Object
200
+ * Identification — but it means an *untyped* lookup cannot stand in for a
201
+ * typed one. Apollo and EmberData avoid the question by keying their identity
202
+ * maps on `Type:id`; with unique ids the equivalent guarantee comes from
203
+ * stating the expected model at the lookup instead.
204
+ *
205
+ * Returns `undefined` for a row belonging to another model: from the asking
206
+ * model's perspective that id is simply absent. Callers that must tell "not
207
+ * here" apart from "here, but another model's" should compare against
208
+ * {@link get}.
209
+ *
210
+ * Prefer this over `get()` anywhere the caller knows which model it wants —
211
+ * `get()` returning another model's row has caused three product bugs, most
212
+ * recently a resize gesture that reverted after every commit.
213
+ */
214
+ // `T` appears only in the return position, which is normally a caller-chosen
215
+ // cast in disguise. It is sound here precisely because `modelName` is checked
216
+ // at runtime below before the row is handed back, so the caller's expected
217
+ // type and the row's registered identity cannot disagree.
218
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters
219
+ getOfType(id, modelName) {
220
+ const model = this.get(id);
221
+ if (!model)
222
+ return undefined;
223
+ // Checked, so the assertion below is sound: `typeIndex` and
224
+ // `getModelName()` are the same registered-name identity.
225
+ return model.getModelName() === modelName ? model : undefined;
226
+ }
227
+ /**
228
+ * Add model with deduplication support
229
+ */
230
+ add(model, scope = ModelScope.live, deltaInfo) {
231
+ const id = model.id;
232
+ const modelType = model.getModelName();
233
+ const addKey = `${modelType}:${id}`;
234
+ // Ensure type index exists for this model type
235
+ if (!this.typeIndex.has(modelType)) {
236
+ this.typeIndex.set(modelType, observable.set());
237
+ }
238
+ // Check if model already exists to prevent duplicates
239
+ const existingEntry = this.entries.get(id);
240
+ if (existingEntry?.model && !existingEntry.model.disposed) {
241
+ // Model already exists and is valid, update its scope if needed
242
+ if (existingEntry.scope !== scope) {
243
+ runInAction(() => {
244
+ this.entries.set(id, { ...existingEntry, scope });
245
+ });
246
+ this.accessTimes.set(id, Date.now());
247
+ }
248
+ this.metrics.duplicatesSkipped++;
249
+ return;
250
+ }
251
+ // Check rapid additions (within 50ms) for better deduplication
252
+ const lastAdded = this.recentAdditions.get(addKey);
253
+ if (lastAdded && Date.now() - lastAdded < 50) {
254
+ this.metrics.duplicatesSkipped++;
255
+ return;
256
+ }
257
+ // Check delta history for duplicate processing
258
+ if (deltaInfo?.syncId) {
259
+ const history = this.deltaHistory.get(addKey);
260
+ if (history) {
261
+ // Skip if we've already processed a newer or equal sync ID
262
+ if (history.lastSyncId >= deltaInfo.syncId) {
263
+ this.metrics.duplicatesSkipped++;
264
+ return;
265
+ }
266
+ // Warn about suspicious patterns
267
+ if (deltaInfo.action === 'I' &&
268
+ (history.lastAction === 'U' || history.lastAction === 'D')) {
269
+ // Internal delta-ordering anomaly that reconciles on the next
270
+ // catch-up — forensic, not consumer-actionable → debug.
271
+ this.runtime.logger.debug(`InstanceCache.add() SUSPICIOUS: INSERT after ${history.lastAction}`, { modelType, id, syncId: deltaInfo.syncId });
272
+ }
273
+ }
274
+ // Update delta history
275
+ this.deltaHistory.set(addKey, {
276
+ lastAction: deltaInfo.action ?? 'U',
277
+ lastSyncId: deltaInfo.syncId,
278
+ timestamp: Date.now(),
279
+ });
280
+ }
281
+ // Track this addition
282
+ this.recentAdditions.set(addKey, Date.now());
283
+ // Clean old tracking entries periodically
284
+ if (this.recentAdditions.size > 100) {
285
+ this.cleanupTracking();
286
+ }
287
+ // Note: existingEntry check is now done earlier for better deduplication
288
+ if (this.entries.size >= this.config.maxSize) {
289
+ this.evictOldest();
290
+ }
291
+ const entry = {
292
+ model,
293
+ scope,
294
+ };
295
+ if (this.config.useWeakRefs && this.isLargeModel(model)) {
296
+ entry.weakRef = new WeakRef(model);
297
+ }
298
+ this.accessTimes.set(id, Date.now());
299
+ runInAction(() => {
300
+ this.entries.set(id, entry);
301
+ this.addToTypeIndex(id, model.getModelName());
302
+ this.metrics.additions++;
303
+ });
304
+ // No cache to invalidate — typeIndex + entries are directly observable
305
+ // Notify views of the addition
306
+ this.notifySubscribers(model);
307
+ this.viewRegistry.notifyAdded(modelType, model);
308
+ }
309
+ /**
310
+ * Upsert a model - INSERT if new, UPDATE if exists
311
+ */
312
+ upsert(model, scope = ModelScope.live) {
313
+ const id = model.id;
314
+ const existingEntry = this.entries.get(id);
315
+ if (existingEntry?.model && !existingEntry.model.disposed) {
316
+ // Model exists - update it in-place
317
+ const existingModel = existingEntry.model;
318
+ // Skip updateFromData if same instance - preserves _local changes for client mutations
319
+ if (model !== existingModel) {
320
+ existingModel.updateFromData(model.toJSON());
321
+ }
322
+ // Update scope if different
323
+ if (existingEntry.scope !== scope) {
324
+ runInAction(() => {
325
+ this.entries.set(id, { ...existingEntry, scope });
326
+ });
327
+ this.accessTimes.set(id, Date.now());
328
+ }
329
+ this.notifySubscribers(existingModel);
330
+ // Notify views of the update
331
+ this.viewRegistry.notifyUpdated(existingModel.getModelName(), existingModel);
332
+ }
333
+ else {
334
+ // Model doesn't exist - add it (add() already notifies views)
335
+ this.add(model, scope);
336
+ }
337
+ }
338
+ /**
339
+ * Batch add models - optimized for hydration
340
+ * All models are added in a single MobX action to minimize reactivity overhead
341
+ */
342
+ addBatch(models, scope = ModelScope.live) {
343
+ if (models.length === 0)
344
+ return 0;
345
+ let addedCount = 0;
346
+ const now = Date.now();
347
+ const newIds = new Set();
348
+ for (const model of models) {
349
+ const existing = this.entries.get(model.id);
350
+ if (!existing?.model || existing.model.disposed)
351
+ newIds.add(model.id);
352
+ }
353
+ const capacityNeeded = Math.max(0, this.entries.size + newIds.size - this.config.maxSize);
354
+ if (capacityNeeded > 0)
355
+ this.evictOldestBatch(capacityNeeded);
356
+ // Process all models in a single action to avoid per-item reaction cycles
357
+ for (const model of models) {
358
+ const id = model.id;
359
+ const modelType = model.getModelName();
360
+ // Ensure type index exists
361
+ if (!this.typeIndex.has(modelType)) {
362
+ this.typeIndex.set(modelType, observable.set());
363
+ }
364
+ // Skip if model already exists and is valid
365
+ const existingEntry = this.entries.get(id);
366
+ if (existingEntry?.model && !existingEntry.model.disposed) {
367
+ if (existingEntry.scope !== scope) {
368
+ this.entries.set(id, { ...existingEntry, scope });
369
+ this.accessTimes.set(id, now);
370
+ }
371
+ this.metrics.duplicatesSkipped++;
372
+ continue;
373
+ }
374
+ const entry = {
375
+ model,
376
+ scope,
377
+ };
378
+ this.accessTimes.set(id, now);
379
+ if (this.config.useWeakRefs && this.isLargeModel(model)) {
380
+ entry.weakRef = new WeakRef(model);
381
+ }
382
+ this.entries.set(id, entry);
383
+ this.addToTypeIndex(id, modelType);
384
+ // Populate the foreign-key indexes. The single-item `add()` path
385
+ // does this; `addBatch()` used to skip it, which meant every
386
+ // block / ledger cell / message that came in through a bulk
387
+ // loader (`ensureReportBlocks`, `prefetchSectionBlocks`, bootstrap
388
+ // hydration) was in the pool but invisible to `hasMany` lookups
389
+ // — `section.blocks` returned `[]` until the user clicked a block
390
+ // and something else ran a non-batch `add` that happened to
391
+ // populate the FK index as a side effect. The UX symptom was
392
+ // "sections show empty until you click on one." Adding this one
393
+ // line closes the gap.
394
+ this.addToForeignKeyIndex(id, model, modelType);
395
+ this.metrics.additions++;
396
+ addedCount++;
397
+ this.notifySubscribers(model);
398
+ // Notify views of the addition
399
+ this.viewRegistry.notifyAdded(modelType, model);
400
+ }
401
+ // No cache to invalidate — typeIndex + entries are directly observable
402
+ return addedCount;
403
+ }
404
+ /**
405
+ * Batch upsert models - optimized for delta processing.
406
+ * All upserts happen in a single MobX action to minimize reactivity overhead.
407
+ */
408
+ upsertBatch(models, scope = ModelScope.live) {
409
+ if (models.length === 0)
410
+ return;
411
+ for (const model of models) {
412
+ const id = model.id;
413
+ const existingEntry = this.entries.get(id);
414
+ if (existingEntry?.model && !existingEntry.model.disposed) {
415
+ if (model !== existingEntry.model) {
416
+ existingEntry.model.updateFromData(model.toJSON());
417
+ }
418
+ if (existingEntry.scope !== scope) {
419
+ this.entries.set(id, { ...existingEntry, scope });
420
+ this.accessTimes.set(id, Date.now());
421
+ }
422
+ this.notifySubscribers(existingEntry.model);
423
+ // Notify views of the update
424
+ this.viewRegistry.notifyUpdated(existingEntry.model.getModelName(), existingEntry.model);
425
+ }
426
+ else {
427
+ // Delegate to inline add logic (same as addBatch internals)
428
+ const modelType = model.getModelName();
429
+ if (!this.typeIndex.has(modelType)) {
430
+ this.typeIndex.set(modelType, observable.set());
431
+ }
432
+ if (this.entries.size >= this.config.maxSize) {
433
+ this.evictOldest();
434
+ }
435
+ const entry = { model, scope };
436
+ this.accessTimes.set(id, Date.now());
437
+ if (this.config.useWeakRefs && this.isLargeModel(model)) {
438
+ entry.weakRef = new WeakRef(model);
439
+ }
440
+ this.entries.set(id, entry);
441
+ this.addToTypeIndex(id, modelType);
442
+ this.metrics.additions++;
443
+ this.notifySubscribers(model);
444
+ // Notify views of the addition
445
+ this.viewRegistry.notifyAdded(modelType, model);
446
+ }
447
+ }
448
+ // No cache to invalidate — typeIndex + entries are directly observable
449
+ }
450
+ /**
451
+ * Batch remove models by ID - optimized for delta processing.
452
+ * All removals happen in a single MobX action to minimize reactivity overhead.
453
+ * Returns the number of models actually removed.
454
+ */
455
+ removeBatch(ids) {
456
+ if (ids.length === 0)
457
+ return 0;
458
+ let removedCount = 0;
459
+ for (const id of ids) {
460
+ const entry = this.entries.get(id);
461
+ if (!entry)
462
+ continue;
463
+ const modelName = entry.model?.getModelName() ?? entry.weakRef?.deref()?.getModelName();
464
+ // FK/type cleanup must run before entries.delete — see `remove()`
465
+ // for the full explanation. Same bug, same fix.
466
+ this.removeFromTypeIndex(id, modelName);
467
+ this.entries.delete(id);
468
+ // Notify views of the removal before disposing
469
+ if (modelName) {
470
+ this.viewRegistry.notifyRemoved(modelName, id);
471
+ }
472
+ const model = entry.model ?? entry.weakRef?.deref();
473
+ // A non-Model object can reach the pool (see `clear`); only dispose a real one.
474
+ if (typeof model?.dispose === 'function')
475
+ model.dispose();
476
+ const addKey = modelName ? `${modelName}:${id}` : id;
477
+ this.recentAdditions.delete(addKey);
478
+ this.deltaHistory.delete(addKey);
479
+ this.accessTimes.delete(id);
480
+ removedCount++;
481
+ }
482
+ // No cache to invalidate — typeIndex + entries are directly observable
483
+ return removedCount;
484
+ }
485
+ /**
486
+ * Read-only accessor for entity IDs by model type.
487
+ * Used by applyBootstrapToPool() and rehydrateFromDatabase() for ghost detection.
488
+ */
489
+ getIdsByModelType(modelType) {
490
+ return this.typeIndex.get(modelType);
491
+ }
492
+ addToArchive(model) {
493
+ this.add(model, ModelScope.archived);
494
+ }
495
+ remove(id) {
496
+ const entry = this.entries.get(id);
497
+ if (!entry)
498
+ return false;
499
+ const modelName = entry.model?.getModelName() ?? entry.weakRef?.deref()?.getModelName();
500
+ // Order matters here: `removeFromTypeIndex` → `removeFromForeignKeyIndex`
501
+ // reads the FK field values off the model via `this.entries.get(id)`.
502
+ // If we `this.entries.delete(id)` first, the model is gone and the
503
+ // FK cleanup silently no-ops — leaving ghost ids in the FK index.
504
+ // That causes `getByForeignKey(..., parentId)` to report
505
+ // `matched > returned` (dropped-no-entry) and, on the UI, keeps the
506
+ // stale block visible until the next reload rebuilds the index
507
+ // from fresh data. Do the FK/type cleanup first, then delete the
508
+ // entry.
509
+ runInAction(() => {
510
+ this.removeFromTypeIndex(id, modelName);
511
+ this.entries.delete(id);
512
+ });
513
+ // No cache to invalidate — typeIndex + entries are directly observable
514
+ // Notify views of the removal before disposing
515
+ if (modelName) {
516
+ this.viewRegistry.notifyRemoved(modelName, id);
517
+ }
518
+ const model = entry.model ?? entry.weakRef?.deref();
519
+ // A non-Model object can reach the pool (see `clear`); only dispose a real one.
520
+ if (typeof model?.dispose === 'function')
521
+ model.dispose();
522
+ // Clean tracking
523
+ const addKey = modelName ? `${modelName}:${id}` : id;
524
+ this.recentAdditions.delete(addKey);
525
+ this.deltaHistory.delete(addKey);
526
+ this.accessTimes.delete(id);
527
+ return true;
528
+ }
529
+ removeFromArchive(id) {
530
+ const entry = this.entries.get(id);
531
+ if (entry?.scope !== ModelScope.archived) {
532
+ return false;
533
+ }
534
+ return this.remove(id);
535
+ }
536
+ getByType(modelClass, scope = ModelScope.all) {
537
+ // Read typeIndex and entries directly. Both are observable maps, so MobX
538
+ // always tracks the dependency — there is no conditional cache path.
539
+ let actualModelName = this.registry.getModelNameFromConstructor(modelClass);
540
+ if (!actualModelName) {
541
+ actualModelName = this.registry.getModelNameFromConstructor(modelClass);
542
+ if (!actualModelName) {
543
+ try {
544
+ const ConcreteClass = modelClass;
545
+ const tempInstance = new ConcreteClass({});
546
+ actualModelName = tempInstance.getModelName();
547
+ // Fallback resolved — hand-coded class not in registry but name matches.
548
+ // This is expected during migration from hand-coded → dynamic models.
549
+ }
550
+ catch (e) {
551
+ this.runtime.observability.breadcrumb(`Failed to create fallback instance for ${modelClass.name}`, 'sync.database', 'error', {
552
+ error: e instanceof Error ? e.message : String(e),
553
+ });
554
+ return [];
555
+ }
556
+ }
557
+ }
558
+ // Read from typeIndex (observable) to get IDs for this model type
559
+ const ids = this.typeIndex.get(actualModelName || '');
560
+ if (!ids || ids.size === 0) {
561
+ return [];
562
+ }
563
+ // Resolve each ID from entries (observable) with scope filtering.
564
+ // Note: we do not check `instanceof modelClass`, because schema-generated
565
+ // dynamic classes and hand-coded classes are different constructors that
566
+ // both represent the same model type. The typeIndex lookup by name is
567
+ // authoritative — if the name matched, the model belongs to this type.
568
+ const result = [];
569
+ for (const id of ids) {
570
+ const entry = this.entries.get(id);
571
+ if (!entry)
572
+ continue;
573
+ if (!this.matchesScope(entry.scope, scope))
574
+ continue;
575
+ const model = this.resolveModel(entry, id);
576
+ if (model && !model.disposed) {
577
+ result.push(model);
578
+ }
579
+ }
580
+ return result;
581
+ }
582
+ /**
583
+ * Get all models of a given type by string name.
584
+ * Used for custom entity types where multiple entity type names share
585
+ * the same CustomEntityModel constructor (getByType can't disambiguate).
586
+ * Reads from the same typeIndex as getByType — MobX tracks the dependency.
587
+ */
588
+ getByTypeName(modelName, scope = ModelScope.all) {
589
+ const ids = this.typeIndex.get(modelName);
590
+ if (!ids || ids.size === 0) {
591
+ return [];
592
+ }
593
+ const result = [];
594
+ for (const id of ids) {
595
+ const entry = this.entries.get(id);
596
+ if (!entry)
597
+ continue;
598
+ if (!this.matchesScope(entry.scope, scope))
599
+ continue;
600
+ const model = this.resolveModel(entry, id);
601
+ if (model && !model.disposed) {
602
+ result.push(model);
603
+ }
604
+ }
605
+ return result;
606
+ }
607
+ *iterateByType(modelClass, scope = ModelScope.all) {
608
+ const actualModelName = this.registry.getModelNameFromConstructor(modelClass);
609
+ if (!actualModelName) {
610
+ throw new AbloValidationError(`Model class ${modelClass.name} not registered in ModelRegistry`, { code: 'pool_model_class_not_registered' });
611
+ }
612
+ const ids = this.typeIndex.get(actualModelName);
613
+ if (!ids)
614
+ return;
615
+ for (const id of ids) {
616
+ const entry = this.entries.get(id);
617
+ if (!entry)
618
+ continue;
619
+ if (!this.matchesScope(entry.scope, scope))
620
+ continue;
621
+ const model = this.get(id);
622
+ if (model && model instanceof modelClass) {
623
+ yield model;
624
+ }
625
+ }
626
+ }
627
+ updateScope(id, scope) {
628
+ const entry = this.entries.get(id);
629
+ if (entry && entry.scope !== scope) {
630
+ // Re-set the entry so ObservableMap notifies observers of the change.
631
+ // Mutating entry.scope in-place wouldn't trigger MobX (plain object property).
632
+ runInAction(() => {
633
+ this.entries.set(id, { ...entry, scope });
634
+ });
635
+ this.accessTimes.set(id, Date.now());
636
+ }
637
+ }
638
+ /**
639
+ * Create (or update) a model instance locally, given a typename and raw
640
+ * data. Cleaner than `createFromData({ __typename, ...data })` — the
641
+ * typename lives in the arg list, not hidden inside the data object.
642
+ *
643
+ * Used for optimistic local writes: `pool.create('Section', { id, reportId, ... })`.
644
+ * For hydration from server deltas (where `__typename` already rides on
645
+ * the payload), use `createFromData(data)` directly — that path is kept
646
+ * because the wire format attaches the discriminator to the data itself.
647
+ */
648
+ create(typename, data) {
649
+ return this.createFromData({ ...data, __typename: typename });
650
+ }
651
+ createFromData(data, ModelClass, opts) {
652
+ // Support multiple model identifier fields for backwards compatibility
653
+ const modelName = data.__typename ?? data.__class ?? data.modelName ?? 'Unknown';
654
+ const Constructor = ModelClass ?? this.registry.getModelByName(modelName);
655
+ if (!Constructor) {
656
+ if (modelName === 'Unknown') {
657
+ // Malformed row with no type marker — dropped, but nothing the consumer
658
+ // can act on (the actionable schema-drift case is handled below) → debug.
659
+ this.runtime.logger.debug('InstanceCache.createFromData: No model identifier found', { data });
660
+ this.runtime.modelDebugLogger?.logError('Unknown', 'CREATE', 'No model identifier found', data);
661
+ return null;
662
+ }
663
+ if (opts?.strict) {
664
+ const known = this.registry.getRegisteredModelNames();
665
+ throw new AbloValidationError(`Model "${modelName}" is not registered on this client` +
666
+ (known.length ? ` (known: ${known.join(', ')})` : '') +
667
+ `. The schema pushed to this org may differ from your local ` +
668
+ `schema — run \`ablo status\` to compare.`, { code: 'model_not_registered' });
669
+ }
670
+ // Genuinely actionable and NOT self-healing: a model the server is sending
671
+ // isn't in your schema, so these rows are silently skipped. Keep at warn,
672
+ // consumer register (their model name + the `ablo status` fix); forensics ride debug.
673
+ this.runtime.logger.warn(`Received data for "${modelName}", which isn't in your schema — these rows will be skipped. Run \`ablo status\` to compare your local schema with the server.`);
674
+ this.runtime.logger.debug(`InstanceCache.createFromData: No constructor found for model "${modelName}"`, { data });
675
+ this.runtime.modelDebugLogger?.logError(modelName, 'CREATE', `No constructor found for model "${modelName}"`, data);
676
+ return null;
677
+ }
678
+ // If the model already exists, update it in place instead of creating a
679
+ // duplicate. Keeping the existing instance alive preserves React's
680
+ // references and MobX's observation tracking.
681
+ if (data.id && this.entries.has(data.id)) {
682
+ const existing = this.get(data.id);
683
+ if (existing?.getModelName() === modelName) {
684
+ // Same ID and same type - update existing model with new data and return it
685
+ existing.updateFromData(data);
686
+ return existing;
687
+ }
688
+ // Different type with same ID - this is a shared PK scenario (e.g., two models sharing one row id)
689
+ // Don't return existing, create new model (will use composite key for storage)
690
+ }
691
+ // Log model creation attempt
692
+ this.runtime.modelDebugLogger?.logCreation(modelName, data, Constructor);
693
+ try {
694
+ if (opts?.deferObservability) {
695
+ Object.defineProperty(data, DEFER_MODEL_OBSERVABILITY, {
696
+ value: true,
697
+ enumerable: false,
698
+ configurable: true,
699
+ });
700
+ }
701
+ // Pass data directly to constructor for Prisma-first models
702
+ const model = new Constructor(data);
703
+ return model;
704
+ }
705
+ catch (error) {
706
+ const errorMessage = error instanceof Error ? error.message : String(error);
707
+ // Internal construction failure — captured via observability below and
708
+ // re-fetched on resync; the stack is forensic → debug.
709
+ this.runtime.logger.debug(`[InstanceCache.createFromData] FAILED ${modelName}`, { errorMessage, stack: error instanceof Error ? error.stack : undefined });
710
+ this.runtime.observability.captureMutationFailure({
711
+ context: 'createFromData',
712
+ modelName,
713
+ modelId: data.id,
714
+ error: errorMessage,
715
+ });
716
+ this.runtime.modelDebugLogger?.logError(modelName, 'CREATE', errorMessage, {
717
+ data,
718
+ constructor: Constructor.name,
719
+ });
720
+ return null;
721
+ }
722
+ }
723
+ /**
724
+ * Clear the object pool
725
+ * @param options.preserveObserved - If true, keep models that are being observed by React
726
+ * This prevents React components from holding stale references
727
+ * after bootstrap/rehydration
728
+ */
729
+ clear(options = {}) {
730
+ const preserveObserved = options.preserveObserved ?? false;
731
+ const preservedIds = [];
732
+ const preservedEntries = [];
733
+ for (const [id, entry] of this.entries) {
734
+ const model = entry.model ?? entry.weakRef?.deref();
735
+ // Check if this model should be preserved (has active React observers)
736
+ if (preserveObserved &&
737
+ model &&
738
+ typeof model.hasObservedCollections === 'function' &&
739
+ model.hasObservedCollections()) {
740
+ // Keep this model alive - React is still using it
741
+ preservedIds.push(id);
742
+ preservedEntries.push([id, entry]);
743
+ continue;
744
+ }
745
+ // `rowAsModel` only casts, so a non-Model object can reach the pool (see
746
+ // the SyncClient no-op UPDATE guard). Guard that `dispose` is actually
747
+ // callable — mirroring the `hasObservedCollections` typeof-check above —
748
+ // rather than assume every pooled entry is a real Model.
749
+ if (typeof model?.dispose === 'function')
750
+ model.dispose();
751
+ }
752
+ // Save access times for preserved entries before clearing
753
+ const preservedAccessTimes = new Map();
754
+ for (const [id] of preservedEntries) {
755
+ const time = this.accessTimes.get(id);
756
+ if (time)
757
+ preservedAccessTimes.set(id, time);
758
+ }
759
+ runInAction(() => {
760
+ this.entries.clear();
761
+ this.typeIndex.clear();
762
+ // Clear foreign key index data (preserves config/structure, just empties the value maps)
763
+ for (const index of this.foreignKeyIndexes.values()) {
764
+ index.clear();
765
+ }
766
+ this.recentAdditions.clear();
767
+ this.deltaHistory.clear();
768
+ this.metrics = {
769
+ hits: 0,
770
+ misses: 0,
771
+ evictions: 0,
772
+ additions: 0,
773
+ duplicatesSkipped: 0,
774
+ };
775
+ // Re-add preserved entries (also rebuilds foreign key indexes via addToTypeIndex)
776
+ for (const [id, entry] of preservedEntries) {
777
+ this.entries.set(id, entry);
778
+ const model = entry.model ?? entry.weakRef?.deref();
779
+ if (model) {
780
+ this.addToTypeIndex(id, model.getModelName());
781
+ }
782
+ }
783
+ });
784
+ // Restore access times: clear then re-add preserved
785
+ this.accessTimes.clear();
786
+ for (const [id, time] of preservedAccessTimes) {
787
+ this.accessTimes.set(id, time);
788
+ }
789
+ // No cache to invalidate — typeIndex + entries are directly observable
790
+ }
791
+ has(id) {
792
+ return this.entries.has(id);
793
+ }
794
+ /**
795
+ * Touch a model to update its access time (prevents premature GC)
796
+ * Used by LazyReferenceCollection to keep parent models alive during active usage
797
+ */
798
+ touch(id) {
799
+ const entry = this.entries.get(id);
800
+ if (!entry) {
801
+ return false;
802
+ }
803
+ this.accessTimes.set(id, Date.now());
804
+ return true;
805
+ }
806
+ getAllIds() {
807
+ return Array.from(this.entries.keys());
808
+ }
809
+ getAllModels() {
810
+ const results = [];
811
+ for (const [id] of this.entries) {
812
+ const model = this.get(id);
813
+ if (model) {
814
+ results.push(model);
815
+ }
816
+ }
817
+ return results;
818
+ }
819
+ get size() {
820
+ return this.entries.size;
821
+ }
822
+ get hitRate() {
823
+ const total = this.metrics.hits + this.metrics.misses;
824
+ return total > 0 ? (this.metrics.hits / total) * 100 : 0;
825
+ }
826
+ getStats() {
827
+ const scopeCounts = { live: 0, archived: 0 };
828
+ const typeCounts = new Map();
829
+ for (const [, entry] of this.entries) {
830
+ if (entry.scope === ModelScope.live)
831
+ scopeCounts.live++;
832
+ else if (entry.scope === ModelScope.archived)
833
+ scopeCounts.archived++;
834
+ const modelName = entry.model?.getModelName() ?? entry.weakRef?.deref()?.getModelName();
835
+ if (modelName) {
836
+ typeCounts.set(modelName, (typeCounts.get(modelName) ?? 0) + 1);
837
+ }
838
+ }
839
+ return {
840
+ size: this.size,
841
+ hitRate: this.hitRate,
842
+ metrics: { ...this.metrics },
843
+ scopeCounts,
844
+ typeCounts: Object.fromEntries(typeCounts),
845
+ deltaHistorySize: this.deltaHistory.size,
846
+ recentAdditionsSize: this.recentAdditions.size,
847
+ config: { ...this.config },
848
+ };
849
+ }
850
+ clearDeltaHistory(olderThanMs = 3600000) {
851
+ const now = Date.now();
852
+ const toDelete = [];
853
+ for (const [key, history] of this.deltaHistory) {
854
+ if (now - history.timestamp > olderThanMs) {
855
+ toDelete.push(key);
856
+ }
857
+ }
858
+ toDelete.forEach((key) => this.deltaHistory.delete(key));
859
+ // Delta history entries cleared silently
860
+ }
861
+ cleanupTracking() {
862
+ const now = Date.now();
863
+ for (const [key, time] of this.recentAdditions) {
864
+ if (now - time > 1000) {
865
+ this.recentAdditions.delete(key);
866
+ }
867
+ }
868
+ }
869
+ gc() {
870
+ return runInAction(() => {
871
+ const now = Date.now();
872
+ const toRemove = [];
873
+ let evicted = 0;
874
+ let skippedObserved = 0;
875
+ for (const [id, entry] of this.entries) {
876
+ // Check if model has expired based on last access time
877
+ const lastAccessed = this.accessTimes.get(id) ?? 0;
878
+ if (now - lastAccessed > this.config.maxAge) {
879
+ // Do not GC a model that has observed collections — disposing one
880
+ // React is still observing would break it (per MobX guidance).
881
+ // See: https://mobx.js.org/lazy-observables.html
882
+ const model = entry.model ?? entry.weakRef?.deref();
883
+ if (model &&
884
+ typeof model.hasObservedCollections === 'function' &&
885
+ model.hasObservedCollections()) {
886
+ // Model has active React observers - refresh access time and skip GC
887
+ this.accessTimes.set(id, now);
888
+ skippedObserved++;
889
+ continue;
890
+ }
891
+ toRemove.push(id);
892
+ continue;
893
+ }
894
+ // Strong-to-weak-ref demotion at `maxAge / 2` used to live here,
895
+ // in service of memory-pressure relief: idle entries would lose
896
+ // their strong reference, V8 would collect them, and the next
897
+ // access would re-hydrate from IDB/network. In practice it
898
+ // caused silent data loss — any model actively being rendered
899
+ // through a schema-driven dynamic class (i.e., most of them)
900
+ // would be demoted, collected, and the next render's
901
+ // `weakRef.deref()` returned undefined, so blocks / cells /
902
+ // messages "disappeared" after ~10 min of idle.
903
+ //
904
+ // The `hasObservedCollections()` guard used by the eviction
905
+ // branch above only protects models that explicitly register a
906
+ // LazyReferenceCollection; plain observer() components reading
907
+ // properties don't register, so for typical UI usage the guard
908
+ // didn't apply. Rather than try to make React-observation
909
+ // globally visible to the pool, we drop the demotion phase
910
+ // entirely — hard eviction at `maxAge` (with its own guard) is
911
+ // the only automated removal now. If memory-pressure relief is
912
+ // needed later, gate it on an explicit policy (e.g.,
913
+ // `documenthidden` + `performance.memory.usedJSHeapSize`) rather
914
+ // than a time-based tick.
915
+ }
916
+ for (const id of toRemove) {
917
+ if (this.remove(id)) {
918
+ evicted++;
919
+ this.metrics.evictions++;
920
+ }
921
+ }
922
+ if (skippedObserved > 0) {
923
+ this.runtime.logger.debug(`[InstanceCache GC] Skipped ${skippedObserved} models with active React observers`);
924
+ }
925
+ // Also clean up old tracking data
926
+ this.clearDeltaHistory();
927
+ this.cleanupTracking();
928
+ return evicted;
929
+ });
930
+ }
931
+ startGC() {
932
+ if (this.gcTimer)
933
+ return;
934
+ this.gcTimer = setInterval(() => this.gc(), this.config.gcInterval);
935
+ // Don't hold a headless Node process open just for pool GC — without
936
+ // this, an agent that never calls disconnect() can never exit. No-op in
937
+ // browsers (where setInterval returns a number without `unref`), which is
938
+ // why the call is optional even though the Node timer type always has it.
939
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
940
+ this.gcTimer.unref?.();
941
+ }
942
+ stopGC() {
943
+ if (this.gcTimer) {
944
+ clearInterval(this.gcTimer);
945
+ this.gcTimer = undefined;
946
+ }
947
+ }
948
+ evictOldest() {
949
+ this.evictOldestBatch(1);
950
+ }
951
+ /**
952
+ * Free capacity for a whole incoming frame in one scan.
953
+ *
954
+ * Calling evictOldest once per model made a full 10k-entry cache scan
955
+ * `incomingCount` times. A 400-row delta frame therefore performed four
956
+ * million entry visits before constructing models. Keep only the `count`
957
+ * oldest candidates in a bounded max-heap: a full sort paid
958
+ * O(cache log cache) for every sustained publication frame even though it
959
+ * consumed only the first few hundred entries. The heap preserves the same
960
+ * LRU/observed-model contract at O(cache log count) time and O(count) space.
961
+ */
962
+ evictOldestBatch(count) {
963
+ if (count <= 0)
964
+ return;
965
+ runInAction(() => {
966
+ const oldest = [];
967
+ const candidateAt = (index) => {
968
+ const candidate = oldest[index];
969
+ if (!candidate)
970
+ throw new Error(`Missing eviction candidate at index ${index}`);
971
+ return candidate;
972
+ };
973
+ const swap = (left, right) => {
974
+ const leftValue = candidateAt(left);
975
+ const rightValue = candidateAt(right);
976
+ oldest[left] = rightValue;
977
+ oldest[right] = leftValue;
978
+ };
979
+ const siftUp = (start) => {
980
+ let index = start;
981
+ while (index > 0) {
982
+ const parent = Math.floor((index - 1) / 2);
983
+ if (candidateAt(parent).accessedAt >= candidateAt(index).accessedAt)
984
+ break;
985
+ swap(parent, index);
986
+ index = parent;
987
+ }
988
+ };
989
+ const siftDown = () => {
990
+ let index = 0;
991
+ for (;;) {
992
+ const left = index * 2 + 1;
993
+ if (left >= oldest.length)
994
+ return;
995
+ const right = left + 1;
996
+ const larger = right < oldest.length &&
997
+ candidateAt(right).accessedAt > candidateAt(left).accessedAt
998
+ ? right
999
+ : left;
1000
+ if (candidateAt(index).accessedAt >= candidateAt(larger).accessedAt)
1001
+ return;
1002
+ swap(index, larger);
1003
+ index = larger;
1004
+ }
1005
+ };
1006
+ for (const [id, entry] of this.entries) {
1007
+ // Skip models that are being observed by React - they must stay alive
1008
+ const model = entry.model ?? entry.weakRef?.deref();
1009
+ if (model &&
1010
+ typeof model.hasObservedCollections === 'function' &&
1011
+ model.hasObservedCollections()) {
1012
+ continue;
1013
+ }
1014
+ const candidate = {
1015
+ id,
1016
+ accessedAt: this.accessTimes.get(id) ?? 0,
1017
+ };
1018
+ if (oldest.length < count) {
1019
+ oldest.push(candidate);
1020
+ siftUp(oldest.length - 1);
1021
+ }
1022
+ else if (candidate.accessedAt < candidateAt(0).accessedAt) {
1023
+ oldest[0] = candidate;
1024
+ siftDown();
1025
+ }
1026
+ }
1027
+ for (const candidate of oldest) {
1028
+ this.remove(candidate.id);
1029
+ this.metrics.evictions++;
1030
+ }
1031
+ });
1032
+ }
1033
+ isLargeModel(model) {
1034
+ try {
1035
+ // Most synchronized rows contain only short scalars. Serializing the
1036
+ // entire model merely to discover that fact doubled wire-ingest object
1037
+ // walking. Accumulate cheap scalar sizes and serialize only nested
1038
+ // payloads that can plausibly cross the WeakRef threshold.
1039
+ let size = 0;
1040
+ for (const value of Object.values(model)) {
1041
+ if (typeof value === 'string') {
1042
+ size += value.length;
1043
+ }
1044
+ else if (typeof value === 'number' ||
1045
+ typeof value === 'bigint' ||
1046
+ typeof value === 'boolean') {
1047
+ size += 8;
1048
+ }
1049
+ else if (value instanceof Date) {
1050
+ size += 24;
1051
+ }
1052
+ else if (value && typeof value === 'object') {
1053
+ size += JSON.stringify(value).length;
1054
+ }
1055
+ if (size > 10240)
1056
+ return true;
1057
+ }
1058
+ return false;
1059
+ }
1060
+ catch {
1061
+ return false;
1062
+ }
1063
+ }
1064
+ // ========== FOREIGN KEY INDEX ==========
1065
+ /**
1066
+ * Register a foreign key field for indexing on a model type.
1067
+ * Call once during app initialization (e.g., after model registration).
1068
+ *
1069
+ * Example: registerForeignKey('Block', 'sectionId')
1070
+ * This enables getByForeignKey('Block', 'sectionId', someSectionId) → O(1) lookup
1071
+ */
1072
+ registerForeignKey(modelName, fieldName) {
1073
+ const fields = this.foreignKeyConfig.get(modelName) ?? [];
1074
+ if (!fields.includes(fieldName)) {
1075
+ fields.push(fieldName);
1076
+ this.foreignKeyConfig.set(modelName, fields);
1077
+ }
1078
+ // Initialize the index map
1079
+ const indexKey = `${modelName}:${fieldName}`;
1080
+ if (!this.foreignKeyIndexes.has(indexKey)) {
1081
+ this.foreignKeyIndexes.set(indexKey, observable.map());
1082
+ }
1083
+ }
1084
+ /**
1085
+ * Check whether a foreign key index exists for a given typename + field.
1086
+ * Used by QueryView to decide whether to use FK-index for initial scan.
1087
+ */
1088
+ hasForeignKeyIndex(typename, fieldName) {
1089
+ const indexKey = `${typename}:${fieldName}`;
1090
+ return this.foreignKeyIndexes.has(indexKey);
1091
+ }
1092
+ /**
1093
+ * Create a QueryView — an incrementally maintained materialized view.
1094
+ * The view registers itself with the ViewRegistry and receives
1095
+ * incremental updates when models of the given typename change.
1096
+ */
1097
+ createView(typename, options) {
1098
+ return new QueryView(typename, this, this.viewRegistry, options);
1099
+ }
1100
+ /**
1101
+ * O(1) lookup of models by foreign key value.
1102
+ * Returns model instances, filtered to live scope by default.
1103
+ */
1104
+ getByForeignKey(modelName, fieldName, fieldValue) {
1105
+ const indexKey = `${modelName}:${fieldName}`;
1106
+ const index = this.foreignKeyIndexes.get(indexKey);
1107
+ // Both empty-path early-returns below are normal states, not errors:
1108
+ // a model with no FK index yet (not populated), or an index with no
1109
+ // entry for this specific parent id (entity genuinely has no
1110
+ // children). These used to `console.warn` diagnostic dumps on every
1111
+ // call, which turned into hundreds of log lines per second during
1112
+ // cursor hover / rapid re-renders on a busy page. If a caller
1113
+ // needs visibility into "why is this empty," wire an opt-in
1114
+ // `logger.debug` at the specific call site rather than re-adding
1115
+ // a blanket warn here.
1116
+ if (!index)
1117
+ return [];
1118
+ const ids = index.get(fieldValue);
1119
+ if (!ids || ids.size === 0)
1120
+ return [];
1121
+ const result = [];
1122
+ let droppedNoEntry = 0;
1123
+ let droppedScope = 0;
1124
+ let droppedDisposed = 0;
1125
+ for (const id of ids) {
1126
+ const entry = this.entries.get(id);
1127
+ if (!entry) {
1128
+ droppedNoEntry++;
1129
+ continue;
1130
+ }
1131
+ if (!this.matchesScope(entry.scope, ModelScope.live)) {
1132
+ droppedScope++;
1133
+ continue;
1134
+ }
1135
+ const model = this.resolveModel(entry, id);
1136
+ if (model && !model.disposed) {
1137
+ result.push(model);
1138
+ }
1139
+ else if (model?.disposed) {
1140
+ droppedDisposed++;
1141
+ }
1142
+ }
1143
+ if (droppedNoEntry || droppedScope || droppedDisposed) {
1144
+ // Debug-level: happens on every render when a foreign-key index
1145
+ // has dangling refs (legacy orphan deltas, pending CREATE
1146
+ // transactions, etc.). Noisy at warn level, useful during
1147
+ // investigation.
1148
+ this.runtime.logger.debug('[InstanceCache.getByForeignKey] ROWS DROPPED', {
1149
+ modelName,
1150
+ fieldName,
1151
+ fieldValue,
1152
+ matched: ids.size,
1153
+ returned: result.length,
1154
+ droppedNoEntry,
1155
+ droppedScope,
1156
+ droppedDisposed,
1157
+ });
1158
+ }
1159
+ return result;
1160
+ }
1161
+ /**
1162
+ * Add a model to foreign key indexes (called from addToTypeIndex path)
1163
+ */
1164
+ addToForeignKeyIndex(id, model, modelName) {
1165
+ // Silent no-ops for "no config / non-string value / missing index"
1166
+ // — all three are legitimate states (non-indexed model, optional
1167
+ // nullable FK, index not yet registered because the batch ran
1168
+ // before schema registration completed). Diagnostic warns that
1169
+ // used to live here spammed the console on every hot-path load.
1170
+ const fields = this.foreignKeyConfig.get(modelName);
1171
+ if (!fields)
1172
+ return;
1173
+ for (const fieldName of fields) {
1174
+ const fieldValue = model.getField(fieldName);
1175
+ if (typeof fieldValue !== 'string')
1176
+ continue;
1177
+ const indexKey = `${modelName}:${fieldName}`;
1178
+ const index = this.foreignKeyIndexes.get(indexKey);
1179
+ if (!index)
1180
+ continue;
1181
+ let ids = index.get(fieldValue);
1182
+ if (!ids) {
1183
+ ids = observable.set();
1184
+ index.set(fieldValue, ids);
1185
+ }
1186
+ ids.add(id);
1187
+ }
1188
+ }
1189
+ /**
1190
+ * Remove a model from foreign key indexes (called from removeFromTypeIndex path)
1191
+ */
1192
+ removeFromForeignKeyIndex(id, modelName) {
1193
+ if (!modelName)
1194
+ return;
1195
+ const fields = this.foreignKeyConfig.get(modelName);
1196
+ if (!fields)
1197
+ return;
1198
+ // We need the model to read the foreign key value
1199
+ const entry = this.entries.get(id);
1200
+ const model = entry?.model ?? entry?.weakRef?.deref();
1201
+ if (!model)
1202
+ return;
1203
+ for (const fieldName of fields) {
1204
+ const fieldValue = model.getField(fieldName);
1205
+ if (typeof fieldValue !== 'string')
1206
+ continue;
1207
+ const indexKey = `${modelName}:${fieldName}`;
1208
+ const index = this.foreignKeyIndexes.get(indexKey);
1209
+ if (!index)
1210
+ continue;
1211
+ const ids = index.get(fieldValue);
1212
+ if (ids) {
1213
+ ids.delete(id);
1214
+ if (ids.size === 0) {
1215
+ index.delete(fieldValue);
1216
+ }
1217
+ }
1218
+ }
1219
+ }
1220
+ addToTypeIndex(id, modelName) {
1221
+ if (!modelName)
1222
+ return;
1223
+ let ids = this.typeIndex.get(modelName);
1224
+ if (!ids) {
1225
+ ids = observable.set();
1226
+ this.typeIndex.set(modelName, ids);
1227
+ }
1228
+ ids.add(id);
1229
+ // Update foreign key indexes. If we can't reach the model object,
1230
+ // the FK index will be (re)populated on the first lookup that
1231
+ // resolves the entry — no need to warn here.
1232
+ const entry = this.entries.get(id);
1233
+ const model = entry?.model ?? entry?.weakRef?.deref();
1234
+ if (model) {
1235
+ this.addToForeignKeyIndex(id, model, modelName);
1236
+ }
1237
+ }
1238
+ removeFromTypeIndex(id, modelName) {
1239
+ if (!modelName)
1240
+ return;
1241
+ // Remove from foreign key indexes BEFORE removing from entries
1242
+ this.removeFromForeignKeyIndex(id, modelName);
1243
+ const ids = this.typeIndex.get(modelName);
1244
+ if (ids) {
1245
+ ids.delete(id);
1246
+ if (ids.size === 0) {
1247
+ this.typeIndex.delete(modelName);
1248
+ }
1249
+ }
1250
+ }
1251
+ matchesScope(entryScope, queryScope) {
1252
+ switch (queryScope) {
1253
+ case ModelScope.all:
1254
+ return true;
1255
+ case ModelScope.live:
1256
+ return entryScope === ModelScope.live;
1257
+ case ModelScope.archived:
1258
+ return entryScope === ModelScope.archived;
1259
+ default:
1260
+ return entryScope === queryScope;
1261
+ }
1262
+ }
1263
+ }