@harperfast/harper 5.2.13 → 5.3.0-beta.1

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 (655) hide show
  1. package/agent/mcpTools.ts +1 -1
  2. package/agent/session.ts +25 -14
  3. package/bin/cliOperations.ts +58 -9
  4. package/bin/copyDb.ts +282 -59
  5. package/bin/deploySetup.ts +16 -5
  6. package/bin/harper.ts +1 -1
  7. package/bin/help.ts +4 -1
  8. package/bin/lite.ts +4 -1
  9. package/bin/restart.ts +120 -4
  10. package/bin/run.ts +6 -11
  11. package/bin/upgrade.js +7 -3
  12. package/bin/workloadIdentity.ts +119 -0
  13. package/components/Application.ts +3436 -242
  14. package/components/ApplicationScope.ts +8 -0
  15. package/components/EntryHandler.ts +59 -39
  16. package/components/OptionsWatcher.ts +440 -98
  17. package/components/RuntimeModuleTracker.ts +38 -7
  18. package/components/Scope.ts +56 -15
  19. package/components/awaitRestart.ts +84 -0
  20. package/components/componentLoader.ts +381 -32
  21. package/components/componentPreparationLock.ts +16 -5
  22. package/components/deploymentOperations.ts +4 -1
  23. package/components/deploymentRecorder.ts +9 -2
  24. package/components/mcp/adapters/harperHttp.ts +4 -0
  25. package/components/mcp/listChanged.ts +4 -0
  26. package/components/mcp/toolRegistry.ts +2 -0
  27. package/components/mcp/tools/operations.ts +9 -0
  28. package/components/mcp/tools/schemas/operationDescriptions.ts +2 -2
  29. package/components/operations.js +537 -113
  30. package/components/operationsValidation.js +98 -3
  31. package/components/packageComponent.ts +25 -1
  32. package/components/requestRestart.ts +11 -0
  33. package/components/status/ComponentStatusRegistry.ts +59 -0
  34. package/config/RootConfigWatcher.ts +240 -40
  35. package/config/configReadRetry.ts +62 -0
  36. package/config/configUtils.ts +357 -48
  37. package/config/harperConfigEnvVars.ts +170 -27
  38. package/config/parseConfigFile.ts +34 -0
  39. package/config/readConfigFileSync.ts +44 -0
  40. package/config/watcherArming.ts +59 -0
  41. package/config-root.schema.json +33 -0
  42. package/dataLayer/blobBackup.ts +160 -50
  43. package/dataLayer/delete.ts +6 -1
  44. package/dataLayer/harperBridge/ResourceBridge.ts +80 -10
  45. package/dataLayer/harperBridge/lmdbBridge/lmdbMethods/DeleteAuditLogsBeforeResults.js +3 -1
  46. package/dataLayer/hdbInfoController.ts +34 -1
  47. package/dataLayer/insert.ts +44 -1
  48. package/dataLayer/rocksdbBackup.ts +53 -10
  49. package/dataLayer/schema.ts +11 -1
  50. package/dataLayer/schemaDescribe.ts +8 -1
  51. package/dist/agent/mcpTools.js +1 -1
  52. package/dist/agent/mcpTools.js.map +1 -1
  53. package/dist/agent/session.d.ts +22 -0
  54. package/dist/agent/session.js +26 -15
  55. package/dist/agent/session.js.map +1 -1
  56. package/dist/bin/cliOperations.js +61 -9
  57. package/dist/bin/cliOperations.js.map +1 -1
  58. package/dist/bin/copyDb.d.ts +12 -1
  59. package/dist/bin/copyDb.js +248 -60
  60. package/dist/bin/copyDb.js.map +1 -1
  61. package/dist/bin/deploySetup.d.ts +2 -0
  62. package/dist/bin/deploySetup.js +11 -3
  63. package/dist/bin/deploySetup.js.map +1 -1
  64. package/dist/bin/harper.js +1 -1
  65. package/dist/bin/harper.js.map +1 -1
  66. package/dist/bin/help.js +4 -1
  67. package/dist/bin/help.js.map +1 -1
  68. package/dist/bin/lite.js +4 -1
  69. package/dist/bin/lite.js.map +1 -1
  70. package/dist/bin/restart.js +91 -6
  71. package/dist/bin/restart.js.map +1 -1
  72. package/dist/bin/run.js +4 -10
  73. package/dist/bin/run.js.map +1 -1
  74. package/dist/bin/upgrade.js +4 -3
  75. package/dist/bin/upgrade.js.map +1 -1
  76. package/dist/bin/workloadIdentity.d.ts +18 -0
  77. package/dist/bin/workloadIdentity.js +100 -0
  78. package/dist/bin/workloadIdentity.js.map +1 -0
  79. package/dist/components/Application.d.ts +235 -17
  80. package/dist/components/Application.js +3038 -238
  81. package/dist/components/Application.js.map +1 -1
  82. package/dist/components/ApplicationScope.d.ts +8 -0
  83. package/dist/components/ApplicationScope.js +7 -0
  84. package/dist/components/ApplicationScope.js.map +1 -1
  85. package/dist/components/EntryHandler.js +26 -10
  86. package/dist/components/EntryHandler.js.map +1 -1
  87. package/dist/components/OptionsWatcher.d.ts +4 -0
  88. package/dist/components/OptionsWatcher.js +440 -99
  89. package/dist/components/OptionsWatcher.js.map +1 -1
  90. package/dist/components/RuntimeModuleTracker.js +40 -6
  91. package/dist/components/RuntimeModuleTracker.js.map +1 -1
  92. package/dist/components/Scope.js +52 -13
  93. package/dist/components/Scope.js.map +1 -1
  94. package/dist/components/awaitRestart.d.ts +33 -0
  95. package/dist/components/awaitRestart.js +61 -0
  96. package/dist/components/awaitRestart.js.map +1 -0
  97. package/dist/components/componentLoader.d.ts +38 -1
  98. package/dist/components/componentLoader.js +313 -24
  99. package/dist/components/componentLoader.js.map +1 -1
  100. package/dist/components/componentPreparationLock.d.ts +5 -0
  101. package/dist/components/componentPreparationLock.js +14 -6
  102. package/dist/components/componentPreparationLock.js.map +1 -1
  103. package/dist/components/deploymentOperations.js +4 -1
  104. package/dist/components/deploymentOperations.js.map +1 -1
  105. package/dist/components/deploymentRecorder.d.ts +4 -2
  106. package/dist/components/deploymentRecorder.js +1 -0
  107. package/dist/components/deploymentRecorder.js.map +1 -1
  108. package/dist/components/mcp/adapters/harperHttp.js +4 -0
  109. package/dist/components/mcp/adapters/harperHttp.js.map +1 -1
  110. package/dist/components/mcp/listChanged.js +5 -0
  111. package/dist/components/mcp/listChanged.js.map +1 -1
  112. package/dist/components/mcp/toolRegistry.d.ts +1 -0
  113. package/dist/components/mcp/toolRegistry.js.map +1 -1
  114. package/dist/components/mcp/tools/operations.d.ts +5 -0
  115. package/dist/components/mcp/tools/operations.js +9 -0
  116. package/dist/components/mcp/tools/operations.js.map +1 -1
  117. package/dist/components/mcp/tools/schemas/operationDescriptions.js +2 -2
  118. package/dist/components/mcp/tools/schemas/operationDescriptions.js.map +1 -1
  119. package/dist/components/operations.d.ts +28 -0
  120. package/dist/components/operations.js +476 -113
  121. package/dist/components/operations.js.map +1 -1
  122. package/dist/components/operationsValidation.js +97 -3
  123. package/dist/components/operationsValidation.js.map +1 -1
  124. package/dist/components/packageComponent.js +24 -0
  125. package/dist/components/packageComponent.js.map +1 -1
  126. package/dist/components/requestRestart.d.ts +1 -0
  127. package/dist/components/requestRestart.js +7 -0
  128. package/dist/components/requestRestart.js.map +1 -1
  129. package/dist/components/status/ComponentStatusRegistry.d.ts +0 -4
  130. package/dist/components/status/ComponentStatusRegistry.js +63 -0
  131. package/dist/components/status/ComponentStatusRegistry.js.map +1 -1
  132. package/dist/config/RootConfigWatcher.d.ts +9 -1
  133. package/dist/config/RootConfigWatcher.js +228 -37
  134. package/dist/config/RootConfigWatcher.js.map +1 -1
  135. package/dist/config/configReadRetry.d.ts +8 -0
  136. package/dist/config/configReadRetry.js +62 -0
  137. package/dist/config/configReadRetry.js.map +1 -0
  138. package/dist/config/configUtils.d.ts +11 -2
  139. package/dist/config/configUtils.js +304 -47
  140. package/dist/config/configUtils.js.map +1 -1
  141. package/dist/config/harperConfigEnvVars.d.ts +16 -0
  142. package/dist/config/harperConfigEnvVars.js +162 -25
  143. package/dist/config/harperConfigEnvVars.js.map +1 -1
  144. package/dist/config/parseConfigFile.d.ts +4 -0
  145. package/dist/config/parseConfigFile.js +35 -0
  146. package/dist/config/parseConfigFile.js.map +1 -0
  147. package/dist/config/readConfigFileSync.d.ts +1 -0
  148. package/dist/config/readConfigFileSync.js +47 -0
  149. package/dist/config/readConfigFileSync.js.map +1 -0
  150. package/dist/config/watcherArming.d.ts +15 -0
  151. package/dist/config/watcherArming.js +59 -0
  152. package/dist/config/watcherArming.js.map +1 -0
  153. package/dist/dataLayer/blobBackup.d.ts +49 -20
  154. package/dist/dataLayer/blobBackup.js +139 -50
  155. package/dist/dataLayer/blobBackup.js.map +1 -1
  156. package/dist/dataLayer/delete.js +1 -1
  157. package/dist/dataLayer/delete.js.map +1 -1
  158. package/dist/dataLayer/harperBridge/ResourceBridge.d.ts +14 -1
  159. package/dist/dataLayer/harperBridge/ResourceBridge.js +72 -12
  160. package/dist/dataLayer/harperBridge/ResourceBridge.js.map +1 -1
  161. package/dist/dataLayer/harperBridge/lmdbBridge/lmdbMethods/DeleteAuditLogsBeforeResults.d.ts +3 -1
  162. package/dist/dataLayer/harperBridge/lmdbBridge/lmdbMethods/DeleteAuditLogsBeforeResults.js +3 -1
  163. package/dist/dataLayer/harperBridge/lmdbBridge/lmdbMethods/DeleteAuditLogsBeforeResults.js.map +1 -1
  164. package/dist/dataLayer/hdbInfoController.d.ts +10 -0
  165. package/dist/dataLayer/hdbInfoController.js +30 -1
  166. package/dist/dataLayer/hdbInfoController.js.map +1 -1
  167. package/dist/dataLayer/insert.d.ts +9 -1
  168. package/dist/dataLayer/insert.js +30 -0
  169. package/dist/dataLayer/insert.js.map +1 -1
  170. package/dist/dataLayer/rocksdbBackup.d.ts +2 -2
  171. package/dist/dataLayer/rocksdbBackup.js +45 -8
  172. package/dist/dataLayer/rocksdbBackup.js.map +1 -1
  173. package/dist/dataLayer/schema.js +8 -0
  174. package/dist/dataLayer/schema.js.map +1 -1
  175. package/dist/dataLayer/schemaDescribe.js +8 -1
  176. package/dist/dataLayer/schemaDescribe.js.map +1 -1
  177. package/dist/index.d.ts +2 -1
  178. package/dist/index.js +4 -1
  179. package/dist/index.js.map +1 -1
  180. package/dist/json/systemSchema.json +55 -0
  181. package/dist/resources/DatabaseTransaction.d.ts +77 -0
  182. package/dist/resources/DatabaseTransaction.js +586 -54
  183. package/dist/resources/DatabaseTransaction.js.map +1 -1
  184. package/dist/resources/LMDBTransaction.d.ts +2 -1
  185. package/dist/resources/LMDBTransaction.js +43 -6
  186. package/dist/resources/LMDBTransaction.js.map +1 -1
  187. package/dist/resources/PrimaryRocksDatabase.d.ts +1 -0
  188. package/dist/resources/PrimaryRocksDatabase.js +49 -5
  189. package/dist/resources/PrimaryRocksDatabase.js.map +1 -1
  190. package/dist/resources/RecordEncoder.d.ts +20 -0
  191. package/dist/resources/RecordEncoder.js +110 -9
  192. package/dist/resources/RecordEncoder.js.map +1 -1
  193. package/dist/resources/RequestTarget.d.ts +2 -0
  194. package/dist/resources/RequestTarget.js.map +1 -1
  195. package/dist/resources/Resource.js +117 -24
  196. package/dist/resources/Resource.js.map +1 -1
  197. package/dist/resources/ResourceInterface.d.ts +28 -1
  198. package/dist/resources/ResourceInterface.js.map +1 -1
  199. package/dist/resources/RocksIndexStore.d.ts +6 -1
  200. package/dist/resources/RocksIndexStore.js +24 -9
  201. package/dist/resources/RocksIndexStore.js.map +1 -1
  202. package/dist/resources/RocksTransactionLogStore.d.ts +24 -1
  203. package/dist/resources/RocksTransactionLogStore.js +160 -49
  204. package/dist/resources/RocksTransactionLogStore.js.map +1 -1
  205. package/dist/resources/Table.d.ts +165 -10
  206. package/dist/resources/Table.js +1805 -303
  207. package/dist/resources/Table.js.map +1 -1
  208. package/dist/resources/analytics/write.js +10 -3
  209. package/dist/resources/analytics/write.js.map +1 -1
  210. package/dist/resources/auditStore.d.ts +181 -2
  211. package/dist/resources/auditStore.js +640 -29
  212. package/dist/resources/auditStore.js.map +1 -1
  213. package/dist/resources/blob.d.ts +129 -9
  214. package/dist/resources/blob.js +992 -114
  215. package/dist/resources/blob.js.map +1 -1
  216. package/dist/resources/branchDatabase.d.ts +48 -0
  217. package/dist/resources/branchDatabase.js +892 -0
  218. package/dist/resources/branchDatabase.js.map +1 -0
  219. package/dist/resources/crdt.js +50 -12
  220. package/dist/resources/crdt.js.map +1 -1
  221. package/dist/resources/dataLoader.js +3 -4
  222. package/dist/resources/dataLoader.js.map +1 -1
  223. package/dist/resources/databases.d.ts +157 -14
  224. package/dist/resources/databases.js +1462 -325
  225. package/dist/resources/databases.js.map +1 -1
  226. package/dist/resources/defineTable.d.ts +10 -2
  227. package/dist/resources/defineTable.js +9 -1
  228. package/dist/resources/defineTable.js.map +1 -1
  229. package/dist/resources/derivedIndexRegistry.d.ts +5 -0
  230. package/dist/resources/derivedIndexRegistry.js +68 -0
  231. package/dist/resources/derivedIndexRegistry.js.map +1 -0
  232. package/dist/resources/derivedIndexRuntime.d.ts +215 -0
  233. package/dist/resources/derivedIndexRuntime.js +2027 -0
  234. package/dist/resources/derivedIndexRuntime.js.map +1 -0
  235. package/dist/resources/graphql.d.ts +1 -1
  236. package/dist/resources/graphql.js +52 -16
  237. package/dist/resources/graphql.js.map +1 -1
  238. package/dist/resources/indexes/HierarchicalNavigableSmallWorld.d.ts +103 -9
  239. package/dist/resources/indexes/HierarchicalNavigableSmallWorld.js +854 -41
  240. package/dist/resources/indexes/HierarchicalNavigableSmallWorld.js.map +1 -1
  241. package/dist/resources/indexes/hnswDerivedIndex.d.ts +67 -0
  242. package/dist/resources/indexes/hnswDerivedIndex.js +464 -0
  243. package/dist/resources/indexes/hnswDerivedIndex.js.map +1 -0
  244. package/dist/resources/indexes/hnswPlaneBinding.d.ts +65 -0
  245. package/dist/resources/indexes/hnswPlaneBinding.js +91 -0
  246. package/dist/resources/indexes/hnswPlaneBinding.js.map +1 -0
  247. package/dist/resources/longLivedTransactions.d.ts +71 -0
  248. package/dist/resources/longLivedTransactions.js +358 -0
  249. package/dist/resources/longLivedTransactions.js.map +1 -0
  250. package/dist/resources/models/backendRegistry.d.ts +26 -0
  251. package/dist/resources/models/backendRegistry.js +60 -2
  252. package/dist/resources/models/backendRegistry.js.map +1 -1
  253. package/dist/resources/models/bootstrap.d.ts +33 -1
  254. package/dist/resources/models/bootstrap.js +416 -31
  255. package/dist/resources/models/bootstrap.js.map +1 -1
  256. package/dist/resources/nodeIdMapping.d.ts +5 -0
  257. package/dist/resources/nodeIdMapping.js +49 -0
  258. package/dist/resources/nodeIdMapping.js.map +1 -1
  259. package/dist/resources/recordLock.d.ts +123 -0
  260. package/dist/resources/recordLock.js +315 -0
  261. package/dist/resources/recordLock.js.map +1 -0
  262. package/dist/resources/recordLockCoordinator.d.ts +557 -0
  263. package/dist/resources/recordLockCoordinator.js +2565 -0
  264. package/dist/resources/recordLockCoordinator.js.map +1 -0
  265. package/dist/resources/replayLogs.d.ts +14 -1
  266. package/dist/resources/replayLogs.js +157 -24
  267. package/dist/resources/replayLogs.js.map +1 -1
  268. package/dist/resources/replayLogsGuards.d.ts +94 -7
  269. package/dist/resources/replayLogsGuards.js +111 -7
  270. package/dist/resources/replayLogsGuards.js.map +1 -1
  271. package/dist/resources/replicatedApplyFailure.d.ts +16 -0
  272. package/dist/resources/replicatedApplyFailure.js +63 -0
  273. package/dist/resources/replicatedApplyFailure.js.map +1 -0
  274. package/dist/resources/scheduler/scheduler.js +3 -3
  275. package/dist/resources/scheduler/scheduler.js.map +1 -1
  276. package/dist/resources/search.d.ts +10 -4
  277. package/dist/resources/search.js +283 -37
  278. package/dist/resources/search.js.map +1 -1
  279. package/dist/resources/tracked.d.ts +5 -1
  280. package/dist/resources/tracked.js +74 -23
  281. package/dist/resources/tracked.js.map +1 -1
  282. package/dist/resources/transactionBroadcast.d.ts +1 -1
  283. package/dist/resources/transactionBroadcast.js +2 -2
  284. package/dist/resources/transactionBroadcast.js.map +1 -1
  285. package/dist/security/auth.js +61 -30
  286. package/dist/security/auth.js.map +1 -1
  287. package/dist/security/authn/oidc/claims.d.ts +22 -0
  288. package/dist/security/authn/oidc/claims.js +71 -0
  289. package/dist/security/authn/oidc/claims.js.map +1 -0
  290. package/dist/security/authn/oidc/identityToken.d.ts +27 -0
  291. package/dist/security/authn/oidc/identityToken.js +111 -0
  292. package/dist/security/authn/oidc/identityToken.js.map +1 -0
  293. package/dist/security/authn/oidc/jwks.d.ts +25 -0
  294. package/dist/security/authn/oidc/jwks.js +261 -0
  295. package/dist/security/authn/oidc/jwks.js.map +1 -0
  296. package/dist/security/authn/oidc/providers/generic.d.ts +13 -0
  297. package/dist/security/authn/oidc/providers/generic.js +34 -0
  298. package/dist/security/authn/oidc/providers/generic.js.map +1 -0
  299. package/dist/security/authn/oidc/providers/githubActions.d.ts +11 -0
  300. package/dist/security/authn/oidc/providers/githubActions.js +129 -0
  301. package/dist/security/authn/oidc/providers/githubActions.js.map +1 -0
  302. package/dist/security/authn/oidc/providers/index.d.ts +37 -0
  303. package/dist/security/authn/oidc/providers/index.js +24 -0
  304. package/dist/security/authn/oidc/providers/index.js.map +1 -0
  305. package/dist/security/authn/oidc/tokenExchange.d.ts +12 -0
  306. package/dist/security/authn/oidc/tokenExchange.js +306 -0
  307. package/dist/security/authn/oidc/tokenExchange.js.map +1 -0
  308. package/dist/security/authn/oidc/trustPolicyOperations.d.ts +49 -0
  309. package/dist/security/authn/oidc/trustPolicyOperations.js +358 -0
  310. package/dist/security/authn/oidc/trustPolicyOperations.js.map +1 -0
  311. package/dist/security/authn/oidc/types.d.ts +38 -0
  312. package/dist/security/authn/oidc/types.js +6 -0
  313. package/dist/security/authn/oidc/types.js.map +1 -0
  314. package/dist/security/certificateVerification/index.js +40 -11
  315. package/dist/security/certificateVerification/index.js.map +1 -1
  316. package/dist/security/certificateVerification/trustedIssuers.d.ts +24 -0
  317. package/dist/security/certificateVerification/trustedIssuers.js +79 -0
  318. package/dist/security/certificateVerification/trustedIssuers.js.map +1 -0
  319. package/dist/security/certificateVerification/types.d.ts +1 -0
  320. package/dist/security/credentialProvenance.d.ts +35 -0
  321. package/dist/security/credentialProvenance.js +51 -0
  322. package/dist/security/credentialProvenance.js.map +1 -0
  323. package/dist/security/credentialRejection.d.ts +4 -0
  324. package/dist/security/credentialRejection.js +24 -0
  325. package/dist/security/credentialRejection.js.map +1 -0
  326. package/dist/security/deferredAuthentication.d.ts +36 -0
  327. package/dist/security/deferredAuthentication.js +70 -0
  328. package/dist/security/deferredAuthentication.js.map +1 -0
  329. package/dist/security/impersonation.d.ts +21 -0
  330. package/dist/security/impersonation.js +108 -9
  331. package/dist/security/impersonation.js.map +1 -1
  332. package/dist/security/jsLoader.d.ts +6 -0
  333. package/dist/security/jsLoader.js +77 -15
  334. package/dist/security/jsLoader.js.map +1 -1
  335. package/dist/security/keys.js +301 -71
  336. package/dist/security/keys.js.map +1 -1
  337. package/dist/security/operationScope.d.ts +21 -0
  338. package/dist/security/operationScope.js +36 -0
  339. package/dist/security/operationScope.js.map +1 -0
  340. package/dist/security/permissionsTranslator.js +21 -0
  341. package/dist/security/permissionsTranslator.js.map +1 -1
  342. package/dist/security/tokenAuthentication.d.ts +19 -1
  343. package/dist/security/tokenAuthentication.js +191 -10
  344. package/dist/security/tokenAuthentication.js.map +1 -1
  345. package/dist/security/user.js +4 -3
  346. package/dist/security/user.js.map +1 -1
  347. package/dist/server/DurableSubscriptionsSession.d.ts +2 -2
  348. package/dist/server/DurableSubscriptionsSession.js +67 -10
  349. package/dist/server/DurableSubscriptionsSession.js.map +1 -1
  350. package/dist/server/REST.js +106 -2
  351. package/dist/server/REST.js.map +1 -1
  352. package/dist/server/graphqlQuerying.js +4 -0
  353. package/dist/server/graphqlQuerying.js.map +1 -1
  354. package/dist/server/http.d.ts +16 -1
  355. package/dist/server/http.js +122 -17
  356. package/dist/server/http.js.map +1 -1
  357. package/dist/server/itc/serverHandlers.js +8 -1
  358. package/dist/server/itc/serverHandlers.js.map +1 -1
  359. package/dist/server/jobs/jobProcess.js +6 -2
  360. package/dist/server/jobs/jobProcess.js.map +1 -1
  361. package/dist/server/jobs/jobs.js +4 -1
  362. package/dist/server/jobs/jobs.js.map +1 -1
  363. package/dist/server/liveSubscriptionAuth.d.ts +26 -4
  364. package/dist/server/liveSubscriptionAuth.js +105 -39
  365. package/dist/server/liveSubscriptionAuth.js.map +1 -1
  366. package/dist/server/loadRootComponents.js +49 -10
  367. package/dist/server/loadRootComponents.js.map +1 -1
  368. package/dist/server/mqtt.d.ts +2 -0
  369. package/dist/server/mqtt.js +165 -30
  370. package/dist/server/mqtt.js.map +1 -1
  371. package/dist/server/nodeName.d.ts +2 -0
  372. package/dist/server/nodeName.js +107 -23
  373. package/dist/server/nodeName.js.map +1 -1
  374. package/dist/server/serverHelpers/Headers.d.ts +27 -0
  375. package/dist/server/serverHelpers/Headers.js +145 -1
  376. package/dist/server/serverHelpers/Headers.js.map +1 -1
  377. package/dist/server/serverHelpers/NodeAdapterResponse.d.ts +48 -0
  378. package/dist/server/serverHelpers/NodeAdapterResponse.js +220 -0
  379. package/dist/server/serverHelpers/NodeAdapterResponse.js.map +1 -0
  380. package/dist/server/serverHelpers/Request.d.ts +5 -10
  381. package/dist/server/serverHelpers/Request.js +38 -136
  382. package/dist/server/serverHelpers/Request.js.map +1 -1
  383. package/dist/server/serverHelpers/contentTypes.d.ts +11 -0
  384. package/dist/server/serverHelpers/contentTypes.js +221 -40
  385. package/dist/server/serverHelpers/contentTypes.js.map +1 -1
  386. package/dist/server/serverHelpers/deployValidationState.d.ts +3 -0
  387. package/dist/server/serverHelpers/deployValidationState.js +9 -19
  388. package/dist/server/serverHelpers/deployValidationState.js.map +1 -1
  389. package/dist/server/serverHelpers/operationAuthorizationState.d.ts +12 -0
  390. package/dist/server/serverHelpers/operationAuthorizationState.js +24 -2
  391. package/dist/server/serverHelpers/operationAuthorizationState.js.map +1 -1
  392. package/dist/server/serverHelpers/registeredOperations.d.ts +5 -4
  393. package/dist/server/serverHelpers/registeredOperations.js +74 -21
  394. package/dist/server/serverHelpers/registeredOperations.js.map +1 -1
  395. package/dist/server/serverHelpers/requestSanitization.d.ts +11 -0
  396. package/dist/server/serverHelpers/requestSanitization.js +20 -0
  397. package/dist/server/serverHelpers/requestSanitization.js.map +1 -0
  398. package/dist/server/serverHelpers/serverHandlers.js +6 -3
  399. package/dist/server/serverHelpers/serverHandlers.js.map +1 -1
  400. package/dist/server/serverHelpers/serverUtilities.d.ts +18 -0
  401. package/dist/server/serverHelpers/serverUtilities.js +178 -29
  402. package/dist/server/serverHelpers/serverUtilities.js.map +1 -1
  403. package/dist/server/serverHelpers/sharedMessageEncoding.d.ts +67 -0
  404. package/dist/server/serverHelpers/sharedMessageEncoding.js +280 -0
  405. package/dist/server/serverHelpers/sharedMessageEncoding.js.map +1 -0
  406. package/dist/server/serverHelpers/uwsServer.js +19 -1
  407. package/dist/server/serverHelpers/uwsServer.js.map +1 -1
  408. package/dist/server/static.js +24 -28
  409. package/dist/server/static.js.map +1 -1
  410. package/dist/server/storageReclamation.d.ts +5 -0
  411. package/dist/server/storageReclamation.js +17 -1
  412. package/dist/server/storageReclamation.js.map +1 -1
  413. package/dist/server/threads/isolatedApplications.d.ts +47 -0
  414. package/dist/server/threads/isolatedApplications.js +171 -0
  415. package/dist/server/threads/isolatedApplications.js.map +1 -0
  416. package/dist/server/threads/itc.d.ts +7 -2
  417. package/dist/server/threads/itc.js +5 -1
  418. package/dist/server/threads/itc.js.map +1 -1
  419. package/dist/server/threads/logRotationTransport.d.ts +1 -0
  420. package/dist/server/threads/logRotationTransport.js +33 -0
  421. package/dist/server/threads/logRotationTransport.js.map +1 -0
  422. package/dist/server/threads/manageThreads.d.ts +83 -3
  423. package/dist/server/threads/manageThreads.js +769 -75
  424. package/dist/server/threads/manageThreads.js.map +1 -1
  425. package/dist/server/threads/socketRouter.d.ts +1 -0
  426. package/dist/server/threads/socketRouter.js +277 -31
  427. package/dist/server/threads/socketRouter.js.map +1 -1
  428. package/dist/server/threads/threadHeapMemory.d.ts +2 -0
  429. package/dist/server/threads/threadHeapMemory.js +31 -0
  430. package/dist/server/threads/threadHeapMemory.js.map +1 -0
  431. package/dist/server/threads/threadServer.js +76 -22
  432. package/dist/server/threads/threadServer.js.map +1 -1
  433. package/dist/sqlEngine/config.d.ts +1 -3
  434. package/dist/sqlEngine/config.js +19 -16
  435. package/dist/sqlEngine/config.js.map +1 -1
  436. package/dist/sqlTranslator/index.d.ts +1 -1
  437. package/dist/sqlTranslator/index.js +30 -7
  438. package/dist/sqlTranslator/index.js.map +1 -1
  439. package/dist/upgrade/directives/5-3-0.d.ts +7 -0
  440. package/dist/upgrade/directives/5-3-0.js +148 -0
  441. package/dist/upgrade/directives/5-3-0.js.map +1 -0
  442. package/dist/upgrade/directives/directivesController.js +2 -1
  443. package/dist/upgrade/directives/directivesController.js.map +1 -1
  444. package/dist/utility/OperationFunctionCaller.js +2 -1
  445. package/dist/utility/OperationFunctionCaller.js.map +1 -1
  446. package/dist/utility/common_utils.d.ts +16 -0
  447. package/dist/utility/common_utils.js +32 -6
  448. package/dist/utility/common_utils.js.map +1 -1
  449. package/dist/utility/componentNames.d.ts +8 -0
  450. package/dist/utility/componentNames.js +12 -1
  451. package/dist/utility/componentNames.js.map +1 -1
  452. package/dist/utility/environment/environmentManager.js +3 -6
  453. package/dist/utility/environment/environmentManager.js.map +1 -1
  454. package/dist/utility/environment/systemInformation.d.ts +1 -0
  455. package/dist/utility/environment/systemInformation.js +1 -0
  456. package/dist/utility/environment/systemInformation.js.map +1 -1
  457. package/dist/utility/errors/commonErrors.d.ts +2 -0
  458. package/dist/utility/errors/commonErrors.js +2 -0
  459. package/dist/utility/errors/commonErrors.js.map +1 -1
  460. package/dist/utility/errors/hdbError.d.ts +33 -0
  461. package/dist/utility/errors/hdbError.js +58 -1
  462. package/dist/utility/errors/hdbError.js.map +1 -1
  463. package/dist/utility/globalSchema.d.ts +18 -0
  464. package/dist/utility/hdbTerms.d.ts +18 -0
  465. package/dist/utility/hdbTerms.js +20 -2
  466. package/dist/utility/hdbTerms.js.map +1 -1
  467. package/dist/utility/logging/harper_logger.d.ts +2 -0
  468. package/dist/utility/logging/harper_logger.js +286 -30
  469. package/dist/utility/logging/harper_logger.js.map +1 -1
  470. package/dist/utility/logging/logGenerationCoordinator.d.ts +35 -0
  471. package/dist/utility/logging/logGenerationCoordinator.js +184 -0
  472. package/dist/utility/logging/logGenerationCoordinator.js.map +1 -0
  473. package/dist/utility/logging/logRotation.d.ts +46 -0
  474. package/dist/utility/logging/logRotation.js +365 -0
  475. package/dist/utility/logging/logRotation.js.map +1 -0
  476. package/dist/utility/logging/logRotator.d.ts +1 -1
  477. package/dist/utility/logging/logRotator.js +192 -85
  478. package/dist/utility/logging/logRotator.js.map +1 -1
  479. package/dist/utility/nodeIdentity.d.ts +9 -0
  480. package/dist/utility/nodeIdentity.js +58 -0
  481. package/dist/utility/nodeIdentity.js.map +1 -0
  482. package/dist/utility/npmUtilities.js +11 -7
  483. package/dist/utility/npmUtilities.js.map +1 -1
  484. package/dist/utility/operationPermissions.d.ts +3 -1
  485. package/dist/utility/operationPermissions.js +16 -1
  486. package/dist/utility/operationPermissions.js.map +1 -1
  487. package/dist/utility/operation_authorization.d.ts +10 -7
  488. package/dist/utility/operation_authorization.js +212 -41
  489. package/dist/utility/operation_authorization.js.map +1 -1
  490. package/dist/utility/watchPath.d.ts +29 -0
  491. package/dist/utility/watchPath.js +68 -0
  492. package/dist/utility/watchPath.js.map +1 -0
  493. package/dist/utility/watcherFallback.d.ts +41 -0
  494. package/dist/utility/watcherFallback.js +153 -0
  495. package/dist/utility/watcherFallback.js.map +1 -1
  496. package/dist/validation/configValidator.d.ts +12 -0
  497. package/dist/validation/configValidator.js +205 -75
  498. package/dist/validation/configValidator.js.map +1 -1
  499. package/dist/validation/installValidator.js +12 -0
  500. package/dist/validation/installValidator.js.map +1 -1
  501. package/dist/validation/validationWrapper.d.ts +11 -0
  502. package/dist/validation/validationWrapper.js +16 -3
  503. package/dist/validation/validationWrapper.js.map +1 -1
  504. package/index.ts +8 -0
  505. package/json/systemSchema.json +55 -0
  506. package/npm-shrinkwrap.json +286 -194
  507. package/package.json +15 -7
  508. package/resources/DESIGN.md +219 -52
  509. package/resources/DatabaseTransaction.ts +657 -52
  510. package/resources/LMDBTransaction.ts +46 -6
  511. package/resources/PrimaryRocksDatabase.ts +46 -7
  512. package/resources/RecordEncoder.ts +124 -9
  513. package/resources/RequestTarget.ts +2 -0
  514. package/resources/Resource.ts +114 -22
  515. package/resources/ResourceInterface.ts +31 -0
  516. package/resources/RocksIndexStore.ts +30 -9
  517. package/resources/RocksTransactionLogStore.ts +188 -51
  518. package/resources/Table.ts +1960 -301
  519. package/resources/analytics/write.ts +10 -3
  520. package/resources/auditStore.ts +647 -32
  521. package/resources/blob.ts +1029 -110
  522. package/resources/branchDatabase.ts +941 -0
  523. package/resources/crdt.ts +76 -12
  524. package/resources/dataLoader.ts +3 -4
  525. package/resources/databases.ts +1584 -320
  526. package/resources/defineTable.ts +18 -2
  527. package/resources/derivedIndexRegistry.ts +56 -0
  528. package/resources/derivedIndexRuntime.ts +2292 -0
  529. package/resources/graphql.ts +72 -17
  530. package/resources/indexes/HierarchicalNavigableSmallWorld.ts +911 -48
  531. package/resources/indexes/hnswDerivedIndex.ts +531 -0
  532. package/resources/indexes/hnswPlaneBinding.ts +174 -0
  533. package/resources/longLivedTransactions.ts +360 -0
  534. package/resources/models/backendRegistry.ts +84 -2
  535. package/resources/models/bootstrap.ts +473 -28
  536. package/resources/nodeIdMapping.ts +50 -0
  537. package/resources/recordLock.ts +419 -0
  538. package/resources/recordLockCoordinator.ts +3043 -0
  539. package/resources/replayLogs.ts +152 -26
  540. package/resources/replayLogsGuards.ts +171 -8
  541. package/resources/replicatedApplyFailure.ts +77 -0
  542. package/resources/scheduler/scheduler.ts +4 -4
  543. package/resources/search.ts +288 -46
  544. package/resources/tracked.ts +73 -22
  545. package/resources/transactionBroadcast.ts +3 -3
  546. package/security/auth.ts +68 -29
  547. package/security/authn/oidc/claims.ts +72 -0
  548. package/security/authn/oidc/identityToken.ts +129 -0
  549. package/security/authn/oidc/jwks.ts +260 -0
  550. package/security/authn/oidc/providers/generic.ts +40 -0
  551. package/security/authn/oidc/providers/githubActions.ts +137 -0
  552. package/security/authn/oidc/providers/index.ts +52 -0
  553. package/security/authn/oidc/tokenExchange.ts +300 -0
  554. package/security/authn/oidc/trustPolicyOperations.ts +343 -0
  555. package/security/authn/oidc/types.ts +41 -0
  556. package/security/certificateVerification/index.ts +54 -13
  557. package/security/certificateVerification/trustedIssuers.ts +76 -0
  558. package/security/certificateVerification/types.ts +1 -0
  559. package/security/credentialProvenance.ts +47 -0
  560. package/security/credentialRejection.ts +22 -0
  561. package/security/deferredAuthentication.ts +71 -0
  562. package/security/impersonation.ts +117 -12
  563. package/security/jsLoader.ts +83 -18
  564. package/security/keys.ts +298 -72
  565. package/security/operationScope.ts +33 -0
  566. package/security/permissionsTranslator.js +23 -0
  567. package/security/tokenAuthentication.ts +233 -12
  568. package/security/user.ts +4 -3
  569. package/server/DESIGN.md +194 -16
  570. package/server/DurableSubscriptionsSession.ts +71 -11
  571. package/server/REST.ts +115 -4
  572. package/server/graphqlQuerying.ts +4 -0
  573. package/server/http.ts +133 -20
  574. package/server/itc/serverHandlers.js +8 -1
  575. package/server/jobs/jobProcess.ts +8 -2
  576. package/server/jobs/jobs.ts +4 -1
  577. package/server/liveSubscriptionAuth.ts +129 -46
  578. package/server/loadRootComponents.js +50 -8
  579. package/server/mqtt.ts +179 -38
  580. package/server/nodeName.ts +103 -21
  581. package/server/serverHelpers/Headers.ts +135 -0
  582. package/server/serverHelpers/NodeAdapterResponse.ts +221 -0
  583. package/server/serverHelpers/Request.ts +33 -131
  584. package/server/serverHelpers/contentTypes.ts +217 -36
  585. package/server/serverHelpers/deployValidationState.ts +24 -13
  586. package/server/serverHelpers/operationAuthorizationState.ts +34 -3
  587. package/server/serverHelpers/registeredOperations.ts +79 -22
  588. package/server/serverHelpers/requestSanitization.ts +15 -0
  589. package/server/serverHelpers/serverHandlers.js +6 -3
  590. package/server/serverHelpers/serverUtilities.ts +232 -40
  591. package/server/serverHelpers/sharedMessageEncoding.ts +307 -0
  592. package/server/serverHelpers/uwsServer.ts +17 -2
  593. package/server/static.ts +23 -29
  594. package/server/storageReclamation.ts +15 -2
  595. package/server/threads/isolatedApplications.ts +157 -0
  596. package/server/threads/itc.js +11 -1
  597. package/server/threads/logRotationTransport.ts +40 -0
  598. package/server/threads/manageThreads.js +771 -66
  599. package/server/threads/socketRouter.ts +291 -30
  600. package/server/threads/threadHeapMemory.ts +26 -0
  601. package/server/threads/threadServer.js +73 -22
  602. package/sqlTranslator/index.ts +31 -8
  603. package/studio/web/assets/{Chat-4RrB5134.js → Chat-D3j-1yY1.js} +1 -1
  604. package/studio/web/assets/{FloatingChat-omlNMDcJ.js → FloatingChat-BxJGYcfB.js} +3 -3
  605. package/studio/web/assets/{apiToken-Bke3wvfZ.js → apiToken-CT55oWOe.js} +1 -1
  606. package/studio/web/assets/{applications-DvFDYJgK.js → applications-D9Ct9_vm.js} +1 -1
  607. package/studio/web/assets/{cssMode-C1vRa7zh.js → cssMode-DV8H7VwA.js} +1 -1
  608. package/studio/web/assets/{editor-3XRWEDWX.js → editor-uatc0unt.js} +1 -1
  609. package/studio/web/assets/{html-CTY5tdMr.js → html-Bm6D6paN.js} +1 -1
  610. package/studio/web/assets/{htmlMode-CG1vSD9t.js → htmlMode-CEn7tpLG.js} +1 -1
  611. package/studio/web/assets/{index-Dfpeofdu.js → index-BIXW6Pu4.js} +5 -5
  612. package/studio/web/assets/{index.lazy-B1VOIv-t.js → index.lazy-UI7L-Vrk.js} +1 -1
  613. package/studio/web/assets/{javascript-CJeJzGnI.js → javascript-CJ0G3AFZ.js} +1 -1
  614. package/studio/web/assets/{jsonMode-CYPBwM82.js → jsonMode-DQADAYEa.js} +1 -1
  615. package/studio/web/assets/{languageServices-IKH4GUHl.js → languageServices-CAQJXWcI.js} +1 -1
  616. package/studio/web/assets/{lspLanguageFeatures-BC8_gxKG.js → lspLanguageFeatures-CCQ8P5sY.js} +1 -1
  617. package/studio/web/assets/{notifications-BfKBpYcq.js → notifications-BbxTU6Aw.js} +1 -1
  618. package/studio/web/assets/{notifications-DFbArTfC.js → notifications-Cvb3P1lB.js} +1 -1
  619. package/studio/web/assets/{profile-Q4-T6c-S.js → profile-Yyb7gsvL.js} +1 -1
  620. package/studio/web/assets/{regions-CtkV0xje.js → regions-OgjGHlU5.js} +1 -1
  621. package/studio/web/assets/{register-1ZZuMsiA.js → register-6qwNEOY3.js} +2 -2
  622. package/studio/web/assets/{setComponentFile-ZWLcWv5X.js → setComponentFile-BilDMtgB.js} +1 -1
  623. package/studio/web/assets/{setup-DWprJyJy.js → setup-J6qJ7OIU.js} +2 -2
  624. package/studio/web/assets/{status--aNm8isn.js → status-0RWGcfyD.js} +1 -1
  625. package/studio/web/assets/{toggleHighContrast-Cq_lt3XD.js → toggleHighContrast-BIn-vErT.js} +1 -1
  626. package/studio/web/assets/{tsMode-pjgytARx.js → tsMode-DgUXku4d.js} +1 -1
  627. package/studio/web/assets/{typescript-Cdg0mqUh.js → typescript-C9orXcsM.js} +1 -1
  628. package/studio/web/assets/{useEntityRestURL-RZhaY8Rn.js → useEntityRestURL-BEoXXbUB.js} +1 -1
  629. package/studio/web/assets/{workers-BgoXIqQe.js → workers-JVzSDmgx.js} +1 -1
  630. package/studio/web/assets/{xml-BwSeDMiP.js → xml-Cq-S8S4X.js} +1 -1
  631. package/studio/web/assets/{yaml-DotCUG5l.js → yaml-sfoRdh1M.js} +1 -1
  632. package/studio/web/index.html +1 -1
  633. package/upgrade/directives/5-3-0.ts +132 -0
  634. package/upgrade/directives/directivesController.ts +2 -1
  635. package/utility/OperationFunctionCaller.ts +2 -1
  636. package/utility/common_utils.ts +30 -5
  637. package/utility/componentNames.ts +12 -0
  638. package/utility/environment/environmentManager.ts +3 -7
  639. package/utility/environment/systemInformation.ts +7 -0
  640. package/utility/errors/commonErrors.ts +4 -0
  641. package/utility/errors/hdbError.ts +57 -0
  642. package/utility/hdbTerms.ts +19 -0
  643. package/utility/logging/harper_logger.ts +278 -26
  644. package/utility/logging/logGenerationCoordinator.ts +196 -0
  645. package/utility/logging/logRotation.ts +367 -0
  646. package/utility/logging/logRotator.ts +213 -81
  647. package/utility/nodeIdentity.ts +45 -0
  648. package/utility/npmUtilities.ts +12 -8
  649. package/utility/operationPermissions.ts +18 -1
  650. package/utility/operation_authorization.ts +231 -42
  651. package/utility/watchPath.ts +63 -0
  652. package/utility/watcherFallback.ts +148 -0
  653. package/validation/configValidator.ts +215 -75
  654. package/validation/installValidator.ts +15 -0
  655. package/validation/validationWrapper.ts +18 -4
@@ -3,14 +3,23 @@ import { randomBytes } from 'node:crypto';
3
3
  import { initSync, getHdbBasePath, get as envGet } from '../utility/environment/environmentManager.ts';
4
4
  import { INTERNAL_DBIS_NAME } from '../utility/lmdb/terms.ts';
5
5
  import { open, compareKeys, type Database, type RootDatabase } from 'lmdb';
6
- import { join, extname, basename } from 'path';
7
- import { existsSync, readdirSync, readFileSync, mkdirSync } from 'node:fs';
6
+ import { join, extname, basename } from 'node:path';
7
+ import {
8
+ closeSync,
9
+ existsSync,
10
+ mkdirSync,
11
+ openSync,
12
+ readFileSync,
13
+ readdirSync,
14
+ realpathSync,
15
+ unlinkSync,
16
+ } from 'node:fs';
8
17
  import { unlink } from 'node:fs/promises';
9
18
  import {
10
19
  getBaseSchemaPath,
11
20
  getTransactionAuditStoreBasePath,
12
21
  } from '../dataLayer/harperBridge/lmdbBridge/lmdbUtility/initializePaths.js';
13
- import { makeTable, ignoreAlreadyDropped } from './Table.ts';
22
+ import { makeTable, ignoreAlreadyDropped, acquireUpdateAttributesLock, releaseUpdateAttributesLock } from './Table.ts';
14
23
  import OpenEnvironmentObject from '../utility/lmdb/OpenEnvironmentObject.ts';
15
24
  import {
16
25
  CONFIG_PARAMS,
@@ -29,15 +38,24 @@ import { workerData } from 'worker_threads';
29
38
  import harperLogger from '../utility/logging/harper_logger.ts';
30
39
  const { forComponent } = harperLogger;
31
40
  import * as manageThreads from '../server/threads/manageThreads.js';
32
- import { openAuditStore, readAuditEntry, createAuditEntry, type AuditRecord } from './auditStore.ts';
33
- import { removeStorageReclamation } from '../server/storageReclamation.ts';
41
+ import {
42
+ establishAuditFloor,
43
+ openAuditStore,
44
+ readAuditEntry,
45
+ createAuditEntry,
46
+ type AuditRecord,
47
+ } from './auditStore.ts';
34
48
  import { handleLocalTimeForGets } from './RecordEncoder.ts';
35
- import { deleteRootBlobPathsForDB } from './blob.ts';
49
+ import { databasePaths, deleteRootBlobPathsForDB } from './blob.ts';
50
+ import { removeStorageReclamation } from '../server/storageReclamation.ts';
51
+ import { commonValidators, schemaRegex } from '../validation/common_validators.ts';
36
52
  import { CUSTOM_INDEXES } from './indexes/customIndexes.ts';
53
+ import { planeFilePathFor, planeStalePathFor } from './indexes/hnswPlaneBinding.ts';
37
54
  import { OpenDBIObject } from '../utility/lmdb/OpenDBIObject.ts';
38
55
  import { RocksDatabase, supportedCompression, type RocksDatabaseOptions } from '@harperfast/rocksdb-js';
39
56
  import { PrimaryRocksDatabase } from './PrimaryRocksDatabase.ts';
40
57
  import { replayLogs } from './replayLogs.ts';
58
+ import { attachDerivedIndexes } from './indexes/hnswDerivedIndex.ts';
41
59
  import { totalmem } from 'node:os';
42
60
  import { RocksIndexStore } from './RocksIndexStore.ts';
43
61
  import { resolveRocksMemoryConfig } from '../utility/rocksMemoryConfig.ts';
@@ -97,8 +115,74 @@ const logger = forComponent('storage');
97
115
 
98
116
  const DEFAULT_DATABASE_NAME = 'data';
99
117
  const DEFINED_TABLES = Symbol('defined-tables');
118
+ const CATALOG_RELATIONSHIP = Symbol('catalog-relationship');
100
119
  const DEFAULT_COMPRESSION_THRESHOLD = (envGet(CONFIG_PARAMS.STORAGE_PAGESIZE) || 4096) - 60; // larger than this requires multiple pages
101
120
  initSync();
121
+
122
+ type RelationshipTarget = { database: string; table: string };
123
+ type PersistedRelationship = {
124
+ name: string;
125
+ type: string;
126
+ elements?: { type: string };
127
+ relationship: { from?: string; to?: string; filterMissing?: boolean };
128
+ target: RelationshipTarget;
129
+ };
130
+
131
+ type RelationshipHydration = {
132
+ table: any;
133
+ databaseName: string;
134
+ tableName: string;
135
+ definitions: unknown[];
136
+ };
137
+
138
+ let relationshipsToHydrate: RelationshipHydration[] = [];
139
+ const reportedRelationshipErrors = new Set<string>();
140
+ // an interrupted create is reported once per table and thread, not on every rescan
141
+ const reportedIncompleteCatalogs = new Set<string>();
142
+
143
+ function normalizeRelationships(attributes: any[]): PersistedRelationship[] {
144
+ const relationships: PersistedRelationship[] = [];
145
+ for (const attribute of attributes) {
146
+ const target = attribute.relationshipReference;
147
+ if (!attribute.relationship || !target) continue;
148
+ const relationship: PersistedRelationship['relationship'] = {};
149
+ if (typeof attribute.relationship.from === 'string') relationship.from = attribute.relationship.from;
150
+ if (typeof attribute.relationship.to === 'string') relationship.to = attribute.relationship.to;
151
+ // the GraphQL parser hands every directive argument over as a string, and the resolver reads
152
+ // filterMissing for truthiness, so persist what the resolver would see rather than the literal
153
+ if (attribute.relationship.filterMissing !== undefined)
154
+ relationship.filterMissing = Boolean(attribute.relationship.filterMissing);
155
+ if (!relationship.from && !relationship.to) continue;
156
+ const definition: PersistedRelationship = {
157
+ name: attribute.name,
158
+ type: attribute.type,
159
+ relationship,
160
+ target: { database: target.database, table: target.table },
161
+ };
162
+ if (attribute.type === 'array') definition.elements = { type: attribute.elements?.type };
163
+ relationships.push(definition);
164
+ }
165
+ return relationships;
166
+ }
167
+
168
+ function relationshipEquals(left: any, right: any): boolean {
169
+ return (
170
+ left?.name === right?.name &&
171
+ left?.type === right?.type &&
172
+ left?.elements?.type === right?.elements?.type &&
173
+ left?.relationship?.from === right?.relationship?.from &&
174
+ left?.relationship?.to === right?.relationship?.to &&
175
+ left?.relationship?.filterMissing === right?.relationship?.filterMissing &&
176
+ left?.target?.database === right?.target?.database &&
177
+ left?.target?.table === right?.target?.table
178
+ );
179
+ }
180
+
181
+ function relationshipListsEqual(left: any, right: PersistedRelationship[]): boolean {
182
+ if (!Array.isArray(left) || left.length !== right.length) return false;
183
+ for (let index = 0; index < right.length; index++) if (!relationshipEquals(left[index], right[index])) return false;
184
+ return true;
185
+ }
102
186
  /**
103
187
  * The RocksDB block/blob codec for every column family this process opens (`storage.rocks.compression`),
104
188
  * or `undefined` to leave rocksdb-js on its own default (lz4 wherever the native build has it).
@@ -332,9 +416,13 @@ function openRocksDatabase(path: string, options: RocksDatabaseOptions & { dupSo
332
416
  } else {
333
417
  db = new PrimaryRocksDatabase(path, options).open() as unknown as RocksRootDatabase;
334
418
  // the RocksDB put and remove return promises, which masks thrown errors in non-awaiting calls to put/remove,
335
- // making them unsafe to replace LMDB methods, which will synchronously throw errors if there is a problem
419
+ // making them unsafe to replace LMDB methods, which will synchronously throw errors if there is a problem.
420
+ // The versioned remove is necessarily async and its callers must await or otherwise track its promise.
336
421
  db.put = db.putSync as any;
337
- db.remove = db.removeSync as any;
422
+ db.remove = ((id: any, removeOptions?: any) =>
423
+ typeof removeOptions === 'number'
424
+ ? (db as unknown as PrimaryRocksDatabase).removeIfVersion(id, removeOptions)
425
+ : db.removeSync(id, removeOptions)) as any;
338
426
  (db.encoder as any).name = options.name;
339
427
  }
340
428
  db.env = {};
@@ -349,6 +437,33 @@ _assignPackageExport('databases', databases);
349
437
  _assignPackageExport('tables', tables);
350
438
 
351
439
  const NEXT_TABLE_ID = Symbol.for('next-table-id');
440
+ // Restore every field used by `commonChanged`, plus `indexed` and `indexNulls`,
441
+ // from the durable descriptor. In particular, preserve `indexNulls: false` so
442
+ // an index that excludes nulls is not reopened as though it contains them.
443
+ const PEER_REDEFINABLE_FIELDS = [
444
+ 'type',
445
+ 'indexed',
446
+ 'indexNulls',
447
+ 'nullable',
448
+ 'enumerable',
449
+ 'version',
450
+ 'elements',
451
+ 'properties',
452
+ 'embed',
453
+ ];
454
+ // `indexNulls` is derived from the durable descriptor, never sent by a peer, so naming it in the
455
+ // discard warn would blame the peer for a field it did not write.
456
+ const PEER_DECLARABLE_FIELDS = PEER_REDEFINABLE_FIELDS.filter((field) => field !== 'indexNulls');
457
+
458
+ // A cluster-origin caller's list can predate a declaration another thread has already committed, so on
459
+ // that path the descriptor — not the caller — decides what the attribute is, in both directions.
460
+ function applyDurableDeclaration(attribute: any, descriptor: any) {
461
+ for (const field of PEER_REDEFINABLE_FIELDS) {
462
+ if (field in descriptor) attribute[field] = descriptor[field];
463
+ else delete attribute[field];
464
+ }
465
+ }
466
+
352
467
  /**
353
468
  * True when a descriptor claims an index build no live operation in this process can own. The PID and
354
469
  * worker generation cannot answer that alone: a container reuses PID 1 and starts the in-memory
@@ -444,6 +559,7 @@ export function getDatabases(): Databases {
444
559
  loadedDatabases = true;
445
560
 
446
561
  definedDatabases = new Map();
562
+ relationshipsToHydrate = [];
447
563
  const hdbBasePath = getHdbBasePath();
448
564
  let databasePath = hdbBasePath && join(hdbBasePath, DATABASES_DIR_NAME);
449
565
  const schemaConfigs = envGet(CONFIG_PARAMS.DATABASES) || {};
@@ -466,9 +582,12 @@ export function getDatabases(): Databases {
466
582
  // (out-of-band) RocksDB directory happens to occupy that reserved name — the API can't
467
583
  // create it (schemaRegex forbids the backtick), but the scan opens any CURRENT+MANIFEST dir
468
584
  if (databaseEntry.name === RESTORE_META_DIR) continue;
585
+ // branch directories are process-local derivatives, never databases in their own right
586
+ if (databaseEntry.name === BRANCH_ROOT_DIR) continue;
469
587
  const dbName = basename(databaseEntry.name, '.mdb');
470
588
  const dbPath = join(databasePath, databaseEntry.name);
471
589
  if (blockedByRestore.has(dbName)) continue;
590
+ if (isOpenBranchPath(dbPath)) continue;
472
591
 
473
592
  if (
474
593
  databaseEntry.isFile() &&
@@ -530,7 +649,9 @@ export function getDatabases(): Databases {
530
649
  for (const databaseEntry of entries) {
531
650
  if (databaseEntry.name.endsWith(MIGRATING_DIR_SUFFIX)) continue; // migration staging dir
532
651
  if (databaseEntry.name === RESTORE_META_DIR) continue; // reserved restore-metadata dir
652
+ if (databaseEntry.name === BRANCH_ROOT_DIR) continue; // reserved branch root
533
653
  if (blockedByRestore.has(basename(databaseEntry.name, '.mdb'))) continue;
654
+ if (isOpenBranchPath(join(databasePath, databaseEntry.name))) continue;
534
655
  if (databaseEntry.isFile() && extname(databaseEntry.name).toLowerCase() === '.mdb') {
535
656
  readMetaDb(join(databasePath, databaseEntry.name), basename(databaseEntry.name, '.mdb'), dbName);
536
657
  } else {
@@ -575,10 +696,13 @@ export function getDatabases(): Databases {
575
696
  for (const tableName in tables) {
576
697
  if (!definedTables.has(tableName)) {
577
698
  logger.trace(`delete table class ${tableName}`);
699
+ tables[tableName]?.cleanup?.();
578
700
  delete tables[tableName];
579
701
  }
580
702
  }
581
703
  } else {
704
+ const removedTables = databases[dbName];
705
+ for (const tableName in removedTables) removedTables[tableName]?.cleanup?.();
582
706
  delete databases[dbName];
583
707
  if (dbName === 'data') {
584
708
  for (const tableName in tables) {
@@ -588,6 +712,7 @@ export function getDatabases(): Databases {
588
712
  }
589
713
  }
590
714
  }
715
+ hydrateCatalogRelationships();
591
716
  if (envGet(CONFIG_PARAMS.ANALYTICS_REPLICATE) === false) {
592
717
  if (!NON_REPLICATING_SYSTEM_TABLES.includes('hdb_analytics')) NON_REPLICATING_SYSTEM_TABLES.push('hdb_analytics');
593
718
  } else {
@@ -605,6 +730,171 @@ export function getDatabases(): Databases {
605
730
  return databases;
606
731
  }
607
732
 
733
+ /**
734
+ * Hydrate one branch's relationships, resolving each target against the application's own branches
735
+ * first and only then against the real databases: a target the application also branched must be its
736
+ * branch's table, and a target it did not branch is legitimately the shared one.
737
+ */
738
+ export function hydrateBranchRelationships(branch: BranchDatabase, branches: Map<string, BranchDatabase>): void {
739
+ const resolveTarget: ResolveRelationshipTarget = (target) => {
740
+ const targetBranch = branches.get(target.database);
741
+ // A branched target resolves ONLY within that branch. A durable branch is a checkpoint frozen
742
+ // at creation while the base keeps evolving, so falling through to the base for a table the
743
+ // branch's own copy lacks would point a branched application's relationship reads at live base
744
+ // data -- the fallback belongs to a database the application did not branch, never to one it did.
745
+ return targetBranch ? targetBranch.tables?.[target.table] : databases[target.database]?.[target.table];
746
+ };
747
+ // Kept, not drained, like the global list: a target declared later (on this or another thread) is
748
+ // picked up by the next pass, and `hydrateTableRelationships` is a no-op once everything resolves.
749
+ for (const hydration of branch.pendingRelationships) {
750
+ try {
751
+ hydrateTableRelationships(hydration, resolveTarget, false);
752
+ } catch (error) {
753
+ logger.error(
754
+ `Unable to hydrate persisted relationships for branch table ${hydration.databaseName}.${hydration.tableName}`,
755
+ error
756
+ );
757
+ }
758
+ }
759
+ }
760
+
761
+ function hydrateCatalogRelationships(): void {
762
+ for (const hydration of relationshipsToHydrate) {
763
+ try {
764
+ hydrateTableRelationships(hydration);
765
+ } catch (error) {
766
+ const key = `${hydration.databaseName}.${hydration.tableName}:hydrate`;
767
+ if (!reportedRelationshipErrors.has(key)) {
768
+ reportedRelationshipErrors.add(key);
769
+ logger.error(
770
+ `Unable to hydrate persisted relationships for ${hydration.databaseName}.${hydration.tableName}`,
771
+ error
772
+ );
773
+ }
774
+ }
775
+ }
776
+ }
777
+
778
+ type ResolveRelationshipTarget = (target: RelationshipTarget) => any;
779
+
780
+ const resolveTargetGlobally: ResolveRelationshipTarget = (target) => databases[target.database]?.[target.table];
781
+
782
+ function hydrateTableRelationships(
783
+ { table, databaseName, tableName, definitions }: RelationshipHydration,
784
+ resolveTarget: ResolveRelationshipTarget = resolveTargetGlobally,
785
+ announce = true
786
+ ): void {
787
+ const hydratable: { definition: PersistedRelationship; targetTable: any }[] = [];
788
+ for (let index = 0; index < definitions.length; index++) {
789
+ const definition = definitions[index] as PersistedRelationship;
790
+ // Keyed by name rather than list position, so a reordered list cannot inherit the previous
791
+ // occupant's reported state and swallow a different relationship's failure — and by reason, so
792
+ // hydrating one entry does not clear the report of a same-named invalid duplicate.
793
+ const errorKey = `${databaseName}.${tableName}:${(definition as any)?.name || `#${index}`}`;
794
+ if (!validRelationshipDefinition(definition, definitions, index)) {
795
+ reportRelationshipError(
796
+ `${errorKey}:invalid`,
797
+ `Ignoring invalid persisted relationship ${databaseName}.${tableName}[${index}]`
798
+ );
799
+ continue;
800
+ }
801
+ // a live schema attribute of the same name owns the name; the catalog copy is only a stand-in
802
+ // for threads that never loaded the schema
803
+ if (table.attributes.some((attribute) => attribute.name === definition.name && !attribute[CATALOG_RELATIONSHIP]))
804
+ continue;
805
+ const targetTable = resolveTarget(definition.target);
806
+ if (!targetTable || !relationshipFieldsExist(table, targetTable, definition)) {
807
+ reportRelationshipError(
808
+ `${errorKey}:unavailable`,
809
+ `Unable to hydrate persisted relationship ${databaseName}.${tableName}.${definition.name}: target or foreign key is unavailable`
810
+ );
811
+ continue;
812
+ }
813
+ reportedRelationshipErrors.delete(`${errorKey}:unavailable`);
814
+ hydratable.push({ definition, targetTable });
815
+ }
816
+
817
+ const installed = table.attributes.filter((attribute) => attribute[CATALOG_RELATIONSHIP]);
818
+ if (
819
+ installed.length === hydratable.length &&
820
+ hydratable.every(
821
+ ({ definition, targetTable }, index) =>
822
+ relationshipEquals(installed[index], definition) &&
823
+ (installed[index].definition || installed[index].elements?.definition)?.tableClass === targetTable
824
+ )
825
+ )
826
+ return;
827
+
828
+ const attributes = table.attributes.filter((attribute) => !attribute[CATALOG_RELATIONSHIP]);
829
+ for (const { definition, targetTable } of hydratable)
830
+ attributes.push(createCatalogRelationship(definition, targetTable));
831
+ table.attributes.splice(0, table.attributes.length, ...attributes);
832
+ table.schemaVersion++;
833
+ table.updatedAttributes();
834
+ if (announce) databaseEventsEmitter.emit('updateTable', table);
835
+ }
836
+
837
+ function validRelationshipDefinition(definition: any, definitions: unknown[], index: number): boolean {
838
+ if (!definition || typeof definition !== 'object') return false;
839
+ const validName = (value: any) => typeof value === 'string' && value.length > 0 && !/[`/]/.test(value);
840
+ if (!validName(definition.name) || !validName(definition.type)) return false;
841
+ if (!validName(definition.target?.database) || !validName(definition.target?.table)) return false;
842
+ if (!definition.relationship || typeof definition.relationship !== 'object') return false;
843
+ const { from, to, filterMissing } = definition.relationship;
844
+ if (from !== undefined && !validName(from)) return false;
845
+ if (to !== undefined && !validName(to)) return false;
846
+ if (!from && !to) return false;
847
+ if (filterMissing !== undefined && typeof filterMissing !== 'boolean') return false;
848
+ if (definition.type === 'array' ? !validName(definition.elements?.type) : definition.elements !== undefined)
849
+ return false;
850
+ for (let earlier = 0; earlier < index; earlier++)
851
+ if ((definitions[earlier] as any)?.name === definition.name) return false;
852
+ return true;
853
+ }
854
+
855
+ function relationshipFieldsExist(sourceTable: any, targetTable: any, definition: PersistedRelationship): boolean {
856
+ if (
857
+ definition.relationship.from &&
858
+ !sourceTable.attributes.some((attribute) => attribute.name === definition.relationship.from)
859
+ )
860
+ return false;
861
+ if (
862
+ definition.relationship.to &&
863
+ !targetTable.attributes.some((attribute) => attribute.name === definition.relationship.to)
864
+ )
865
+ return false;
866
+ return true;
867
+ }
868
+
869
+ function createCatalogRelationship(definition: PersistedRelationship, targetTable: any): any {
870
+ const attribute: any = {
871
+ name: definition.name,
872
+ attribute: definition.name,
873
+ type: definition.type,
874
+ relationship: { ...definition.relationship },
875
+ target: { ...definition.target },
876
+ };
877
+ const targetDefinition = {
878
+ tableClass: targetTable,
879
+ type: targetTable.tableName,
880
+ attributes: targetTable.attributes,
881
+ };
882
+ if (definition.elements) {
883
+ attribute.elements = { type: definition.elements.type };
884
+ Object.defineProperty(attribute.elements, 'definition', { value: targetDefinition, configurable: true });
885
+ } else {
886
+ Object.defineProperty(attribute, 'definition', { value: targetDefinition, configurable: true });
887
+ }
888
+ Object.defineProperty(attribute, CATALOG_RELATIONSHIP, { value: true });
889
+ return attribute;
890
+ }
891
+
892
+ function reportRelationshipError(key: string, message: string): void {
893
+ if (reportedRelationshipErrors.has(key)) return;
894
+ reportedRelationshipErrors.add(key);
895
+ logger.error(message);
896
+ }
897
+
608
898
  /**
609
899
  * Scan a databases directory's entries for restore lock/marker files and return the names of
610
900
  * databases that must not be loaded: a held restore lock means a restore is in progress in some
@@ -653,14 +943,19 @@ export function readMetaDb(
653
943
  }
654
944
 
655
945
  rootStore.dbisDb?.resetReadTxn();
656
- return initStores(path, rootStore, databaseName, defaultTable, auditPath, isLegacy);
946
+ return initStores(path, rootStore, databaseName, { defaultTable, auditPath, isLegacy });
657
947
  } catch (error) {
658
948
  error.message += ` opening database ${path}`;
659
949
  throw error;
660
950
  }
661
951
  }
662
952
 
663
- function readRocksMetaDb(path: string, defaultTable?: string, databaseName: string = DEFAULT_DATABASE_NAME) {
953
+ function readRocksMetaDb(
954
+ path: string,
955
+ defaultTable?: string,
956
+ databaseName: string = DEFAULT_DATABASE_NAME,
957
+ { destination, storeName, openedStores }: Pick<InitStoresOptions, 'destination' | 'storeName' | 'openedStores'> = {}
958
+ ) {
664
959
  try {
665
960
  logger.trace(`loading rocksdb database: ${path}`);
666
961
 
@@ -673,13 +968,17 @@ function readRocksMetaDb(path: string, defaultTable?: string, databaseName: stri
673
968
 
674
969
  let rootStore: RocksRootDatabase | undefined = rocksdbDatabaseEnvs.get(path);
675
970
  if (rootStore) {
676
- initStores(path, rootStore, databaseName, defaultTable);
971
+ initStores(path, rootStore, databaseName, { defaultTable, destination, storeName, openedStores });
677
972
  } else {
678
973
  rootStore = openRocksDatabase(path, { disableWAL: false, enableStats: true }) as any;
679
974
  rocksdbDatabaseEnvs.set(path, rootStore);
680
- initStores(path, rootStore, databaseName, defaultTable);
681
- // Skip transaction log replay in read-only mode
682
- if (!isReadOnlyMode()) {
975
+ initStores(path, rootStore, databaseName, { defaultTable, destination, storeName, openedStores });
976
+ // A branch (`destination`) recovers its transaction-log tail in `openOrCreate`
977
+ // (branchDatabase.ts), not here: the branch claim elects exactly one replaying thread —
978
+ // applications load on workers, where this call would be a no-op — and awaits the replay
979
+ // before the branch is published to any reader. See the contract note on
980
+ // `openBranchDatabase` (harper#643).
981
+ if (!isReadOnlyMode() && !destination) {
683
982
  replayLogs(rootStore, databases[databaseName]);
684
983
  }
685
984
  }
@@ -690,14 +989,32 @@ function readRocksMetaDb(path: string, defaultTable?: string, databaseName: stri
690
989
  }
691
990
  }
692
991
 
992
+ interface InitStoresOptions {
993
+ defaultTable?: string;
994
+ auditPath?: string;
995
+ isLegacy?: boolean;
996
+ /** Build the Table classes here instead of the global `databases` map, and emit no global event. */
997
+ destination?: Tables;
998
+ /**
999
+ * Identity stamped on the root store, when it must differ from the logical `databaseName` the
1000
+ * Table classes carry. `getRootBlobPathsForDB` resolves blob directories from it.
1001
+ */
1002
+ storeName?: string;
1003
+ /**
1004
+ * Every column family opened here is appended, so a caller can release the ones a failure left
1005
+ * unreachable — a table's stores are opened well before `setTable` publishes it into the graph.
1006
+ */
1007
+ openedStores?: any[];
1008
+ }
1009
+
693
1010
  function initStores(
694
1011
  path: string,
695
1012
  rootStore: RootDatabaseKind,
696
1013
  databaseName: string,
697
- defaultTable?: string,
698
- auditPath?: string,
699
- isLegacy?: boolean
1014
+ { defaultTable, auditPath, isLegacy, destination, storeName, openedStores }: InitStoresOptions = {}
700
1015
  ) {
1016
+ // a store with no tables never reaches the per-table loop below, and blob roots resolve from this
1017
+ rootStore.databaseName = storeName ?? databaseName;
701
1018
  const envInit = new OpenEnvironmentObject(path, isReadOnlyMode());
702
1019
  const internalDbiInit = createOpenDBIObject(false);
703
1020
  let attributesDbi = rootStore.dbisDb;
@@ -711,6 +1028,7 @@ function initStores(
711
1028
  } else {
712
1029
  attributesDbi = rootStore.openDB(INTERNAL_DBIS_NAME, internalDbiInit as any);
713
1030
  }
1031
+ openedStores?.push(attributesDbi);
714
1032
  rootStore.dbisDb = markInternalDbiNonVersioned(attributesDbi);
715
1033
  }
716
1034
 
@@ -731,13 +1049,17 @@ function initStores(
731
1049
  }) as any;
732
1050
  }
733
1051
  auditStore.isLegacy = true;
1052
+ // A legacy standalone audit root skips openAuditStore, so give it a floor here or it
1053
+ // reports its retention horizon as permanently unknown.
1054
+ establishAuditFloor(auditStore);
734
1055
  }
735
1056
  } else {
736
1057
  auditStore = openAuditStore(rootStore);
737
1058
  }
738
1059
  }
739
1060
 
740
- const tables = ensureDB(databaseName);
1061
+ const tables = destination ?? ensureDB(databaseName);
1062
+ if (destination && !destination[DEFINED_TABLES]) destination[DEFINED_TABLES] = new Set<string>();
741
1063
  const definedTables = tables[DEFINED_TABLES];
742
1064
  (definedTables as any).rootStore = rootStore;
743
1065
  const tablesToLoad = new Map<string, any>();
@@ -835,17 +1157,29 @@ function initStores(
835
1157
  }
836
1158
  }
837
1159
  if (!primaryAttribute) {
838
- logger.warn(
839
- `Unable to find a primary key attribute on table ${tableName}, with attributes: ${JSON.stringify(attributes)}`
840
- );
1160
+ const tableKey = `${databaseName}/${tableName}`;
1161
+ if (reportedIncompleteCatalogs.has(tableKey))
1162
+ logger.debug(`Skipping table ${databaseName}.${tableName}: still no primary key row`);
1163
+ else {
1164
+ reportedIncompleteCatalogs.add(tableKey);
1165
+ logger.warn(
1166
+ `Skipping table ${databaseName}.${tableName}: its catalog has attribute rows (${attributes.map((attribute) => attribute.name).join(', ')}) but no primary key row - a create in progress on another thread, or an interrupted one that re-running create_table repairs`
1167
+ );
1168
+ }
1169
+ // not defined until it loads, so the cleanup pass evicts a class left from a dropped same-name table
1170
+ definedTables?.delete(tableName);
841
1171
  continue;
842
1172
  }
843
1173
  }
1174
+ if (reportedIncompleteCatalogs.size) reportedIncompleteCatalogs.delete(`${databaseName}/${tableName}`);
844
1175
  // if the table has already been defined, use that class, don't create a new one
845
1176
  let table = tables[tableName];
846
1177
  // unless its store was migrated to a different engine (e.g. LMDB to RocksDB on startup)
847
1178
  const recreateForEngineChange =
848
1179
  !!table && (table as any).primaryStore?.rootStore instanceof RocksDatabase !== rootStore instanceof RocksDatabase;
1180
+ const recreateForTableIdChange =
1181
+ !!table && primaryAttribute.tableId != null && table.tableId !== primaryAttribute.tableId;
1182
+ const recreateTable = recreateForEngineChange || recreateForTableIdChange;
849
1183
  let indices = {},
850
1184
  existingAttributes = [];
851
1185
  let tableId;
@@ -859,7 +1193,8 @@ function initStores(
859
1193
  const cacheControl = primaryAttribute.cacheControl;
860
1194
  const splitSegments = primaryAttribute.splitSegments;
861
1195
  const replicate = primaryAttribute.replicate;
862
- if (table && !recreateForEngineChange) {
1196
+ if (table && !recreateTable) {
1197
+ if (primaryAttribute.audit === true && table.audit !== true) table.enableAuditing();
863
1198
  indices = table.indices;
864
1199
  existingAttributes = table.attributes;
865
1200
  table.schemaVersion++;
@@ -887,18 +1222,14 @@ function initStores(
887
1222
  // per-table override of the storage.randomAccessFields default (see OpenDBIObject)
888
1223
  if (typeof primaryAttribute.randomAccessFields === 'boolean')
889
1224
  dbiInit.randomAccessStructure = primaryAttribute.randomAccessFields;
890
- if (rootStore instanceof RocksDatabase) {
891
- primaryStore = handleLocalTimeForGets(
892
- openRocksDatabase(rootStore.path, { ...dbiInit, name: primaryAttribute.key, cache: true } as any),
893
- rootStore
894
- );
895
- } else {
896
- primaryStore = handleLocalTimeForGets(
897
- (rootStore as any).openDB(primaryAttribute.key, dbiInit as any),
898
- rootStore
899
- );
900
- }
901
- rootStore.databaseName = databaseName;
1225
+ // recorded before the wrapper below, which is the only thing between the native open and
1226
+ // the only list a failed open can release it from
1227
+ const opened =
1228
+ rootStore instanceof RocksDatabase
1229
+ ? openRocksDatabase(rootStore.path, { ...dbiInit, name: primaryAttribute.key, cache: true } as any)
1230
+ : (rootStore as any).openDB(primaryAttribute.key, dbiInit as any);
1231
+ openedStores?.push(opened);
1232
+ primaryStore = handleLocalTimeForGets(opened, rootStore);
902
1233
  primaryStore.tableId = tableId;
903
1234
  }
904
1235
  let attributesUpdated: boolean;
@@ -909,6 +1240,7 @@ function initStores(
909
1240
  if (!attribute.isPrimaryKey && (attribute.indexed || (attribute.attribute && !attribute.name))) {
910
1241
  if (!indices[attribute.name]) {
911
1242
  const dbi = openIndex(attribute.key, rootStore, attribute);
1243
+ openedStores?.push(dbi);
912
1244
  indices[attribute.name] = dbi;
913
1245
  indices[attribute.name].indexNulls = attribute.indexNulls;
914
1246
  }
@@ -979,16 +1311,19 @@ function initStores(
979
1311
  existingAttributes.splice(existingAttributes.indexOf(existingAttribute), 1);
980
1312
  attributesUpdated = true;
981
1313
  }
982
- if (table && !recreateForEngineChange) {
1314
+ if (table && !recreateTable) {
983
1315
  if (attributesUpdated) {
984
1316
  table.schemaVersion++;
985
1317
  table.updatedAttributes();
986
1318
  }
987
1319
  } else {
1320
+ if (recreateForTableIdChange) table.cleanup();
988
1321
  table = setTable(
989
1322
  tables,
990
1323
  tableName,
991
1324
  makeTable({
1325
+ // A branch builds into a caller-owned destination; its tables must refuse DDL.
1326
+ isBranch: Boolean(destination),
992
1327
  primaryStore,
993
1328
  auditStore,
994
1329
  audit,
@@ -1011,12 +1346,436 @@ function initStores(
1011
1346
  })
1012
1347
  );
1013
1348
  table.schemaVersion = 1;
1014
- databaseEventsEmitter.emit('updateTable', table);
1349
+ if (!destination) databaseEventsEmitter.emit('updateTable', table);
1350
+ }
1351
+ void table.derivedIndexRuntime?.close();
1352
+ table.derivedIndexRuntime = attachDerivedIndexes(table);
1353
+ if (Array.isArray(primaryAttribute.relationships)) {
1354
+ relationshipsToHydrate.push({ table, databaseName, tableName, definitions: primaryAttribute.relationships });
1355
+ } else if (primaryAttribute.relationships !== undefined) {
1356
+ reportRelationshipError(
1357
+ `${databaseName}.${tableName}:list`,
1358
+ `Ignoring invalid persisted relationship list for ${databaseName}.${tableName}`
1359
+ );
1360
+ relationshipsToHydrate.push({ table, databaseName, tableName, definitions: [] });
1015
1361
  }
1016
1362
  }
1017
1363
  return rootStore;
1018
1364
  }
1019
1365
 
1366
+ /**
1367
+ * Branch directories live beside the base database's own storage root, never under the HDB root: a
1368
+ * database can be placed on its own volume, and `createCheckpoint` only hardlinks when source and
1369
+ * target share a filesystem — off-volume it degrades to a full byte copy, which is the property the
1370
+ * whole feature rests on.
1371
+ *
1372
+ * The backticks are what make the name reserved rather than merely conventional: `schemaRegex`
1373
+ * (validation/common_validators.ts) excludes 0x60, so no database can ever be created under this
1374
+ * name and shadow the branch root -- the same protection RESTORE_META_DIR uses.
1375
+ */
1376
+ export const BRANCH_ROOT_DIR = '`branches`';
1377
+
1378
+ /**
1379
+ * Where the branch of `baseName` belonging to `appName` lives. Derived only from those two names, so
1380
+ * every node in a cluster resolves the same application's branch to the same place — the identity an
1381
+ * application's data needs if it is to be addressed, and eventually replicated, cluster-wide.
1382
+ *
1383
+ * App and database are separate path segments: joining them (`<app>__<db>`) is not injective —
1384
+ * `(a__b, c)` and `(a, b__c)` collide — so two declarations could otherwise open one directory.
1385
+ */
1386
+ export function resolveBranchPath(baseName: string, appName: string): string {
1387
+ for (const [label, segment] of [
1388
+ ['application', appName],
1389
+ ['database', baseName],
1390
+ ]) {
1391
+ if (!segment || segment.includes('/') || segment.includes('\\') || segment === '.' || segment === '..') {
1392
+ throw new Error(`Invalid ${label} name for a branch path: ${JSON.stringify(segment)}`);
1393
+ }
1394
+ }
1395
+ return join(resolveDatabaseStorageRoot(baseName), BRANCH_ROOT_DIR, appName, baseName);
1396
+ }
1397
+
1398
+ /** A branch's private table graph plus the handle needed to tear it down. */
1399
+ export interface BranchDatabase {
1400
+ tables: Tables;
1401
+ rootStore: RootDatabaseKind;
1402
+ /** The realpath of the branch directory; what a schema-change signal names to address this branch. */
1403
+ path: string;
1404
+ /** The logical name the application uses (`data`); every Table class in `tables` carries it. */
1405
+ databaseName: string;
1406
+ /** The branch's own store identity, which its blob roots resolve from. */
1407
+ storeName: string;
1408
+ /**
1409
+ * Every column-family wrapper opened on this store -- by the open, by a reload, or by a table
1410
+ * declaration -- so `close()` can release them all. Recorded at acquisition rather than
1411
+ * reconstructed from `tables` at close: a re-declaration displaces the index and catalog wrappers it
1412
+ * replaces, and a failed declaration can leave one that no class ever held.
1413
+ */
1414
+ openedStores: any[];
1415
+ /**
1416
+ * Relationships this branch's tables declared, still un-hydrated. They cannot be resolved at open
1417
+ * time: a branch's definitions name the BASE database (its tables carry the base's logical names),
1418
+ * so resolving them through the global map would point the application's relationship reads at the
1419
+ * base. `hydrateBranchRelationships` finishes the job once the whole branch set is known.
1420
+ */
1421
+ pendingRelationships: RelationshipHydration[];
1422
+ /**
1423
+ * The application's whole branch set, once `prepareBranches` has opened it, so a reload can hydrate
1424
+ * a relationship whose target the application also branched against that branch.
1425
+ */
1426
+ relatedBranches?: Map<string, BranchDatabase>;
1427
+ close(): void;
1428
+ }
1429
+
1430
+ /** `undefined` marks a path reserved by an open still in flight, which owns it just as firmly. */
1431
+ const openBranches = new Map<string, BranchDatabase | undefined>();
1432
+ /** Store identities in use, so two branches cannot resolve one set of blob roots. */
1433
+ const openBranchIdentities = new Set<string>();
1434
+
1435
+ /**
1436
+ * Materialization renames its clone in from `<blobRoot>.staging`, so a branch owns two database
1437
+ * names rather than one: a database legally called `<storeName>.staging` resolves its own blob root
1438
+ * to exactly the path the clone removes and renames over. Every check, reservation and release
1439
+ * covers the pair, so the name cannot be claimed at any point where a branch operation may still
1440
+ * delete what it resolves to.
1441
+ */
1442
+ const BRANCH_STAGING_SUFFIX = '.staging';
1443
+ /**
1444
+ * Suffix of the sibling a branch is renamed to while being removed. A backtick, not a dot:
1445
+ * `schemaRegex` excludes 0x60, so no database can be named such that `<db>` + this suffix is another
1446
+ * branch's directory (with `.removing`, an application branching both `data` and `data.removing`, both
1447
+ * legal names, would destroy one by opening the other).
1448
+ */
1449
+ export const BRANCH_REMOVING_SUFFIX = '`removing`';
1450
+ function branchIdentityPair(storeName: string): string[] {
1451
+ return [storeName, storeName + BRANCH_STAGING_SUFFIX];
1452
+ }
1453
+
1454
+ /**
1455
+ * Identities whose blob roots outlived the branch that owned them, because a removal or an abandoned
1456
+ * materialization could not delete them. A database created under such a name would resolve its own
1457
+ * fresh file ids onto files it never wrote, so the name stays refused -- but only against DATABASES.
1458
+ * The branch itself may take it back: materializing it replaces those roots wholesale, which is the
1459
+ * only route that clears the condition without an operator.
1460
+ */
1461
+ const quarantinedBranchIdentities = new Set<string>();
1462
+
1463
+ export function quarantineBranchIdentity(storeName: string): void {
1464
+ for (const name of branchIdentityPair(storeName)) {
1465
+ quarantinedBranchIdentities.add(name);
1466
+ openBranchIdentities.delete(name);
1467
+ }
1468
+ }
1469
+
1470
+ /**
1471
+ * True when `dbPath` is a directory an open branch owns. The database scan opens any directory that
1472
+ * holds CURRENT + MANIFEST-*, and harper#643 places a branch inside the directory it walks, so
1473
+ * without this a rescan would rebuild the branch's tables into the global map, overwrite the store
1474
+ * identity its blob roots resolve from, and hand its store to `closeLoadedDatabases`.
1475
+ */
1476
+ function isOpenBranchPath(dbPath: string): boolean {
1477
+ if (openBranches.size === 0) return false;
1478
+ // the literal path first: `rocksdbDatabaseEnvs` is keyed by it too, so a directory unlinked under
1479
+ // a live branch handle (realpathSync then throws) must not read as unowned
1480
+ if (openBranches.has(dbPath)) return true;
1481
+ try {
1482
+ return openBranches.has(realpathSync(dbPath));
1483
+ } catch {
1484
+ return false;
1485
+ }
1486
+ }
1487
+
1488
+ /**
1489
+ * A branch identity resolves its blob roots through `join(…, 'blobs', storeName)`, so it must be a
1490
+ * single path segment: `schemaRegex`, which every other database name is validated against, plus the
1491
+ * dot segments and backslash that regex permits but a path component must not be.
1492
+ */
1493
+ function assertLegalBranchName(name: string, description: string): void {
1494
+ if (
1495
+ !name ||
1496
+ name.length > commonValidators.schema_length.maximum ||
1497
+ !schemaRegex.test(name) ||
1498
+ name.includes('\\') ||
1499
+ name === '.' ||
1500
+ name === '..'
1501
+ ) {
1502
+ throw new Error(`Cannot use '${name}' as a branch ${description}: it is not a legal database name`);
1503
+ }
1504
+ }
1505
+
1506
+ /**
1507
+ * Refuse a branch store identity that something else already answers to.
1508
+ *
1509
+ * `storeName` picks the branch's blob roots, and blob file ids restart from each store's own counter,
1510
+ * so two holders of one identity write the same file paths and truncate each other. It must be
1511
+ * checked BEFORE anything destructive runs: materialization removes and replaces the blob root that
1512
+ * this name resolves to, and a real database may legally be called `5_myapp__data` -- `schemaRegex`
1513
+ * permits digits, `_` and `.`. The `.staging` sibling materialization writes is covered too, since a
1514
+ * database may legally carry that name as well.
1515
+ */
1516
+ export function assertBranchIdentityAvailable(storeName: string): void {
1517
+ // The on-disk scan, not just the in-memory maps: a database that exists on disk but has not been
1518
+ // loaded is absent from both, and it owns the blob root this identity would destroy.
1519
+ getDatabases();
1520
+ for (const name of branchIdentityPair(storeName)) {
1521
+ // The directory as well as the maps. `getDatabases` skips a database blocked by restore, so an
1522
+ // in-memory check alone reports its name as free while its blob root is very much real -- and
1523
+ // materialization would then remove and replace it.
1524
+ if (
1525
+ databases[name] ||
1526
+ definedDatabases?.has(name) ||
1527
+ openBranchIdentities.has(name) ||
1528
+ existsSync(resolveDatabasePath(name)) ||
1529
+ anotherBranchOwns(name, storeName)
1530
+ ) {
1531
+ throw new Error(`Cannot use '${storeName}' as a branch store identity: '${name}' is already in use`);
1532
+ }
1533
+ }
1534
+ }
1535
+
1536
+ /**
1537
+ * Does a branch OTHER than the one being opened already answer to this name on disk? `.staging` is
1538
+ * what makes the question two-sided: `<identity>.staging` is both the path a clone renames over and
1539
+ * a legal identity for a branch of a database literally named `<base>.staging`, so each of the pair
1540
+ * can belong to somebody else. Only the primary name read as itself is excluded -- that directory is
1541
+ * the very branch this call is opening.
1542
+ */
1543
+ function anotherBranchOwns(name: string, storeName: string): boolean {
1544
+ if (name !== storeName) return branchDirectoryExistsFor(name);
1545
+ return name.endsWith(BRANCH_STAGING_SUFFIX)
1546
+ ? branchDirectoryExistsFor(name.slice(0, -BRANCH_STAGING_SUFFIX.length))
1547
+ : false;
1548
+ }
1549
+
1550
+ /**
1551
+ * Claim the identity as well as checking it, so the window between the check and the branch actually
1552
+ * opening cannot be filled by a concurrent create or a second branch. `releaseBranchIdentity` hands
1553
+ * it back if materialization never gets as far as opening.
1554
+ */
1555
+ export function reserveBranchIdentity(storeName: string): void {
1556
+ assertBranchIdentityAvailable(storeName);
1557
+ retakeBranchIdentity(storeName);
1558
+ }
1559
+
1560
+ /**
1561
+ * Take the pair back for an operation that owned it a statement ago -- cleanup, which has to keep
1562
+ * holding the names through the deletions its `close()` just released them for. Deliberately without
1563
+ * the availability check: nothing can have taken a name the caller held until now, and the check runs
1564
+ * the database scan, which at that exact moment would find the branch directory unowned.
1565
+ */
1566
+ export function retakeBranchIdentity(storeName: string): void {
1567
+ for (const name of branchIdentityPair(storeName)) {
1568
+ openBranchIdentities.add(name);
1569
+ quarantinedBranchIdentities.delete(name);
1570
+ }
1571
+ }
1572
+
1573
+ export function releaseBranchIdentity(storeName: string): void {
1574
+ for (const name of branchIdentityPair(storeName)) openBranchIdentities.delete(name);
1575
+ }
1576
+
1577
+ /** Is this name spoken for by a branch? Database creation has to refuse it -- they share a blob root. */
1578
+ export function isBranchIdentity(name: string): boolean {
1579
+ if (openBranchIdentities.has(name) || quarantinedBranchIdentities.has(name)) return true;
1580
+ // The in-memory set covers only branches open in THIS process, so after a restart -- or for an
1581
+ // application that is simply not loaded -- a database could take the name of an on-disk branch and
1582
+ // share its blob root. The staging sibling goes through the same route, because it names the path
1583
+ // materialization renames over -- but BOTH readings of a name ending in `.staging` have to be
1584
+ // tried: `schemaRegex` permits `.`, so `4_myapp__data.staging` is either the sibling of a branch of
1585
+ // `data` or a branch of a database actually called `data.staging`.
1586
+ if (branchDirectoryExistsFor(name)) return true;
1587
+ return name.endsWith(BRANCH_STAGING_SUFFIX)
1588
+ ? branchDirectoryExistsFor(name.slice(0, -BRANCH_STAGING_SUFFIX.length))
1589
+ : false;
1590
+ }
1591
+
1592
+ /**
1593
+ * Is there a branch directory answering to this store identity? The identity carries the application
1594
+ * name's length precisely so it can be taken apart again without guessing where the name ends.
1595
+ */
1596
+ function branchDirectoryExistsFor(storeName: string): boolean {
1597
+ const prefix = /^(\d+)_/.exec(storeName);
1598
+ if (!prefix) return false;
1599
+ const appLength = Number(prefix[1]);
1600
+ const appName = storeName.slice(prefix[0].length, prefix[0].length + appLength);
1601
+ if (
1602
+ appName.length !== appLength ||
1603
+ storeName.slice(prefix[0].length + appLength, prefix[0].length + appLength + 2) !== '__'
1604
+ )
1605
+ return false;
1606
+ const baseName = storeName.slice(prefix[0].length + appLength + 2);
1607
+ if (!baseName) return false;
1608
+ try {
1609
+ const branchPath = resolveBranchPath(baseName, appName);
1610
+ return existsSync(branchPath) || existsSync(branchPath + BRANCH_REMOVING_SUFFIX);
1611
+ } catch {
1612
+ // Not a name a branch path could hold, so no branch owns it.
1613
+ return false;
1614
+ }
1615
+ }
1616
+
1617
+ /**
1618
+ * Open a RocksDB directory as a **scope-private** database: its Table classes are built into an
1619
+ * object the caller owns and nothing is registered in the global `databases` map, so no enumerator
1620
+ * of that map — analytics, `describe_all`, worker teardown, replication — can observe it.
1621
+ *
1622
+ * `databaseName` is the *logical* name the application knows (`data`), so its schema and code need
1623
+ * no changes. `storeName` is the branch's own identity and is what `getRootBlobPathsForDB` resolves
1624
+ * blob directories from, which is how a branch gets its own blob roots rather than writing into the
1625
+ * base's.
1626
+ *
1627
+ * The caller owns the returned handle; the only thing that closes it on the caller's behalf is
1628
+ * `closeBranchDatabases`, run by an exiting job worker (via `closeLoadedDatabases`) and by an HTTP
1629
+ * worker's shutdown path, so a branch left open on an exiting worker does not linger in the
1630
+ * process-global RocksDB registry.
1631
+ *
1632
+ * Schema changes reach a branch only through its own bound factory (`scopedTableFactory`): a
1633
+ * declaration re-asserted against the branch's store. A branch's Table classes carry the base's
1634
+ * logical name, so the Table statics (`dropTable()`, `addAttributes()`) — which resolve the global
1635
+ * schema by that name and would act on the live base table — stay refused (`assertSchemaMutable`).
1636
+ *
1637
+ * A branch's blob roots are a hard-link clone of the base's, taken with the checkpoint, so a row
1638
+ * whose blob predates the branch reads back normally and the branch allocates new file ids in its own
1639
+ * directory (harper#644).
1640
+ *
1641
+ * A branch is the checkpoint's SST content plus its own transaction-log tail. This function opens
1642
+ * only the stores; replaying the tail is `openOrCreate`'s job (branchDatabase.ts), where the
1643
+ * cross-thread claim elects exactly one replayer and awaits it before any thread may open the
1644
+ * branch — the same recovery contract a base database gets at boot, without which a process that
1645
+ * died unflushed silently rewinds the branch to its last memtable flush (harper#643).
1646
+ *
1647
+ * Pass `blobRoots` to pin the handle to the roots the branch was published with; without it the
1648
+ * store resolves them from current configuration, which is only right for a branch being created.
1649
+ */
1650
+ export function openBranchDatabase(
1651
+ path: string,
1652
+ databaseName: string,
1653
+ storeName: string,
1654
+ blobRoots?: string[]
1655
+ ): BranchDatabase {
1656
+ assertLegalBranchName(databaseName, 'logical database name');
1657
+ assertLegalBranchName(storeName, 'store identity');
1658
+ if (!existsSync(path)) throw new Error(`Cannot open branch database: no directory at ${path}`);
1659
+ // the guards compare against env-map keys, so two spellings of one directory must not read as two
1660
+ path = realpathSync(path);
1661
+ // FIRST: the guards below read the registry, and loading is itself what populates
1662
+ // `rocksdbDatabaseEnvs`. Claiming the path ahead of this scan would make the scan skip it, which
1663
+ // also means a directory that IS a real database no longer reads as one — so the pre-open window
1664
+ // where the scan can adopt a branch directory stays open, by choice (harper#643).
1665
+ getDatabases();
1666
+ // a rival graph over one shared root store; the two callers would disagree about who may close it
1667
+ if (openBranches.has(path)) throw new Error(`Branch database at ${path} is already open`);
1668
+ // a loaded database's store is closed by `closeLoadedDatabases`, so adopting it would mean this
1669
+ // handle's `close()` tears down a live database
1670
+ if (rocksdbDatabaseEnvs.has(path)) throw new Error(`Cannot branch ${path}: it is already open as a database`);
1671
+ assertBranchIdentityAvailable(storeName);
1672
+
1673
+ const tables: Tables = Object.create(null);
1674
+ // initStores opens a table's column families well before `setTable` publishes it into `tables`,
1675
+ // so the graph is not a complete record of what a failed open must release
1676
+ const openedStores: any[] = [];
1677
+ // The boot-time hydration pass has already run by the time a branch opens, so anything this open
1678
+ // queues would never be drained. It is handed to the caller instead, which is the only place that
1679
+ // knows the application's other branches and can therefore resolve targets without leaking to base.
1680
+ const queuedRelationshipsAt = relationshipsToHydrate.length;
1681
+ let rootStore: RootDatabaseKind;
1682
+ // claim the path before the open, not after: readRocksMetaDb registers the store in
1683
+ // `rocksdbDatabaseEnvs` partway through, so anything re-entering `database()` during initStores
1684
+ // would otherwise find the branch's store on an unowned path
1685
+ openBranches.set(path, undefined);
1686
+ retakeBranchIdentity(storeName);
1687
+ try {
1688
+ // before the open: table load schedules TTL, eviction and audit cleanup, which ask who owns this store
1689
+ manageThreads.markBranchStorePath(path);
1690
+ rootStore = readRocksMetaDb(path, null, databaseName, { destination: tables, storeName, openedStores });
1691
+ // Pin the handle to the roots the caller proved this branch was published with, before it is
1692
+ // handed out. A row's `storageIndex` is a position in that list, so resolving through current
1693
+ // configuration instead would let an appended volume take writes at an index the branch's own
1694
+ // completion marker never recorded -- and a later change at that index would then silently
1695
+ // re-address them. `closeBranchHandles` clears the entry with the rest of the handle.
1696
+ if (blobRoots) databasePaths.set(rootStore as unknown as RootDatabase, blobRoots);
1697
+ } catch (error) {
1698
+ openBranches.delete(path);
1699
+ manageThreads.markBranchStorePath(path, false);
1700
+ releaseBranchIdentity(storeName);
1701
+ const stranded = rocksdbDatabaseEnvs.get(path);
1702
+ rocksdbDatabaseEnvs.delete(path);
1703
+ closeBranchHandles(path, stranded, openedStores, tables);
1704
+ throw error;
1705
+ }
1706
+ let closed = false;
1707
+ const branch: BranchDatabase = {
1708
+ tables,
1709
+ rootStore,
1710
+ path,
1711
+ databaseName,
1712
+ storeName,
1713
+ openedStores,
1714
+ pendingRelationships: relationshipsToHydrate.splice(queuedRelationshipsAt),
1715
+ close() {
1716
+ // guard on the handle, not on the registrations: those are keyed by path, and a closed
1717
+ // branch frees its path, so a stale handle would otherwise tear down its successor
1718
+ if (closed) return;
1719
+ closed = true;
1720
+ openBranches.delete(path);
1721
+ releaseBranchIdentity(storeName);
1722
+ rocksdbDatabaseEnvs.delete(path);
1723
+ manageThreads.markBranchStorePath(path, false);
1724
+ closeBranchHandles(path, rootStore, openedStores, tables);
1725
+ },
1726
+ };
1727
+ openBranches.set(path, branch);
1728
+ return branch;
1729
+ }
1730
+
1731
+ /**
1732
+ * Release everything a branch open created. Each table's primary store and each index is its own
1733
+ * column family, on top of the internal-dbis and audit families, so closing the root alone leaves
1734
+ * all of them behind — which is why `closeDatabase` walks them individually for a real database.
1735
+ * Two process-global registrations outlive the stores as well, neither with a lifetime of its own:
1736
+ * a storage-reclamation handler per store path, whose closure pins the now-closed store, and the
1737
+ * memoized blob roots in `databasePaths`. A real database is opened once per thread; harper#643
1738
+ * makes branch open/close routine, so both would grow with branch churn.
1739
+ */
1740
+ function closeBranchHandles(
1741
+ path: string,
1742
+ rootStore?: RootDatabaseKind,
1743
+ openedStores: any[] = [],
1744
+ tables: Tables = {}
1745
+ ): void {
1746
+ const reclamationPaths = new Set<string>([path]);
1747
+ (rootStore as any)?.auditStore?.stopAuditCleanup?.();
1748
+ const closeStore = (store: any, description: string) => {
1749
+ if (!store || store.status === 'closed') return;
1750
+ if (store.path) reclamationPaths.add(store.path);
1751
+ try {
1752
+ store.close?.();
1753
+ } catch (error) {
1754
+ logger.warn(`Error closing ${description} for branch database at ${path}`, error);
1755
+ }
1756
+ };
1757
+ // the class, before its stores: an expiration timer or a reclamation handler on a closed store
1758
+ // would otherwise keep firing against it for the life of the process
1759
+ for (const tableName in tables) {
1760
+ try {
1761
+ tables[tableName]?.cleanup?.();
1762
+ } catch (error) {
1763
+ logger.warn(`Error releasing table ${tableName} of branch database at ${path}`, error);
1764
+ }
1765
+ }
1766
+ for (const store of openedStores) closeStore(store, 'column family');
1767
+ closeStore((rootStore as any)?.dbisDb, 'attributes store');
1768
+ closeStore((rootStore as any)?.auditStore, 'audit store');
1769
+ closeStore(rootStore, 'root store');
1770
+ if (rootStore) databasePaths.delete(rootStore as RootDatabase);
1771
+ for (const reclamationPath of reclamationPaths) removeStorageReclamation(reclamationPath);
1772
+ }
1773
+
1774
+ /** Branches are process-local, so this is shutdown, not a data operation. */
1775
+ export function closeBranchDatabases(): void {
1776
+ for (const branch of [...openBranches.values()]) branch?.close();
1777
+ }
1778
+
1020
1779
  export function resetDatabases() {
1021
1780
  loadedDatabases = false;
1022
1781
  for (const store of Object.values(lmdbDatabaseEnvs)) {
@@ -1047,6 +1806,7 @@ interface TableDefinition {
1047
1806
  trackDeletes?: boolean;
1048
1807
  attributes: any[];
1049
1808
  schemaDefined?: boolean;
1809
+ schemaRelationshipsDefined?: boolean;
1050
1810
  origin?: string;
1051
1811
  description?: string;
1052
1812
  properties?: Record<string, any>;
@@ -1054,6 +1814,8 @@ interface TableDefinition {
1054
1814
  // default Cache-Control for anonymous REST reads; null = schema explicitly has none (clears a
1055
1815
  // prior value on reload), undefined = caller is not schema-defining (leave the current value)
1056
1816
  cacheControl?: string | null;
1817
+ /** Internal: this declaration came from the application owned by the current dedicated worker. */
1818
+ isolatedApplicationOwner?: boolean;
1057
1819
  }
1058
1820
  /**
1059
1821
  * Ensure that we have this database object (that holds a set of tables) set up
@@ -1159,6 +1921,14 @@ export function database({ database: databaseName, table: tableName }) {
1159
1921
  const useRocksdb = (process.env.HARPER_STORAGE_ENGINE || envGet(CONFIG_PARAMS.STORAGE_ENGINE)) !== 'lmdb';
1160
1922
  if (useRocksdb) {
1161
1923
  const path = join(databasePath, tablePath ? tableName : databaseName);
1924
+ // the scan is not the only way to reach a branch's directory: a branch leaves its store in
1925
+ // `rocksdbDatabaseEnvs`, so without this an on-demand open would staple it onto
1926
+ // `definedDatabases` and the next `closeDatabase` would close it under the live handle
1927
+ if (isOpenBranchPath(path)) {
1928
+ const error: any = new Error(`Database '${databaseName}' is open as a scope-private branch`);
1929
+ error.statusCode = 409;
1930
+ throw error;
1931
+ }
1162
1932
  rootStore = rocksdbDatabaseEnvs.get(path);
1163
1933
  if (!rootStore || rootStore.status === 'closed') {
1164
1934
  // this on-demand open (create_table/create_database and friends) must not resurrect a
@@ -1378,8 +2148,12 @@ export function closeDatabase(databaseName: string): boolean {
1378
2148
  * database is closed). The `system` database is intentionally left open: it is non-enumerable here
1379
2149
  * (skipped by the loop), is never restored online, and the exiting worker may still touch the job
1380
2150
  * table during teardown. Best-effort: closing failures are swallowed inside `closeDatabase`.
2151
+ *
2152
+ * Branches are invisible to the loop below but hold handles from the same registry, so this — the
2153
+ * thread's one teardown entry point — closes them too.
1381
2154
  */
1382
2155
  export function closeLoadedDatabases(): void {
2156
+ closeBranchDatabases();
1383
2157
  // snapshot the names first: closeDatabase() deletes from `databases` as it goes
1384
2158
  for (const databaseName of Object.keys(databases)) {
1385
2159
  const dbTables = databases[databaseName];
@@ -1493,23 +2267,37 @@ function openIndex(dbiKey: string, rootStore: RootDatabaseKind, attribute: any)
1493
2267
  cache: isCustomObjectIndex,
1494
2268
  } as any) as any;
1495
2269
  (dbi as any).rootStore = rootStore;
1496
- // Custom-index object stores (e.g. HNSW) write graph nodes via plain put() with no staged
1497
- // transaction timestamp, so their values carry no version and the PrimaryRocksDatabase
1498
- // Verification-Table cache can't track them. A versioned index initialises its encoder as a
1499
- // versioned RocksDB store (isRocksDB → metadata-prefix encode/decode) and marks it
1500
- // self-versioning, so each node gets a monotonic version the VT can extract — enabling cached,
1501
- // decode-free graph traversal. The format is resolved from the persisted attribute descriptor
1502
- // (decided once at create — see resolveIndexFormat) so every worker and reload agree on it.
1503
- if (isCustomObjectIndex && resolveIndexFormat(dbiKey, rootStore, dbi, attribute) === 'versioned') {
1504
- armVersionedIndexEncoder(dbi, rootStore);
2270
+ try {
2271
+ // Custom-index object stores (e.g. HNSW) write graph nodes via plain put() with no staged
2272
+ // transaction timestamp, so their values carry no version and the PrimaryRocksDatabase
2273
+ // Verification-Table cache can't track them. A versioned index initialises its encoder as a
2274
+ // versioned RocksDB store (isRocksDB → metadata-prefix encode/decode) and marks it
2275
+ // self-versioning, so each node gets a monotonic version the VT can extract — enabling cached,
2276
+ // decode-free graph traversal. The format is resolved from the persisted attribute descriptor
2277
+ // (decided once at create — see resolveIndexFormat) so every worker and reload agree on it.
2278
+ if (isCustomObjectIndex && resolveIndexFormat(dbiKey, rootStore, dbi, attribute) === 'versioned') {
2279
+ armVersionedIndexEncoder(dbi, rootStore);
2280
+ }
2281
+ installCustomIndex(dbi);
2282
+ } catch (error) {
2283
+ // the handle is not yet owned by any table, so nobody else can close it
2284
+ try {
2285
+ dbi.close();
2286
+ } catch {}
2287
+ throw error;
1505
2288
  }
1506
2289
  } else {
1507
2290
  dbi = (rootStore as any).openDB(dbiKey, dbiInit as any);
2291
+ installCustomIndex(dbi);
1508
2292
  }
1509
- if (attribute.indexed.type) {
2293
+ function installCustomIndex(indexStore: any) {
2294
+ if (!attribute.indexed.type) return;
1510
2295
  const CustomIndex = CUSTOM_INDEXES[attribute.indexed.type];
1511
2296
  if (CustomIndex) {
1512
- dbi.customIndex = new CustomIndex(dbi, attribute.indexed);
2297
+ indexStore.customIndex = new CustomIndex(indexStore, attribute.indexed);
2298
+ // derived state whose maintaining option is now off must not linger to be adopted
2299
+ // stale on a later re-enable
2300
+ indexStore.customIndex.cleanupDisabledPlane?.();
1513
2301
  } else {
1514
2302
  logger.error(`The indexing type '${attribute.indexed.type}' is unknown`);
1515
2303
  }
@@ -1532,6 +2320,107 @@ function openIndex(dbiKey: string, rootStore: RootDatabaseKind, attribute: any)
1532
2320
  * @param replicate
1533
2321
  */
1534
2322
  export function table<TableResourceType>(tableDefinition: TableDefinition): TableResourceType {
2323
+ return declareTable(GLOBAL_TARGET, tableDefinition);
2324
+ }
2325
+
2326
+ /**
2327
+ * Where a declaration lands. `table()` is bound to the global catalog; a branched application's
2328
+ * declarations are bound to its branch (`scopedTableFactory`). Everything in `declareTable` that is
2329
+ * global by construction -- the root store, the `tables` graph a class is published into, the reload
2330
+ * after a lost create race, who owns the handles it opens -- goes through this, and nothing else does,
2331
+ * so the unbranched path is the same code with the same objects behind it.
2332
+ */
2333
+ interface TableTarget {
2334
+ rootStore(databaseName: string, tableName: string): RootDatabaseKind;
2335
+ tables(databaseName: string): Tables;
2336
+ /** Another thread created the table this declaration was about to; make `tables` reflect it. */
2337
+ reload(databaseName: string): void;
2338
+ /** Records a column-family wrapper the declaration opened, for whoever closes the store. */
2339
+ adopt(store: any): void;
2340
+ /** Set for a branch: its Table classes refuse DDL, and its schema signals address it by path. */
2341
+ branch?: BranchDatabase;
2342
+ }
2343
+
2344
+ const GLOBAL_TARGET: TableTarget = {
2345
+ rootStore: (databaseName, tableName) => database({ database: databaseName, table: tableName }),
2346
+ tables: (databaseName) => databases[databaseName],
2347
+ reload: () => resetDatabases(),
2348
+ // a real database's stores live until the process (or `closeDatabase`, which walks the graph) ends
2349
+ adopt: () => {},
2350
+ };
2351
+
2352
+ /**
2353
+ * The factory a branched application declares tables through: each declaration goes to the branch
2354
+ * of the database it names, or to `table()` itself for a database the application did not branch.
2355
+ * An unbranched, shared application gets `table` by identity. An isolated application still gets a
2356
+ * wrapper so its own declarations can claim their single-threaded maintenance work.
2357
+ */
2358
+ export function scopedTableFactory(
2359
+ branches?: Map<string, BranchDatabase>,
2360
+ isolatedApplicationOwner = false
2361
+ ): typeof table {
2362
+ if (!branches?.size && !isolatedApplicationOwner) return table;
2363
+ return function scopedTable<TableResourceType>(tableDefinition: TableDefinition): TableResourceType {
2364
+ if (isolatedApplicationOwner) tableDefinition = { ...tableDefinition, isolatedApplicationOwner: true };
2365
+ // `||`, not `??`: `table()` resolves every falsy name to the default database
2366
+ const branch = branches?.get(tableDefinition.database || DEFAULT_DATABASE_NAME);
2367
+ return branch ? declareTable(branchTarget(branch), tableDefinition) : table(tableDefinition);
2368
+ };
2369
+ }
2370
+
2371
+ function branchTarget(branch: BranchDatabase): TableTarget {
2372
+ return {
2373
+ rootStore: () => branch.rootStore,
2374
+ tables: () => branch.tables,
2375
+ reload: () => reloadBranch(branch),
2376
+ adopt: (store) => branch.openedStores.push(store),
2377
+ branch,
2378
+ };
2379
+ }
2380
+
2381
+ /**
2382
+ * Re-read a branch's catalog into its `tables`: tables and indexes another thread declared since the
2383
+ * open (or since the last reload) are opened here, the same way a schema-change rescan does for a
2384
+ * real database. Existing classes are kept and their attribute lists refreshed.
2385
+ */
2386
+ function reloadBranch(branch: BranchDatabase): void {
2387
+ const { rootStore, tables, databaseName, storeName, openedStores } = branch;
2388
+ const queuedRelationshipsAt = relationshipsToHydrate.length;
2389
+ try {
2390
+ initStores(rootStore.path, rootStore, databaseName, { destination: tables, storeName, openedStores });
2391
+ } finally {
2392
+ for (const hydration of relationshipsToHydrate.splice(queuedRelationshipsAt))
2393
+ queueBranchHydration(branch, hydration);
2394
+ }
2395
+ // Until `prepareBranches` has the whole set, a cross-database target cannot be resolved without
2396
+ // falling through to the base; it hydrates the complete set once. After that, every sibling is
2397
+ // re-hydrated: the table this reload brought in may be the target a sibling's relationship waited for.
2398
+ if (!branch.relatedBranches) return;
2399
+ for (const sibling of branch.relatedBranches.values()) hydrateBranchRelationships(sibling, branch.relatedBranches);
2400
+ }
2401
+
2402
+ /** One pending hydration per table: a re-declaration replaces the entry the earlier declaration queued. */
2403
+ function queueBranchHydration(branch: BranchDatabase, hydration: RelationshipHydration): void {
2404
+ const existing = branch.pendingRelationships.findIndex(
2405
+ (pending) => pending.databaseName === hydration.databaseName && pending.tableName === hydration.tableName
2406
+ );
2407
+ if (existing >= 0) branch.pendingRelationships[existing] = hydration;
2408
+ else branch.pendingRelationships.push(hydration);
2409
+ }
2410
+
2411
+ /**
2412
+ * The receiving side of a branch's schema-change signal: a thread that holds this branch open reloads
2413
+ * it, any other thread has nothing to do. Returns the branch's tables so the caller can address the
2414
+ * table the signal named.
2415
+ */
2416
+ export function reloadBranchAt(path: string): Tables | undefined {
2417
+ const branch = openBranches.get(path);
2418
+ if (!branch) return undefined;
2419
+ reloadBranch(branch);
2420
+ return branch.tables;
2421
+ }
2422
+
2423
+ function declareTable<TableResourceType>(target: TableTarget, tableDefinition: TableDefinition): TableResourceType {
1535
2424
  let {
1536
2425
  table: tableName,
1537
2426
  database: databaseName,
@@ -1546,12 +2435,16 @@ export function table<TableResourceType>(tableDefinition: TableDefinition): Tabl
1546
2435
  randomAccessFields,
1547
2436
  trackDeletes,
1548
2437
  schemaDefined,
2438
+ schemaRelationshipsDefined,
1549
2439
  origin,
1550
2440
  description,
1551
2441
  properties,
1552
2442
  hidden,
1553
2443
  cacheControl,
2444
+ isolatedApplicationOwner,
1554
2445
  } = tableDefinition;
2446
+ const auditExplicitlyEnabled = audit === true;
2447
+ const auditExplicitlyDisabled = audit === false;
1555
2448
  if (!databaseName) databaseName = DEFAULT_DATABASE_NAME;
1556
2449
  // Reject reserved names here too, not only at the operations API: a database
1557
2450
  // is also created by schema authoring — a `schema.graphql` `@table(database:)`
@@ -1563,8 +2456,14 @@ export function table<TableResourceType>(tableDefinition: TableDefinition): Tabl
1563
2456
  if ((RESERVED_DATABASE_NAMES as readonly string[]).includes(databaseName)) {
1564
2457
  throw new ClientError(`'${databaseName}' is a reserved name and cannot be used as a database name`);
1565
2458
  }
1566
- const rootStore = database({ database: databaseName, table: tableName });
1567
- const tables = databases[databaseName];
2459
+ // A branch resolves its blob root from its store identity, so a database created under that same
2460
+ // name would share the root: two allocators minting the same file paths and truncating each other,
2461
+ // and the branch's teardown removing the database's blobs.
2462
+ if (isBranchIdentity(databaseName)) {
2463
+ throw new ClientError(`'${databaseName}' is in use as a branch store identity and cannot be a database name`);
2464
+ }
2465
+ const rootStore = target.rootStore(databaseName, tableName);
2466
+ const tables = target.tables(databaseName);
1568
2467
  logger.trace(`Defining ${tableName} in ${databaseName}`);
1569
2468
  let Table = tables?.[tableName];
1570
2469
  if (rootStore.status === 'closed') {
@@ -1578,124 +2477,296 @@ export function table<TableResourceType>(tableDefinition: TableDefinition): Tabl
1578
2477
  // flag must be left as-is. Only an explicit value can re-assert on the existing-Table branch.
1579
2478
  const schemaDefinedExplicit = tableDefinition.schemaDefined !== undefined;
1580
2479
  if (schemaDefined == undefined) schemaDefined = true;
2480
+ const relationshipDefinitions = schemaRelationshipsDefined ? normalizeRelationships(attributes) : undefined;
1581
2481
  const internalDbiInit = createOpenDBIObject(false);
1582
-
1583
- for (const attribute of attributes) {
1584
- if (attribute.attribute && !attribute.name) {
1585
- // there is some legacy code that calls the attribute's name the attribute's attribute
1586
- attribute.name = attribute.attribute;
1587
- attribute.indexed = true;
1588
- } else attribute.attribute = attribute.name;
1589
- if (attribute.expiresAt) attribute.indexed = true;
2482
+ let releaseExclusiveLock: (() => void) | undefined;
2483
+
2484
+ const hasHnswAtEntry = attributes.some((attribute) => attribute.indexed?.type === 'HNSW');
2485
+ const persistedPrimaryDescriptor = (catalog: any) => {
2486
+ const declaredPrimaryKey = attributes.find((attribute) => attribute.isPrimaryKey)?.name ?? Table?.primaryKey;
2487
+ if (declaredPrimaryKey) {
2488
+ const key = `${tableName}/${declaredPrimaryKey}`;
2489
+ const descriptor = catalog?.getSync(key);
2490
+ if (descriptor?.isPrimaryKey) return { key, descriptor };
2491
+ }
2492
+ const key = `${tableName}/`;
2493
+ return { key, descriptor: catalog?.getSync(key) };
2494
+ };
2495
+ const hasLegacyHnswStateAtEntry =
2496
+ Table &&
2497
+ origin !== 'cluster' &&
2498
+ attributes.some((attribute) => {
2499
+ if (attribute.indexed?.type !== 'HNSW') return false;
2500
+ const persisted = Table.dbisDB?.getSync(`${tableName}/${attribute.name || attribute.attribute || ''}`)?.indexed;
2501
+ if (persisted?.type !== 'HNSW') return false;
2502
+ if (Object.hasOwn(persisted, 'nativePlane') && typeof persisted.nativePlane !== 'boolean') return true;
2503
+ for (const name of CUSTOM_INDEXES.HNSW.numericOptions)
2504
+ if (Object.hasOwn(persisted, name) && typeof persisted[name] !== 'number') return true;
2505
+ return false;
2506
+ });
2507
+ try {
2508
+ if (
2509
+ Table &&
2510
+ hasHnswAtEntry &&
2511
+ origin !== 'cluster' &&
2512
+ (rootStore instanceof RocksDatabase || hasLegacyHnswStateAtEntry)
2513
+ )
2514
+ exclusiveLock();
2515
+ const persistedAuditAtEntry =
2516
+ hasHnswAtEntry || (Table && Table.audit !== true)
2517
+ ? persistedPrimaryDescriptor(Table?.dbisDB).descriptor?.audit
2518
+ : undefined;
2519
+ if (!auditExplicitlyDisabled && persistedAuditAtEntry === true && Table?.audit !== true) Table.enableAuditing();
2520
+ for (const attribute of attributes) {
2521
+ if (attribute.attribute && !attribute.name) {
2522
+ // there is some legacy code that calls the attribute's name the attribute's attribute
2523
+ attribute.name = attribute.attribute;
2524
+ attribute.indexed = true;
2525
+ } else attribute.attribute = attribute.name;
2526
+ if (attribute.expiresAt) attribute.indexed = true;
2527
+ if (attribute.indexed?.type === 'HNSW' && origin !== 'cluster') {
2528
+ const existingAttribute = Table?.attributes.find((existing: any) => existing.name === attribute.name);
2529
+ const persistedIndexed =
2530
+ Table?.dbisDB?.getSync(`${tableName}/${attribute.name || ''}`)?.indexed ?? existingAttribute?.indexed;
2531
+ CUSTOM_INDEXES.HNSW.normalizeDeclarationOptions(attribute.indexed, persistedIndexed);
2532
+ if (attribute.indexed.nativePlane != null) {
2533
+ const persistedNativePlane = persistedIndexed?.nativePlane;
2534
+ const matchesPersistedLegacySpelling =
2535
+ persistedIndexed?.type === 'HNSW' &&
2536
+ Object.hasOwn(persistedIndexed, 'nativePlane') &&
2537
+ typeof persistedNativePlane !== 'boolean' &&
2538
+ (Object.is(persistedNativePlane, attribute.indexed.nativePlane) ||
2539
+ (typeof persistedNativePlane === 'string' &&
2540
+ typeof attribute.indexed.nativePlane === 'number' &&
2541
+ persistedNativePlane.trim() !== '' &&
2542
+ Number(persistedNativePlane) === attribute.indexed.nativePlane));
2543
+ if (matchesPersistedLegacySpelling) {
2544
+ attribute.indexed.nativePlane = persistedNativePlane;
2545
+ } else {
2546
+ attribute.indexed.nativePlane = CUSTOM_INDEXES.HNSW.normalizeNativePlaneDeclaration(
2547
+ attribute.indexed.nativePlane
2548
+ );
2549
+ }
2550
+ }
2551
+ }
2552
+ }
2553
+ const auditEnabledAtEntry =
2554
+ auditExplicitlyEnabled ||
2555
+ (!auditExplicitlyDisabled &&
2556
+ (persistedAuditAtEntry === true || (persistedAuditAtEntry == null && Table?.audit === true)));
2557
+ if (
2558
+ origin !== 'cluster' &&
2559
+ attributes.some((attribute) => {
2560
+ if (attribute.indexed?.type !== 'HNSW') return false;
2561
+ if (attribute.indexed.nativePlane != null) return Boolean(attribute.indexed.nativePlane);
2562
+ const existingAttribute = Table?.attributes.find(
2563
+ (existing: any) => existing.name === attribute.name && existing.indexed?.type === 'HNSW'
2564
+ );
2565
+ return Boolean(existingAttribute?.indexed.nativePlane);
2566
+ }) &&
2567
+ !auditEnabledAtEntry
2568
+ ) {
2569
+ throw new ClientError(
2570
+ `Table '${databaseName}.${tableName}' must enable audit logging before using nativePlane because its transaction log is the derived-index recovery source; set nativePlane: false to use the JS index`
2571
+ );
2572
+ }
2573
+ } catch (error) {
2574
+ releaseLock();
2575
+ throw error;
1590
2576
  }
2577
+ const validateHnswOptions = (catalog: any, auditQualifiesDefault: boolean) => {
2578
+ for (const attribute of attributes) {
2579
+ const indexed = attribute.indexed;
2580
+ if (indexed?.type !== 'HNSW') continue;
2581
+ const persistedIndexed = catalog?.getSync(`${tableName}/${attribute.name || ''}`)?.indexed;
2582
+ if (indexed.nativePlane) {
2583
+ CUSTOM_INDEXES.HNSW.validateNativePlaneOptions(rootStore, indexed);
2584
+ continue;
2585
+ }
2586
+ if (indexed.nativePlane != null) continue;
2587
+ if (persistedIndexed?.type === 'HNSW' && Object.hasOwn(persistedIndexed, 'nativePlane')) {
2588
+ if (persistedIndexed.nativePlane)
2589
+ CUSTOM_INDEXES.HNSW.validateNativePlaneOptions(rootStore, {
2590
+ ...indexed,
2591
+ nativePlane: persistedIndexed.nativePlane,
2592
+ });
2593
+ continue;
2594
+ }
2595
+ if (!auditQualifiesDefault) continue;
2596
+ if (persistedIndexed?.type !== 'HNSW') CUSTOM_INDEXES.HNSW.canDefaultToNativePlane(rootStore, indexed);
2597
+ }
2598
+ };
2599
+ if (!Table && origin !== 'cluster') validateHnswOptions(undefined, auditExplicitlyEnabled);
1591
2600
  let hasChanges;
1592
- let releaseExclusiveLock: () => void;
1593
- if (Table) {
1594
- primaryKey = Table.primaryKey;
1595
- if (Table.primaryStore.rootStore.status === 'closed') {
1596
- throw new Error(`Can not use a closed data store from ${tableName} class`);
1597
- }
1598
- // Reject moving the primary key to a different attribute on a table that already has records.
1599
- // The storage key (Table.primaryKey) is never re-pointed here, so honoring the change would
1600
- // leave describe reporting the new attribute while every record — old and newly inserted — stays
1601
- // keyed by the original one; search_by_id/update/delete by the declared key then all miss. Only
1602
- // schema-authored callers (@table / defineTable / create_table) reassert the declaration, so
1603
- // gate on schemaDefinedExplicit to leave cluster schema-replication / data-loader callers alone.
1604
- // See HarperFast/studio#1199.
1605
- const declaredPrimaryKey = attributes.find((attribute) => attribute.isPrimaryKey)?.name;
1606
- if (schemaDefinedExplicit && declaredPrimaryKey && declaredPrimaryKey !== Table.primaryKey) {
1607
- let hasRecords = false;
1608
- for (const _entry of Table.primaryStore.getRange({ start: true })) {
1609
- hasRecords = true;
1610
- break;
2601
+ let refreshRelationshipAttributes = false;
2602
+ let refreshedLiveAttributes = false;
2603
+ let deferredPrimaryRow: any;
2604
+ let unpublishedPrimaryStore: any;
2605
+ let published = false;
2606
+ const attributesToIndex = [];
2607
+ const indicesToRemove = [];
2608
+ try {
2609
+ if (Table) {
2610
+ refreshedLiveAttributes = true;
2611
+ primaryKey = Table.primaryKey;
2612
+ if (Table.primaryStore.rootStore.status === 'closed') {
2613
+ throw new Error(`Can not use a closed data store from ${tableName} class`);
2614
+ }
2615
+ // Reject moving the primary key to a different attribute on a table that already has records.
2616
+ // The storage key (Table.primaryKey) is never re-pointed here, so honoring the change would
2617
+ // leave describe reporting the new attribute while every record — old and newly inserted — stays
2618
+ // keyed by the original one; search_by_id/update/delete by the declared key then all miss. Only
2619
+ // schema-authored callers (@table / defineTable / create_table) reassert the declaration, so
2620
+ // gate on schemaDefinedExplicit to leave cluster schema-replication / data-loader callers alone.
2621
+ // See HarperFast/studio#1199.
2622
+ const declaredPrimaryKey = attributes.find((attribute) => attribute.isPrimaryKey)?.name;
2623
+ if (schemaDefinedExplicit && declaredPrimaryKey && declaredPrimaryKey !== Table.primaryKey) {
2624
+ let hasRecords = false;
2625
+ for (const _entry of Table.primaryStore.getRange({ start: true })) {
2626
+ hasRecords = true;
2627
+ break;
2628
+ }
2629
+ if (hasRecords) {
2630
+ throw new ClientError(
2631
+ `Cannot change the primary key of table '${databaseName}.${tableName}' from '${Table.primaryKey}' to ` +
2632
+ `'${declaredPrimaryKey}' because it already contains records. Recreate the table with the new primary ` +
2633
+ `key, or migrate the existing records.`,
2634
+ 400
2635
+ );
2636
+ }
1611
2637
  }
1612
- if (hasRecords) {
1613
- throw new ClientError(
1614
- `Cannot change the primary key of table '${databaseName}.${tableName}' from '${Table.primaryKey}' to ` +
1615
- `'${declaredPrimaryKey}' because it already contains records. Recreate the table with the new primary ` +
1616
- `key, or migrate the existing records.`,
1617
- 400
2638
+ // Acquire before the first mutation of the live Table below, so a lost race leaves no
2639
+ // attributes this worker describes but never persisted. Only the RocksDB acquire is bounded;
2640
+ // ordinary LMDB declarations stay lazy, while legacy HNSW normalization locks at entry.
2641
+ if (rootStore instanceof RocksDatabase) exclusiveLock();
2642
+ if (origin !== 'cluster') {
2643
+ const lockedAttributesDbi = Table.dbisDB;
2644
+ const persistedAuditUnderLock = persistedPrimaryDescriptor(lockedAttributesDbi).descriptor?.audit;
2645
+ validateHnswOptions(
2646
+ lockedAttributesDbi,
2647
+ auditExplicitlyEnabled || (!auditExplicitlyDisabled && persistedAuditUnderLock === true)
1618
2648
  );
1619
2649
  }
1620
- }
1621
- // it table already exists, get the split segments setting
1622
- if (splitSegments == undefined) splitSegments = Table.splitSegments;
1623
- Table.attributes.splice(0, Table.attributes.length, ...attributes);
1624
- // Re-assert from the live declaration so a stale value on disk (replicated event,
1625
- // v4-era backfill) is corrected on every reload. Gated on `schemaDefinedExplicit` so
1626
- // callers that omit the flag (cluster schema-replication, data loader) don't flip a
1627
- // dynamic table to true via the default at the top of table().
1628
- if (schemaDefinedExplicit) Table.schemaDefined = schemaDefined;
1629
- // Refresh class-level schema metadata to track docstring/directive changes across reloads.
1630
- Table.description = description;
1631
- Table.properties = properties;
1632
- Table.hidden = hidden;
1633
- // undefined means a non-schema caller (add_attribute, cluster schema events) — don't clobber
1634
- if (cacheControl !== undefined) Table.cacheControl = cacheControl;
1635
- } else {
1636
- const auditStore = rootStore.auditStore;
1637
- primaryKeyAttribute = attributes.find((attribute) => attribute.isPrimaryKey) || {};
1638
- primaryKey = primaryKeyAttribute.name;
1639
- primaryKeyAttribute.isPrimaryKey = true;
1640
- primaryKeyAttribute.is_hash_attribute = true; // backward-compat: harperdb@4.x reads this field to open the DBI with correct flags
1641
- primaryKeyAttribute.schemaDefined = schemaDefined;
1642
- // can't change compression after the fact (except threshold), so save only when we create the table
1643
- primaryKeyAttribute.compression = getDefaultCompression();
1644
- if (trackDeletes) primaryKeyAttribute.trackDeletes = true;
1645
- audit = primaryKeyAttribute.audit = typeof audit === 'boolean' ? audit : envGet(CONFIG_PARAMS.LOGGING_AUDITLOG);
1646
- if (expiration) primaryKeyAttribute.expiration = expiration;
1647
- if (eviction) primaryKeyAttribute.eviction = eviction;
1648
- // persist cacheControl so all threads (and future boots) see it; undefined callers inherit
1649
- // a descriptor value carried by cluster schema events; null (schema has no directive)
1650
- // clears a stale value the carried descriptor may hold
1651
- if (cacheControl === undefined) cacheControl = primaryKeyAttribute.cacheControl;
1652
- else if (cacheControl === null) delete primaryKeyAttribute.cacheControl;
1653
- else primaryKeyAttribute.cacheControl = cacheControl;
1654
- splitSegments ??= false;
1655
- primaryKeyAttribute.splitSegments = splitSegments; // always default to not splitting segments going forward
1656
- if (typeof sealed === 'boolean') primaryKeyAttribute.sealed = sealed;
1657
- if (typeof replicate === 'boolean') primaryKeyAttribute.replicate = replicate;
1658
- // An explicit directive PINS this table's encoding: we persist the boolean, so later changes
1659
- // to the global storage.randomAccessFields default never affect this table. Tables WITHOUT the
1660
- // directive are intentionally not persisted here — they follow the current global default on
1661
- // each open (a runtime lever to flip encoding fleet-wide). Switching either way is safe: the
1662
- // struct READ hook always stays on and struct (0x20-0x3f) vs classic-record (0x40-0x7f) bytes
1663
- // are disjoint, so already-written records still decode; only the encoding of NEW writes changes.
1664
- if (typeof randomAccessFields === 'boolean') primaryKeyAttribute.randomAccessFields = randomAccessFields;
1665
- if (origin) {
1666
- if (!primaryKeyAttribute.origins) primaryKeyAttribute.origins = [origin];
1667
- else if (!primaryKeyAttribute.origins.includes(origin)) primaryKeyAttribute.origins.push(origin);
1668
- }
1669
- logger.trace(`${tableName} table loading, opening primary store`);
1670
- const dbiInit = createOpenDBIObject(false, true);
1671
- dbiInit.compression = primaryKeyAttribute.compression;
1672
- // per-table override of the storage.randomAccessFields default (see OpenDBIObject)
1673
- if (typeof primaryKeyAttribute.randomAccessFields === 'boolean')
1674
- dbiInit.randomAccessStructure = primaryKeyAttribute.randomAccessFields;
1675
- const dbiName = tableName + '/';
1676
-
1677
- if (rootStore instanceof RocksDatabase) {
1678
- attributesDbi = (rootStore as any).dbisDb = openRocksDatabase(rootStore.path, {
1679
- ...internalDbiInit,
1680
- disableWAL: false,
1681
- name: INTERNAL_DBIS_NAME,
1682
- } as any);
2650
+ // it table already exists, get the split segments setting
2651
+ if (splitSegments == undefined) splitSegments = Table.splitSegments;
2652
+ if (origin === 'cluster') {
2653
+ const merged = Table.attributes.slice();
2654
+ for (const attribute of attributes) {
2655
+ const existing = merged.find((existingAttribute) => existingAttribute.name === attribute.name);
2656
+ if (!existing) {
2657
+ merged.push(attribute);
2658
+ continue;
2659
+ }
2660
+ // Nodes that apply the same peer definitions in a different order keep different index sets, and
2661
+ // this warn is the only signal of it. An absent field and an explicit falsy one declare the same
2662
+ // thing, so neither direction of that pair is a difference.
2663
+ const discarded = PEER_DECLARABLE_FIELDS.filter(
2664
+ (field) =>
2665
+ (attribute[field] || existing[field]) &&
2666
+ JSON.stringify(attribute[field]) !== JSON.stringify(existing[field])
2667
+ );
2668
+ if (discarded.length > 0)
2669
+ logger.warn(
2670
+ `Ignoring peer redefinition of ${databaseName}.${tableName}.${attribute.name} (${discarded
2671
+ .map(
2672
+ (field) =>
2673
+ `${field}: local ${JSON.stringify(existing[field])}, peer ${JSON.stringify(attribute[field])}`
2674
+ )
2675
+ .join('; ')}); the local schema is authoritative`
2676
+ );
2677
+ }
2678
+ attributes = merged;
2679
+ } else if (!attributes.some((attribute) => attribute.isPrimaryKey)) {
2680
+ const existingPrimary = Table.attributes.find((attribute: any) => attribute.isPrimaryKey);
2681
+ if (existingPrimary && attributes.some((attribute) => attribute.name === existingPrimary.name))
2682
+ throw new ClientError(
2683
+ `Cannot remove the primary key designation from '${databaseName}.${tableName}.${existingPrimary.name}'`
2684
+ );
2685
+ if (existingPrimary) attributes = [existingPrimary, ...attributes];
2686
+ }
2687
+ Table.attributes.splice(0, Table.attributes.length, ...attributes);
2688
+ // Re-assert from the live declaration so a stale value on disk (replicated event,
2689
+ // v4-era backfill) is corrected on every reload. Gated on `schemaDefinedExplicit` so
2690
+ // callers that omit the flag (cluster schema-replication, data loader) don't flip a
2691
+ // dynamic table to true via the default at the top of table(), and on origin so a
2692
+ // peer-derived definition never overrides the local declaration.
2693
+ if (schemaDefinedExplicit && origin !== 'cluster') Table.schemaDefined = schemaDefined;
2694
+ // Refresh class-level schema metadata to track docstring/directive changes across reloads.
2695
+ Table.description = description;
2696
+ Table.properties = properties;
2697
+ Table.hidden = hidden;
2698
+ // undefined means a non-schema caller (add_attribute, cluster schema events) — don't clobber
2699
+ if (cacheControl !== undefined) Table.cacheControl = cacheControl;
1683
2700
  } else {
1684
- attributesDbi = (rootStore as any).dbisDb = (rootStore as any).openDB(INTERNAL_DBIS_NAME, internalDbiInit as any);
1685
- }
1686
- markInternalDbiNonVersioned(attributesDbi);
2701
+ const auditStore = rootStore.auditStore;
2702
+ primaryKeyAttribute = attributes.find((attribute) => attribute.isPrimaryKey) || {};
2703
+ primaryKey = primaryKeyAttribute.name;
2704
+ primaryKeyAttribute.isPrimaryKey = true;
2705
+ primaryKeyAttribute.is_hash_attribute = true; // backward-compat: harperdb@4.x reads this field to open the DBI with correct flags
2706
+ primaryKeyAttribute.schemaDefined = schemaDefined;
2707
+ // Old readers treat every attribute row as live schema, so relationships stay on the ignored primary descriptor.
2708
+ if (relationshipDefinitions) primaryKeyAttribute.relationships = relationshipDefinitions;
2709
+ // can't change compression after the fact (except threshold), so save only when we create the table
2710
+ primaryKeyAttribute.compression = getDefaultCompression();
2711
+ if (trackDeletes) primaryKeyAttribute.trackDeletes = true;
2712
+ audit = primaryKeyAttribute.audit = typeof audit === 'boolean' ? audit : envGet(CONFIG_PARAMS.LOGGING_AUDITLOG);
2713
+ if (expiration) primaryKeyAttribute.expiration = expiration;
2714
+ if (eviction) primaryKeyAttribute.eviction = eviction;
2715
+ // persist cacheControl so all threads (and future boots) see it; undefined callers inherit
2716
+ // a descriptor value carried by cluster schema events; null (schema has no directive)
2717
+ // clears a stale value the carried descriptor may hold
2718
+ if (cacheControl === undefined) cacheControl = primaryKeyAttribute.cacheControl;
2719
+ else if (cacheControl === null) delete primaryKeyAttribute.cacheControl;
2720
+ else primaryKeyAttribute.cacheControl = cacheControl;
2721
+ splitSegments ??= false;
2722
+ primaryKeyAttribute.splitSegments = splitSegments; // always default to not splitting segments going forward
2723
+ if (typeof sealed === 'boolean') primaryKeyAttribute.sealed = sealed;
2724
+ if (typeof replicate === 'boolean') primaryKeyAttribute.replicate = replicate;
2725
+ // An explicit directive PINS this table's encoding: we persist the boolean, so later changes
2726
+ // to the global storage.randomAccessFields default never affect this table. Tables WITHOUT the
2727
+ // directive are intentionally not persisted here — they follow the current global default on
2728
+ // each open (a runtime lever to flip encoding fleet-wide). Switching either way is safe: the
2729
+ // struct READ hook always stays on and struct (0x20-0x3f) vs classic-record (0x40-0x7f) bytes
2730
+ // are disjoint, so already-written records still decode; only the encoding of NEW writes changes.
2731
+ if (typeof randomAccessFields === 'boolean') primaryKeyAttribute.randomAccessFields = randomAccessFields;
2732
+ if (origin) {
2733
+ if (!primaryKeyAttribute.origins) primaryKeyAttribute.origins = [origin];
2734
+ else if (!primaryKeyAttribute.origins.includes(origin)) primaryKeyAttribute.origins.push(origin);
2735
+ }
2736
+ logger.trace(`${tableName} table loading, opening primary store`);
2737
+ const dbiInit = createOpenDBIObject(false, true);
2738
+ dbiInit.compression = primaryKeyAttribute.compression;
2739
+ // per-table override of the storage.randomAccessFields default (see OpenDBIObject)
2740
+ if (typeof primaryKeyAttribute.randomAccessFields === 'boolean')
2741
+ dbiInit.randomAccessStructure = primaryKeyAttribute.randomAccessFields;
2742
+ const dbiName = tableName + '/';
1687
2743
 
1688
- exclusiveLock(); // get an exclusive lock on the database so we can verify that we are the only thread creating the table (and assigning the table id)
1689
- const existingTableMeta = (attributesDbi as any).getSync(dbiName);
1690
- if (existingTableMeta && !existingTableMeta.dropping) {
1691
- // table was created while we were setting up
1692
- if (releaseExclusiveLock) releaseExclusiveLock();
1693
- resetDatabases();
1694
- return table(tableDefinition);
1695
- }
2744
+ if (rootStore instanceof RocksDatabase) {
2745
+ attributesDbi = (rootStore as any).dbisDb = openRocksDatabase(rootStore.path, {
2746
+ ...internalDbiInit,
2747
+ disableWAL: false,
2748
+ name: INTERNAL_DBIS_NAME,
2749
+ } as any);
2750
+ } else {
2751
+ attributesDbi = (rootStore as any).dbisDb = (rootStore as any).openDB(
2752
+ INTERNAL_DBIS_NAME,
2753
+ internalDbiInit as any
2754
+ );
2755
+ }
2756
+ target.adopt(attributesDbi);
2757
+ markInternalDbiNonVersioned(attributesDbi);
2758
+
2759
+ exclusiveLock(); // get an exclusive lock on the database so we can verify that we are the only thread creating the table (and assigning the table id)
2760
+ const existingTableMeta = (attributesDbi as any).getSync(dbiName);
2761
+ if (existingTableMeta && !existingTableMeta.dropping) {
2762
+ // table was created while we were setting up; the lock is not reentrant, so release
2763
+ // before the recursive reload
2764
+ releaseLock();
2765
+ target.reload(databaseName);
2766
+ return declareTable(target, tableDefinition);
2767
+ }
1696
2768
 
1697
- let primaryStore;
1698
- try {
2769
+ let primaryStore;
1699
2770
  if (existingTableMeta?.dropping) {
1700
2771
  // A previous drop of this table was interrupted after its tombstone
1701
2772
  // was written. Complete it now (under the exclusive lock) so the
@@ -1722,124 +2793,197 @@ export function table<TableResourceType>(tableDefinition: TableDefinition): Tabl
1722
2793
  } else {
1723
2794
  primaryStore = (rootStore as any).openDB(dbiName, dbiInit as any);
1724
2795
  }
2796
+ target.adopt(primaryStore);
2797
+ unpublishedPrimaryStore = primaryStore;
1725
2798
  primaryStore = handleLocalTimeForGets(primaryStore, rootStore);
1726
- rootStore.databaseName = databaseName;
2799
+ // only a store no table has loaded yet is unnamed; a branch's store carries its own store
2800
+ // identity here, which its blob roots resolve from, and must not take the logical name
2801
+ rootStore.databaseName ??= databaseName;
1727
2802
  primaryStore.tableId = attributesDbi.getSync(NEXT_TABLE_ID);
1728
2803
  logger.trace(`Assigning new table id ${primaryStore.tableId} for ${tableName}`);
1729
2804
  if (!primaryStore.tableId) primaryStore.tableId = 1;
1730
2805
  attributesDbi.put(NEXT_TABLE_ID, primaryStore.tableId + 1);
1731
2806
 
1732
2807
  primaryKeyAttribute.tableId = primaryStore.tableId;
1733
- Table = setTable(
1734
- tables,
2808
+ Table = makeTable({
2809
+ isBranch: Boolean(target.branch),
2810
+ primaryStore,
2811
+ auditStore,
2812
+ audit,
2813
+ sealed,
2814
+ splitSegments,
2815
+ replicate,
2816
+ trackDeletes,
2817
+ expirationMS: expiration && expiration * 1000,
2818
+ evictionMS: eviction && eviction * 1000,
2819
+ primaryKey,
1735
2820
  tableName,
1736
- makeTable({
1737
- primaryStore,
1738
- auditStore,
1739
- audit,
1740
- sealed,
1741
- splitSegments,
1742
- replicate,
1743
- trackDeletes,
1744
- expirationMS: expiration && expiration * 1000,
1745
- evictionMS: eviction && eviction * 1000,
1746
- primaryKey,
1747
- tableName,
1748
- tableId: primaryStore.tableId,
1749
- databasePath: databaseName,
1750
- databaseName,
1751
- indices: {},
1752
- attributes,
1753
- schemaDefined,
1754
- dbisDB: attributesDbi,
1755
- description,
1756
- properties,
1757
- hidden,
1758
- cacheControl,
1759
- })
1760
- );
2821
+ tableId: primaryStore.tableId,
2822
+ databasePath: databaseName,
2823
+ databaseName,
2824
+ indices: {},
2825
+ attributes,
2826
+ schemaDefined,
2827
+ dbisDB: attributesDbi,
2828
+ description,
2829
+ properties,
2830
+ hidden,
2831
+ cacheControl,
2832
+ });
1761
2833
  Table.schemaVersion = 1;
1762
2834
  hasChanges = true;
1763
-
1764
- attributesDbi.put(dbiName, primaryKeyAttribute);
1765
- } catch (error) {
1766
- // A failure while opening/creating the column family or writing the
1767
- // table id / catalog entry (e.g. into an env poisoned by a prior
1768
- // dangling column family) must NOT leak the exclusive
1769
- // 'update-attributes' spin lock. If it leaks, every subsequent
1770
- // create_table / attribute update on this database spins forever
1771
- // (a hard wedge that pins a worker at 100% CPU). Release before rethrow.
1772
- if (releaseExclusiveLock) releaseExclusiveLock();
1773
- throw error;
2835
+ deferredPrimaryRow = primaryKeyAttribute;
1774
2836
  }
1775
- }
1776
- const indices = Table.indices;
1777
- if (!attributesDbi) {
1778
- if (rootStore instanceof RocksDatabase) {
1779
- (rootStore as any).dbisDb = openRocksDatabase(rootStore.path, {
1780
- ...internalDbiInit,
1781
- disableWAL: false,
1782
- name: INTERNAL_DBIS_NAME,
1783
- } as any);
1784
- } else {
1785
- (rootStore as any).dbisDb = (rootStore as any).openDB(INTERNAL_DBIS_NAME, internalDbiInit as any);
2837
+ const indices = Table.indices;
2838
+ if (!attributesDbi) {
2839
+ if (rootStore instanceof RocksDatabase) {
2840
+ (rootStore as any).dbisDb = openRocksDatabase(rootStore.path, {
2841
+ ...internalDbiInit,
2842
+ disableWAL: false,
2843
+ name: INTERNAL_DBIS_NAME,
2844
+ } as any);
2845
+ } else {
2846
+ (rootStore as any).dbisDb = (rootStore as any).openDB(INTERNAL_DBIS_NAME, internalDbiInit as any);
2847
+ }
2848
+ target.adopt((rootStore as any).dbisDb);
2849
+ attributesDbi = markInternalDbiNonVersioned((rootStore as any).dbisDb);
1786
2850
  }
1787
- attributesDbi = markInternalDbiNonVersioned((rootStore as any).dbisDb);
1788
- }
1789
- Table.dbisDB = attributesDbi;
1790
- const indicesToRemove = [];
1791
- for (const { key, value } of attributesDbi.getRange({ start: true })) {
1792
- if (value == null) continue;
1793
- let [attributeTableName, attribute_name] = key.toString().split('/');
1794
- if (attribute_name === '') attribute_name = value.name; // primary key
1795
- if (attribute_name) {
1796
- if (attributeTableName !== tableName) continue;
1797
- } else {
1798
- // table attribute for a table with no primary key, we don't want to remove this, so continue on
1799
- continue;
2851
+ Table.dbisDB = attributesDbi;
2852
+ // A cluster-origin list can miss a descriptor another thread committed moments ago, so removal
2853
+ // reconciliation is reserved for local schema authoring; on a create the rows can only be aborted state.
2854
+ const reconcileRemovals = origin !== 'cluster' || Boolean(deferredPrimaryRow);
2855
+ for (const { key, value } of reconcileRemovals
2856
+ ? attributesDbi.getRange({ start: tableName + '/', end: tableName + '0' })
2857
+ : []) {
2858
+ if (value == null) continue;
2859
+ let [attributeTableName, attribute_name] = key.toString().split('/');
2860
+ if (attribute_name === '') attribute_name = value.name; // primary key
2861
+ if (attribute_name) {
2862
+ if (attributeTableName !== tableName) continue;
2863
+ } else {
2864
+ // table attribute for a table with no primary key, we don't want to remove this, so continue on
2865
+ continue;
2866
+ }
2867
+ const attribute = attributes.find((attribute) => attribute.name === attribute_name);
2868
+ const removeIndex = !attribute?.indexed && value.indexed && !value.isPrimaryKey;
2869
+ // rows already present under a create are aborted state
2870
+ const staleRow = (!attribute && !value.isPrimaryKey) || Boolean(deferredPrimaryRow);
2871
+ if (staleRow || removeIndex) {
2872
+ exclusiveLock();
2873
+ hasChanges = true;
2874
+ if (staleRow) attributesDbi.remove(key);
2875
+ if (removeIndex) {
2876
+ const indexDbi = Table.indices[attributeTableName];
2877
+ if (indexDbi) indicesToRemove.push(indexDbi);
2878
+ }
2879
+ }
1800
2880
  }
1801
- const attribute = attributes.find((attribute) => attribute.name === attribute_name);
1802
- const removeIndex = !attribute?.indexed && value.indexed && !value.isPrimaryKey;
1803
- if (!attribute || removeIndex) {
2881
+ const hasHnswDeclaration = attributes.some((attribute) => attribute.indexed?.type === 'HNSW');
2882
+ const persistedAudit = hasHnswDeclaration ? persistedPrimaryDescriptor(attributesDbi).descriptor?.audit : undefined;
2883
+ // A cluster declaration can apply audit on a create, but deliberately cannot rewrite an existing
2884
+ // table's primary row. Do not let an incoming audit value qualify a replicated native descriptor
2885
+ // that this node would then persist beside its durable audit:false row.
2886
+ const explicitAuditCanBeApplied = origin !== 'cluster' || Boolean(deferredPrimaryRow);
2887
+ const auditEnabledForNativeDefault =
2888
+ (auditExplicitlyEnabled && explicitAuditCanBeApplied) || (!auditExplicitlyDisabled && persistedAudit === true);
2889
+ const auditEnabledForNativePlane =
2890
+ !auditExplicitlyDisabled && (auditEnabledForNativeDefault || (persistedAudit == null && Table.audit === true));
2891
+ for (const attribute of attributes) {
2892
+ const indexed = attribute.indexed;
2893
+ if (!indexed || typeof indexed !== 'object' || indexed.type !== 'HNSW') continue;
2894
+ const descriptor = attributesDbi.getSync(tableName + '/' + (attribute.name || ''));
2895
+ const existingHnsw = descriptor?.indexed?.type === 'HNSW';
2896
+ if (indexed.nativePlane == null) {
2897
+ if (existingHnsw) {
2898
+ if (Object.hasOwn(descriptor.indexed, 'nativePlane')) {
2899
+ indexed.nativePlane = descriptor.indexed.nativePlane;
2900
+ }
2901
+ } else if (
2902
+ origin !== 'cluster' &&
2903
+ auditEnabledForNativeDefault &&
2904
+ CUSTOM_INDEXES.HNSW.canDefaultToNativePlane(rootStore, indexed)
2905
+ ) {
2906
+ indexed.nativePlane = true;
2907
+ }
2908
+ } else if (origin === 'cluster' && !existingHnsw && indexed.nativePlane) {
2909
+ let canRunNative = false;
2910
+ try {
2911
+ canRunNative = auditEnabledForNativeDefault && CUSTOM_INDEXES.HNSW.canRunNativePlane(rootStore, indexed);
2912
+ } catch {}
2913
+ if (!canRunNative) {
2914
+ logger.warn(
2915
+ `Using the JS HNSW index for replicated attribute ${databaseName}.${tableName}.${attribute.name} because this node does not satisfy the nativePlane requirements`
2916
+ );
2917
+ indexed.nativePlane = false;
2918
+ }
2919
+ }
2920
+ }
2921
+ const nativePlaneEnabled =
2922
+ origin !== 'cluster' &&
2923
+ attributes.some((attribute) => attribute.indexed?.type === 'HNSW' && attribute.indexed.nativePlane);
2924
+ if (nativePlaneEnabled && !auditEnabledForNativePlane) {
2925
+ throw new ClientError(
2926
+ `Table '${databaseName}.${tableName}' must enable audit logging before using nativePlane because its transaction log is the derived-index recovery source; set nativePlane: false to use the JS index`
2927
+ );
2928
+ }
2929
+ if (nativePlaneEnabled && persistedAudit !== true) audit = true;
2930
+ if (nativePlaneEnabled && persistedAudit !== true && !attributes.some((attribute) => attribute.isPrimaryKey)) {
1804
2931
  exclusiveLock();
1805
- hasChanges = true;
1806
- if (!attribute) attributesDbi.remove(key);
1807
- if (removeIndex) {
1808
- const indexDbi = Table.indices[attributeTableName];
1809
- if (indexDbi) indicesToRemove.push(indexDbi);
2932
+ const primaryKey = primaryDescriptorKey();
2933
+ const primaryDescriptor = attributesDbi.getSync(primaryKey);
2934
+ if (primaryDescriptor && !tableIsDropping(primaryDescriptor, primaryKey)) {
2935
+ Table.enableAuditing();
2936
+ attributesDbi.put(primaryKey, { ...primaryDescriptor, audit: true });
2937
+ hasChanges = true;
1810
2938
  }
1811
2939
  }
1812
- }
1813
- const attributesToIndex = [];
1814
- try {
1815
2940
  // TODO: If we have attributes and the schemaDefined flag is not set, turn it on
1816
2941
  // iterate through the attributes to ensure that we have all the dbis created and indexed
1817
- for (const attribute of attributes || []) {
1818
- if (attribute.relationship || attribute.computed) {
1819
- hasChanges = true; // need to update the table so the computed properties are translated to property resolvers
1820
- if (attribute.relationship) continue;
2942
+ const attributesInPersistenceOrder = nativePlaneEnabled
2943
+ ? [
2944
+ ...attributes.filter((attribute) => attribute.isPrimaryKey),
2945
+ ...attributes.filter((attribute) => !attribute.isPrimaryKey),
2946
+ ]
2947
+ : auditExplicitlyDisabled
2948
+ ? [
2949
+ ...attributes.filter((attribute) => !attribute.isPrimaryKey),
2950
+ ...attributes.filter((attribute) => attribute.isPrimaryKey),
2951
+ ]
2952
+ : attributes;
2953
+ for (const attribute of attributesInPersistenceOrder) {
2954
+ if (attribute.relationship) {
2955
+ refreshRelationshipAttributes = true;
2956
+ continue;
1821
2957
  }
2958
+ if (attribute.computed) hasChanges = true;
1822
2959
  let dbiKey = tableName + '/' + (attribute.name || '');
1823
2960
  Object.defineProperty(attribute, 'key', { value: dbiKey, configurable: true });
1824
2961
  let attributeDescriptor = attributesDbi.getSync(dbiKey);
1825
2962
  if (attribute.isPrimaryKey) {
2963
+ if (deferredPrimaryRow) continue;
1826
2964
  attributeDescriptor = attributeDescriptor || attributesDbi.getSync((dbiKey = tableName + '/')) || {};
1827
2965
  // Persist schemaDefined when the explicit live value disagrees with disk. Without this,
1828
2966
  // a stale `false` (from a v4-era write or replicated event) survives every reload: the
1829
2967
  // in-memory re-assert in the existing-Table branch only fixes the worker that ran @table,
1830
- // but other workers' next disk-load re-reads the stale value.
2968
+ // but other workers' next disk-load re-reads the stale value. The whole settings update is
2969
+ // gated off for cluster-origin callers: their values come from this worker's (possibly
2970
+ // stale) snapshot, so a rewrite could revert a newer local declaration already on disk.
1831
2971
  const schemaDefinedMismatch = schemaDefinedExplicit && attributeDescriptor.schemaDefined !== schemaDefined;
1832
2972
  // primary key can't change indexing, but settings can change
1833
2973
  if (
1834
- schemaDefinedMismatch ||
1835
- (audit !== undefined && audit !== Table.audit) ||
1836
- (sealed !== undefined && sealed !== Table.sealed) ||
1837
- (replicate !== undefined && replicate !== Table.replicate) ||
1838
- (+expiration || undefined) !== (+attributeDescriptor.expiration || undefined) ||
1839
- (+eviction || undefined) !== (+attributeDescriptor.eviction || undefined) ||
1840
- attribute.type !== attributeDescriptor.type
2974
+ origin !== 'cluster' &&
2975
+ (schemaDefinedMismatch ||
2976
+ (typeof audit === 'boolean' && audit !== attributeDescriptor.audit) ||
2977
+ (sealed !== undefined && sealed !== Table.sealed) ||
2978
+ (replicate !== undefined && replicate !== Table.replicate) ||
2979
+ (+expiration || undefined) !== (+attributeDescriptor.expiration || undefined) ||
2980
+ (+eviction || undefined) !== (+attributeDescriptor.eviction || undefined) ||
2981
+ attribute.type !== attributeDescriptor.type)
1841
2982
  ) {
1842
- const updatedPrimaryAttribute = { ...attributeDescriptor };
2983
+ exclusiveLock();
2984
+ const currentPrimaryAttribute = attributesDbi.getSync(dbiKey);
2985
+ if (!currentPrimaryAttribute || tableIsDropping(currentPrimaryAttribute, dbiKey)) continue;
2986
+ const updatedPrimaryAttribute = { ...currentPrimaryAttribute };
1843
2987
  if (typeof audit === 'boolean') {
1844
2988
  if (audit) Table.enableAuditing();
1845
2989
  updatedPrimaryAttribute.audit = audit;
@@ -1851,15 +2995,53 @@ export function table<TableResourceType>(tableDefinition: TableDefinition): Tabl
1851
2995
  if (attribute.type) updatedPrimaryAttribute.type = attribute.type;
1852
2996
  if (schemaDefinedMismatch) updatedPrimaryAttribute.schemaDefined = schemaDefined;
1853
2997
  hasChanges = true; // send out notification of the change
1854
- exclusiveLock();
1855
2998
  attributesDbi.put(dbiKey, updatedPrimaryAttribute);
1856
2999
  }
1857
3000
 
1858
3001
  continue;
1859
3002
  }
1860
3003
 
1861
- // note that non-indexed attributes do not need a dbi
1862
3004
  if (attributeDescriptor?.attribute && !attributeDescriptor.name) attributeDescriptor.indexed = true; // legacy descriptor
3005
+
3006
+ if (origin === 'cluster' && attributeDescriptor) {
3007
+ // An existing descriptor is a local declaration this caller may not have seen yet, so it wins
3008
+ // over the incoming definition and is never written back from it.
3009
+ applyDurableDeclaration(attribute, attributeDescriptor);
3010
+ const abandonedIndexBuild =
3011
+ attribute.indexed &&
3012
+ (attributeDescriptor.indexingFailed ||
3013
+ isAbandonedIndexBuild(attributeDescriptor, workerData?.restartNumber ?? manageThreads.restartNumber));
3014
+ if (abandonedIndexBuild) {
3015
+ // Recovery is the exception to skipping the handling below, because without it `isIndexing`
3016
+ // stays pinned on with nothing left to clear it and every query on the attribute fails with
3017
+ // IndexRebuildingError for the life of the worker. It persists the attribute (here and again
3018
+ // from runIndexing), so restate the declaration from a descriptor read under the lock.
3019
+ exclusiveLock();
3020
+ applyDurableDeclaration(attribute, attributesDbi.getSync(dbiKey) ?? attributeDescriptor);
3021
+ } else {
3022
+ if (attribute.indexed) {
3023
+ const dbi = openIndex(dbiKey, rootStore, attribute);
3024
+ target.adopt(dbi);
3025
+ // Persisting the indexFormat openIndex just resolved adds a field the descriptor lacks
3026
+ // rather than rewriting one it has. Without it an empty index resolves 'versioned', writes
3027
+ // versioned nodes, then re-derives 'legacy' on the next load — see indexFormatNeedsPersist.
3028
+ if (attribute.indexFormat != null && attributeDescriptor.indexFormat == null) {
3029
+ exclusiveLock();
3030
+ const durableDescriptor = attributesDbi.getSync(dbiKey);
3031
+ if (durableDescriptor && durableDescriptor.indexFormat == null) {
3032
+ hasChanges = true;
3033
+ attributesDbi.put(dbiKey, { ...durableDescriptor, indexFormat: attribute.indexFormat });
3034
+ }
3035
+ }
3036
+ if (attributeDescriptor.indexingPID) dbi.isIndexing = true;
3037
+ dbi.indexNulls = attribute.indexNulls;
3038
+ indices[attribute.name] = dbi;
3039
+ }
3040
+ continue;
3041
+ }
3042
+ }
3043
+
3044
+ // note that non-indexed attributes do not need a dbi
1863
3045
  // Some index options affect only search, not the stored structure (e.g. HNSW's
1864
3046
  // efConstructionSearch). Changing those should persist the new metadata but NOT trigger a
1865
3047
  // reindex. A custom index declares such keys via a static `searchOnlyOptions`.
@@ -1903,6 +3085,8 @@ export function table<TableResourceType>(tableDefinition: TableDefinition): Tabl
1903
3085
  // on the main thread, where workerData is undefined (and it is initialized to 1).
1904
3086
  const currentRestartGeneration = workerData?.restartNumber ?? manageThreads.restartNumber;
1905
3087
  const dbi = openIndex(dbiKey, rootStore, attribute);
3088
+ target.adopt(dbi);
3089
+ if (deferredPrimaryRow) indices[attribute.name] = dbi; // private until published; lets the rollback close it
1906
3090
  // openIndex resolves and stamps attribute.indexFormat for a versioned-capable (RocksDB
1907
3091
  // custom-object) index. An index created before this field existed has no indexFormat on
1908
3092
  // disk; persist the resolved value now — even when nothing else changed — so the format is
@@ -2049,45 +3233,133 @@ export function table<TableResourceType>(tableDefinition: TableDefinition): Tabl
2049
3233
  attributesDbi.put(dbiKey, attribute);
2050
3234
  }
2051
3235
  }
3236
+ // The primary row is what makes a table loadable, so it lands last: a scan on another thread that
3237
+ // runs mid-create skips the table instead of building (and announcing) a partial one. It already
3238
+ // carries this table's relationships (set on primaryKeyAttribute above), so the persistence block
3239
+ // below is a no-op for a create — a table is never published with an incomplete relationship list.
3240
+ if (deferredPrimaryRow) {
3241
+ attributesDbi.put(tableName + '/', deferredPrimaryRow);
3242
+ // That write, not the registration below, is the publish point: it is durable from here
3243
+ // (on LMDB releaseLock()'s finally commits this create's write transaction even while an
3244
+ // error unwinds), so any later throw must leave the catalog alone. Rolling back past it
3245
+ // would delete the attribute rows out from under a live primary row and leave every
3246
+ // thread loading the primary-only schema this change exists to prevent.
3247
+ published = true;
3248
+ setTable(tables, tableName, Table);
3249
+ }
3250
+ // a table with no declared primary key has no attribute row to carry relationships, and the
3251
+ // loop above never visits its descriptor
3252
+ if (relationshipDefinitions) {
3253
+ const relationshipsKey = primaryDescriptorKey();
3254
+ if (!relationshipListsEqual(attributesDbi.getSync(relationshipsKey)?.relationships, relationshipDefinitions)) {
3255
+ exclusiveLock();
3256
+ const currentPrimaryAttribute = attributesDbi.getSync(relationshipsKey);
3257
+ // a missing row means a concurrent drop completed; writing one back would resurrect the table
3258
+ if (
3259
+ currentPrimaryAttribute &&
3260
+ !tableIsDropping(currentPrimaryAttribute, relationshipsKey) &&
3261
+ !relationshipListsEqual(currentPrimaryAttribute.relationships, relationshipDefinitions)
3262
+ ) {
3263
+ attributesDbi.put(relationshipsKey, { ...currentPrimaryAttribute, relationships: relationshipDefinitions });
3264
+ hasChanges = true;
3265
+ }
3266
+ }
3267
+ }
3268
+ } catch (error) {
3269
+ if (unpublishedPrimaryStore && !published) discardUnpublishedTable();
3270
+ else if (published && tables[tableName] !== Table) discardUnregisteredClass();
3271
+ throw error;
2052
3272
  } finally {
2053
- if (releaseExclusiveLock) releaseExclusiveLock();
2054
- }
2055
- if (hasChanges) {
2056
- Table.schemaVersion++;
2057
- Table.updatedAttributes();
3273
+ releaseLock();
2058
3274
  }
3275
+ if (hasChanges || refreshRelationshipAttributes) Table.schemaVersion++;
3276
+ if (hasChanges || refreshRelationshipAttributes || refreshedLiveAttributes) Table.updatedAttributes();
2059
3277
  logger.trace(`${tableName} table loading, running index`);
3278
+ const branchPath = target.branch?.path;
2060
3279
  if (attributesToIndex.length > 0 || indicesToRemove.length > 0) {
2061
3280
  // captured before the backfill can rewrite the attributes
2062
3281
  const buildIds = new Map(attributesToIndex.map((attribute) => [attribute, attribute.indexingBuildId]));
2063
3282
  const markSettled = () => markAbandonedIndexBuild(Table, rootStore, buildIds);
2064
- Table.indexingOperation = runIndexing(Table, attributesToIndex, indicesToRemove).then(markSettled, markSettled);
3283
+ Table.indexingOperation = runIndexing(Table, attributesToIndex, indicesToRemove, branchPath).then(
3284
+ markSettled,
3285
+ markSettled
3286
+ );
2065
3287
  } else if (hasChanges)
2066
3288
  signalling.signalSchemaChange(
2067
- new SchemaEventMsg(process.pid, 'schema-change', Table.databaseName, Table.tableName)
3289
+ new SchemaEventMsg(process.pid, 'schema-change', Table.databaseName, Table.tableName, undefined, branchPath)
2068
3290
  );
3291
+ void Table.derivedIndexRuntime?.close();
3292
+ Table.derivedIndexRuntime = attachDerivedIndexes(Table);
2069
3293
 
2070
3294
  Table.origin = origin;
2071
- if (hasChanges) {
3295
+ // scope-private: replication and other global subscribers must not learn of a branch class
3296
+ if ((hasChanges || refreshRelationshipAttributes) && !target.branch) {
2072
3297
  databaseEventsEmitter.emit('updateTable', Table, origin !== 'cluster');
2073
3298
  }
2074
- if (expiration || eviction || scanInterval)
3299
+ if (expiration || eviction || scanInterval || attributes.some((attribute) => attribute.expiresAt))
2075
3300
  Table.setTTLExpiration({
2076
3301
  expiration,
2077
3302
  eviction,
2078
3303
  scanInterval,
3304
+ fromSchema: true,
3305
+ isolatedApplicationOwner,
2079
3306
  });
2080
3307
  logger.trace(`${tableName} table loaded`);
2081
3308
 
2082
3309
  return Table as TableResourceType;
3310
+ // A migrated catalog can retain a named primary descriptor beside a bare table tombstone, so a
3311
+ // drop in flight has to be checked on both representations.
3312
+ function tableIsDropping(descriptor: any, descriptorKey: string) {
3313
+ if (descriptor?.dropping) return true;
3314
+ return descriptorKey !== tableName + '/' && attributesDbi.getSync(tableName + '/')?.dropping;
3315
+ }
3316
+ // The catalog row initStores() reads a table's settings from: the primary key's own row when it
3317
+ // has one, and the bare table row otherwise.
3318
+ function primaryDescriptorKey() {
3319
+ return persistedPrimaryDescriptor(attributesDbi).key;
3320
+ }
3321
+ // The catalog of a published table stays, but a class the registration never accepted is
3322
+ // unreachable, so release what makeTable() registered process-wide instead of leaving its timers
3323
+ // and reclamation handler live for the process. The stores stay open: the table is durable, and
3324
+ // whichever scan reloads it opens its own handles.
3325
+ function discardUnregisteredClass() {
3326
+ try {
3327
+ Table.cleanup();
3328
+ } catch (discardError) {
3329
+ logger.warn(`Error releasing the unregistered class of ${databaseName}.${tableName}`, discardError);
3330
+ }
3331
+ }
3332
+ function discardUnpublishedTable() {
3333
+ const discard = (description: string, action: () => unknown) => {
3334
+ try {
3335
+ action();
3336
+ } catch (discardError) {
3337
+ logger.warn(
3338
+ `Error discarding ${description} of the failed create of ${databaseName}.${tableName}`,
3339
+ discardError
3340
+ );
3341
+ }
3342
+ };
3343
+ discard('catalog rows', () => {
3344
+ for (const attribute of attributes) {
3345
+ if (!attribute.isPrimaryKey && !attribute.relationship) attributesDbi.remove(tableName + '/' + attribute.name);
3346
+ }
3347
+ });
3348
+ if (Table) discard('callbacks', () => Table.cleanup());
3349
+ // an LMDB store is a per-environment handle slot shared with every thread and still inside this
3350
+ // create's write transaction; only RocksDB column-family handles hold native state to release
3351
+ if (rootStore instanceof RocksDatabase) {
3352
+ for (const indexName in Table?.indices ?? {})
3353
+ discard(`index ${indexName}`, () => Table.indices[indexName].close());
3354
+ discard('primary store', () => unpublishedPrimaryStore.close());
3355
+ }
3356
+ }
2083
3357
  // Acquire an exclusive lock for attribute updates
2084
3358
  function exclusiveLock() {
2085
3359
  if (releaseExclusiveLock) return;
2086
3360
  if (rootStore instanceof RocksDatabase) {
2087
- while (!rootStore.tryLock('update-attributes')) {} // use a spin lock, we really need an synchronous exclusive lock here
2088
- releaseExclusiveLock = () => {
2089
- rootStore.unlock('update-attributes');
2090
- };
3361
+ acquireUpdateAttributesLock(rootStore, `table '${databaseName}.${tableName}'`);
3362
+ releaseExclusiveLock = () => releaseUpdateAttributesLock(rootStore);
2091
3363
  } else {
2092
3364
  // we only need an exclusive transaction lock in lmdb
2093
3365
  rootStore.transactionSync(() => {
@@ -2099,35 +3371,41 @@ export function table<TableResourceType>(tableDefinition: TableDefinition): Tabl
2099
3371
  });
2100
3372
  }
2101
3373
  }
3374
+ // idempotent: the early release before the recursive reload and the finally both run, and a
3375
+ // second unlock could release another thread's lock
3376
+ function releaseLock() {
3377
+ const release = releaseExclusiveLock;
3378
+ releaseExclusiveLock = undefined;
3379
+ if (release) release();
3380
+ }
2102
3381
  }
2103
3382
  /**
2104
- * Canonical form used ONLY for the structural (reindex-triggering) comparison of index options.
2105
- * `@indexed(...)` records options in source-argument order and as strings, while the operations API
2106
- * and config objects can supply them reordered or as numbers; without canonicalizing, such a
2107
- * representation-only difference flips the structural comparison and forces a needless full rebuild
2108
- * (clearing + rebuilding the index, 503-ing the attribute throughout) for a semantically identical
2109
- * index. Sorts object keys and coerces numeric-looking (non-zero) string scalars to numbers.
2110
- * Conservative by design: boolean-vs-object, absent-vs-present, and string-"0"-vs-number-0
2111
- * differences are all preserved, so a genuine change (`true` vs `{ type: 'HNSW' }`, an added/removed
2112
- * option, a changed value) still triggers a rebuild. Persistence keys off the raw form, so the stored
2113
- * descriptor self-heals toward this shape over time. harper#1357
3383
+ * Stable structural form for deciding whether an index must be rebuilt. `coerceZero` extends numeric
3384
+ * coercion to zero; a truthiness-sensitive numeric option must normalize its value before using it.
2114
3385
  */
2115
- export function canonicalizeIndexOptions(value: any): any {
2116
- if (Array.isArray(value)) return value.map(canonicalizeIndexOptions);
3386
+ export function canonicalizeIndexOptions(value: any, coerceZero = false): any {
3387
+ if (Array.isArray(value)) return value.map((item) => canonicalizeIndexOptions(item, coerceZero));
2117
3388
  if (value && typeof value === 'object') {
2118
3389
  const canonical: Record<string, any> = {};
2119
- for (const key of Object.keys(value).sort()) canonical[key] = canonicalizeIndexOptions(value[key]);
3390
+ const customIndex = value.type && (CUSTOM_INDEXES as Record<string, any>)[value.type];
3391
+ for (const key of Object.keys(value).sort()) {
3392
+ if (customIndex?.truthyStructuralOptions?.has(key)) {
3393
+ if (value[key]) canonical[key] = true;
3394
+ continue;
3395
+ }
3396
+ const optionValue = customIndex?.normalizeOptionValue
3397
+ ? customIndex.normalizeOptionValue(key, value[key])
3398
+ : value[key];
3399
+ canonical[key] = canonicalizeIndexOptions(
3400
+ optionValue,
3401
+ coerceZero || Boolean(customIndex?.numericOptions?.has(key))
3402
+ );
3403
+ }
2120
3404
  return canonical;
2121
3405
  }
2122
- // Coerce numeric-looking strings ("16" -> 16) so string-vs-number representations of the same
2123
- // option compare equal — EXCEPT zero: the string "0" is truthy while the number 0 is falsy, and
2124
- // index code may branch on truthiness (e.g. HNSW `if (this.optimizeRouting)` doubles maxConnections),
2125
- // so "0" and 0 build structurally different indexes and must still trigger a rebuild. Zero is the
2126
- // only finite number whose string and numeric forms diverge in truthiness, so excluding it fully
2127
- // closes that gap. Leave non-numeric strings, booleans, null, etc. intact.
2128
3406
  if (typeof value === 'string' && value.trim() !== '') {
2129
3407
  const numeric = Number(value);
2130
- if (numeric !== 0 && Number.isFinite(numeric)) return numeric;
3408
+ if ((numeric !== 0 || coerceZero) && Number.isFinite(numeric)) return numeric;
2131
3409
  }
2132
3410
  return value;
2133
3411
  }
@@ -2180,34 +3458,6 @@ export function resumeStartKey(attributes: { lastIndexedKey?: any }[]): any {
2180
3458
  return start;
2181
3459
  }
2182
3460
 
2183
- // Bounded, unlike the exclusiveLock() spin above: that one runs on the declaring path, where the
2184
- // caller is waiting on the result and there is nothing useful to do without the lock. This one runs
2185
- // after a backfill has already settled, so a holder that never releases would wedge the worker's
2186
- // event loop for nothing. Giving up costs only the marker, and the next load of the table
2187
- // re-triggers the build regardless.
2188
- export const ABANDONED_MARK_LOCK_TIMEOUT = 10000;
2189
- const abandonedMarkLockWait = new Int32Array(new SharedArrayBuffer(4));
2190
- // `timeout` is the test seam; production callers take the default.
2191
- export function tryAcquireUpdateAttributesLock(
2192
- rootStore: RocksDatabase,
2193
- timeout = ABANDONED_MARK_LOCK_TIMEOUT
2194
- ): boolean {
2195
- if (rootStore.tryLock('update-attributes')) return true;
2196
- const startTime = performance.now();
2197
- let waitTime = 1;
2198
- while (!rootStore.tryLock('update-attributes')) {
2199
- const elapsed = performance.now() - startTime;
2200
- if (elapsed >= timeout) return false;
2201
- // Atomics.wait rather than a busy spin: the section this guards is synchronous, so the wait
2202
- // blocks this thread either way, but sleeping does not burn a core while it does.
2203
- if (elapsed >= 2) {
2204
- Atomics.wait(abandonedMarkLockWait, 0, 0, Math.min(waitTime, timeout - elapsed));
2205
- if (waitTime < 16) waitTime *= 2;
2206
- }
2207
- }
2208
- return true;
2209
- }
2210
-
2211
3461
  /**
2212
3462
  * Persists the failure marker for a build that ended without running one of runIndexing's own exit
2213
3463
  * paths, so something re-triggers it. Fenced on `indexingBuildId` inside the storage engine's catalog
@@ -2229,18 +3479,11 @@ async function markAbandonedIndexBuild(Table, rootStore, buildIds: Map<any, stri
2229
3479
  }
2230
3480
  };
2231
3481
  if (rootStore instanceof RocksDatabase) {
2232
- if (!tryAcquireUpdateAttributesLock(rootStore)) {
2233
- logger.warn(
2234
- `Could not mark the abandoned index build of ${Table.databaseName}.${Table.tableName}.${attribute.name}: ` +
2235
- `timed out after ${ABANDONED_MARK_LOCK_TIMEOUT}ms waiting for the exclusive 'update-attributes' lock. ` +
2236
- `The index stays incomplete and the next load of the table re-triggers the backfill.`
2237
- );
2238
- continue;
2239
- }
3482
+ acquireUpdateAttributesLock(rootStore, `abandoned index build '${Table.tableName}.${attribute.name}'`);
2240
3483
  try {
2241
3484
  markIfOwned();
2242
3485
  } finally {
2243
- rootStore.unlock('update-attributes');
3486
+ releaseUpdateAttributesLock(rootStore);
2244
3487
  }
2245
3488
  } else {
2246
3489
  rootStore.transactionSync(markIfOwned);
@@ -2259,7 +3502,7 @@ async function markAbandonedIndexBuild(Table, rootStore, buildIds: Map<any, stri
2259
3502
  }
2260
3503
  }
2261
3504
  }
2262
- async function runIndexing(Table, attributes, indicesToRemove) {
3505
+ async function runIndexing(Table, attributes, indicesToRemove, branchPath?: string) {
2263
3506
  let checkpointing;
2264
3507
  let hadIndexingErrors = false;
2265
3508
  const attributeErrorReported = {};
@@ -2273,7 +3516,7 @@ async function runIndexing(Table, attributes, indicesToRemove) {
2273
3516
  try {
2274
3517
  logger.info(`Indexing ${Table.tableName} attributes`, attributes);
2275
3518
  await signalling.signalSchemaChange(
2276
- new SchemaEventMsg(process.pid, 'schema-change', Table.databaseName, Table.tableName)
3519
+ new SchemaEventMsg(process.pid, 'schema-change', Table.databaseName, Table.tableName, undefined, branchPath)
2277
3520
  );
2278
3521
  let lastResolution;
2279
3522
  // The checkpoint and completion barriers have to cover every mutation still in flight: any of them
@@ -2312,6 +3555,7 @@ async function runIndexing(Table, attributes, indicesToRemove) {
2312
3555
  };
2313
3556
  });
2314
3557
  for (const index of indicesToRemove) {
3558
+ index.customIndex?.resetDerivedStorage?.();
2315
3559
  track(index.drop(), (error) => onIndexPutRejected(index.name, error));
2316
3560
  }
2317
3561
  let interrupted;
@@ -2322,6 +3566,8 @@ async function runIndexing(Table, attributes, indicesToRemove) {
2322
3566
  const start = resumeStartKey(attributes);
2323
3567
  if (start === undefined) {
2324
3568
  for (const attribute of attributes) {
3569
+ // if we are starting from the beginning, clear out any previous index entries since we are rewriting
3570
+ attribute.dbi.customIndex?.resetDerivedStorage?.();
2325
3571
  if (attribute.dbi.clearAsync) {
2326
3572
  // LMDB enqueues this ahead of the index writes, so the scan need not wait for it — but the
2327
3573
  // barriers must, or a rejected clear certifies a checkpoint over stale entries.
@@ -2491,7 +3737,7 @@ async function runIndexing(Table, attributes, indicesToRemove) {
2491
3737
  await lastResolution;
2492
3738
  // now notify all the threads that we are done and the index is ready to use
2493
3739
  await signalling.signalSchemaChange(
2494
- new SchemaEventMsg(process.pid, 'indexing-finished', Table.databaseName, Table.tableName)
3740
+ new SchemaEventMsg(process.pid, 'indexing-finished', Table.databaseName, Table.tableName, undefined, branchPath)
2495
3741
  );
2496
3742
  logger.info(`Finished indexing ${Table.tableName} attributes`, attributes);
2497
3743
  }
@@ -2560,6 +3806,24 @@ function completeInterruptedDrop(rootStore, attributesDbi, databaseName: string,
2560
3806
  } finally {
2561
3807
  columnStore.close();
2562
3808
  }
3809
+ // derived HNSW plane files live next to the store; the normal drop path removes
3810
+ // them through the custom index, but this recovery path drops raw column stores,
3811
+ // and a same-name recreate must never open a stale plane over a fresh CF
3812
+ try {
3813
+ unlinkSync(planeFilePathFor(rootStore.path, columnName));
3814
+ } catch (error: any) {
3815
+ // a stale plane left behind (e.g. Windows EBUSY while still mapped) would be
3816
+ // opened over a fresh same-name CF, resolving another graph's node ids
3817
+ // against it — tombstone it so no attach ever adopts it
3818
+ if (error?.code !== 'ENOENT') {
3819
+ logger.warn(`could not delete the HNSW plane file for ${columnName}; tombstoning it as stale`, error);
3820
+ try {
3821
+ closeSync(openSync(planeStalePathFor(planeFilePathFor(rootStore.path, columnName)), 'w'));
3822
+ } catch (tombstoneError) {
3823
+ logger.warn(`could not tombstone the stale HNSW plane file for ${columnName}`, tombstoneError);
3824
+ }
3825
+ }
3826
+ }
2563
3827
  }
2564
3828
  }
2565
3829
  } else {