@harperfast/harper 5.2.10 → 5.3.0-alpha.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 (554) hide show
  1. package/agent/mcpTools.ts +1 -1
  2. package/agent/session.ts +25 -14
  3. package/bin/cliOperations.ts +46 -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 +59 -3
  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 +2414 -242
  14. package/components/ApplicationScope.ts +8 -0
  15. package/components/EntryHandler.ts +59 -39
  16. package/components/OptionsWatcher.ts +150 -74
  17. package/components/RuntimeModuleTracker.ts +38 -7
  18. package/components/Scope.ts +37 -15
  19. package/components/awaitRestart.ts +84 -0
  20. package/components/componentLoader.ts +340 -30
  21. package/components/componentPreparationLock.ts +16 -5
  22. package/components/mcp/adapters/harperHttp.ts +4 -0
  23. package/components/mcp/listChanged.ts +4 -0
  24. package/components/mcp/toolRegistry.ts +2 -0
  25. package/components/mcp/tools/operations.ts +9 -0
  26. package/components/mcp/tools/schemas/operationDescriptions.ts +2 -2
  27. package/components/operations.js +270 -78
  28. package/components/operationsValidation.js +49 -1
  29. package/components/status/ComponentStatusRegistry.ts +59 -0
  30. package/config/RootConfigWatcher.ts +80 -34
  31. package/config/configUtils.ts +291 -34
  32. package/config/harperConfigEnvVars.ts +170 -27
  33. package/config-root.schema.json +29 -0
  34. package/dataLayer/blobBackup.ts +160 -50
  35. package/dataLayer/delete.ts +6 -1
  36. package/dataLayer/harperBridge/ResourceBridge.ts +52 -8
  37. package/dataLayer/harperBridge/lmdbBridge/lmdbMethods/DeleteAuditLogsBeforeResults.js +3 -1
  38. package/dataLayer/hdbInfoController.ts +34 -1
  39. package/dataLayer/insert.ts +44 -1
  40. package/dataLayer/rocksdbBackup.ts +53 -10
  41. package/dataLayer/schema.ts +11 -1
  42. package/dataLayer/schemaDescribe.ts +8 -1
  43. package/dist/agent/mcpTools.js +1 -1
  44. package/dist/agent/mcpTools.js.map +1 -1
  45. package/dist/agent/session.d.ts +22 -0
  46. package/dist/agent/session.js +26 -15
  47. package/dist/agent/session.js.map +1 -1
  48. package/dist/bin/cliOperations.js +48 -9
  49. package/dist/bin/cliOperations.js.map +1 -1
  50. package/dist/bin/copyDb.d.ts +12 -1
  51. package/dist/bin/copyDb.js +248 -60
  52. package/dist/bin/copyDb.js.map +1 -1
  53. package/dist/bin/deploySetup.d.ts +2 -0
  54. package/dist/bin/deploySetup.js +11 -3
  55. package/dist/bin/deploySetup.js.map +1 -1
  56. package/dist/bin/harper.js +1 -1
  57. package/dist/bin/harper.js.map +1 -1
  58. package/dist/bin/help.js +4 -1
  59. package/dist/bin/help.js.map +1 -1
  60. package/dist/bin/lite.js +4 -1
  61. package/dist/bin/lite.js.map +1 -1
  62. package/dist/bin/restart.js +54 -5
  63. package/dist/bin/restart.js.map +1 -1
  64. package/dist/bin/run.js +4 -10
  65. package/dist/bin/run.js.map +1 -1
  66. package/dist/bin/upgrade.js +4 -3
  67. package/dist/bin/upgrade.js.map +1 -1
  68. package/dist/bin/workloadIdentity.d.ts +18 -0
  69. package/dist/bin/workloadIdentity.js +100 -0
  70. package/dist/bin/workloadIdentity.js.map +1 -0
  71. package/dist/components/Application.d.ts +139 -16
  72. package/dist/components/Application.js +2215 -267
  73. package/dist/components/Application.js.map +1 -1
  74. package/dist/components/ApplicationScope.d.ts +8 -0
  75. package/dist/components/ApplicationScope.js +7 -0
  76. package/dist/components/ApplicationScope.js.map +1 -1
  77. package/dist/components/EntryHandler.js +26 -10
  78. package/dist/components/EntryHandler.js.map +1 -1
  79. package/dist/components/OptionsWatcher.d.ts +1 -0
  80. package/dist/components/OptionsWatcher.js +141 -74
  81. package/dist/components/OptionsWatcher.js.map +1 -1
  82. package/dist/components/RuntimeModuleTracker.js +40 -6
  83. package/dist/components/RuntimeModuleTracker.js.map +1 -1
  84. package/dist/components/Scope.js +38 -13
  85. package/dist/components/Scope.js.map +1 -1
  86. package/dist/components/awaitRestart.d.ts +33 -0
  87. package/dist/components/awaitRestart.js +61 -0
  88. package/dist/components/awaitRestart.js.map +1 -0
  89. package/dist/components/componentLoader.d.ts +38 -1
  90. package/dist/components/componentLoader.js +279 -22
  91. package/dist/components/componentLoader.js.map +1 -1
  92. package/dist/components/componentPreparationLock.d.ts +5 -0
  93. package/dist/components/componentPreparationLock.js +14 -6
  94. package/dist/components/componentPreparationLock.js.map +1 -1
  95. package/dist/components/mcp/adapters/harperHttp.js +4 -0
  96. package/dist/components/mcp/adapters/harperHttp.js.map +1 -1
  97. package/dist/components/mcp/listChanged.js +5 -0
  98. package/dist/components/mcp/listChanged.js.map +1 -1
  99. package/dist/components/mcp/toolRegistry.d.ts +1 -0
  100. package/dist/components/mcp/toolRegistry.js.map +1 -1
  101. package/dist/components/mcp/tools/operations.d.ts +5 -0
  102. package/dist/components/mcp/tools/operations.js +9 -0
  103. package/dist/components/mcp/tools/operations.js.map +1 -1
  104. package/dist/components/mcp/tools/schemas/operationDescriptions.js +2 -2
  105. package/dist/components/mcp/tools/schemas/operationDescriptions.js.map +1 -1
  106. package/dist/components/operations.js +231 -77
  107. package/dist/components/operations.js.map +1 -1
  108. package/dist/components/operationsValidation.js +49 -1
  109. package/dist/components/operationsValidation.js.map +1 -1
  110. package/dist/components/status/ComponentStatusRegistry.d.ts +0 -4
  111. package/dist/components/status/ComponentStatusRegistry.js +63 -0
  112. package/dist/components/status/ComponentStatusRegistry.js.map +1 -1
  113. package/dist/config/RootConfigWatcher.d.ts +7 -1
  114. package/dist/config/RootConfigWatcher.js +64 -27
  115. package/dist/config/RootConfigWatcher.js.map +1 -1
  116. package/dist/config/configUtils.d.ts +9 -1
  117. package/dist/config/configUtils.js +254 -33
  118. package/dist/config/configUtils.js.map +1 -1
  119. package/dist/config/harperConfigEnvVars.d.ts +16 -0
  120. package/dist/config/harperConfigEnvVars.js +162 -25
  121. package/dist/config/harperConfigEnvVars.js.map +1 -1
  122. package/dist/dataLayer/blobBackup.d.ts +49 -20
  123. package/dist/dataLayer/blobBackup.js +139 -50
  124. package/dist/dataLayer/blobBackup.js.map +1 -1
  125. package/dist/dataLayer/delete.js +1 -1
  126. package/dist/dataLayer/delete.js.map +1 -1
  127. package/dist/dataLayer/harperBridge/ResourceBridge.d.ts +14 -1
  128. package/dist/dataLayer/harperBridge/ResourceBridge.js +51 -10
  129. package/dist/dataLayer/harperBridge/ResourceBridge.js.map +1 -1
  130. package/dist/dataLayer/harperBridge/lmdbBridge/lmdbMethods/DeleteAuditLogsBeforeResults.d.ts +3 -1
  131. package/dist/dataLayer/harperBridge/lmdbBridge/lmdbMethods/DeleteAuditLogsBeforeResults.js +3 -1
  132. package/dist/dataLayer/harperBridge/lmdbBridge/lmdbMethods/DeleteAuditLogsBeforeResults.js.map +1 -1
  133. package/dist/dataLayer/hdbInfoController.d.ts +10 -0
  134. package/dist/dataLayer/hdbInfoController.js +30 -1
  135. package/dist/dataLayer/hdbInfoController.js.map +1 -1
  136. package/dist/dataLayer/insert.d.ts +9 -1
  137. package/dist/dataLayer/insert.js +30 -0
  138. package/dist/dataLayer/insert.js.map +1 -1
  139. package/dist/dataLayer/rocksdbBackup.d.ts +2 -2
  140. package/dist/dataLayer/rocksdbBackup.js +45 -8
  141. package/dist/dataLayer/rocksdbBackup.js.map +1 -1
  142. package/dist/dataLayer/schema.js +8 -0
  143. package/dist/dataLayer/schema.js.map +1 -1
  144. package/dist/dataLayer/schemaDescribe.js +8 -1
  145. package/dist/dataLayer/schemaDescribe.js.map +1 -1
  146. package/dist/index.d.ts +1 -1
  147. package/dist/index.js.map +1 -1
  148. package/dist/json/systemSchema.json +52 -0
  149. package/dist/resources/DatabaseTransaction.d.ts +55 -3
  150. package/dist/resources/DatabaseTransaction.js +460 -134
  151. package/dist/resources/DatabaseTransaction.js.map +1 -1
  152. package/dist/resources/LMDBTransaction.js +22 -4
  153. package/dist/resources/LMDBTransaction.js.map +1 -1
  154. package/dist/resources/PrimaryRocksDatabase.d.ts +1 -0
  155. package/dist/resources/PrimaryRocksDatabase.js +30 -2
  156. package/dist/resources/PrimaryRocksDatabase.js.map +1 -1
  157. package/dist/resources/RecordEncoder.d.ts +20 -0
  158. package/dist/resources/RecordEncoder.js +94 -9
  159. package/dist/resources/RecordEncoder.js.map +1 -1
  160. package/dist/resources/RequestTarget.d.ts +2 -0
  161. package/dist/resources/RequestTarget.js.map +1 -1
  162. package/dist/resources/Resource.js +20 -11
  163. package/dist/resources/Resource.js.map +1 -1
  164. package/dist/resources/ResourceInterface.d.ts +20 -1
  165. package/dist/resources/ResourceInterface.js.map +1 -1
  166. package/dist/resources/RocksIndexStore.d.ts +6 -1
  167. package/dist/resources/RocksIndexStore.js +24 -10
  168. package/dist/resources/RocksIndexStore.js.map +1 -1
  169. package/dist/resources/RocksTransactionLogStore.d.ts +14 -1
  170. package/dist/resources/RocksTransactionLogStore.js +57 -17
  171. package/dist/resources/RocksTransactionLogStore.js.map +1 -1
  172. package/dist/resources/Table.d.ts +112 -8
  173. package/dist/resources/Table.js +1071 -197
  174. package/dist/resources/Table.js.map +1 -1
  175. package/dist/resources/auditStore.d.ts +11 -2
  176. package/dist/resources/auditStore.js +183 -18
  177. package/dist/resources/auditStore.js.map +1 -1
  178. package/dist/resources/blob.d.ts +129 -9
  179. package/dist/resources/blob.js +992 -114
  180. package/dist/resources/blob.js.map +1 -1
  181. package/dist/resources/branchDatabase.d.ts +48 -0
  182. package/dist/resources/branchDatabase.js +892 -0
  183. package/dist/resources/branchDatabase.js.map +1 -0
  184. package/dist/resources/crdt.js +50 -12
  185. package/dist/resources/crdt.js.map +1 -1
  186. package/dist/resources/databases.d.ts +150 -9
  187. package/dist/resources/databases.js +1247 -525
  188. package/dist/resources/databases.js.map +1 -1
  189. package/dist/resources/defineTable.d.ts +10 -2
  190. package/dist/resources/defineTable.js +9 -1
  191. package/dist/resources/defineTable.js.map +1 -1
  192. package/dist/resources/graphql.d.ts +1 -1
  193. package/dist/resources/graphql.js +50 -15
  194. package/dist/resources/graphql.js.map +1 -1
  195. package/dist/resources/indexes/HierarchicalNavigableSmallWorld.d.ts +1 -0
  196. package/dist/resources/indexes/HierarchicalNavigableSmallWorld.js +6 -2
  197. package/dist/resources/indexes/HierarchicalNavigableSmallWorld.js.map +1 -1
  198. package/dist/resources/longLivedTransactions.d.ts +71 -0
  199. package/dist/resources/longLivedTransactions.js +358 -0
  200. package/dist/resources/longLivedTransactions.js.map +1 -0
  201. package/dist/resources/models/backendRegistry.d.ts +26 -0
  202. package/dist/resources/models/backendRegistry.js +60 -2
  203. package/dist/resources/models/backendRegistry.js.map +1 -1
  204. package/dist/resources/models/bootstrap.d.ts +33 -1
  205. package/dist/resources/models/bootstrap.js +416 -31
  206. package/dist/resources/models/bootstrap.js.map +1 -1
  207. package/dist/resources/recordLock.d.ts +80 -0
  208. package/dist/resources/recordLock.js +184 -0
  209. package/dist/resources/recordLock.js.map +1 -0
  210. package/dist/resources/replayLogs.d.ts +14 -1
  211. package/dist/resources/replayLogs.js +152 -24
  212. package/dist/resources/replayLogs.js.map +1 -1
  213. package/dist/resources/replayLogsGuards.d.ts +94 -7
  214. package/dist/resources/replayLogsGuards.js +111 -7
  215. package/dist/resources/replayLogsGuards.js.map +1 -1
  216. package/dist/resources/search.js +158 -32
  217. package/dist/resources/search.js.map +1 -1
  218. package/dist/resources/transactionBroadcast.d.ts +1 -1
  219. package/dist/resources/transactionBroadcast.js +2 -2
  220. package/dist/resources/transactionBroadcast.js.map +1 -1
  221. package/dist/security/auth.js +61 -30
  222. package/dist/security/auth.js.map +1 -1
  223. package/dist/security/authn/oidc/claims.d.ts +22 -0
  224. package/dist/security/authn/oidc/claims.js +71 -0
  225. package/dist/security/authn/oidc/claims.js.map +1 -0
  226. package/dist/security/authn/oidc/identityToken.d.ts +27 -0
  227. package/dist/security/authn/oidc/identityToken.js +111 -0
  228. package/dist/security/authn/oidc/identityToken.js.map +1 -0
  229. package/dist/security/authn/oidc/jwks.d.ts +25 -0
  230. package/dist/security/authn/oidc/jwks.js +261 -0
  231. package/dist/security/authn/oidc/jwks.js.map +1 -0
  232. package/dist/security/authn/oidc/providers/generic.d.ts +13 -0
  233. package/dist/security/authn/oidc/providers/generic.js +34 -0
  234. package/dist/security/authn/oidc/providers/generic.js.map +1 -0
  235. package/dist/security/authn/oidc/providers/githubActions.d.ts +11 -0
  236. package/dist/security/authn/oidc/providers/githubActions.js +129 -0
  237. package/dist/security/authn/oidc/providers/githubActions.js.map +1 -0
  238. package/dist/security/authn/oidc/providers/index.d.ts +37 -0
  239. package/dist/security/authn/oidc/providers/index.js +24 -0
  240. package/dist/security/authn/oidc/providers/index.js.map +1 -0
  241. package/dist/security/authn/oidc/tokenExchange.d.ts +12 -0
  242. package/dist/security/authn/oidc/tokenExchange.js +306 -0
  243. package/dist/security/authn/oidc/tokenExchange.js.map +1 -0
  244. package/dist/security/authn/oidc/trustPolicyOperations.d.ts +49 -0
  245. package/dist/security/authn/oidc/trustPolicyOperations.js +358 -0
  246. package/dist/security/authn/oidc/trustPolicyOperations.js.map +1 -0
  247. package/dist/security/authn/oidc/types.d.ts +38 -0
  248. package/dist/security/authn/oidc/types.js +6 -0
  249. package/dist/security/authn/oidc/types.js.map +1 -0
  250. package/dist/security/certificateVerification/index.js +40 -11
  251. package/dist/security/certificateVerification/index.js.map +1 -1
  252. package/dist/security/certificateVerification/trustedIssuers.d.ts +24 -0
  253. package/dist/security/certificateVerification/trustedIssuers.js +79 -0
  254. package/dist/security/certificateVerification/trustedIssuers.js.map +1 -0
  255. package/dist/security/certificateVerification/types.d.ts +1 -0
  256. package/dist/security/credentialProvenance.d.ts +35 -0
  257. package/dist/security/credentialProvenance.js +51 -0
  258. package/dist/security/credentialProvenance.js.map +1 -0
  259. package/dist/security/credentialRejection.d.ts +4 -0
  260. package/dist/security/credentialRejection.js +24 -0
  261. package/dist/security/credentialRejection.js.map +1 -0
  262. package/dist/security/deferredAuthentication.d.ts +36 -0
  263. package/dist/security/deferredAuthentication.js +70 -0
  264. package/dist/security/deferredAuthentication.js.map +1 -0
  265. package/dist/security/impersonation.d.ts +21 -0
  266. package/dist/security/impersonation.js +108 -9
  267. package/dist/security/impersonation.js.map +1 -1
  268. package/dist/security/jsLoader.d.ts +6 -0
  269. package/dist/security/jsLoader.js +75 -15
  270. package/dist/security/jsLoader.js.map +1 -1
  271. package/dist/security/keys.js +301 -71
  272. package/dist/security/keys.js.map +1 -1
  273. package/dist/security/operationScope.d.ts +21 -0
  274. package/dist/security/operationScope.js +36 -0
  275. package/dist/security/operationScope.js.map +1 -0
  276. package/dist/security/permissionsTranslator.js +21 -0
  277. package/dist/security/permissionsTranslator.js.map +1 -1
  278. package/dist/security/tokenAuthentication.d.ts +19 -1
  279. package/dist/security/tokenAuthentication.js +191 -10
  280. package/dist/security/tokenAuthentication.js.map +1 -1
  281. package/dist/security/user.js +4 -3
  282. package/dist/security/user.js.map +1 -1
  283. package/dist/server/DurableSubscriptionsSession.d.ts +2 -2
  284. package/dist/server/DurableSubscriptionsSession.js +67 -10
  285. package/dist/server/DurableSubscriptionsSession.js.map +1 -1
  286. package/dist/server/REST.js +73 -0
  287. package/dist/server/REST.js.map +1 -1
  288. package/dist/server/graphqlQuerying.js +4 -0
  289. package/dist/server/graphqlQuerying.js.map +1 -1
  290. package/dist/server/http.d.ts +11 -0
  291. package/dist/server/http.js +88 -15
  292. package/dist/server/http.js.map +1 -1
  293. package/dist/server/itc/serverHandlers.js +8 -1
  294. package/dist/server/itc/serverHandlers.js.map +1 -1
  295. package/dist/server/jobs/jobProcess.js +6 -2
  296. package/dist/server/jobs/jobProcess.js.map +1 -1
  297. package/dist/server/jobs/jobs.js +4 -1
  298. package/dist/server/jobs/jobs.js.map +1 -1
  299. package/dist/server/liveSubscriptionAuth.d.ts +26 -4
  300. package/dist/server/liveSubscriptionAuth.js +105 -39
  301. package/dist/server/liveSubscriptionAuth.js.map +1 -1
  302. package/dist/server/loadRootComponents.js +49 -10
  303. package/dist/server/loadRootComponents.js.map +1 -1
  304. package/dist/server/mqtt.d.ts +2 -0
  305. package/dist/server/mqtt.js +165 -30
  306. package/dist/server/mqtt.js.map +1 -1
  307. package/dist/server/nodeName.d.ts +2 -0
  308. package/dist/server/nodeName.js +107 -23
  309. package/dist/server/nodeName.js.map +1 -1
  310. package/dist/server/serverHelpers/Headers.d.ts +25 -0
  311. package/dist/server/serverHelpers/Headers.js +139 -1
  312. package/dist/server/serverHelpers/Headers.js.map +1 -1
  313. package/dist/server/serverHelpers/contentTypes.d.ts +9 -0
  314. package/dist/server/serverHelpers/contentTypes.js +32 -25
  315. package/dist/server/serverHelpers/contentTypes.js.map +1 -1
  316. package/dist/server/serverHelpers/deployValidationState.d.ts +3 -0
  317. package/dist/server/serverHelpers/deployValidationState.js +9 -19
  318. package/dist/server/serverHelpers/deployValidationState.js.map +1 -1
  319. package/dist/server/serverHelpers/operationAuthorizationState.d.ts +12 -0
  320. package/dist/server/serverHelpers/operationAuthorizationState.js +24 -2
  321. package/dist/server/serverHelpers/operationAuthorizationState.js.map +1 -1
  322. package/dist/server/serverHelpers/registeredOperations.d.ts +5 -4
  323. package/dist/server/serverHelpers/registeredOperations.js +74 -21
  324. package/dist/server/serverHelpers/registeredOperations.js.map +1 -1
  325. package/dist/server/serverHelpers/requestSanitization.d.ts +11 -0
  326. package/dist/server/serverHelpers/requestSanitization.js +20 -0
  327. package/dist/server/serverHelpers/requestSanitization.js.map +1 -0
  328. package/dist/server/serverHelpers/serverHandlers.js +6 -3
  329. package/dist/server/serverHelpers/serverHandlers.js.map +1 -1
  330. package/dist/server/serverHelpers/serverUtilities.d.ts +18 -0
  331. package/dist/server/serverHelpers/serverUtilities.js +90 -20
  332. package/dist/server/serverHelpers/serverUtilities.js.map +1 -1
  333. package/dist/server/serverHelpers/sharedMessageEncoding.d.ts +67 -0
  334. package/dist/server/serverHelpers/sharedMessageEncoding.js +280 -0
  335. package/dist/server/serverHelpers/sharedMessageEncoding.js.map +1 -0
  336. package/dist/server/serverHelpers/uwsServer.js +19 -1
  337. package/dist/server/serverHelpers/uwsServer.js.map +1 -1
  338. package/dist/server/static.js +24 -28
  339. package/dist/server/static.js.map +1 -1
  340. package/dist/server/storageReclamation.d.ts +5 -0
  341. package/dist/server/storageReclamation.js +16 -0
  342. package/dist/server/storageReclamation.js.map +1 -1
  343. package/dist/server/threads/itc.d.ts +7 -2
  344. package/dist/server/threads/itc.js +5 -1
  345. package/dist/server/threads/itc.js.map +1 -1
  346. package/dist/server/threads/manageThreads.d.ts +26 -4
  347. package/dist/server/threads/manageThreads.js +517 -72
  348. package/dist/server/threads/manageThreads.js.map +1 -1
  349. package/dist/server/threads/socketRouter.js +89 -26
  350. package/dist/server/threads/socketRouter.js.map +1 -1
  351. package/dist/server/threads/threadHeapMemory.d.ts +2 -0
  352. package/dist/server/threads/threadHeapMemory.js +31 -0
  353. package/dist/server/threads/threadHeapMemory.js.map +1 -0
  354. package/dist/server/threads/threadServer.js +46 -15
  355. package/dist/server/threads/threadServer.js.map +1 -1
  356. package/dist/sqlEngine/config.d.ts +1 -3
  357. package/dist/sqlEngine/config.js +19 -16
  358. package/dist/sqlEngine/config.js.map +1 -1
  359. package/dist/sqlTranslator/index.d.ts +1 -1
  360. package/dist/sqlTranslator/index.js +30 -7
  361. package/dist/sqlTranslator/index.js.map +1 -1
  362. package/dist/upgrade/directives/5-3-0.d.ts +7 -0
  363. package/dist/upgrade/directives/5-3-0.js +148 -0
  364. package/dist/upgrade/directives/5-3-0.js.map +1 -0
  365. package/dist/upgrade/directives/directivesController.js +2 -1
  366. package/dist/upgrade/directives/directivesController.js.map +1 -1
  367. package/dist/utility/OperationFunctionCaller.js +2 -1
  368. package/dist/utility/OperationFunctionCaller.js.map +1 -1
  369. package/dist/utility/common_utils.d.ts +16 -0
  370. package/dist/utility/common_utils.js +32 -6
  371. package/dist/utility/common_utils.js.map +1 -1
  372. package/dist/utility/componentNames.d.ts +8 -0
  373. package/dist/utility/componentNames.js +12 -1
  374. package/dist/utility/componentNames.js.map +1 -1
  375. package/dist/utility/environment/environmentManager.js +3 -6
  376. package/dist/utility/environment/environmentManager.js.map +1 -1
  377. package/dist/utility/environment/systemInformation.d.ts +1 -0
  378. package/dist/utility/environment/systemInformation.js +1 -0
  379. package/dist/utility/environment/systemInformation.js.map +1 -1
  380. package/dist/utility/errors/commonErrors.d.ts +2 -0
  381. package/dist/utility/errors/commonErrors.js +2 -0
  382. package/dist/utility/errors/commonErrors.js.map +1 -1
  383. package/dist/utility/errors/hdbError.d.ts +17 -0
  384. package/dist/utility/errors/hdbError.js +30 -1
  385. package/dist/utility/errors/hdbError.js.map +1 -1
  386. package/dist/utility/globalSchema.d.ts +18 -0
  387. package/dist/utility/hdbTerms.d.ts +16 -0
  388. package/dist/utility/hdbTerms.js +18 -2
  389. package/dist/utility/hdbTerms.js.map +1 -1
  390. package/dist/utility/logging/harper_logger.d.ts +2 -0
  391. package/dist/utility/logging/harper_logger.js +91 -14
  392. package/dist/utility/logging/harper_logger.js.map +1 -1
  393. package/dist/utility/logging/logRotator.js +76 -49
  394. package/dist/utility/logging/logRotator.js.map +1 -1
  395. package/dist/utility/nodeIdentity.d.ts +9 -0
  396. package/dist/utility/nodeIdentity.js +58 -0
  397. package/dist/utility/nodeIdentity.js.map +1 -0
  398. package/dist/utility/npmUtilities.js +9 -7
  399. package/dist/utility/npmUtilities.js.map +1 -1
  400. package/dist/utility/operationPermissions.d.ts +3 -1
  401. package/dist/utility/operationPermissions.js +16 -1
  402. package/dist/utility/operationPermissions.js.map +1 -1
  403. package/dist/utility/operation_authorization.d.ts +10 -7
  404. package/dist/utility/operation_authorization.js +212 -41
  405. package/dist/utility/operation_authorization.js.map +1 -1
  406. package/dist/utility/watchPath.d.ts +29 -0
  407. package/dist/utility/watchPath.js +68 -0
  408. package/dist/utility/watchPath.js.map +1 -0
  409. package/dist/utility/watcherFallback.d.ts +86 -0
  410. package/dist/utility/watcherFallback.js +278 -1
  411. package/dist/utility/watcherFallback.js.map +1 -1
  412. package/dist/validation/configValidator.d.ts +12 -0
  413. package/dist/validation/configValidator.js +199 -72
  414. package/dist/validation/configValidator.js.map +1 -1
  415. package/dist/validation/installValidator.js +12 -0
  416. package/dist/validation/installValidator.js.map +1 -1
  417. package/dist/validation/validationWrapper.d.ts +11 -0
  418. package/dist/validation/validationWrapper.js +16 -3
  419. package/dist/validation/validationWrapper.js.map +1 -1
  420. package/index.ts +2 -0
  421. package/json/systemSchema.json +52 -0
  422. package/npm-shrinkwrap.json +235 -233
  423. package/package.json +8 -7
  424. package/resources/DESIGN.md +114 -52
  425. package/resources/DatabaseTransaction.ts +522 -130
  426. package/resources/LMDBTransaction.ts +26 -4
  427. package/resources/PrimaryRocksDatabase.ts +29 -3
  428. package/resources/RecordEncoder.ts +103 -8
  429. package/resources/RequestTarget.ts +2 -0
  430. package/resources/Resource.ts +17 -9
  431. package/resources/ResourceInterface.ts +23 -0
  432. package/resources/RocksIndexStore.ts +30 -10
  433. package/resources/RocksTransactionLogStore.ts +78 -21
  434. package/resources/Table.ts +1107 -164
  435. package/resources/auditStore.ts +187 -21
  436. package/resources/blob.ts +1029 -110
  437. package/resources/branchDatabase.ts +941 -0
  438. package/resources/crdt.ts +76 -12
  439. package/resources/databases.ts +1354 -522
  440. package/resources/defineTable.ts +18 -2
  441. package/resources/graphql.ts +70 -16
  442. package/resources/indexes/HierarchicalNavigableSmallWorld.ts +6 -2
  443. package/resources/longLivedTransactions.ts +360 -0
  444. package/resources/models/backendRegistry.ts +84 -2
  445. package/resources/models/bootstrap.ts +473 -28
  446. package/resources/recordLock.ts +253 -0
  447. package/resources/replayLogs.ts +147 -26
  448. package/resources/replayLogsGuards.ts +171 -8
  449. package/resources/search.ts +154 -32
  450. package/resources/transactionBroadcast.ts +3 -3
  451. package/security/auth.ts +68 -29
  452. package/security/authn/oidc/claims.ts +72 -0
  453. package/security/authn/oidc/identityToken.ts +129 -0
  454. package/security/authn/oidc/jwks.ts +260 -0
  455. package/security/authn/oidc/providers/generic.ts +40 -0
  456. package/security/authn/oidc/providers/githubActions.ts +137 -0
  457. package/security/authn/oidc/providers/index.ts +52 -0
  458. package/security/authn/oidc/tokenExchange.ts +300 -0
  459. package/security/authn/oidc/trustPolicyOperations.ts +343 -0
  460. package/security/authn/oidc/types.ts +41 -0
  461. package/security/certificateVerification/index.ts +54 -13
  462. package/security/certificateVerification/trustedIssuers.ts +76 -0
  463. package/security/certificateVerification/types.ts +1 -0
  464. package/security/credentialProvenance.ts +47 -0
  465. package/security/credentialRejection.ts +22 -0
  466. package/security/deferredAuthentication.ts +71 -0
  467. package/security/impersonation.ts +117 -12
  468. package/security/jsLoader.ts +81 -18
  469. package/security/keys.ts +298 -72
  470. package/security/operationScope.ts +33 -0
  471. package/security/permissionsTranslator.js +23 -0
  472. package/security/tokenAuthentication.ts +233 -12
  473. package/security/user.ts +4 -3
  474. package/server/DESIGN.md +183 -16
  475. package/server/DurableSubscriptionsSession.ts +71 -11
  476. package/server/REST.ts +79 -1
  477. package/server/graphqlQuerying.ts +4 -0
  478. package/server/http.ts +99 -18
  479. package/server/itc/serverHandlers.js +8 -1
  480. package/server/jobs/jobProcess.ts +8 -2
  481. package/server/jobs/jobs.ts +4 -1
  482. package/server/liveSubscriptionAuth.ts +129 -46
  483. package/server/loadRootComponents.js +50 -8
  484. package/server/mqtt.ts +179 -38
  485. package/server/nodeName.ts +103 -21
  486. package/server/serverHelpers/Headers.ts +131 -0
  487. package/server/serverHelpers/contentTypes.ts +29 -21
  488. package/server/serverHelpers/deployValidationState.ts +24 -13
  489. package/server/serverHelpers/operationAuthorizationState.ts +34 -3
  490. package/server/serverHelpers/registeredOperations.ts +79 -22
  491. package/server/serverHelpers/requestSanitization.ts +15 -0
  492. package/server/serverHelpers/serverHandlers.js +6 -3
  493. package/server/serverHelpers/serverUtilities.ts +104 -31
  494. package/server/serverHelpers/sharedMessageEncoding.ts +307 -0
  495. package/server/serverHelpers/uwsServer.ts +17 -2
  496. package/server/static.ts +23 -29
  497. package/server/storageReclamation.ts +13 -0
  498. package/server/threads/itc.js +11 -1
  499. package/server/threads/manageThreads.js +526 -63
  500. package/server/threads/socketRouter.ts +81 -26
  501. package/server/threads/threadHeapMemory.ts +26 -0
  502. package/server/threads/threadServer.js +43 -15
  503. package/sqlTranslator/index.ts +31 -8
  504. package/studio/web/assets/{Chat-Br06zdMA.js → Chat-BnCBegQz.js} +1 -1
  505. package/studio/web/assets/{FloatingChat-BWImX5fA.js → FloatingChat-CoDW1ySS.js} +3 -3
  506. package/studio/web/assets/{apiToken-DN0nmDsq.js → apiToken-Bwk5BLXW.js} +1 -1
  507. package/studio/web/assets/{applications-kSxVoyeU.js → applications-DHxGi7JH.js} +1 -1
  508. package/studio/web/assets/{cssMode-C1JeufH5.js → cssMode-s0cWI-_M.js} +1 -1
  509. package/studio/web/assets/{editor-qoo9CrGO.js → editor-DNcRHK54.js} +1 -1
  510. package/studio/web/assets/{html-Dt4IIy04.js → html-Bdssedlg.js} +1 -1
  511. package/studio/web/assets/{htmlMode-DXgKKr4C.js → htmlMode-CoDlJ3fw.js} +1 -1
  512. package/studio/web/assets/{index-6onkYFOG.js → index-D6sxmFLR.js} +5 -5
  513. package/studio/web/assets/{index.lazy-BrCFnpNJ.js → index.lazy-tmU5BS8s.js} +1 -1
  514. package/studio/web/assets/{javascript-DNCQGUBc.js → javascript-B8meVSTH.js} +1 -1
  515. package/studio/web/assets/{jsonMode-CR6HWruP.js → jsonMode-DpIPd35T.js} +1 -1
  516. package/studio/web/assets/{languageServices-BM4fI4rS.js → languageServices-C_5FMJzQ.js} +1 -1
  517. package/studio/web/assets/{lspLanguageFeatures-DSa1ttcD.js → lspLanguageFeatures-BIzNBzPK.js} +1 -1
  518. package/studio/web/assets/{notifications-BHXLnh6x.js → notifications-CQf18QKb.js} +1 -1
  519. package/studio/web/assets/{notifications-CUmtIA6z.js → notifications-CvZivSbh.js} +1 -1
  520. package/studio/web/assets/{profile-8BeFSF3j.js → profile-DdOwtntb.js} +1 -1
  521. package/studio/web/assets/{regions-C8qR0HhD.js → regions-n69fwagr.js} +1 -1
  522. package/studio/web/assets/{register-B4n5i0SD.js → register-PfWTCXWB.js} +2 -2
  523. package/studio/web/assets/{setComponentFile-g0_B0lgX.js → setComponentFile-Bg6O7X0S.js} +1 -1
  524. package/studio/web/assets/{setup-B0CTj_Q5.js → setup-CUx_aUDl.js} +2 -2
  525. package/studio/web/assets/{status-C6Yib7-K.js → status-D7BVKqX9.js} +1 -1
  526. package/studio/web/assets/{toggleHighContrast-Dgta7bVi.js → toggleHighContrast-DBSyXzMr.js} +1 -1
  527. package/studio/web/assets/{tsMode-CH_jHvU-.js → tsMode-BByKCjBS.js} +1 -1
  528. package/studio/web/assets/{typescript-Co9LCXd5.js → typescript-DDLnLpw9.js} +1 -1
  529. package/studio/web/assets/{useEntityRestURL-wKC8NsC_.js → useEntityRestURL-31CHGaHk.js} +1 -1
  530. package/studio/web/assets/{workers-CWeLxCXA.js → workers-pR3jRY9D.js} +1 -1
  531. package/studio/web/assets/{xml-BadC-0Rk.js → xml-2iRnMhQO.js} +1 -1
  532. package/studio/web/assets/{yaml-BiUfxPbC.js → yaml-Bf92gJpd.js} +1 -1
  533. package/studio/web/index.html +1 -1
  534. package/upgrade/directives/5-3-0.ts +132 -0
  535. package/upgrade/directives/directivesController.ts +2 -1
  536. package/utility/OperationFunctionCaller.ts +2 -1
  537. package/utility/common_utils.ts +30 -5
  538. package/utility/componentNames.ts +12 -0
  539. package/utility/environment/environmentManager.ts +3 -7
  540. package/utility/environment/systemInformation.ts +7 -0
  541. package/utility/errors/commonErrors.ts +4 -0
  542. package/utility/errors/hdbError.ts +29 -0
  543. package/utility/hdbTerms.ts +17 -0
  544. package/utility/logging/harper_logger.ts +87 -14
  545. package/utility/logging/logRotator.ts +72 -45
  546. package/utility/nodeIdentity.ts +45 -0
  547. package/utility/npmUtilities.ts +10 -8
  548. package/utility/operationPermissions.ts +18 -1
  549. package/utility/operation_authorization.ts +231 -42
  550. package/utility/watchPath.ts +63 -0
  551. package/utility/watcherFallback.ts +270 -0
  552. package/validation/configValidator.ts +209 -72
  553. package/validation/installValidator.ts +15 -0
  554. package/validation/validationWrapper.ts +18 -4
@@ -1,16 +1,15 @@
1
1
  import { EventEmitter } from 'node:events';
2
- import { randomBytes } from 'node:crypto';
3
2
  import { initSync, getHdbBasePath, get as envGet } from '../utility/environment/environmentManager.ts';
4
3
  import { INTERNAL_DBIS_NAME } from '../utility/lmdb/terms.ts';
5
4
  import { open, compareKeys, type Database, type RootDatabase } from 'lmdb';
6
5
  import { join, extname, basename } from 'path';
7
- import { existsSync, readdirSync, readFileSync, mkdirSync } from 'node:fs';
6
+ import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync } from 'node:fs';
8
7
  import { unlink } from 'node:fs/promises';
9
8
  import {
10
9
  getBaseSchemaPath,
11
10
  getTransactionAuditStoreBasePath,
12
11
  } from '../dataLayer/harperBridge/lmdbBridge/lmdbUtility/initializePaths.js';
13
- import { makeTable, ignoreAlreadyDropped } from './Table.ts';
12
+ import { makeTable, ignoreAlreadyDropped, acquireUpdateAttributesLock, releaseUpdateAttributesLock } from './Table.ts';
14
13
  import OpenEnvironmentObject from '../utility/lmdb/OpenEnvironmentObject.ts';
15
14
  import {
16
15
  CONFIG_PARAMS,
@@ -30,9 +29,10 @@ import harperLogger from '../utility/logging/harper_logger.ts';
30
29
  const { forComponent } = harperLogger;
31
30
  import * as manageThreads from '../server/threads/manageThreads.js';
32
31
  import { openAuditStore, readAuditEntry, createAuditEntry, type AuditRecord } from './auditStore.ts';
33
- import { removeStorageReclamation } from '../server/storageReclamation.ts';
34
32
  import { handleLocalTimeForGets } from './RecordEncoder.ts';
35
- import { deleteRootBlobPathsForDB } from './blob.ts';
33
+ import { databasePaths, deleteRootBlobPathsForDB } from './blob.ts';
34
+ import { removeStorageReclamation } from '../server/storageReclamation.ts';
35
+ import { commonValidators, schemaRegex } from '../validation/common_validators.ts';
36
36
  import { CUSTOM_INDEXES } from './indexes/customIndexes.ts';
37
37
  import { OpenDBIObject } from '../utility/lmdb/OpenDBIObject.ts';
38
38
  import { RocksDatabase, supportedCompression, type RocksDatabaseOptions } from '@harperfast/rocksdb-js';
@@ -98,8 +98,74 @@ const logger = forComponent('storage');
98
98
 
99
99
  const DEFAULT_DATABASE_NAME = 'data';
100
100
  const DEFINED_TABLES = Symbol('defined-tables');
101
+ const CATALOG_RELATIONSHIP = Symbol('catalog-relationship');
101
102
  const DEFAULT_COMPRESSION_THRESHOLD = (envGet(CONFIG_PARAMS.STORAGE_PAGESIZE) || 4096) - 60; // larger than this requires multiple pages
102
103
  initSync();
104
+
105
+ type RelationshipTarget = { database: string; table: string };
106
+ type PersistedRelationship = {
107
+ name: string;
108
+ type: string;
109
+ elements?: { type: string };
110
+ relationship: { from?: string; to?: string; filterMissing?: boolean };
111
+ target: RelationshipTarget;
112
+ };
113
+
114
+ type RelationshipHydration = {
115
+ table: any;
116
+ databaseName: string;
117
+ tableName: string;
118
+ definitions: unknown[];
119
+ };
120
+
121
+ let relationshipsToHydrate: RelationshipHydration[] = [];
122
+ const reportedRelationshipErrors = new Set<string>();
123
+ // an interrupted create is reported once per table and thread, not on every rescan
124
+ const reportedIncompleteCatalogs = new Set<string>();
125
+
126
+ function normalizeRelationships(attributes: any[]): PersistedRelationship[] {
127
+ const relationships: PersistedRelationship[] = [];
128
+ for (const attribute of attributes) {
129
+ const target = attribute.relationshipReference;
130
+ if (!attribute.relationship || !target) continue;
131
+ const relationship: PersistedRelationship['relationship'] = {};
132
+ if (typeof attribute.relationship.from === 'string') relationship.from = attribute.relationship.from;
133
+ if (typeof attribute.relationship.to === 'string') relationship.to = attribute.relationship.to;
134
+ // the GraphQL parser hands every directive argument over as a string, and the resolver reads
135
+ // filterMissing for truthiness, so persist what the resolver would see rather than the literal
136
+ if (attribute.relationship.filterMissing !== undefined)
137
+ relationship.filterMissing = Boolean(attribute.relationship.filterMissing);
138
+ if (!relationship.from && !relationship.to) continue;
139
+ const definition: PersistedRelationship = {
140
+ name: attribute.name,
141
+ type: attribute.type,
142
+ relationship,
143
+ target: { database: target.database, table: target.table },
144
+ };
145
+ if (attribute.type === 'array') definition.elements = { type: attribute.elements?.type };
146
+ relationships.push(definition);
147
+ }
148
+ return relationships;
149
+ }
150
+
151
+ function relationshipEquals(left: any, right: any): boolean {
152
+ return (
153
+ left?.name === right?.name &&
154
+ left?.type === right?.type &&
155
+ left?.elements?.type === right?.elements?.type &&
156
+ left?.relationship?.from === right?.relationship?.from &&
157
+ left?.relationship?.to === right?.relationship?.to &&
158
+ left?.relationship?.filterMissing === right?.relationship?.filterMissing &&
159
+ left?.target?.database === right?.target?.database &&
160
+ left?.target?.table === right?.target?.table
161
+ );
162
+ }
163
+
164
+ function relationshipListsEqual(left: any, right: PersistedRelationship[]): boolean {
165
+ if (!Array.isArray(left) || left.length !== right.length) return false;
166
+ for (let index = 0; index < right.length; index++) if (!relationshipEquals(left[index], right[index])) return false;
167
+ return true;
168
+ }
103
169
  /**
104
170
  * The RocksDB block/blob codec for every column family this process opens (`storage.rocks.compression`),
105
171
  * or `undefined` to leave rocksdb-js on its own default (lz4 wherever the native build has it).
@@ -333,9 +399,13 @@ function openRocksDatabase(path: string, options: RocksDatabaseOptions & { dupSo
333
399
  } else {
334
400
  db = new PrimaryRocksDatabase(path, options).open() as unknown as RocksRootDatabase;
335
401
  // the RocksDB put and remove return promises, which masks thrown errors in non-awaiting calls to put/remove,
336
- // making them unsafe to replace LMDB methods, which will synchronously throw errors if there is a problem
402
+ // making them unsafe to replace LMDB methods, which will synchronously throw errors if there is a problem.
403
+ // The versioned remove is necessarily async and its callers must await or otherwise track its promise.
337
404
  db.put = db.putSync as any;
338
- db.remove = db.removeSync as any;
405
+ db.remove = ((id: any, removeOptions?: any) =>
406
+ typeof removeOptions === 'number'
407
+ ? (db as unknown as PrimaryRocksDatabase).removeIfVersion(id, removeOptions)
408
+ : db.removeSync(id, removeOptions)) as any;
339
409
  (db.encoder as any).name = options.name;
340
410
  }
341
411
  db.env = {};
@@ -350,19 +420,31 @@ _assignPackageExport('databases', databases);
350
420
  _assignPackageExport('tables', tables);
351
421
 
352
422
  const NEXT_TABLE_ID = Symbol.for('next-table-id');
353
- /**
354
- * True when a descriptor claims an index build no live operation in this process can own. The PID and
355
- * worker generation cannot answer that alone: a container reuses PID 1 and starts the in-memory
356
- * generation back at 1 while the persisted one is higher. A descriptor with no incarnation was written
357
- * before the field existed, so it belongs to an earlier process; a thread started without one of its
358
- * own cannot judge, and falls back rather than declaring a live build dead.
359
- */
360
- function isAbandonedIndexBuild(descriptor: any, currentRestartGeneration: number): boolean {
361
- if (!descriptor) return false;
362
- if (descriptor.indexingPID && descriptor.indexingPID !== process.pid) return true;
363
- if (descriptor.restartNumber < currentRestartGeneration) return true;
364
- const incarnation = manageThreads.processIncarnation;
365
- return !!descriptor.indexingPID && incarnation != null && descriptor.indexingIncarnation !== incarnation;
423
+ // Restore every field used by `commonChanged`, plus `indexed` and `indexNulls`,
424
+ // from the durable descriptor. In particular, preserve `indexNulls: false` so
425
+ // an index that excludes nulls is not reopened as though it contains them.
426
+ const PEER_REDEFINABLE_FIELDS = [
427
+ 'type',
428
+ 'indexed',
429
+ 'indexNulls',
430
+ 'nullable',
431
+ 'enumerable',
432
+ 'version',
433
+ 'elements',
434
+ 'properties',
435
+ 'embed',
436
+ ];
437
+ // `indexNulls` is derived from the durable descriptor, never sent by a peer, so naming it in the
438
+ // discard warn would blame the peer for a field it did not write.
439
+ const PEER_DECLARABLE_FIELDS = PEER_REDEFINABLE_FIELDS.filter((field) => field !== 'indexNulls');
440
+
441
+ // A cluster-origin caller's list can predate a declaration another thread has already committed, so on
442
+ // that path the descriptor — not the caller — decides what the attribute is, in both directions.
443
+ function applyDurableDeclaration(attribute: any, descriptor: any) {
444
+ for (const field of PEER_REDEFINABLE_FIELDS) {
445
+ if (field in descriptor) attribute[field] = descriptor[field];
446
+ else delete attribute[field];
447
+ }
366
448
  }
367
449
  // How many times the schema load will try to finish a tombstoned drop before
368
450
  // giving up for the rest of this process's lifetime. A drop that fails once
@@ -445,6 +527,7 @@ export function getDatabases(): Databases {
445
527
  loadedDatabases = true;
446
528
 
447
529
  definedDatabases = new Map();
530
+ relationshipsToHydrate = [];
448
531
  const hdbBasePath = getHdbBasePath();
449
532
  let databasePath = hdbBasePath && join(hdbBasePath, DATABASES_DIR_NAME);
450
533
  const schemaConfigs = envGet(CONFIG_PARAMS.DATABASES) || {};
@@ -467,9 +550,12 @@ export function getDatabases(): Databases {
467
550
  // (out-of-band) RocksDB directory happens to occupy that reserved name — the API can't
468
551
  // create it (schemaRegex forbids the backtick), but the scan opens any CURRENT+MANIFEST dir
469
552
  if (databaseEntry.name === RESTORE_META_DIR) continue;
553
+ // branch directories are process-local derivatives, never databases in their own right
554
+ if (databaseEntry.name === BRANCH_ROOT_DIR) continue;
470
555
  const dbName = basename(databaseEntry.name, '.mdb');
471
556
  const dbPath = join(databasePath, databaseEntry.name);
472
557
  if (blockedByRestore.has(dbName)) continue;
558
+ if (isOpenBranchPath(dbPath)) continue;
473
559
 
474
560
  if (
475
561
  databaseEntry.isFile() &&
@@ -531,7 +617,9 @@ export function getDatabases(): Databases {
531
617
  for (const databaseEntry of entries) {
532
618
  if (databaseEntry.name.endsWith(MIGRATING_DIR_SUFFIX)) continue; // migration staging dir
533
619
  if (databaseEntry.name === RESTORE_META_DIR) continue; // reserved restore-metadata dir
620
+ if (databaseEntry.name === BRANCH_ROOT_DIR) continue; // reserved branch root
534
621
  if (blockedByRestore.has(basename(databaseEntry.name, '.mdb'))) continue;
622
+ if (isOpenBranchPath(join(databasePath, databaseEntry.name))) continue;
535
623
  if (databaseEntry.isFile() && extname(databaseEntry.name).toLowerCase() === '.mdb') {
536
624
  readMetaDb(join(databasePath, databaseEntry.name), basename(databaseEntry.name, '.mdb'), dbName);
537
625
  } else {
@@ -576,10 +664,13 @@ export function getDatabases(): Databases {
576
664
  for (const tableName in tables) {
577
665
  if (!definedTables.has(tableName)) {
578
666
  logger.trace(`delete table class ${tableName}`);
667
+ tables[tableName]?.cleanup?.();
579
668
  delete tables[tableName];
580
669
  }
581
670
  }
582
671
  } else {
672
+ const removedTables = databases[dbName];
673
+ for (const tableName in removedTables) removedTables[tableName]?.cleanup?.();
583
674
  delete databases[dbName];
584
675
  if (dbName === 'data') {
585
676
  for (const tableName in tables) {
@@ -589,6 +680,7 @@ export function getDatabases(): Databases {
589
680
  }
590
681
  }
591
682
  }
683
+ hydrateCatalogRelationships();
592
684
  if (envGet(CONFIG_PARAMS.ANALYTICS_REPLICATE) === false) {
593
685
  if (!NON_REPLICATING_SYSTEM_TABLES.includes('hdb_analytics')) NON_REPLICATING_SYSTEM_TABLES.push('hdb_analytics');
594
686
  } else {
@@ -606,6 +698,171 @@ export function getDatabases(): Databases {
606
698
  return databases;
607
699
  }
608
700
 
701
+ /**
702
+ * Hydrate one branch's relationships, resolving each target against the application's own branches
703
+ * first and only then against the real databases: a target the application also branched must be its
704
+ * branch's table, and a target it did not branch is legitimately the shared one.
705
+ */
706
+ export function hydrateBranchRelationships(branch: BranchDatabase, branches: Map<string, BranchDatabase>): void {
707
+ const resolveTarget: ResolveRelationshipTarget = (target) => {
708
+ const targetBranch = branches.get(target.database);
709
+ // A branched target resolves ONLY within that branch. A durable branch is a checkpoint frozen
710
+ // at creation while the base keeps evolving, so falling through to the base for a table the
711
+ // branch's own copy lacks would point a branched application's relationship reads at live base
712
+ // data -- the fallback belongs to a database the application did not branch, never to one it did.
713
+ return targetBranch ? targetBranch.tables?.[target.table] : databases[target.database]?.[target.table];
714
+ };
715
+ // Kept, not drained, like the global list: a target declared later (on this or another thread) is
716
+ // picked up by the next pass, and `hydrateTableRelationships` is a no-op once everything resolves.
717
+ for (const hydration of branch.pendingRelationships) {
718
+ try {
719
+ hydrateTableRelationships(hydration, resolveTarget, false);
720
+ } catch (error) {
721
+ logger.error(
722
+ `Unable to hydrate persisted relationships for branch table ${hydration.databaseName}.${hydration.tableName}`,
723
+ error
724
+ );
725
+ }
726
+ }
727
+ }
728
+
729
+ function hydrateCatalogRelationships(): void {
730
+ for (const hydration of relationshipsToHydrate) {
731
+ try {
732
+ hydrateTableRelationships(hydration);
733
+ } catch (error) {
734
+ const key = `${hydration.databaseName}.${hydration.tableName}:hydrate`;
735
+ if (!reportedRelationshipErrors.has(key)) {
736
+ reportedRelationshipErrors.add(key);
737
+ logger.error(
738
+ `Unable to hydrate persisted relationships for ${hydration.databaseName}.${hydration.tableName}`,
739
+ error
740
+ );
741
+ }
742
+ }
743
+ }
744
+ }
745
+
746
+ type ResolveRelationshipTarget = (target: RelationshipTarget) => any;
747
+
748
+ const resolveTargetGlobally: ResolveRelationshipTarget = (target) => databases[target.database]?.[target.table];
749
+
750
+ function hydrateTableRelationships(
751
+ { table, databaseName, tableName, definitions }: RelationshipHydration,
752
+ resolveTarget: ResolveRelationshipTarget = resolveTargetGlobally,
753
+ announce = true
754
+ ): void {
755
+ const hydratable: { definition: PersistedRelationship; targetTable: any }[] = [];
756
+ for (let index = 0; index < definitions.length; index++) {
757
+ const definition = definitions[index] as PersistedRelationship;
758
+ // Keyed by name rather than list position, so a reordered list cannot inherit the previous
759
+ // occupant's reported state and swallow a different relationship's failure — and by reason, so
760
+ // hydrating one entry does not clear the report of a same-named invalid duplicate.
761
+ const errorKey = `${databaseName}.${tableName}:${(definition as any)?.name || `#${index}`}`;
762
+ if (!validRelationshipDefinition(definition, definitions, index)) {
763
+ reportRelationshipError(
764
+ `${errorKey}:invalid`,
765
+ `Ignoring invalid persisted relationship ${databaseName}.${tableName}[${index}]`
766
+ );
767
+ continue;
768
+ }
769
+ // a live schema attribute of the same name owns the name; the catalog copy is only a stand-in
770
+ // for threads that never loaded the schema
771
+ if (table.attributes.some((attribute) => attribute.name === definition.name && !attribute[CATALOG_RELATIONSHIP]))
772
+ continue;
773
+ const targetTable = resolveTarget(definition.target);
774
+ if (!targetTable || !relationshipFieldsExist(table, targetTable, definition)) {
775
+ reportRelationshipError(
776
+ `${errorKey}:unavailable`,
777
+ `Unable to hydrate persisted relationship ${databaseName}.${tableName}.${definition.name}: target or foreign key is unavailable`
778
+ );
779
+ continue;
780
+ }
781
+ reportedRelationshipErrors.delete(`${errorKey}:unavailable`);
782
+ hydratable.push({ definition, targetTable });
783
+ }
784
+
785
+ const installed = table.attributes.filter((attribute) => attribute[CATALOG_RELATIONSHIP]);
786
+ if (
787
+ installed.length === hydratable.length &&
788
+ hydratable.every(
789
+ ({ definition, targetTable }, index) =>
790
+ relationshipEquals(installed[index], definition) &&
791
+ (installed[index].definition || installed[index].elements?.definition)?.tableClass === targetTable
792
+ )
793
+ )
794
+ return;
795
+
796
+ const attributes = table.attributes.filter((attribute) => !attribute[CATALOG_RELATIONSHIP]);
797
+ for (const { definition, targetTable } of hydratable)
798
+ attributes.push(createCatalogRelationship(definition, targetTable));
799
+ table.attributes.splice(0, table.attributes.length, ...attributes);
800
+ table.schemaVersion++;
801
+ table.updatedAttributes();
802
+ if (announce) databaseEventsEmitter.emit('updateTable', table);
803
+ }
804
+
805
+ function validRelationshipDefinition(definition: any, definitions: unknown[], index: number): boolean {
806
+ if (!definition || typeof definition !== 'object') return false;
807
+ const validName = (value: any) => typeof value === 'string' && value.length > 0 && !/[`/]/.test(value);
808
+ if (!validName(definition.name) || !validName(definition.type)) return false;
809
+ if (!validName(definition.target?.database) || !validName(definition.target?.table)) return false;
810
+ if (!definition.relationship || typeof definition.relationship !== 'object') return false;
811
+ const { from, to, filterMissing } = definition.relationship;
812
+ if (from !== undefined && !validName(from)) return false;
813
+ if (to !== undefined && !validName(to)) return false;
814
+ if (!from && !to) return false;
815
+ if (filterMissing !== undefined && typeof filterMissing !== 'boolean') return false;
816
+ if (definition.type === 'array' ? !validName(definition.elements?.type) : definition.elements !== undefined)
817
+ return false;
818
+ for (let earlier = 0; earlier < index; earlier++)
819
+ if ((definitions[earlier] as any)?.name === definition.name) return false;
820
+ return true;
821
+ }
822
+
823
+ function relationshipFieldsExist(sourceTable: any, targetTable: any, definition: PersistedRelationship): boolean {
824
+ if (
825
+ definition.relationship.from &&
826
+ !sourceTable.attributes.some((attribute) => attribute.name === definition.relationship.from)
827
+ )
828
+ return false;
829
+ if (
830
+ definition.relationship.to &&
831
+ !targetTable.attributes.some((attribute) => attribute.name === definition.relationship.to)
832
+ )
833
+ return false;
834
+ return true;
835
+ }
836
+
837
+ function createCatalogRelationship(definition: PersistedRelationship, targetTable: any): any {
838
+ const attribute: any = {
839
+ name: definition.name,
840
+ attribute: definition.name,
841
+ type: definition.type,
842
+ relationship: { ...definition.relationship },
843
+ target: { ...definition.target },
844
+ };
845
+ const targetDefinition = {
846
+ tableClass: targetTable,
847
+ type: targetTable.tableName,
848
+ attributes: targetTable.attributes,
849
+ };
850
+ if (definition.elements) {
851
+ attribute.elements = { type: definition.elements.type };
852
+ Object.defineProperty(attribute.elements, 'definition', { value: targetDefinition, configurable: true });
853
+ } else {
854
+ Object.defineProperty(attribute, 'definition', { value: targetDefinition, configurable: true });
855
+ }
856
+ Object.defineProperty(attribute, CATALOG_RELATIONSHIP, { value: true });
857
+ return attribute;
858
+ }
859
+
860
+ function reportRelationshipError(key: string, message: string): void {
861
+ if (reportedRelationshipErrors.has(key)) return;
862
+ reportedRelationshipErrors.add(key);
863
+ logger.error(message);
864
+ }
865
+
609
866
  /**
610
867
  * Scan a databases directory's entries for restore lock/marker files and return the names of
611
868
  * databases that must not be loaded: a held restore lock means a restore is in progress in some
@@ -653,15 +910,19 @@ export function readMetaDb(
653
910
  lmdbDatabaseEnvs.set(path, rootStore);
654
911
  }
655
912
 
656
- rootStore.dbisDb?.resetReadTxn();
657
- return initStores(path, rootStore, databaseName, defaultTable, auditPath, isLegacy);
913
+ return initStores(path, rootStore, databaseName, { defaultTable, auditPath, isLegacy });
658
914
  } catch (error) {
659
915
  error.message += ` opening database ${path}`;
660
916
  throw error;
661
917
  }
662
918
  }
663
919
 
664
- function readRocksMetaDb(path: string, defaultTable?: string, databaseName: string = DEFAULT_DATABASE_NAME) {
920
+ function readRocksMetaDb(
921
+ path: string,
922
+ defaultTable?: string,
923
+ databaseName: string = DEFAULT_DATABASE_NAME,
924
+ { destination, storeName, openedStores }: Pick<InitStoresOptions, 'destination' | 'storeName' | 'openedStores'> = {}
925
+ ) {
665
926
  try {
666
927
  logger.trace(`loading rocksdb database: ${path}`);
667
928
 
@@ -674,13 +935,17 @@ function readRocksMetaDb(path: string, defaultTable?: string, databaseName: stri
674
935
 
675
936
  let rootStore: RocksRootDatabase | undefined = rocksdbDatabaseEnvs.get(path);
676
937
  if (rootStore) {
677
- initStores(path, rootStore, databaseName, defaultTable);
938
+ initStores(path, rootStore, databaseName, { defaultTable, destination, storeName, openedStores });
678
939
  } else {
679
940
  rootStore = openRocksDatabase(path, { disableWAL: false, enableStats: true }) as any;
680
941
  rocksdbDatabaseEnvs.set(path, rootStore);
681
- initStores(path, rootStore, databaseName, defaultTable);
682
- // Skip transaction log replay in read-only mode
683
- if (!isReadOnlyMode()) {
942
+ initStores(path, rootStore, databaseName, { defaultTable, destination, storeName, openedStores });
943
+ // A branch (`destination`) recovers its transaction-log tail in `openOrCreate`
944
+ // (branchDatabase.ts), not here: the branch claim elects exactly one replaying thread —
945
+ // applications load on workers, where this call would be a no-op — and awaits the replay
946
+ // before the branch is published to any reader. See the contract note on
947
+ // `openBranchDatabase` (harper#643).
948
+ if (!isReadOnlyMode() && !destination) {
684
949
  replayLogs(rootStore, databases[databaseName]);
685
950
  }
686
951
  }
@@ -691,14 +956,32 @@ function readRocksMetaDb(path: string, defaultTable?: string, databaseName: stri
691
956
  }
692
957
  }
693
958
 
959
+ interface InitStoresOptions {
960
+ defaultTable?: string;
961
+ auditPath?: string;
962
+ isLegacy?: boolean;
963
+ /** Build the Table classes here instead of the global `databases` map, and emit no global event. */
964
+ destination?: Tables;
965
+ /**
966
+ * Identity stamped on the root store, when it must differ from the logical `databaseName` the
967
+ * Table classes carry. `getRootBlobPathsForDB` resolves blob directories from it.
968
+ */
969
+ storeName?: string;
970
+ /**
971
+ * Every column family opened here is appended, so a caller can release the ones a failure left
972
+ * unreachable — a table's stores are opened well before `setTable` publishes it into the graph.
973
+ */
974
+ openedStores?: any[];
975
+ }
976
+
694
977
  function initStores(
695
978
  path: string,
696
979
  rootStore: RootDatabaseKind,
697
980
  databaseName: string,
698
- defaultTable?: string,
699
- auditPath?: string,
700
- isLegacy?: boolean
981
+ { defaultTable, auditPath, isLegacy, destination, storeName, openedStores }: InitStoresOptions = {}
701
982
  ) {
983
+ // a store with no tables never reaches the per-table loop below, and blob roots resolve from this
984
+ rootStore.databaseName = storeName ?? databaseName;
702
985
  const envInit = new OpenEnvironmentObject(path, isReadOnlyMode());
703
986
  const internalDbiInit = createOpenDBIObject(false);
704
987
  let attributesDbi = rootStore.dbisDb;
@@ -712,6 +995,7 @@ function initStores(
712
995
  } else {
713
996
  attributesDbi = rootStore.openDB(INTERNAL_DBIS_NAME, internalDbiInit as any);
714
997
  }
998
+ openedStores?.push(attributesDbi);
715
999
  rootStore.dbisDb = markInternalDbiNonVersioned(attributesDbi);
716
1000
  }
717
1001
 
@@ -738,7 +1022,8 @@ function initStores(
738
1022
  }
739
1023
  }
740
1024
 
741
- const tables = ensureDB(databaseName);
1025
+ const tables = destination ?? ensureDB(databaseName);
1026
+ if (destination && !destination[DEFINED_TABLES]) destination[DEFINED_TABLES] = new Set<string>();
742
1027
  const definedTables = tables[DEFINED_TABLES];
743
1028
  (definedTables as any).rootStore = rootStore;
744
1029
  const tablesToLoad = new Map<string, any>();
@@ -836,12 +1121,21 @@ function initStores(
836
1121
  }
837
1122
  }
838
1123
  if (!primaryAttribute) {
839
- logger.warn(
840
- `Unable to find a primary key attribute on table ${tableName}, with attributes: ${JSON.stringify(attributes)}`
841
- );
1124
+ const tableKey = `${databaseName}/${tableName}`;
1125
+ if (reportedIncompleteCatalogs.has(tableKey))
1126
+ logger.debug(`Skipping table ${databaseName}.${tableName}: still no primary key row`);
1127
+ else {
1128
+ reportedIncompleteCatalogs.add(tableKey);
1129
+ logger.warn(
1130
+ `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`
1131
+ );
1132
+ }
1133
+ // not defined until it loads, so the cleanup pass evicts a class left from a dropped same-name table
1134
+ definedTables?.delete(tableName);
842
1135
  continue;
843
1136
  }
844
1137
  }
1138
+ if (reportedIncompleteCatalogs.size) reportedIncompleteCatalogs.delete(`${databaseName}/${tableName}`);
845
1139
  // if the table has already been defined, use that class, don't create a new one
846
1140
  let table = tables[tableName];
847
1141
  // unless its store was migrated to a different engine (e.g. LMDB to RocksDB on startup)
@@ -888,18 +1182,14 @@ function initStores(
888
1182
  // per-table override of the storage.randomAccessFields default (see OpenDBIObject)
889
1183
  if (typeof primaryAttribute.randomAccessFields === 'boolean')
890
1184
  dbiInit.randomAccessStructure = primaryAttribute.randomAccessFields;
891
- if (rootStore instanceof RocksDatabase) {
892
- primaryStore = handleLocalTimeForGets(
893
- openRocksDatabase(rootStore.path, { ...dbiInit, name: primaryAttribute.key, cache: true } as any),
894
- rootStore
895
- );
896
- } else {
897
- primaryStore = handleLocalTimeForGets(
898
- (rootStore as any).openDB(primaryAttribute.key, dbiInit as any),
899
- rootStore
900
- );
901
- }
902
- rootStore.databaseName = databaseName;
1185
+ // recorded before the wrapper below, which is the only thing between the native open and
1186
+ // the only list a failed open can release it from
1187
+ const opened =
1188
+ rootStore instanceof RocksDatabase
1189
+ ? openRocksDatabase(rootStore.path, { ...dbiInit, name: primaryAttribute.key, cache: true } as any)
1190
+ : (rootStore as any).openDB(primaryAttribute.key, dbiInit as any);
1191
+ openedStores?.push(opened);
1192
+ primaryStore = handleLocalTimeForGets(opened, rootStore);
903
1193
  primaryStore.tableId = tableId;
904
1194
  }
905
1195
  let attributesUpdated: boolean;
@@ -910,11 +1200,10 @@ function initStores(
910
1200
  if (!attribute.isPrimaryKey && (attribute.indexed || (attribute.attribute && !attribute.name))) {
911
1201
  if (!indices[attribute.name]) {
912
1202
  const dbi = openIndex(attribute.key, rootStore, attribute);
1203
+ openedStores?.push(dbi);
913
1204
  indices[attribute.name] = dbi;
914
1205
  indices[attribute.name].indexNulls = attribute.indexNulls;
915
1206
  }
916
- // the only way a thread that never declares the schema reaches Table.indices
917
- indices[attribute.name].isIndexing = !!attribute.indexingPID;
918
1207
  const existingAttribute = existingAttributes.find(
919
1208
  (existingAttribute) => existingAttribute.name === attribute.name
920
1209
  );
@@ -990,6 +1279,8 @@ function initStores(
990
1279
  tables,
991
1280
  tableName,
992
1281
  makeTable({
1282
+ // A branch builds into a caller-owned destination; its tables must refuse DDL.
1283
+ isBranch: Boolean(destination),
993
1284
  primaryStore,
994
1285
  auditStore,
995
1286
  audit,
@@ -1012,12 +1303,430 @@ function initStores(
1012
1303
  })
1013
1304
  );
1014
1305
  table.schemaVersion = 1;
1015
- databaseEventsEmitter.emit('updateTable', table);
1306
+ if (!destination) databaseEventsEmitter.emit('updateTable', table);
1307
+ }
1308
+ if (Array.isArray(primaryAttribute.relationships)) {
1309
+ relationshipsToHydrate.push({ table, databaseName, tableName, definitions: primaryAttribute.relationships });
1310
+ } else if (primaryAttribute.relationships !== undefined) {
1311
+ reportRelationshipError(
1312
+ `${databaseName}.${tableName}:list`,
1313
+ `Ignoring invalid persisted relationship list for ${databaseName}.${tableName}`
1314
+ );
1315
+ relationshipsToHydrate.push({ table, databaseName, tableName, definitions: [] });
1016
1316
  }
1017
1317
  }
1018
1318
  return rootStore;
1019
1319
  }
1020
1320
 
1321
+ /**
1322
+ * Branch directories live beside the base database's own storage root, never under the HDB root: a
1323
+ * database can be placed on its own volume, and `createCheckpoint` only hardlinks when source and
1324
+ * target share a filesystem — off-volume it degrades to a full byte copy, which is the property the
1325
+ * whole feature rests on.
1326
+ *
1327
+ * The backticks are what make the name reserved rather than merely conventional: `schemaRegex`
1328
+ * (validation/common_validators.ts) excludes 0x60, so no database can ever be created under this
1329
+ * name and shadow the branch root -- the same protection RESTORE_META_DIR uses.
1330
+ */
1331
+ export const BRANCH_ROOT_DIR = '`branches`';
1332
+
1333
+ /**
1334
+ * Where the branch of `baseName` belonging to `appName` lives. Derived only from those two names, so
1335
+ * every node in a cluster resolves the same application's branch to the same place — the identity an
1336
+ * application's data needs if it is to be addressed, and eventually replicated, cluster-wide.
1337
+ *
1338
+ * App and database are separate path segments: joining them (`<app>__<db>`) is not injective —
1339
+ * `(a__b, c)` and `(a, b__c)` collide — so two declarations could otherwise open one directory.
1340
+ */
1341
+ export function resolveBranchPath(baseName: string, appName: string): string {
1342
+ for (const [label, segment] of [
1343
+ ['application', appName],
1344
+ ['database', baseName],
1345
+ ]) {
1346
+ if (!segment || segment.includes('/') || segment.includes('\\') || segment === '.' || segment === '..') {
1347
+ throw new Error(`Invalid ${label} name for a branch path: ${JSON.stringify(segment)}`);
1348
+ }
1349
+ }
1350
+ return join(resolveDatabaseStorageRoot(baseName), BRANCH_ROOT_DIR, appName, baseName);
1351
+ }
1352
+
1353
+ /** A branch's private table graph plus the handle needed to tear it down. */
1354
+ export interface BranchDatabase {
1355
+ tables: Tables;
1356
+ rootStore: RootDatabaseKind;
1357
+ /** The realpath of the branch directory; what a schema-change signal names to address this branch. */
1358
+ path: string;
1359
+ /** The logical name the application uses (`data`); every Table class in `tables` carries it. */
1360
+ databaseName: string;
1361
+ /** The branch's own store identity, which its blob roots resolve from. */
1362
+ storeName: string;
1363
+ /**
1364
+ * Every column-family wrapper opened on this store -- by the open, by a reload, or by a table
1365
+ * declaration -- so `close()` can release them all. Recorded at acquisition rather than
1366
+ * reconstructed from `tables` at close: a re-declaration displaces the index and catalog wrappers it
1367
+ * replaces, and a failed declaration can leave one that no class ever held.
1368
+ */
1369
+ openedStores: any[];
1370
+ /**
1371
+ * Relationships this branch's tables declared, still un-hydrated. They cannot be resolved at open
1372
+ * time: a branch's definitions name the BASE database (its tables carry the base's logical names),
1373
+ * so resolving them through the global map would point the application's relationship reads at the
1374
+ * base. `hydrateBranchRelationships` finishes the job once the whole branch set is known.
1375
+ */
1376
+ pendingRelationships: RelationshipHydration[];
1377
+ /**
1378
+ * The application's whole branch set, once `prepareBranches` has opened it, so a reload can hydrate
1379
+ * a relationship whose target the application also branched against that branch.
1380
+ */
1381
+ relatedBranches?: Map<string, BranchDatabase>;
1382
+ close(): void;
1383
+ }
1384
+
1385
+ /** `undefined` marks a path reserved by an open still in flight, which owns it just as firmly. */
1386
+ const openBranches = new Map<string, BranchDatabase | undefined>();
1387
+ /** Store identities in use, so two branches cannot resolve one set of blob roots. */
1388
+ const openBranchIdentities = new Set<string>();
1389
+
1390
+ /**
1391
+ * Materialization renames its clone in from `<blobRoot>.staging`, so a branch owns two database
1392
+ * names rather than one: a database legally called `<storeName>.staging` resolves its own blob root
1393
+ * to exactly the path the clone removes and renames over. Every check, reservation and release
1394
+ * covers the pair, so the name cannot be claimed at any point where a branch operation may still
1395
+ * delete what it resolves to.
1396
+ */
1397
+ const BRANCH_STAGING_SUFFIX = '.staging';
1398
+ /**
1399
+ * Suffix of the sibling a branch is renamed to while being removed. A backtick, not a dot:
1400
+ * `schemaRegex` excludes 0x60, so no database can be named such that `<db>` + this suffix is another
1401
+ * branch's directory (with `.removing`, an application branching both `data` and `data.removing`, both
1402
+ * legal names, would destroy one by opening the other).
1403
+ */
1404
+ export const BRANCH_REMOVING_SUFFIX = '`removing`';
1405
+ function branchIdentityPair(storeName: string): string[] {
1406
+ return [storeName, storeName + BRANCH_STAGING_SUFFIX];
1407
+ }
1408
+
1409
+ /**
1410
+ * Identities whose blob roots outlived the branch that owned them, because a removal or an abandoned
1411
+ * materialization could not delete them. A database created under such a name would resolve its own
1412
+ * fresh file ids onto files it never wrote, so the name stays refused -- but only against DATABASES.
1413
+ * The branch itself may take it back: materializing it replaces those roots wholesale, which is the
1414
+ * only route that clears the condition without an operator.
1415
+ */
1416
+ const quarantinedBranchIdentities = new Set<string>();
1417
+
1418
+ export function quarantineBranchIdentity(storeName: string): void {
1419
+ for (const name of branchIdentityPair(storeName)) {
1420
+ quarantinedBranchIdentities.add(name);
1421
+ openBranchIdentities.delete(name);
1422
+ }
1423
+ }
1424
+
1425
+ /**
1426
+ * True when `dbPath` is a directory an open branch owns. The database scan opens any directory that
1427
+ * holds CURRENT + MANIFEST-*, and harper#643 places a branch inside the directory it walks, so
1428
+ * without this a rescan would rebuild the branch's tables into the global map, overwrite the store
1429
+ * identity its blob roots resolve from, and hand its store to `closeLoadedDatabases`.
1430
+ */
1431
+ function isOpenBranchPath(dbPath: string): boolean {
1432
+ if (openBranches.size === 0) return false;
1433
+ // the literal path first: `rocksdbDatabaseEnvs` is keyed by it too, so a directory unlinked under
1434
+ // a live branch handle (realpathSync then throws) must not read as unowned
1435
+ if (openBranches.has(dbPath)) return true;
1436
+ try {
1437
+ return openBranches.has(realpathSync(dbPath));
1438
+ } catch {
1439
+ return false;
1440
+ }
1441
+ }
1442
+
1443
+ /**
1444
+ * A branch identity resolves its blob roots through `join(…, 'blobs', storeName)`, so it must be a
1445
+ * single path segment: `schemaRegex`, which every other database name is validated against, plus the
1446
+ * dot segments and backslash that regex permits but a path component must not be.
1447
+ */
1448
+ function assertLegalBranchName(name: string, description: string): void {
1449
+ if (
1450
+ !name ||
1451
+ name.length > commonValidators.schema_length.maximum ||
1452
+ !schemaRegex.test(name) ||
1453
+ name.includes('\\') ||
1454
+ name === '.' ||
1455
+ name === '..'
1456
+ ) {
1457
+ throw new Error(`Cannot use '${name}' as a branch ${description}: it is not a legal database name`);
1458
+ }
1459
+ }
1460
+
1461
+ /**
1462
+ * Refuse a branch store identity that something else already answers to.
1463
+ *
1464
+ * `storeName` picks the branch's blob roots, and blob file ids restart from each store's own counter,
1465
+ * so two holders of one identity write the same file paths and truncate each other. It must be
1466
+ * checked BEFORE anything destructive runs: materialization removes and replaces the blob root that
1467
+ * this name resolves to, and a real database may legally be called `5_myapp__data` -- `schemaRegex`
1468
+ * permits digits, `_` and `.`. The `.staging` sibling materialization writes is covered too, since a
1469
+ * database may legally carry that name as well.
1470
+ */
1471
+ export function assertBranchIdentityAvailable(storeName: string): void {
1472
+ // The on-disk scan, not just the in-memory maps: a database that exists on disk but has not been
1473
+ // loaded is absent from both, and it owns the blob root this identity would destroy.
1474
+ getDatabases();
1475
+ for (const name of branchIdentityPair(storeName)) {
1476
+ // The directory as well as the maps. `getDatabases` skips a database blocked by restore, so an
1477
+ // in-memory check alone reports its name as free while its blob root is very much real -- and
1478
+ // materialization would then remove and replace it.
1479
+ if (
1480
+ databases[name] ||
1481
+ definedDatabases?.has(name) ||
1482
+ openBranchIdentities.has(name) ||
1483
+ existsSync(resolveDatabasePath(name)) ||
1484
+ anotherBranchOwns(name, storeName)
1485
+ ) {
1486
+ throw new Error(`Cannot use '${storeName}' as a branch store identity: '${name}' is already in use`);
1487
+ }
1488
+ }
1489
+ }
1490
+
1491
+ /**
1492
+ * Does a branch OTHER than the one being opened already answer to this name on disk? `.staging` is
1493
+ * what makes the question two-sided: `<identity>.staging` is both the path a clone renames over and
1494
+ * a legal identity for a branch of a database literally named `<base>.staging`, so each of the pair
1495
+ * can belong to somebody else. Only the primary name read as itself is excluded -- that directory is
1496
+ * the very branch this call is opening.
1497
+ */
1498
+ function anotherBranchOwns(name: string, storeName: string): boolean {
1499
+ if (name !== storeName) return branchDirectoryExistsFor(name);
1500
+ return name.endsWith(BRANCH_STAGING_SUFFIX)
1501
+ ? branchDirectoryExistsFor(name.slice(0, -BRANCH_STAGING_SUFFIX.length))
1502
+ : false;
1503
+ }
1504
+
1505
+ /**
1506
+ * Claim the identity as well as checking it, so the window between the check and the branch actually
1507
+ * opening cannot be filled by a concurrent create or a second branch. `releaseBranchIdentity` hands
1508
+ * it back if materialization never gets as far as opening.
1509
+ */
1510
+ export function reserveBranchIdentity(storeName: string): void {
1511
+ assertBranchIdentityAvailable(storeName);
1512
+ retakeBranchIdentity(storeName);
1513
+ }
1514
+
1515
+ /**
1516
+ * Take the pair back for an operation that owned it a statement ago -- cleanup, which has to keep
1517
+ * holding the names through the deletions its `close()` just released them for. Deliberately without
1518
+ * the availability check: nothing can have taken a name the caller held until now, and the check runs
1519
+ * the database scan, which at that exact moment would find the branch directory unowned.
1520
+ */
1521
+ export function retakeBranchIdentity(storeName: string): void {
1522
+ for (const name of branchIdentityPair(storeName)) {
1523
+ openBranchIdentities.add(name);
1524
+ quarantinedBranchIdentities.delete(name);
1525
+ }
1526
+ }
1527
+
1528
+ export function releaseBranchIdentity(storeName: string): void {
1529
+ for (const name of branchIdentityPair(storeName)) openBranchIdentities.delete(name);
1530
+ }
1531
+
1532
+ /** Is this name spoken for by a branch? Database creation has to refuse it -- they share a blob root. */
1533
+ export function isBranchIdentity(name: string): boolean {
1534
+ if (openBranchIdentities.has(name) || quarantinedBranchIdentities.has(name)) return true;
1535
+ // The in-memory set covers only branches open in THIS process, so after a restart -- or for an
1536
+ // application that is simply not loaded -- a database could take the name of an on-disk branch and
1537
+ // share its blob root. The staging sibling goes through the same route, because it names the path
1538
+ // materialization renames over -- but BOTH readings of a name ending in `.staging` have to be
1539
+ // tried: `schemaRegex` permits `.`, so `4_myapp__data.staging` is either the sibling of a branch of
1540
+ // `data` or a branch of a database actually called `data.staging`.
1541
+ if (branchDirectoryExistsFor(name)) return true;
1542
+ return name.endsWith(BRANCH_STAGING_SUFFIX)
1543
+ ? branchDirectoryExistsFor(name.slice(0, -BRANCH_STAGING_SUFFIX.length))
1544
+ : false;
1545
+ }
1546
+
1547
+ /**
1548
+ * Is there a branch directory answering to this store identity? The identity carries the application
1549
+ * name's length precisely so it can be taken apart again without guessing where the name ends.
1550
+ */
1551
+ function branchDirectoryExistsFor(storeName: string): boolean {
1552
+ const prefix = /^(\d+)_/.exec(storeName);
1553
+ if (!prefix) return false;
1554
+ const appLength = Number(prefix[1]);
1555
+ const appName = storeName.slice(prefix[0].length, prefix[0].length + appLength);
1556
+ if (
1557
+ appName.length !== appLength ||
1558
+ storeName.slice(prefix[0].length + appLength, prefix[0].length + appLength + 2) !== '__'
1559
+ )
1560
+ return false;
1561
+ const baseName = storeName.slice(prefix[0].length + appLength + 2);
1562
+ if (!baseName) return false;
1563
+ try {
1564
+ const branchPath = resolveBranchPath(baseName, appName);
1565
+ return existsSync(branchPath) || existsSync(branchPath + BRANCH_REMOVING_SUFFIX);
1566
+ } catch {
1567
+ // Not a name a branch path could hold, so no branch owns it.
1568
+ return false;
1569
+ }
1570
+ }
1571
+
1572
+ /**
1573
+ * Open a RocksDB directory as a **scope-private** database: its Table classes are built into an
1574
+ * object the caller owns and nothing is registered in the global `databases` map, so no enumerator
1575
+ * of that map — analytics, `describe_all`, worker teardown, replication — can observe it.
1576
+ *
1577
+ * `databaseName` is the *logical* name the application knows (`data`), so its schema and code need
1578
+ * no changes. `storeName` is the branch's own identity and is what `getRootBlobPathsForDB` resolves
1579
+ * blob directories from, which is how a branch gets its own blob roots rather than writing into the
1580
+ * base's.
1581
+ *
1582
+ * The caller owns the returned handle; the only thing that closes it on the caller's behalf is
1583
+ * `closeBranchDatabases`, run by an exiting job worker (via `closeLoadedDatabases`) and by an HTTP
1584
+ * worker's shutdown path, so a branch left open on an exiting worker does not linger in the
1585
+ * process-global RocksDB registry.
1586
+ *
1587
+ * Schema changes reach a branch only through its own bound factory (`scopedTableFactory`): a
1588
+ * declaration re-asserted against the branch's store. A branch's Table classes carry the base's
1589
+ * logical name, so the Table statics (`dropTable()`, `addAttributes()`) — which resolve the global
1590
+ * schema by that name and would act on the live base table — stay refused (`assertSchemaMutable`).
1591
+ *
1592
+ * A branch's blob roots are a hard-link clone of the base's, taken with the checkpoint, so a row
1593
+ * whose blob predates the branch reads back normally and the branch allocates new file ids in its own
1594
+ * directory (harper#644).
1595
+ *
1596
+ * A branch is the checkpoint's SST content plus its own transaction-log tail. This function opens
1597
+ * only the stores; replaying the tail is `openOrCreate`'s job (branchDatabase.ts), where the
1598
+ * cross-thread claim elects exactly one replayer and awaits it before any thread may open the
1599
+ * branch — the same recovery contract a base database gets at boot, without which a process that
1600
+ * died unflushed silently rewinds the branch to its last memtable flush (harper#643).
1601
+ *
1602
+ * Pass `blobRoots` to pin the handle to the roots the branch was published with; without it the
1603
+ * store resolves them from current configuration, which is only right for a branch being created.
1604
+ */
1605
+ export function openBranchDatabase(
1606
+ path: string,
1607
+ databaseName: string,
1608
+ storeName: string,
1609
+ blobRoots?: string[]
1610
+ ): BranchDatabase {
1611
+ assertLegalBranchName(databaseName, 'logical database name');
1612
+ assertLegalBranchName(storeName, 'store identity');
1613
+ if (!existsSync(path)) throw new Error(`Cannot open branch database: no directory at ${path}`);
1614
+ // the guards compare against env-map keys, so two spellings of one directory must not read as two
1615
+ path = realpathSync(path);
1616
+ // FIRST: the guards below read the registry, and loading is itself what populates
1617
+ // `rocksdbDatabaseEnvs`. Claiming the path ahead of this scan would make the scan skip it, which
1618
+ // also means a directory that IS a real database no longer reads as one — so the pre-open window
1619
+ // where the scan can adopt a branch directory stays open, by choice (harper#643).
1620
+ getDatabases();
1621
+ // a rival graph over one shared root store; the two callers would disagree about who may close it
1622
+ if (openBranches.has(path)) throw new Error(`Branch database at ${path} is already open`);
1623
+ // a loaded database's store is closed by `closeLoadedDatabases`, so adopting it would mean this
1624
+ // handle's `close()` tears down a live database
1625
+ if (rocksdbDatabaseEnvs.has(path)) throw new Error(`Cannot branch ${path}: it is already open as a database`);
1626
+ assertBranchIdentityAvailable(storeName);
1627
+
1628
+ const tables: Tables = Object.create(null);
1629
+ // initStores opens a table's column families well before `setTable` publishes it into `tables`,
1630
+ // so the graph is not a complete record of what a failed open must release
1631
+ const openedStores: any[] = [];
1632
+ // The boot-time hydration pass has already run by the time a branch opens, so anything this open
1633
+ // queues would never be drained. It is handed to the caller instead, which is the only place that
1634
+ // knows the application's other branches and can therefore resolve targets without leaking to base.
1635
+ const queuedRelationshipsAt = relationshipsToHydrate.length;
1636
+ let rootStore: RootDatabaseKind;
1637
+ // claim the path before the open, not after: readRocksMetaDb registers the store in
1638
+ // `rocksdbDatabaseEnvs` partway through, so anything re-entering `database()` during initStores
1639
+ // would otherwise find the branch's store on an unowned path
1640
+ openBranches.set(path, undefined);
1641
+ retakeBranchIdentity(storeName);
1642
+ try {
1643
+ rootStore = readRocksMetaDb(path, null, databaseName, { destination: tables, storeName, openedStores });
1644
+ // Pin the handle to the roots the caller proved this branch was published with, before it is
1645
+ // handed out. A row's `storageIndex` is a position in that list, so resolving through current
1646
+ // configuration instead would let an appended volume take writes at an index the branch's own
1647
+ // completion marker never recorded -- and a later change at that index would then silently
1648
+ // re-address them. `closeBranchHandles` clears the entry with the rest of the handle.
1649
+ if (blobRoots) databasePaths.set(rootStore as unknown as RootDatabase, blobRoots);
1650
+ } catch (error) {
1651
+ openBranches.delete(path);
1652
+ releaseBranchIdentity(storeName);
1653
+ const stranded = rocksdbDatabaseEnvs.get(path);
1654
+ rocksdbDatabaseEnvs.delete(path);
1655
+ closeBranchHandles(path, stranded, openedStores, tables);
1656
+ throw error;
1657
+ }
1658
+ let closed = false;
1659
+ const branch: BranchDatabase = {
1660
+ tables,
1661
+ rootStore,
1662
+ path,
1663
+ databaseName,
1664
+ storeName,
1665
+ openedStores,
1666
+ pendingRelationships: relationshipsToHydrate.splice(queuedRelationshipsAt),
1667
+ close() {
1668
+ // guard on the handle, not on the registrations: those are keyed by path, and a closed
1669
+ // branch frees its path, so a stale handle would otherwise tear down its successor
1670
+ if (closed) return;
1671
+ closed = true;
1672
+ openBranches.delete(path);
1673
+ releaseBranchIdentity(storeName);
1674
+ rocksdbDatabaseEnvs.delete(path);
1675
+ closeBranchHandles(path, rootStore, openedStores, tables);
1676
+ },
1677
+ };
1678
+ openBranches.set(path, branch);
1679
+ return branch;
1680
+ }
1681
+
1682
+ /**
1683
+ * Release everything a branch open created. Each table's primary store and each index is its own
1684
+ * column family, on top of the internal-dbis and audit families, so closing the root alone leaves
1685
+ * all of them behind — which is why `closeDatabase` walks them individually for a real database.
1686
+ * Two process-global registrations outlive the stores as well, neither with a lifetime of its own:
1687
+ * a storage-reclamation handler per store path, whose closure pins the now-closed store, and the
1688
+ * memoized blob roots in `databasePaths`. A real database is opened once per thread; harper#643
1689
+ * makes branch open/close routine, so both would grow with branch churn.
1690
+ */
1691
+ function closeBranchHandles(
1692
+ path: string,
1693
+ rootStore?: RootDatabaseKind,
1694
+ openedStores: any[] = [],
1695
+ tables: Tables = {}
1696
+ ): void {
1697
+ const reclamationPaths = new Set<string>([path]);
1698
+ (rootStore as any)?.auditStore?.stopAuditCleanup?.();
1699
+ const closeStore = (store: any, description: string) => {
1700
+ if (!store || store.status === 'closed') return;
1701
+ if (store.path) reclamationPaths.add(store.path);
1702
+ try {
1703
+ store.close?.();
1704
+ } catch (error) {
1705
+ logger.warn(`Error closing ${description} for branch database at ${path}`, error);
1706
+ }
1707
+ };
1708
+ // the class, before its stores: an expiration timer or a reclamation handler on a closed store
1709
+ // would otherwise keep firing against it for the life of the process
1710
+ for (const tableName in tables) {
1711
+ try {
1712
+ tables[tableName]?.cleanup?.();
1713
+ } catch (error) {
1714
+ logger.warn(`Error releasing table ${tableName} of branch database at ${path}`, error);
1715
+ }
1716
+ }
1717
+ for (const store of openedStores) closeStore(store, 'column family');
1718
+ closeStore((rootStore as any)?.dbisDb, 'attributes store');
1719
+ closeStore((rootStore as any)?.auditStore, 'audit store');
1720
+ closeStore(rootStore, 'root store');
1721
+ if (rootStore) databasePaths.delete(rootStore as RootDatabase);
1722
+ for (const reclamationPath of reclamationPaths) removeStorageReclamation(reclamationPath);
1723
+ }
1724
+
1725
+ /** Branches are process-local, so this is shutdown, not a data operation. */
1726
+ export function closeBranchDatabases(): void {
1727
+ for (const branch of [...openBranches.values()]) branch?.close();
1728
+ }
1729
+
1021
1730
  export function resetDatabases() {
1022
1731
  loadedDatabases = false;
1023
1732
  for (const store of Object.values(lmdbDatabaseEnvs)) {
@@ -1048,6 +1757,7 @@ interface TableDefinition {
1048
1757
  trackDeletes?: boolean;
1049
1758
  attributes: any[];
1050
1759
  schemaDefined?: boolean;
1760
+ schemaRelationshipsDefined?: boolean;
1051
1761
  origin?: string;
1052
1762
  description?: string;
1053
1763
  properties?: Record<string, any>;
@@ -1160,6 +1870,14 @@ export function database({ database: databaseName, table: tableName }) {
1160
1870
  const useRocksdb = (process.env.HARPER_STORAGE_ENGINE || envGet(CONFIG_PARAMS.STORAGE_ENGINE)) !== 'lmdb';
1161
1871
  if (useRocksdb) {
1162
1872
  const path = join(databasePath, tablePath ? tableName : databaseName);
1873
+ // the scan is not the only way to reach a branch's directory: a branch leaves its store in
1874
+ // `rocksdbDatabaseEnvs`, so without this an on-demand open would staple it onto
1875
+ // `definedDatabases` and the next `closeDatabase` would close it under the live handle
1876
+ if (isOpenBranchPath(path)) {
1877
+ const error: any = new Error(`Database '${databaseName}' is open as a scope-private branch`);
1878
+ error.statusCode = 409;
1879
+ throw error;
1880
+ }
1163
1881
  rootStore = rocksdbDatabaseEnvs.get(path);
1164
1882
  if (!rootStore || rootStore.status === 'closed') {
1165
1883
  // this on-demand open (create_table/create_database and friends) must not resurrect a
@@ -1379,8 +2097,12 @@ export function closeDatabase(databaseName: string): boolean {
1379
2097
  * database is closed). The `system` database is intentionally left open: it is non-enumerable here
1380
2098
  * (skipped by the loop), is never restored online, and the exiting worker may still touch the job
1381
2099
  * table during teardown. Best-effort: closing failures are swallowed inside `closeDatabase`.
2100
+ *
2101
+ * Branches are invisible to the loop below but hold handles from the same registry, so this — the
2102
+ * thread's one teardown entry point — closes them too.
1382
2103
  */
1383
2104
  export function closeLoadedDatabases(): void {
2105
+ closeBranchDatabases();
1384
2106
  // snapshot the names first: closeDatabase() deletes from `databases` as it goes
1385
2107
  for (const databaseName of Object.keys(databases)) {
1386
2108
  const dbTables = databases[databaseName];
@@ -1494,23 +2216,34 @@ function openIndex(dbiKey: string, rootStore: RootDatabaseKind, attribute: any)
1494
2216
  cache: isCustomObjectIndex,
1495
2217
  } as any) as any;
1496
2218
  (dbi as any).rootStore = rootStore;
1497
- // Custom-index object stores (e.g. HNSW) write graph nodes via plain put() with no staged
1498
- // transaction timestamp, so their values carry no version and the PrimaryRocksDatabase
1499
- // Verification-Table cache can't track them. A versioned index initialises its encoder as a
1500
- // versioned RocksDB store (isRocksDB → metadata-prefix encode/decode) and marks it
1501
- // self-versioning, so each node gets a monotonic version the VT can extract — enabling cached,
1502
- // decode-free graph traversal. The format is resolved from the persisted attribute descriptor
1503
- // (decided once at create — see resolveIndexFormat) so every worker and reload agree on it.
1504
- if (isCustomObjectIndex && resolveIndexFormat(dbiKey, rootStore, dbi, attribute) === 'versioned') {
1505
- armVersionedIndexEncoder(dbi, rootStore);
2219
+ try {
2220
+ // Custom-index object stores (e.g. HNSW) write graph nodes via plain put() with no staged
2221
+ // transaction timestamp, so their values carry no version and the PrimaryRocksDatabase
2222
+ // Verification-Table cache can't track them. A versioned index initialises its encoder as a
2223
+ // versioned RocksDB store (isRocksDB → metadata-prefix encode/decode) and marks it
2224
+ // self-versioning, so each node gets a monotonic version the VT can extract — enabling cached,
2225
+ // decode-free graph traversal. The format is resolved from the persisted attribute descriptor
2226
+ // (decided once at create — see resolveIndexFormat) so every worker and reload agree on it.
2227
+ if (isCustomObjectIndex && resolveIndexFormat(dbiKey, rootStore, dbi, attribute) === 'versioned') {
2228
+ armVersionedIndexEncoder(dbi, rootStore);
2229
+ }
2230
+ installCustomIndex(dbi);
2231
+ } catch (error) {
2232
+ // the handle is not yet owned by any table, so nobody else can close it
2233
+ try {
2234
+ dbi.close();
2235
+ } catch {}
2236
+ throw error;
1506
2237
  }
1507
2238
  } else {
1508
2239
  dbi = (rootStore as any).openDB(dbiKey, dbiInit as any);
2240
+ installCustomIndex(dbi);
1509
2241
  }
1510
- if (attribute.indexed.type) {
2242
+ function installCustomIndex(indexStore: any) {
2243
+ if (!attribute.indexed.type) return;
1511
2244
  const CustomIndex = CUSTOM_INDEXES[attribute.indexed.type];
1512
2245
  if (CustomIndex) {
1513
- dbi.customIndex = new CustomIndex(dbi, attribute.indexed);
2246
+ indexStore.customIndex = new CustomIndex(indexStore, attribute.indexed);
1514
2247
  } else {
1515
2248
  logger.error(`The indexing type '${attribute.indexed.type}' is unknown`);
1516
2249
  }
@@ -1533,6 +2266,102 @@ function openIndex(dbiKey: string, rootStore: RootDatabaseKind, attribute: any)
1533
2266
  * @param replicate
1534
2267
  */
1535
2268
  export function table<TableResourceType>(tableDefinition: TableDefinition): TableResourceType {
2269
+ return declareTable(GLOBAL_TARGET, tableDefinition);
2270
+ }
2271
+
2272
+ /**
2273
+ * Where a declaration lands. `table()` is bound to the global catalog; a branched application's
2274
+ * declarations are bound to its branch (`scopedTableFactory`). Everything in `declareTable` that is
2275
+ * global by construction -- the root store, the `tables` graph a class is published into, the reload
2276
+ * after a lost create race, who owns the handles it opens -- goes through this, and nothing else does,
2277
+ * so the unbranched path is the same code with the same objects behind it.
2278
+ */
2279
+ interface TableTarget {
2280
+ rootStore(databaseName: string, tableName: string): RootDatabaseKind;
2281
+ tables(databaseName: string): Tables;
2282
+ /** Another thread created the table this declaration was about to; make `tables` reflect it. */
2283
+ reload(databaseName: string): void;
2284
+ /** Records a column-family wrapper the declaration opened, for whoever closes the store. */
2285
+ adopt(store: any): void;
2286
+ /** Set for a branch: its Table classes refuse DDL, and its schema signals address it by path. */
2287
+ branch?: BranchDatabase;
2288
+ }
2289
+
2290
+ const GLOBAL_TARGET: TableTarget = {
2291
+ rootStore: (databaseName, tableName) => database({ database: databaseName, table: tableName }),
2292
+ tables: (databaseName) => databases[databaseName],
2293
+ reload: () => resetDatabases(),
2294
+ // a real database's stores live until the process (or `closeDatabase`, which walks the graph) ends
2295
+ adopt: () => {},
2296
+ };
2297
+
2298
+ /**
2299
+ * The factory a branched application declares tables through: each declaration goes to the branch
2300
+ * of the database it names, or to `table()` itself for a database the application did not branch.
2301
+ * An unbranched application gets `table` by identity -- no wrapper, no per-call routing.
2302
+ */
2303
+ export function scopedTableFactory(branches?: Map<string, BranchDatabase>): typeof table {
2304
+ if (!branches?.size) return table;
2305
+ return function scopedTable<TableResourceType>(tableDefinition: TableDefinition): TableResourceType {
2306
+ // `||`, not `??`: `table()` resolves every falsy name to the default database
2307
+ const branch = branches.get(tableDefinition.database || DEFAULT_DATABASE_NAME);
2308
+ return branch ? declareTable(branchTarget(branch), tableDefinition) : table(tableDefinition);
2309
+ };
2310
+ }
2311
+
2312
+ function branchTarget(branch: BranchDatabase): TableTarget {
2313
+ return {
2314
+ rootStore: () => branch.rootStore,
2315
+ tables: () => branch.tables,
2316
+ reload: () => reloadBranch(branch),
2317
+ adopt: (store) => branch.openedStores.push(store),
2318
+ branch,
2319
+ };
2320
+ }
2321
+
2322
+ /**
2323
+ * Re-read a branch's catalog into its `tables`: tables and indexes another thread declared since the
2324
+ * open (or since the last reload) are opened here, the same way a schema-change rescan does for a
2325
+ * real database. Existing classes are kept and their attribute lists refreshed.
2326
+ */
2327
+ function reloadBranch(branch: BranchDatabase): void {
2328
+ const { rootStore, tables, databaseName, storeName, openedStores } = branch;
2329
+ const queuedRelationshipsAt = relationshipsToHydrate.length;
2330
+ try {
2331
+ initStores(rootStore.path, rootStore, databaseName, { destination: tables, storeName, openedStores });
2332
+ } finally {
2333
+ for (const hydration of relationshipsToHydrate.splice(queuedRelationshipsAt))
2334
+ queueBranchHydration(branch, hydration);
2335
+ }
2336
+ // Until `prepareBranches` has the whole set, a cross-database target cannot be resolved without
2337
+ // falling through to the base; it hydrates the complete set once. After that, every sibling is
2338
+ // re-hydrated: the table this reload brought in may be the target a sibling's relationship waited for.
2339
+ if (!branch.relatedBranches) return;
2340
+ for (const sibling of branch.relatedBranches.values()) hydrateBranchRelationships(sibling, branch.relatedBranches);
2341
+ }
2342
+
2343
+ /** One pending hydration per table: a re-declaration replaces the entry the earlier declaration queued. */
2344
+ function queueBranchHydration(branch: BranchDatabase, hydration: RelationshipHydration): void {
2345
+ const existing = branch.pendingRelationships.findIndex(
2346
+ (pending) => pending.databaseName === hydration.databaseName && pending.tableName === hydration.tableName
2347
+ );
2348
+ if (existing >= 0) branch.pendingRelationships[existing] = hydration;
2349
+ else branch.pendingRelationships.push(hydration);
2350
+ }
2351
+
2352
+ /**
2353
+ * The receiving side of a branch's schema-change signal: a thread that holds this branch open reloads
2354
+ * it, any other thread has nothing to do. Returns the branch's tables so the caller can address the
2355
+ * table the signal named.
2356
+ */
2357
+ export function reloadBranchAt(path: string): Tables | undefined {
2358
+ const branch = openBranches.get(path);
2359
+ if (!branch) return undefined;
2360
+ reloadBranch(branch);
2361
+ return branch.tables;
2362
+ }
2363
+
2364
+ function declareTable<TableResourceType>(target: TableTarget, tableDefinition: TableDefinition): TableResourceType {
1536
2365
  let {
1537
2366
  table: tableName,
1538
2367
  database: databaseName,
@@ -1547,6 +2376,7 @@ export function table<TableResourceType>(tableDefinition: TableDefinition): Tabl
1547
2376
  randomAccessFields,
1548
2377
  trackDeletes,
1549
2378
  schemaDefined,
2379
+ schemaRelationshipsDefined,
1550
2380
  origin,
1551
2381
  description,
1552
2382
  properties,
@@ -1564,8 +2394,14 @@ export function table<TableResourceType>(tableDefinition: TableDefinition): Tabl
1564
2394
  if ((RESERVED_DATABASE_NAMES as readonly string[]).includes(databaseName)) {
1565
2395
  throw new ClientError(`'${databaseName}' is a reserved name and cannot be used as a database name`);
1566
2396
  }
1567
- const rootStore = database({ database: databaseName, table: tableName });
1568
- const tables = databases[databaseName];
2397
+ // A branch resolves its blob root from its store identity, so a database created under that same
2398
+ // name would share the root: two allocators minting the same file paths and truncating each other,
2399
+ // and the branch's teardown removing the database's blobs.
2400
+ if (isBranchIdentity(databaseName)) {
2401
+ throw new ClientError(`'${databaseName}' is in use as a branch store identity and cannot be a database name`);
2402
+ }
2403
+ const rootStore = target.rootStore(databaseName, tableName);
2404
+ const tables = target.tables(databaseName);
1569
2405
  logger.trace(`Defining ${tableName} in ${databaseName}`);
1570
2406
  let Table = tables?.[tableName];
1571
2407
  if (rootStore.status === 'closed') {
@@ -1579,6 +2415,7 @@ export function table<TableResourceType>(tableDefinition: TableDefinition): Tabl
1579
2415
  // flag must be left as-is. Only an explicit value can re-assert on the existing-Table branch.
1580
2416
  const schemaDefinedExplicit = tableDefinition.schemaDefined !== undefined;
1581
2417
  if (schemaDefined == undefined) schemaDefined = true;
2418
+ const relationshipDefinitions = schemaRelationshipsDefined ? normalizeRelationships(attributes) : undefined;
1582
2419
  const internalDbiInit = createOpenDBIObject(false);
1583
2420
 
1584
2421
  for (const attribute of attributes) {
@@ -1590,113 +2427,160 @@ export function table<TableResourceType>(tableDefinition: TableDefinition): Tabl
1590
2427
  if (attribute.expiresAt) attribute.indexed = true;
1591
2428
  }
1592
2429
  let hasChanges;
1593
- let releaseExclusiveLock: () => void;
1594
- if (Table) {
1595
- primaryKey = Table.primaryKey;
1596
- if (Table.primaryStore.rootStore.status === 'closed') {
1597
- throw new Error(`Can not use a closed data store from ${tableName} class`);
1598
- }
1599
- // Reject moving the primary key to a different attribute on a table that already has records.
1600
- // The storage key (Table.primaryKey) is never re-pointed here, so honoring the change would
1601
- // leave describe reporting the new attribute while every record — old and newly inserted — stays
1602
- // keyed by the original one; search_by_id/update/delete by the declared key then all miss. Only
1603
- // schema-authored callers (@table / defineTable / create_table) reassert the declaration, so
1604
- // gate on schemaDefinedExplicit to leave cluster schema-replication / data-loader callers alone.
1605
- // See HarperFast/studio#1199.
1606
- const declaredPrimaryKey = attributes.find((attribute) => attribute.isPrimaryKey)?.name;
1607
- if (schemaDefinedExplicit && declaredPrimaryKey && declaredPrimaryKey !== Table.primaryKey) {
1608
- let hasRecords = false;
1609
- for (const _entry of Table.primaryStore.getRange({ start: true })) {
1610
- hasRecords = true;
1611
- break;
2430
+ let refreshRelationshipAttributes = false;
2431
+ let deferredPrimaryRow: any;
2432
+ let unpublishedPrimaryStore: any;
2433
+ let published = false;
2434
+ let releaseExclusiveLock: (() => void) | undefined;
2435
+ const attributesToIndex = [];
2436
+ const indicesToRemove = [];
2437
+ try {
2438
+ if (Table) {
2439
+ primaryKey = Table.primaryKey;
2440
+ if (Table.primaryStore.rootStore.status === 'closed') {
2441
+ throw new Error(`Can not use a closed data store from ${tableName} class`);
1612
2442
  }
1613
- if (hasRecords) {
1614
- throw new ClientError(
1615
- `Cannot change the primary key of table '${databaseName}.${tableName}' from '${Table.primaryKey}' to ` +
1616
- `'${declaredPrimaryKey}' because it already contains records. Recreate the table with the new primary ` +
1617
- `key, or migrate the existing records.`,
1618
- 400
1619
- );
2443
+ // Reject moving the primary key to a different attribute on a table that already has records.
2444
+ // The storage key (Table.primaryKey) is never re-pointed here, so honoring the change would
2445
+ // leave describe reporting the new attribute while every record — old and newly inserted — stays
2446
+ // keyed by the original one; search_by_id/update/delete by the declared key then all miss. Only
2447
+ // schema-authored callers (@table / defineTable / create_table) reassert the declaration, so
2448
+ // gate on schemaDefinedExplicit to leave cluster schema-replication / data-loader callers alone.
2449
+ // See HarperFast/studio#1199.
2450
+ const declaredPrimaryKey = attributes.find((attribute) => attribute.isPrimaryKey)?.name;
2451
+ if (schemaDefinedExplicit && declaredPrimaryKey && declaredPrimaryKey !== Table.primaryKey) {
2452
+ let hasRecords = false;
2453
+ for (const _entry of Table.primaryStore.getRange({ start: true })) {
2454
+ hasRecords = true;
2455
+ break;
2456
+ }
2457
+ if (hasRecords) {
2458
+ throw new ClientError(
2459
+ `Cannot change the primary key of table '${databaseName}.${tableName}' from '${Table.primaryKey}' to ` +
2460
+ `'${declaredPrimaryKey}' because it already contains records. Recreate the table with the new primary ` +
2461
+ `key, or migrate the existing records.`,
2462
+ 400
2463
+ );
2464
+ }
1620
2465
  }
1621
- }
1622
- // it table already exists, get the split segments setting
1623
- if (splitSegments == undefined) splitSegments = Table.splitSegments;
1624
- Table.attributes.splice(0, Table.attributes.length, ...attributes);
1625
- // Re-assert from the live declaration so a stale value on disk (replicated event,
1626
- // v4-era backfill) is corrected on every reload. Gated on `schemaDefinedExplicit` so
1627
- // callers that omit the flag (cluster schema-replication, data loader) don't flip a
1628
- // dynamic table to true via the default at the top of table().
1629
- if (schemaDefinedExplicit) Table.schemaDefined = schemaDefined;
1630
- // Refresh class-level schema metadata to track docstring/directive changes across reloads.
1631
- Table.description = description;
1632
- Table.properties = properties;
1633
- Table.hidden = hidden;
1634
- // undefined means a non-schema caller (add_attribute, cluster schema events) — don't clobber
1635
- if (cacheControl !== undefined) Table.cacheControl = cacheControl;
1636
- } else {
1637
- const auditStore = rootStore.auditStore;
1638
- primaryKeyAttribute = attributes.find((attribute) => attribute.isPrimaryKey) || {};
1639
- primaryKey = primaryKeyAttribute.name;
1640
- primaryKeyAttribute.isPrimaryKey = true;
1641
- primaryKeyAttribute.is_hash_attribute = true; // backward-compat: harperdb@4.x reads this field to open the DBI with correct flags
1642
- primaryKeyAttribute.schemaDefined = schemaDefined;
1643
- // can't change compression after the fact (except threshold), so save only when we create the table
1644
- primaryKeyAttribute.compression = getDefaultCompression();
1645
- if (trackDeletes) primaryKeyAttribute.trackDeletes = true;
1646
- audit = primaryKeyAttribute.audit = typeof audit === 'boolean' ? audit : envGet(CONFIG_PARAMS.LOGGING_AUDITLOG);
1647
- if (expiration) primaryKeyAttribute.expiration = expiration;
1648
- if (eviction) primaryKeyAttribute.eviction = eviction;
1649
- // persist cacheControl so all threads (and future boots) see it; undefined callers inherit
1650
- // a descriptor value carried by cluster schema events; null (schema has no directive)
1651
- // clears a stale value the carried descriptor may hold
1652
- if (cacheControl === undefined) cacheControl = primaryKeyAttribute.cacheControl;
1653
- else if (cacheControl === null) delete primaryKeyAttribute.cacheControl;
1654
- else primaryKeyAttribute.cacheControl = cacheControl;
1655
- splitSegments ??= false;
1656
- primaryKeyAttribute.splitSegments = splitSegments; // always default to not splitting segments going forward
1657
- if (typeof sealed === 'boolean') primaryKeyAttribute.sealed = sealed;
1658
- if (typeof replicate === 'boolean') primaryKeyAttribute.replicate = replicate;
1659
- // An explicit directive PINS this table's encoding: we persist the boolean, so later changes
1660
- // to the global storage.randomAccessFields default never affect this table. Tables WITHOUT the
1661
- // directive are intentionally not persisted here — they follow the current global default on
1662
- // each open (a runtime lever to flip encoding fleet-wide). Switching either way is safe: the
1663
- // struct READ hook always stays on and struct (0x20-0x3f) vs classic-record (0x40-0x7f) bytes
1664
- // are disjoint, so already-written records still decode; only the encoding of NEW writes changes.
1665
- if (typeof randomAccessFields === 'boolean') primaryKeyAttribute.randomAccessFields = randomAccessFields;
1666
- if (origin) {
1667
- if (!primaryKeyAttribute.origins) primaryKeyAttribute.origins = [origin];
1668
- else if (!primaryKeyAttribute.origins.includes(origin)) primaryKeyAttribute.origins.push(origin);
1669
- }
1670
- logger.trace(`${tableName} table loading, opening primary store`);
1671
- const dbiInit = createOpenDBIObject(false, true);
1672
- dbiInit.compression = primaryKeyAttribute.compression;
1673
- // per-table override of the storage.randomAccessFields default (see OpenDBIObject)
1674
- if (typeof primaryKeyAttribute.randomAccessFields === 'boolean')
1675
- dbiInit.randomAccessStructure = primaryKeyAttribute.randomAccessFields;
1676
- const dbiName = tableName + '/';
1677
-
1678
- if (rootStore instanceof RocksDatabase) {
1679
- attributesDbi = (rootStore as any).dbisDb = openRocksDatabase(rootStore.path, {
1680
- ...internalDbiInit,
1681
- disableWAL: false,
1682
- name: INTERNAL_DBIS_NAME,
1683
- } as any);
2466
+ // Acquire before the first mutation of the live Table below, so a lost race leaves no
2467
+ // attributes this worker describes but never persisted. Only the RocksDB acquire is bounded
2468
+ // and can throw, and only it is cheap when uncontended: LMDB's exclusiveLock() opens an
2469
+ // environment-wide write transaction that cannot time out, so it stays lazy.
2470
+ if (rootStore instanceof RocksDatabase) exclusiveLock();
2471
+ // it table already exists, get the split segments setting
2472
+ if (splitSegments == undefined) splitSegments = Table.splitSegments;
2473
+ if (origin === 'cluster') {
2474
+ const merged = Table.attributes.slice();
2475
+ for (const attribute of attributes) {
2476
+ const existing = merged.find((existingAttribute) => existingAttribute.name === attribute.name);
2477
+ if (!existing) {
2478
+ merged.push(attribute);
2479
+ continue;
2480
+ }
2481
+ // Nodes that apply the same peer definitions in a different order keep different index sets, and
2482
+ // this warn is the only signal of it. An absent field and an explicit falsy one declare the same
2483
+ // thing, so neither direction of that pair is a difference.
2484
+ const discarded = PEER_DECLARABLE_FIELDS.filter(
2485
+ (field) =>
2486
+ (attribute[field] || existing[field]) &&
2487
+ JSON.stringify(attribute[field]) !== JSON.stringify(existing[field])
2488
+ );
2489
+ if (discarded.length > 0)
2490
+ logger.warn(
2491
+ `Ignoring peer redefinition of ${databaseName}.${tableName}.${attribute.name} (${discarded
2492
+ .map(
2493
+ (field) =>
2494
+ `${field}: local ${JSON.stringify(existing[field])}, peer ${JSON.stringify(attribute[field])}`
2495
+ )
2496
+ .join('; ')}); the local schema is authoritative`
2497
+ );
2498
+ }
2499
+ attributes = merged;
2500
+ }
2501
+ Table.attributes.splice(0, Table.attributes.length, ...attributes);
2502
+ // Re-assert from the live declaration so a stale value on disk (replicated event,
2503
+ // v4-era backfill) is corrected on every reload. Gated on `schemaDefinedExplicit` so
2504
+ // callers that omit the flag (cluster schema-replication, data loader) don't flip a
2505
+ // dynamic table to true via the default at the top of table(), and on origin so a
2506
+ // peer-derived definition never overrides the local declaration.
2507
+ if (schemaDefinedExplicit && origin !== 'cluster') Table.schemaDefined = schemaDefined;
2508
+ // Refresh class-level schema metadata to track docstring/directive changes across reloads.
2509
+ Table.description = description;
2510
+ Table.properties = properties;
2511
+ Table.hidden = hidden;
2512
+ // undefined means a non-schema caller (add_attribute, cluster schema events) — don't clobber
2513
+ if (cacheControl !== undefined) Table.cacheControl = cacheControl;
1684
2514
  } else {
1685
- attributesDbi = (rootStore as any).dbisDb = (rootStore as any).openDB(INTERNAL_DBIS_NAME, internalDbiInit as any);
1686
- }
1687
- markInternalDbiNonVersioned(attributesDbi);
2515
+ const auditStore = rootStore.auditStore;
2516
+ primaryKeyAttribute = attributes.find((attribute) => attribute.isPrimaryKey) || {};
2517
+ primaryKey = primaryKeyAttribute.name;
2518
+ primaryKeyAttribute.isPrimaryKey = true;
2519
+ primaryKeyAttribute.is_hash_attribute = true; // backward-compat: harperdb@4.x reads this field to open the DBI with correct flags
2520
+ primaryKeyAttribute.schemaDefined = schemaDefined;
2521
+ // Old readers treat every attribute row as live schema, so relationships stay on the ignored primary descriptor.
2522
+ if (relationshipDefinitions) primaryKeyAttribute.relationships = relationshipDefinitions;
2523
+ // can't change compression after the fact (except threshold), so save only when we create the table
2524
+ primaryKeyAttribute.compression = getDefaultCompression();
2525
+ if (trackDeletes) primaryKeyAttribute.trackDeletes = true;
2526
+ audit = primaryKeyAttribute.audit = typeof audit === 'boolean' ? audit : envGet(CONFIG_PARAMS.LOGGING_AUDITLOG);
2527
+ if (expiration) primaryKeyAttribute.expiration = expiration;
2528
+ if (eviction) primaryKeyAttribute.eviction = eviction;
2529
+ // persist cacheControl so all threads (and future boots) see it; undefined callers inherit
2530
+ // a descriptor value carried by cluster schema events; null (schema has no directive)
2531
+ // clears a stale value the carried descriptor may hold
2532
+ if (cacheControl === undefined) cacheControl = primaryKeyAttribute.cacheControl;
2533
+ else if (cacheControl === null) delete primaryKeyAttribute.cacheControl;
2534
+ else primaryKeyAttribute.cacheControl = cacheControl;
2535
+ splitSegments ??= false;
2536
+ primaryKeyAttribute.splitSegments = splitSegments; // always default to not splitting segments going forward
2537
+ if (typeof sealed === 'boolean') primaryKeyAttribute.sealed = sealed;
2538
+ if (typeof replicate === 'boolean') primaryKeyAttribute.replicate = replicate;
2539
+ // An explicit directive PINS this table's encoding: we persist the boolean, so later changes
2540
+ // to the global storage.randomAccessFields default never affect this table. Tables WITHOUT the
2541
+ // directive are intentionally not persisted here — they follow the current global default on
2542
+ // each open (a runtime lever to flip encoding fleet-wide). Switching either way is safe: the
2543
+ // struct READ hook always stays on and struct (0x20-0x3f) vs classic-record (0x40-0x7f) bytes
2544
+ // are disjoint, so already-written records still decode; only the encoding of NEW writes changes.
2545
+ if (typeof randomAccessFields === 'boolean') primaryKeyAttribute.randomAccessFields = randomAccessFields;
2546
+ if (origin) {
2547
+ if (!primaryKeyAttribute.origins) primaryKeyAttribute.origins = [origin];
2548
+ else if (!primaryKeyAttribute.origins.includes(origin)) primaryKeyAttribute.origins.push(origin);
2549
+ }
2550
+ logger.trace(`${tableName} table loading, opening primary store`);
2551
+ const dbiInit = createOpenDBIObject(false, true);
2552
+ dbiInit.compression = primaryKeyAttribute.compression;
2553
+ // per-table override of the storage.randomAccessFields default (see OpenDBIObject)
2554
+ if (typeof primaryKeyAttribute.randomAccessFields === 'boolean')
2555
+ dbiInit.randomAccessStructure = primaryKeyAttribute.randomAccessFields;
2556
+ const dbiName = tableName + '/';
1688
2557
 
1689
- 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)
1690
- const existingTableMeta = (attributesDbi as any).getSync(dbiName);
1691
- if (existingTableMeta && !existingTableMeta.dropping) {
1692
- // table was created while we were setting up
1693
- if (releaseExclusiveLock) releaseExclusiveLock();
1694
- resetDatabases();
1695
- return table(tableDefinition);
1696
- }
2558
+ if (rootStore instanceof RocksDatabase) {
2559
+ attributesDbi = (rootStore as any).dbisDb = openRocksDatabase(rootStore.path, {
2560
+ ...internalDbiInit,
2561
+ disableWAL: false,
2562
+ name: INTERNAL_DBIS_NAME,
2563
+ } as any);
2564
+ } else {
2565
+ attributesDbi = (rootStore as any).dbisDb = (rootStore as any).openDB(
2566
+ INTERNAL_DBIS_NAME,
2567
+ internalDbiInit as any
2568
+ );
2569
+ }
2570
+ target.adopt(attributesDbi);
2571
+ markInternalDbiNonVersioned(attributesDbi);
2572
+
2573
+ 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)
2574
+ const existingTableMeta = (attributesDbi as any).getSync(dbiName);
2575
+ if (existingTableMeta && !existingTableMeta.dropping) {
2576
+ // table was created while we were setting up; the lock is not reentrant, so release
2577
+ // before the recursive reload
2578
+ releaseLock();
2579
+ target.reload(databaseName);
2580
+ return declareTable(target, tableDefinition);
2581
+ }
1697
2582
 
1698
- let primaryStore;
1699
- try {
2583
+ let primaryStore;
1700
2584
  if (existingTableMeta?.dropping) {
1701
2585
  // A previous drop of this table was interrupted after its tombstone
1702
2586
  // was written. Complete it now (under the exclusive lock) so the
@@ -1723,124 +2607,127 @@ export function table<TableResourceType>(tableDefinition: TableDefinition): Tabl
1723
2607
  } else {
1724
2608
  primaryStore = (rootStore as any).openDB(dbiName, dbiInit as any);
1725
2609
  }
2610
+ target.adopt(primaryStore);
2611
+ unpublishedPrimaryStore = primaryStore;
1726
2612
  primaryStore = handleLocalTimeForGets(primaryStore, rootStore);
1727
- rootStore.databaseName = databaseName;
2613
+ // only a store no table has loaded yet is unnamed; a branch's store carries its own store
2614
+ // identity here, which its blob roots resolve from, and must not take the logical name
2615
+ rootStore.databaseName ??= databaseName;
1728
2616
  primaryStore.tableId = attributesDbi.getSync(NEXT_TABLE_ID);
1729
2617
  logger.trace(`Assigning new table id ${primaryStore.tableId} for ${tableName}`);
1730
2618
  if (!primaryStore.tableId) primaryStore.tableId = 1;
1731
2619
  attributesDbi.put(NEXT_TABLE_ID, primaryStore.tableId + 1);
1732
2620
 
1733
2621
  primaryKeyAttribute.tableId = primaryStore.tableId;
1734
- Table = setTable(
1735
- tables,
2622
+ Table = makeTable({
2623
+ isBranch: Boolean(target.branch),
2624
+ primaryStore,
2625
+ auditStore,
2626
+ audit,
2627
+ sealed,
2628
+ splitSegments,
2629
+ replicate,
2630
+ trackDeletes,
2631
+ expirationMS: expiration && expiration * 1000,
2632
+ evictionMS: eviction && eviction * 1000,
2633
+ primaryKey,
1736
2634
  tableName,
1737
- makeTable({
1738
- primaryStore,
1739
- auditStore,
1740
- audit,
1741
- sealed,
1742
- splitSegments,
1743
- replicate,
1744
- trackDeletes,
1745
- expirationMS: expiration && expiration * 1000,
1746
- evictionMS: eviction && eviction * 1000,
1747
- primaryKey,
1748
- tableName,
1749
- tableId: primaryStore.tableId,
1750
- databasePath: databaseName,
1751
- databaseName,
1752
- indices: {},
1753
- attributes,
1754
- schemaDefined,
1755
- dbisDB: attributesDbi,
1756
- description,
1757
- properties,
1758
- hidden,
1759
- cacheControl,
1760
- })
1761
- );
2635
+ tableId: primaryStore.tableId,
2636
+ databasePath: databaseName,
2637
+ databaseName,
2638
+ indices: {},
2639
+ attributes,
2640
+ schemaDefined,
2641
+ dbisDB: attributesDbi,
2642
+ description,
2643
+ properties,
2644
+ hidden,
2645
+ cacheControl,
2646
+ });
1762
2647
  Table.schemaVersion = 1;
1763
2648
  hasChanges = true;
1764
-
1765
- attributesDbi.put(dbiName, primaryKeyAttribute);
1766
- } catch (error) {
1767
- // A failure while opening/creating the column family or writing the
1768
- // table id / catalog entry (e.g. into an env poisoned by a prior
1769
- // dangling column family) must NOT leak the exclusive
1770
- // 'update-attributes' spin lock. If it leaks, every subsequent
1771
- // create_table / attribute update on this database spins forever
1772
- // (a hard wedge that pins a worker at 100% CPU). Release before rethrow.
1773
- if (releaseExclusiveLock) releaseExclusiveLock();
1774
- throw error;
2649
+ deferredPrimaryRow = primaryKeyAttribute;
1775
2650
  }
1776
- }
1777
- const indices = Table.indices;
1778
- if (!attributesDbi) {
1779
- if (rootStore instanceof RocksDatabase) {
1780
- (rootStore as any).dbisDb = openRocksDatabase(rootStore.path, {
1781
- ...internalDbiInit,
1782
- disableWAL: false,
1783
- name: INTERNAL_DBIS_NAME,
1784
- } as any);
1785
- } else {
1786
- (rootStore as any).dbisDb = (rootStore as any).openDB(INTERNAL_DBIS_NAME, internalDbiInit as any);
1787
- }
1788
- attributesDbi = markInternalDbiNonVersioned((rootStore as any).dbisDb);
1789
- }
1790
- Table.dbisDB = attributesDbi;
1791
- const indicesToRemove = [];
1792
- for (const { key, value } of attributesDbi.getRange({ start: true })) {
1793
- if (value == null) continue;
1794
- let [attributeTableName, attribute_name] = key.toString().split('/');
1795
- if (attribute_name === '') attribute_name = value.name; // primary key
1796
- if (attribute_name) {
1797
- if (attributeTableName !== tableName) continue;
1798
- } else {
1799
- // table attribute for a table with no primary key, we don't want to remove this, so continue on
1800
- continue;
2651
+ const indices = Table.indices;
2652
+ if (!attributesDbi) {
2653
+ if (rootStore instanceof RocksDatabase) {
2654
+ (rootStore as any).dbisDb = openRocksDatabase(rootStore.path, {
2655
+ ...internalDbiInit,
2656
+ disableWAL: false,
2657
+ name: INTERNAL_DBIS_NAME,
2658
+ } as any);
2659
+ } else {
2660
+ (rootStore as any).dbisDb = (rootStore as any).openDB(INTERNAL_DBIS_NAME, internalDbiInit as any);
2661
+ }
2662
+ target.adopt((rootStore as any).dbisDb);
2663
+ attributesDbi = markInternalDbiNonVersioned((rootStore as any).dbisDb);
1801
2664
  }
1802
- const attribute = attributes.find((attribute) => attribute.name === attribute_name);
1803
- const removeIndex = !attribute?.indexed && value.indexed && !value.isPrimaryKey;
1804
- if (!attribute || removeIndex) {
1805
- exclusiveLock();
1806
- hasChanges = true;
1807
- if (!attribute) attributesDbi.remove(key);
1808
- if (removeIndex) {
1809
- const indexDbi = Table.indices[attributeTableName];
1810
- if (indexDbi) indicesToRemove.push(indexDbi);
2665
+ Table.dbisDB = attributesDbi;
2666
+ // A cluster-origin list can miss a descriptor another thread committed moments ago, so removal
2667
+ // reconciliation is reserved for local schema authoring; on a create the rows can only be aborted state.
2668
+ const reconcileRemovals = origin !== 'cluster' || Boolean(deferredPrimaryRow);
2669
+ for (const { key, value } of reconcileRemovals
2670
+ ? attributesDbi.getRange({ start: tableName + '/', end: tableName + '0' })
2671
+ : []) {
2672
+ if (value == null) continue;
2673
+ let [attributeTableName, attribute_name] = key.toString().split('/');
2674
+ if (attribute_name === '') attribute_name = value.name; // primary key
2675
+ if (attribute_name) {
2676
+ if (attributeTableName !== tableName) continue;
2677
+ } else {
2678
+ // table attribute for a table with no primary key, we don't want to remove this, so continue on
2679
+ continue;
2680
+ }
2681
+ const attribute = attributes.find((attribute) => attribute.name === attribute_name);
2682
+ const removeIndex = !attribute?.indexed && value.indexed && !value.isPrimaryKey;
2683
+ // rows already present under a create are aborted state
2684
+ const staleRow = !attribute || Boolean(deferredPrimaryRow);
2685
+ if (staleRow || removeIndex) {
2686
+ exclusiveLock();
2687
+ hasChanges = true;
2688
+ if (staleRow) attributesDbi.remove(key);
2689
+ if (removeIndex) {
2690
+ const indexDbi = Table.indices[attributeTableName];
2691
+ if (indexDbi) indicesToRemove.push(indexDbi);
2692
+ }
1811
2693
  }
1812
2694
  }
1813
- }
1814
- const attributesToIndex = [];
1815
- try {
1816
2695
  // TODO: If we have attributes and the schemaDefined flag is not set, turn it on
1817
2696
  // iterate through the attributes to ensure that we have all the dbis created and indexed
1818
2697
  for (const attribute of attributes || []) {
1819
- if (attribute.relationship || attribute.computed) {
1820
- hasChanges = true; // need to update the table so the computed properties are translated to property resolvers
1821
- if (attribute.relationship) continue;
2698
+ if (attribute.relationship) {
2699
+ refreshRelationshipAttributes = true;
2700
+ continue;
1822
2701
  }
2702
+ if (attribute.computed) hasChanges = true;
1823
2703
  let dbiKey = tableName + '/' + (attribute.name || '');
1824
2704
  Object.defineProperty(attribute, 'key', { value: dbiKey, configurable: true });
1825
2705
  let attributeDescriptor = attributesDbi.getSync(dbiKey);
1826
2706
  if (attribute.isPrimaryKey) {
2707
+ if (deferredPrimaryRow) continue;
1827
2708
  attributeDescriptor = attributeDescriptor || attributesDbi.getSync((dbiKey = tableName + '/')) || {};
1828
2709
  // Persist schemaDefined when the explicit live value disagrees with disk. Without this,
1829
2710
  // a stale `false` (from a v4-era write or replicated event) survives every reload: the
1830
2711
  // in-memory re-assert in the existing-Table branch only fixes the worker that ran @table,
1831
- // but other workers' next disk-load re-reads the stale value.
2712
+ // but other workers' next disk-load re-reads the stale value. The whole settings update is
2713
+ // gated off for cluster-origin callers: their values come from this worker's (possibly
2714
+ // stale) snapshot, so a rewrite could revert a newer local declaration already on disk.
1832
2715
  const schemaDefinedMismatch = schemaDefinedExplicit && attributeDescriptor.schemaDefined !== schemaDefined;
1833
2716
  // primary key can't change indexing, but settings can change
1834
2717
  if (
1835
- schemaDefinedMismatch ||
1836
- (audit !== undefined && audit !== Table.audit) ||
1837
- (sealed !== undefined && sealed !== Table.sealed) ||
1838
- (replicate !== undefined && replicate !== Table.replicate) ||
1839
- (+expiration || undefined) !== (+attributeDescriptor.expiration || undefined) ||
1840
- (+eviction || undefined) !== (+attributeDescriptor.eviction || undefined) ||
1841
- attribute.type !== attributeDescriptor.type
2718
+ origin !== 'cluster' &&
2719
+ (schemaDefinedMismatch ||
2720
+ (audit !== undefined && audit !== Table.audit) ||
2721
+ (sealed !== undefined && sealed !== Table.sealed) ||
2722
+ (replicate !== undefined && replicate !== Table.replicate) ||
2723
+ (+expiration || undefined) !== (+attributeDescriptor.expiration || undefined) ||
2724
+ (+eviction || undefined) !== (+attributeDescriptor.eviction || undefined) ||
2725
+ attribute.type !== attributeDescriptor.type)
1842
2726
  ) {
1843
- const updatedPrimaryAttribute = { ...attributeDescriptor };
2727
+ exclusiveLock();
2728
+ const currentPrimaryAttribute = attributesDbi.getSync(dbiKey);
2729
+ if (!currentPrimaryAttribute || tableIsDropping(currentPrimaryAttribute, dbiKey)) continue;
2730
+ const updatedPrimaryAttribute = { ...currentPrimaryAttribute };
1844
2731
  if (typeof audit === 'boolean') {
1845
2732
  if (audit) Table.enableAuditing();
1846
2733
  updatedPrimaryAttribute.audit = audit;
@@ -1852,15 +2739,54 @@ export function table<TableResourceType>(tableDefinition: TableDefinition): Tabl
1852
2739
  if (attribute.type) updatedPrimaryAttribute.type = attribute.type;
1853
2740
  if (schemaDefinedMismatch) updatedPrimaryAttribute.schemaDefined = schemaDefined;
1854
2741
  hasChanges = true; // send out notification of the change
1855
- exclusiveLock();
1856
2742
  attributesDbi.put(dbiKey, updatedPrimaryAttribute);
1857
2743
  }
1858
2744
 
1859
2745
  continue;
1860
2746
  }
1861
2747
 
1862
- // note that non-indexed attributes do not need a dbi
1863
2748
  if (attributeDescriptor?.attribute && !attributeDescriptor.name) attributeDescriptor.indexed = true; // legacy descriptor
2749
+
2750
+ if (origin === 'cluster' && attributeDescriptor) {
2751
+ // An existing descriptor is a local declaration this caller may not have seen yet, so it wins
2752
+ // over the incoming definition and is never written back from it.
2753
+ applyDurableDeclaration(attribute, attributeDescriptor);
2754
+ const abandonedIndexBuild =
2755
+ attribute.indexed &&
2756
+ (attributeDescriptor.indexingFailed ||
2757
+ (attributeDescriptor.indexingPID && attributeDescriptor.indexingPID !== process.pid) ||
2758
+ attributeDescriptor.restartNumber < (workerData?.restartNumber ?? manageThreads.restartNumber));
2759
+ if (abandonedIndexBuild) {
2760
+ // Recovery is the exception to skipping the handling below, because without it `isIndexing`
2761
+ // stays pinned on with nothing left to clear it and every query on the attribute fails with
2762
+ // IndexRebuildingError for the life of the worker. It persists the attribute (here and again
2763
+ // from runIndexing), so restate the declaration from a descriptor read under the lock.
2764
+ exclusiveLock();
2765
+ applyDurableDeclaration(attribute, attributesDbi.getSync(dbiKey) ?? attributeDescriptor);
2766
+ } else {
2767
+ if (attribute.indexed) {
2768
+ const dbi = openIndex(dbiKey, rootStore, attribute);
2769
+ target.adopt(dbi);
2770
+ // Persisting the indexFormat openIndex just resolved adds a field the descriptor lacks
2771
+ // rather than rewriting one it has. Without it an empty index resolves 'versioned', writes
2772
+ // versioned nodes, then re-derives 'legacy' on the next load — see indexFormatNeedsPersist.
2773
+ if (attribute.indexFormat != null && attributeDescriptor.indexFormat == null) {
2774
+ exclusiveLock();
2775
+ const durableDescriptor = attributesDbi.getSync(dbiKey);
2776
+ if (durableDescriptor && durableDescriptor.indexFormat == null) {
2777
+ hasChanges = true;
2778
+ attributesDbi.put(dbiKey, { ...durableDescriptor, indexFormat: attribute.indexFormat });
2779
+ }
2780
+ }
2781
+ if (attributeDescriptor.indexingPID) dbi.isIndexing = true;
2782
+ dbi.indexNulls = attribute.indexNulls;
2783
+ indices[attribute.name] = dbi;
2784
+ }
2785
+ continue;
2786
+ }
2787
+ }
2788
+
2789
+ // note that non-indexed attributes do not need a dbi
1864
2790
  // Some index options affect only search, not the stored structure (e.g. HNSW's
1865
2791
  // efConstructionSearch). Changing those should persist the new metadata but NOT trigger a
1866
2792
  // reindex. A custom index declares such keys via a static `searchOnlyOptions`.
@@ -1904,6 +2830,8 @@ export function table<TableResourceType>(tableDefinition: TableDefinition): Tabl
1904
2830
  // on the main thread, where workerData is undefined (and it is initialized to 1).
1905
2831
  const currentRestartGeneration = workerData?.restartNumber ?? manageThreads.restartNumber;
1906
2832
  const dbi = openIndex(dbiKey, rootStore, attribute);
2833
+ target.adopt(dbi);
2834
+ if (deferredPrimaryRow) indices[attribute.name] = dbi; // private until published; lets the rollback close it
1907
2835
  // openIndex resolves and stamps attribute.indexFormat for a versioned-capable (RocksDB
1908
2836
  // custom-object) index. An index created before this field existed has no indexFormat on
1909
2837
  // disk; persist the resolved value now — even when nothing else changed — so the format is
@@ -1917,7 +2845,8 @@ export function table<TableResourceType>(tableDefinition: TableDefinition): Tabl
1917
2845
  changed ||
1918
2846
  indexFormatNeedsPersist ||
1919
2847
  attributeDescriptor?.indexingFailed ||
1920
- isAbandonedIndexBuild(attributeDescriptor, currentRestartGeneration)
2848
+ (attributeDescriptor?.indexingPID && attributeDescriptor?.indexingPID !== process.pid) ||
2849
+ attributeDescriptor?.restartNumber < currentRestartGeneration
1921
2850
  ) {
1922
2851
  hasChanges = true;
1923
2852
  exclusiveLock();
@@ -1925,7 +2854,8 @@ export function table<TableResourceType>(tableDefinition: TableDefinition): Tabl
1925
2854
  if (
1926
2855
  structurallyChanged ||
1927
2856
  attributeDescriptor?.indexingFailed ||
1928
- isAbandonedIndexBuild(attributeDescriptor, currentRestartGeneration)
2857
+ (attributeDescriptor?.indexingPID && attributeDescriptor?.indexingPID !== process.pid) ||
2858
+ attributeDescriptor?.restartNumber < currentRestartGeneration
1929
2859
  ) {
1930
2860
  hasChanges = true;
1931
2861
  if (attribute.indexNulls === undefined) attribute.indexNulls = true;
@@ -1945,17 +2875,9 @@ export function table<TableResourceType>(tableDefinition: TableDefinition): Tabl
1945
2875
  // resumes rather than restarts. Canonicalized to match structurallyChanged above.
1946
2876
  const indexOptionsChanged =
1947
2877
  canonicalIndexKey(attributeDescriptor?.indexed) !== canonicalIndexKey(attribute.indexed);
1948
- // Only a checkpoint runIndexing stamped with its own key resumes: earlier releases advanced
1949
- // lastIndexedKey past failed and unflushed index writes, so any other is a full rebuild.
1950
- const uncertifiedCheckpoint =
1951
- attributeDescriptor?.lastIndexedKey !== undefined &&
1952
- (attributeDescriptor.checkpointCertified === undefined ||
1953
- compareKeys(attributeDescriptor.checkpointCertified, attributeDescriptor.lastIndexedKey) !== 0);
1954
- attribute.lastIndexedKey =
1955
- indexOptionsChanged || uncertifiedCheckpoint
1956
- ? undefined
1957
- : (attributeDescriptor?.lastIndexedKey ?? undefined);
1958
- if (attribute.lastIndexedKey !== undefined) attribute.checkpointCertified = attribute.lastIndexedKey;
2878
+ attribute.lastIndexedKey = indexOptionsChanged
2879
+ ? undefined
2880
+ : (attributeDescriptor?.lastIndexedKey ?? undefined);
1959
2881
  // Explicit reindex is the upgrade path from a legacy (un-versioned) custom-index
1960
2882
  // object store to the versioned, VT-cacheable format. A full rebuild from scratch
1961
2883
  // (lastIndexedKey === undefined) clears the store and rewrites every node, so the
@@ -1979,9 +2901,6 @@ export function table<TableResourceType>(tableDefinition: TableDefinition): Tabl
1979
2901
  // the new process reuses the old PID. Cleared on clean completion; left in place
1980
2902
  // on failure/crash so the next, higher-numbered restart re-triggers the backfill.
1981
2903
  attribute.restartNumber = currentRestartGeneration;
1982
- if (manageThreads.processIncarnation != null)
1983
- attribute.indexingIncarnation = manageThreads.processIncarnation;
1984
- attribute.indexingBuildId = randomBytes(8).toString('hex');
1985
2904
  delete attribute.indexingFailed; // clear failure flag for the new run
1986
2905
  dbi.isIndexing = true;
1987
2906
  Object.defineProperty(attribute, 'dbi', { value: dbi, configurable: true, enumerable: false });
@@ -1995,13 +2914,6 @@ export function table<TableResourceType>(tableDefinition: TableDefinition): Tabl
1995
2914
  if (attributeDescriptor?.indexingPID && attributeDescriptor.indexingPID !== process.pid)
1996
2915
  reindexReasons.push(`crash-recovery(pid=${attributeDescriptor.indexingPID})`);
1997
2916
  if (attributeDescriptor?.restartNumber < currentRestartGeneration) reindexReasons.push('restart-number');
1998
- if (uncertifiedCheckpoint) reindexReasons.push('uncertified-checkpoint');
1999
- if (
2000
- attributeDescriptor?.indexingPID === process.pid &&
2001
- manageThreads.processIncarnation != null &&
2002
- attributeDescriptor.indexingIncarnation !== manageThreads.processIncarnation
2003
- )
2004
- reindexReasons.push('abandoned-build(previous process incarnation)');
2005
2917
  logger.info(
2006
2918
  `reindex ${databaseName}.${tableName}.${attribute.name}: reason=${reindexReasons.join(',') || 'unknown'}`
2007
2919
  );
@@ -2015,13 +2927,9 @@ export function table<TableResourceType>(tableDefinition: TableDefinition): Tabl
2015
2927
  // workers / a reload would treat the still-partial index as ready and return incomplete results.
2016
2928
  attribute.indexingPID = attributeDescriptor.indexingPID;
2017
2929
  attribute.lastIndexedKey = attributeDescriptor.lastIndexedKey;
2018
- if (attributeDescriptor.checkpointCertified !== undefined)
2019
- attribute.checkpointCertified = attributeDescriptor.checkpointCertified;
2020
2930
  // Carry the in-progress restart generation too, so persisting this metadata-only
2021
2931
  // change doesn't drop it and break the crash-recovery trigger for the running backfill.
2022
2932
  attribute.restartNumber = attributeDescriptor.restartNumber;
2023
- attribute.indexingIncarnation = attributeDescriptor.indexingIncarnation;
2024
- attribute.indexingBuildId = attributeDescriptor.indexingBuildId;
2025
2933
  if (attributeDescriptor.indexingFailed) attribute.indexingFailed = attributeDescriptor.indexingFailed;
2026
2934
  }
2027
2935
  attributesDbi.put(dbiKey, attribute);
@@ -2040,26 +2948,61 @@ export function table<TableResourceType>(tableDefinition: TableDefinition): Tabl
2040
2948
  attributesDbi.put(dbiKey, attribute);
2041
2949
  }
2042
2950
  }
2951
+ // The primary row is what makes a table loadable, so it lands last: a scan on another thread that
2952
+ // runs mid-create skips the table instead of building (and announcing) a partial one. It already
2953
+ // carries this table's relationships (set on primaryKeyAttribute above), so the persistence block
2954
+ // below is a no-op for a create — a table is never published with an incomplete relationship list.
2955
+ if (deferredPrimaryRow) {
2956
+ attributesDbi.put(tableName + '/', deferredPrimaryRow);
2957
+ // That write, not the registration below, is the publish point: it is durable from here
2958
+ // (on LMDB releaseLock()'s finally commits this create's write transaction even while an
2959
+ // error unwinds), so any later throw must leave the catalog alone. Rolling back past it
2960
+ // would delete the attribute rows out from under a live primary row and leave every
2961
+ // thread loading the primary-only schema this change exists to prevent.
2962
+ published = true;
2963
+ setTable(tables, tableName, Table);
2964
+ }
2965
+ // a table with no declared primary key has no attribute row to carry relationships, and the
2966
+ // loop above never visits its descriptor
2967
+ if (relationshipDefinitions) {
2968
+ const relationshipsKey = primaryDescriptorKey();
2969
+ if (!relationshipListsEqual(attributesDbi.getSync(relationshipsKey)?.relationships, relationshipDefinitions)) {
2970
+ exclusiveLock();
2971
+ const currentPrimaryAttribute = attributesDbi.getSync(relationshipsKey);
2972
+ // a missing row means a concurrent drop completed; writing one back would resurrect the table
2973
+ if (
2974
+ currentPrimaryAttribute &&
2975
+ !tableIsDropping(currentPrimaryAttribute, relationshipsKey) &&
2976
+ !relationshipListsEqual(currentPrimaryAttribute.relationships, relationshipDefinitions)
2977
+ ) {
2978
+ attributesDbi.put(relationshipsKey, { ...currentPrimaryAttribute, relationships: relationshipDefinitions });
2979
+ hasChanges = true;
2980
+ }
2981
+ }
2982
+ }
2983
+ } catch (error) {
2984
+ if (unpublishedPrimaryStore && !published) discardUnpublishedTable();
2985
+ else if (published && tables[tableName] !== Table) discardUnregisteredClass();
2986
+ throw error;
2043
2987
  } finally {
2044
- if (releaseExclusiveLock) releaseExclusiveLock();
2988
+ releaseLock();
2045
2989
  }
2046
- if (hasChanges) {
2990
+ if (hasChanges || refreshRelationshipAttributes) {
2047
2991
  Table.schemaVersion++;
2048
2992
  Table.updatedAttributes();
2049
2993
  }
2050
2994
  logger.trace(`${tableName} table loading, running index`);
2995
+ const branchPath = target.branch?.path;
2051
2996
  if (attributesToIndex.length > 0 || indicesToRemove.length > 0) {
2052
- // captured before the backfill can rewrite the attributes
2053
- const buildIds = new Map(attributesToIndex.map((attribute) => [attribute, attribute.indexingBuildId]));
2054
- const markSettled = () => markAbandonedIndexBuild(Table, rootStore, buildIds);
2055
- Table.indexingOperation = runIndexing(Table, attributesToIndex, indicesToRemove).then(markSettled, markSettled);
2997
+ Table.indexingOperation = runIndexing(Table, attributesToIndex, indicesToRemove, branchPath);
2056
2998
  } else if (hasChanges)
2057
2999
  signalling.signalSchemaChange(
2058
- new SchemaEventMsg(process.pid, 'schema-change', Table.databaseName, Table.tableName)
3000
+ new SchemaEventMsg(process.pid, 'schema-change', Table.databaseName, Table.tableName, undefined, branchPath)
2059
3001
  );
2060
3002
 
2061
3003
  Table.origin = origin;
2062
- if (hasChanges) {
3004
+ // scope-private: replication and other global subscribers must not learn of a branch class
3005
+ if ((hasChanges || refreshRelationshipAttributes) && !target.branch) {
2063
3006
  databaseEventsEmitter.emit('updateTable', Table, origin !== 'cluster');
2064
3007
  }
2065
3008
  if (expiration || eviction || scanInterval)
@@ -2071,14 +3014,64 @@ export function table<TableResourceType>(tableDefinition: TableDefinition): Tabl
2071
3014
  logger.trace(`${tableName} table loaded`);
2072
3015
 
2073
3016
  return Table as TableResourceType;
3017
+ // dropTable() tombstones the bare table row, which is not the row a legacy catalog keeps the
3018
+ // table's settings in, so a drop in flight has to be checked on both.
3019
+ function tableIsDropping(descriptor: any, descriptorKey: string) {
3020
+ if (descriptor?.dropping) return true;
3021
+ return descriptorKey !== tableName + '/' && attributesDbi.getSync(tableName + '/')?.dropping;
3022
+ }
3023
+ // The catalog row initStores() reads a table's settings from: the primary key's own row when it
3024
+ // has one, and the bare table row otherwise.
3025
+ function primaryDescriptorKey() {
3026
+ const declaredPrimaryKey = attributes?.find((attribute) => attribute.isPrimaryKey)?.name;
3027
+ if (declaredPrimaryKey) {
3028
+ const attributeKey = tableName + '/' + declaredPrimaryKey;
3029
+ if (attributesDbi.getSync(attributeKey)) return attributeKey;
3030
+ }
3031
+ return tableName + '/';
3032
+ }
3033
+ // The catalog of a published table stays, but a class the registration never accepted is
3034
+ // unreachable, so release what makeTable() registered process-wide instead of leaving its timers
3035
+ // and reclamation handler live for the process. The stores stay open: the table is durable, and
3036
+ // whichever scan reloads it opens its own handles.
3037
+ function discardUnregisteredClass() {
3038
+ try {
3039
+ Table.cleanup();
3040
+ } catch (discardError) {
3041
+ logger.warn(`Error releasing the unregistered class of ${databaseName}.${tableName}`, discardError);
3042
+ }
3043
+ }
3044
+ function discardUnpublishedTable() {
3045
+ const discard = (description: string, action: () => unknown) => {
3046
+ try {
3047
+ action();
3048
+ } catch (discardError) {
3049
+ logger.warn(
3050
+ `Error discarding ${description} of the failed create of ${databaseName}.${tableName}`,
3051
+ discardError
3052
+ );
3053
+ }
3054
+ };
3055
+ discard('catalog rows', () => {
3056
+ for (const attribute of attributes) {
3057
+ if (!attribute.isPrimaryKey && !attribute.relationship) attributesDbi.remove(tableName + '/' + attribute.name);
3058
+ }
3059
+ });
3060
+ if (Table) discard('callbacks', () => Table.cleanup());
3061
+ // an LMDB store is a per-environment handle slot shared with every thread and still inside this
3062
+ // create's write transaction; only RocksDB column-family handles hold native state to release
3063
+ if (rootStore instanceof RocksDatabase) {
3064
+ for (const indexName in Table?.indices ?? {})
3065
+ discard(`index ${indexName}`, () => Table.indices[indexName].close());
3066
+ discard('primary store', () => unpublishedPrimaryStore.close());
3067
+ }
3068
+ }
2074
3069
  // Acquire an exclusive lock for attribute updates
2075
3070
  function exclusiveLock() {
2076
3071
  if (releaseExclusiveLock) return;
2077
3072
  if (rootStore instanceof RocksDatabase) {
2078
- while (!rootStore.tryLock('update-attributes')) {} // use a spin lock, we really need an synchronous exclusive lock here
2079
- releaseExclusiveLock = () => {
2080
- rootStore.unlock('update-attributes');
2081
- };
3073
+ acquireUpdateAttributesLock(rootStore, `table '${databaseName}.${tableName}'`);
3074
+ releaseExclusiveLock = () => releaseUpdateAttributesLock(rootStore);
2082
3075
  } else {
2083
3076
  // we only need an exclusive transaction lock in lmdb
2084
3077
  rootStore.transactionSync(() => {
@@ -2090,6 +3083,13 @@ export function table<TableResourceType>(tableDefinition: TableDefinition): Tabl
2090
3083
  });
2091
3084
  }
2092
3085
  }
3086
+ // idempotent: the early release before the recursive reload and the finally both run, and a
3087
+ // second unlock could release another thread's lock
3088
+ function releaseLock() {
3089
+ const release = releaseExclusiveLock;
3090
+ releaseExclusiveLock = undefined;
3091
+ if (release) release();
3092
+ }
2093
3093
  }
2094
3094
  /**
2095
3095
  * Canonical form used ONLY for the structural (reindex-triggering) comparison of index options.
@@ -2124,157 +3124,29 @@ export function canonicalizeIndexOptions(value: any): any {
2124
3124
  }
2125
3125
  const MAX_OUTSTANDING_INDEXING = 1000;
2126
3126
  const MIN_OUTSTANDING_INDEXING = 10;
2127
- const INDEXING_YIELD_INTERVAL = 100;
2128
- // A resumable checkpoint is written only after a flush (see flushIndexStores), at most once per period
2129
- // and never before this many more records: the flush seals every column family in the database, so a
2130
- // slow backfill must not impose the period's flush rate on unrelated tables.
2131
- let indexingCheckpointPeriodMs = 5000;
2132
- let indexingCheckpointMinRecords = 10000;
2133
- export function setIndexingCheckpointPeriod(ms: number, minRecords = indexingCheckpointMinRecords) {
2134
- const previous = { ms: indexingCheckpointPeriodMs, minRecords: indexingCheckpointMinRecords };
2135
- indexingCheckpointPeriodMs = ms;
2136
- indexingCheckpointMinRecords = minRecords;
2137
- return previous;
2138
- }
2139
- const yieldEventTurn = () => new Promise((resolve) => setImmediate(resolve));
2140
- // RocksDB index stores have no WAL (openRocksDatabase defaults disableWAL), so a flush is what makes the
2141
- // entries a checkpoint certifies durable. A flush only covers writes issued before it started, so a caller
2142
- // never joins one in flight: it joins the next one, which every backfill on that database asking meanwhile
2143
- // shares — at most one in flight and one queued.
2144
- const indexingFlushes = new WeakMap<object, { inFlight?: Promise<void>; queued?: Promise<void> }>();
2145
- function flushIndexStores(rootStore: any): Promise<void> | undefined {
2146
- if (!(rootStore instanceof RocksDatabase)) return;
2147
- let flushes = indexingFlushes.get(rootStore);
2148
- if (!flushes) indexingFlushes.set(rootStore, (flushes = {}));
2149
- if (flushes.queued) return flushes.queued;
2150
- const start = () => {
2151
- flushes.queued = undefined;
2152
- const flush = rootStore.flush().finally(() => {
2153
- if (flushes.inFlight === flush) flushes.inFlight = undefined;
2154
- });
2155
- flushes.inFlight = flush;
2156
- return flush;
2157
- };
2158
- if (!flushes.inFlight) return start();
2159
- return (flushes.queued = flushes.inFlight.then(start, start));
2160
- }
2161
- export function resumeStartKey(attributes: { lastIndexedKey?: any }[]): any {
2162
- let start: any;
2163
- for (const attribute of attributes) {
2164
- if (attribute.lastIndexedKey == undefined) return undefined;
2165
- if (start === undefined || compareKeys(attribute.lastIndexedKey, start) < 0) start = attribute.lastIndexedKey;
2166
- }
2167
- return start;
2168
- }
2169
-
2170
- // Bounded, unlike the exclusiveLock() spin above: that one runs on the declaring path, where the
2171
- // caller is waiting on the result and there is nothing useful to do without the lock. This one runs
2172
- // after a backfill has already settled, so a holder that never releases would wedge the worker's
2173
- // event loop for nothing. Giving up costs only the marker, and the next load of the table
2174
- // re-triggers the build regardless.
2175
- export const ABANDONED_MARK_LOCK_TIMEOUT = 10000;
2176
- const abandonedMarkLockWait = new Int32Array(new SharedArrayBuffer(4));
2177
- // `timeout` is the test seam; production callers take the default.
2178
- export function tryAcquireUpdateAttributesLock(
2179
- rootStore: RocksDatabase,
2180
- timeout = ABANDONED_MARK_LOCK_TIMEOUT
2181
- ): boolean {
2182
- if (rootStore.tryLock('update-attributes')) return true;
2183
- const startTime = performance.now();
2184
- let waitTime = 1;
2185
- while (!rootStore.tryLock('update-attributes')) {
2186
- const elapsed = performance.now() - startTime;
2187
- if (elapsed >= timeout) return false;
2188
- // Atomics.wait rather than a busy spin: the section this guards is synchronous, so the wait
2189
- // blocks this thread either way, but sleeping does not burn a core while it does.
2190
- if (elapsed >= 2) {
2191
- Atomics.wait(abandonedMarkLockWait, 0, 0, Math.min(waitTime, timeout - elapsed));
2192
- if (waitTime < 16) waitTime *= 2;
2193
- }
2194
- }
2195
- return true;
2196
- }
2197
-
2198
- /**
2199
- * Persists the failure marker for a build that ended without running one of runIndexing's own exit
2200
- * paths, so something re-triggers it. Fenced on `indexingBuildId` inside the storage engine's catalog
2201
- * serialization boundary, because a replacement generation (or another thread declaring different index
2202
- * options) can claim the attribute before an outgoing build's promise settles, and marking that would fail
2203
- * a live build. The fence read and write stay synchronous, and nothing here may throw because
2204
- * `Table.indexingOperation` reaches operations-API callers.
2205
- */
2206
- async function markAbandonedIndexBuild(Table, rootStore, buildIds: Map<any, string>) {
2207
- for (const [attribute, buildId] of buildIds) {
2208
- try {
2209
- let marked;
2210
- if (buildId == null || Table.dbisDB.getSync(attribute.key)?.indexingBuildId !== buildId) continue;
2211
- const markIfOwned = () => {
2212
- const descriptor = Table.dbisDB.getSync(attribute.key);
2213
- if (descriptor?.indexingBuildId === buildId && !descriptor.indexingFailed) {
2214
- Table.dbisDB.putSync(attribute.key, { ...descriptor, indexingFailed: true });
2215
- marked = true;
2216
- }
2217
- };
2218
- if (rootStore instanceof RocksDatabase) {
2219
- if (!tryAcquireUpdateAttributesLock(rootStore)) {
2220
- logger.warn(
2221
- `Could not mark the abandoned index build of ${Table.databaseName}.${Table.tableName}.${attribute.name}: ` +
2222
- `timed out after ${ABANDONED_MARK_LOCK_TIMEOUT}ms waiting for the exclusive 'update-attributes' lock. ` +
2223
- `The index stays incomplete and the next load of the table re-triggers the backfill.`
2224
- );
2225
- continue;
2226
- }
2227
- try {
2228
- markIfOwned();
2229
- } finally {
2230
- rootStore.unlock('update-attributes');
2231
- }
2232
- } else {
2233
- rootStore.transactionSync(markIfOwned);
2234
- }
2235
- if (marked)
2236
- logger.warn(
2237
- `Indexing of ${Table.databaseName}.${Table.tableName}.${attribute.name} ended without completing. ` +
2238
- `The index stays incomplete and every query on the attribute reports it as not indexed yet; ` +
2239
- `the next load of the table retries the backfill from the last checkpoint (indexingFailed=true).`
2240
- );
2241
- } catch (error) {
2242
- // A store closed by shutdown is the common case, and it cannot be written to at all.
2243
- try {
2244
- logger.debug(`Could not mark the abandoned index build of ${Table.tableName}.${attribute.name}`, error);
2245
- } catch {}
2246
- }
2247
- }
2248
- }
2249
- async function runIndexing(Table, attributes, indicesToRemove) {
2250
- let checkpointing;
2251
- let hadIndexingErrors = false;
2252
- const attributeErrorReported = {};
2253
- const onIndexPutRejected = (property, error) => {
2254
- hadIndexingErrors = true;
2255
- if (attributeErrorReported[property]) return;
2256
- attributeErrorReported[property] = true;
2257
- logger.error(`Error indexing attribute ${property}`, error);
2258
- };
2259
- const putRejectionHandlers = attributes.map((attribute) => (error) => onIndexPutRejected(attribute.name, error));
3127
+ async function runIndexing(Table, attributes, indicesToRemove, branchPath?: string) {
2260
3128
  try {
2261
3129
  logger.info(`Indexing ${Table.tableName} attributes`, attributes);
2262
3130
  await signalling.signalSchemaChange(
2263
- new SchemaEventMsg(process.pid, 'schema-change', Table.databaseName, Table.tableName)
3131
+ new SchemaEventMsg(process.pid, 'schema-change', Table.databaseName, Table.tableName, undefined, branchPath)
2264
3132
  );
2265
3133
  let lastResolution;
2266
3134
  for (const index of indicesToRemove) {
2267
3135
  lastResolution = index.drop();
2268
- if (lastResolution?.then) lastResolution.then(undefined, (error) => onIndexPutRejected(index.name, error));
2269
3136
  }
2270
3137
  let interrupted;
3138
+ let hadIndexingErrors = false;
3139
+ const attributeErrorReported = {};
2271
3140
  let indexed = 0;
2272
3141
  const attributesLength = attributes.length;
2273
3142
  await new Promise((resolve) => setImmediate(resolve)); // yield event turn, indexing should consistently take at least one event turn
2274
3143
  if (attributesLength > 0) {
2275
- const start = resumeStartKey(attributes);
2276
- if (start === undefined) {
2277
- for (const attribute of attributes) {
3144
+ let start: any;
3145
+ for (const attribute of attributes) {
3146
+ // if we are resuming, we need to start from the last key we indexed by all attributes
3147
+ if (compareKeys(attribute.lastIndexedKey, start) < 0) start = attribute.lastIndexedKey;
3148
+ if (attribute.lastIndexedKey == undefined) {
3149
+ // if we are starting from the beginning, clear out any previous index entries since we are rewriting
2278
3150
  if (attribute.dbi.clearAsync) {
2279
3151
  // LMDB, note that we don't need to wait for this to complete, just gets enqueued in front of the other writes
2280
3152
  attribute.dbi.clearAsync();
@@ -2284,26 +3156,6 @@ async function runIndexing(Table, attributes, indicesToRemove) {
2284
3156
  }
2285
3157
  }
2286
3158
  let outstanding = 0;
2287
- // A resumed scan starts at the checkpoint, so it must only name a key whose every predecessor is
2288
- // durably indexed: persisted once the writes it covers have settled and flushed, frozen after any
2289
- // record fails so the retry re-covers it, and stamped with its own key (see the trigger in table()).
2290
- const persistCheckpoint = async (key) => {
2291
- if (hadIndexingErrors) return;
2292
- try {
2293
- await flushIndexStores(Table.primaryStore.rootStore);
2294
- const puts = [];
2295
- for (const attribute of attributes) {
2296
- attribute.lastIndexedKey = key;
2297
- attribute.checkpointCertified = key;
2298
- puts.push(Table.dbisDB.put(attribute.key, attribute));
2299
- }
2300
- await Promise.all(puts);
2301
- } catch (error) {
2302
- logger.warn(`Could not persist the indexing checkpoint for ${Table.tableName}`, error);
2303
- }
2304
- };
2305
- let nextCheckpointAt = performance.now() + indexingCheckpointPeriodMs;
2306
- let nextCheckpointRecord = indexingCheckpointMinRecords;
2307
3159
  // this means that a new attribute has been introduced that needs to be indexed
2308
3160
  for (const { key, value: record } of Table.primaryStore.getRange({
2309
3161
  start,
@@ -2311,7 +3163,7 @@ async function runIndexing(Table, attributes, indicesToRemove) {
2311
3163
  versions: true,
2312
3164
  snapshot: false, // don't hold a read transaction this whole time
2313
3165
  })) {
2314
- const atInterval = ++indexed % INDEXING_YIELD_INTERVAL === 0;
3166
+ if (!record) continue; // deletion entry
2315
3167
  // TODO: Do we ever need to interrupt due to a schema change that was not a restart?
2316
3168
  //if (Table.schemaVersion !== schemaVersion) return; // break out if there are any schema changes and let someone else pick it up
2317
3169
  outstanding++;
@@ -2323,77 +3175,71 @@ async function runIndexing(Table, attributes, indicesToRemove) {
2323
3175
  // we index, that's fine because indexing is idempotent, we can just put the same values again. If it changes
2324
3176
  // during the indexing, the indexing here will fail. This is also fine because it means the other thread will have
2325
3177
  // performed indexing and we don't need to do anything further
2326
- if (record) {
2327
- for (let i = 0; i < attributesLength; i++) {
2328
- const attribute = attributes[i];
2329
- const property = attribute.name;
2330
- const index = attribute.dbi;
2331
- const onPutRejected = putRejectionHandlers[i];
2332
- try {
2333
- const resolver = attribute.resolve;
2334
- const value = record && (resolver ? resolver(record) : record[property]);
2335
- if (index.customIndex) {
2336
- index.customIndex.index(key, value);
2337
- didSynchronousIndexing = true;
2338
- continue;
2339
- }
2340
- const values = getIndexedValues(value, index.indexNulls);
2341
- if (values) {
2342
- for (let i = 0, l = values.length; i < l; i++) {
2343
- lastResolution = index.put(values[i], key);
2344
- if (lastResolution?.then) lastResolution.then(undefined, onPutRejected);
2345
- }
2346
- }
2347
- } catch (error) {
2348
- hadIndexingErrors = true;
2349
- if (!attributeErrorReported[property]) {
2350
- // just report an indexing error once per attribute so we don't spam the logs.
2351
- // A store closed by worker shutdown surfaces here as "Database not open"; that is
2352
- // a benign interruption (the next generation re-runs the backfill), so don't log
2353
- // it as an error — the outer catch returns quietly once the iterator also throws.
2354
- attributeErrorReported[property] = true;
2355
- if (Table.primaryStore?.rootStore?.status === 'closed')
2356
- logger.debug(`Indexing attribute ${property} interrupted by store shutdown`, error);
2357
- else logger.error(`Error indexing attribute ${property}`, error);
3178
+ for (let i = 0; i < attributesLength; i++) {
3179
+ const attribute = attributes[i];
3180
+ const property = attribute.name;
3181
+ const index = attribute.dbi;
3182
+ try {
3183
+ const resolver = attribute.resolve;
3184
+ const value = record && (resolver ? resolver(record) : record[property]);
3185
+ if (index.customIndex) {
3186
+ index.customIndex.index(key, value);
3187
+ didSynchronousIndexing = true;
3188
+ continue;
3189
+ }
3190
+ const values = getIndexedValues(value, index.indexNulls);
3191
+ if (values) {
3192
+ for (let i = 0, l = values.length; i < l; i++) {
3193
+ lastResolution = index.put(values[i], key);
2358
3194
  }
2359
3195
  }
3196
+ } catch (error) {
3197
+ hadIndexingErrors = true;
3198
+ if (!attributeErrorReported[property]) {
3199
+ // just report an indexing error once per attribute so we don't spam the logs.
3200
+ // A store closed by worker shutdown surfaces here as "Database not open"; that is
3201
+ // a benign interruption (the next generation re-runs the backfill), so don't log
3202
+ // it as an error — the outer catch returns quietly once the iterator also throws.
3203
+ attributeErrorReported[property] = true;
3204
+ if (Table.primaryStore?.rootStore?.status === 'closed')
3205
+ logger.debug(`Indexing attribute ${property} interrupted by store shutdown`, error);
3206
+ else logger.error(`Error indexing attribute ${property}`, error);
3207
+ }
2360
3208
  }
2361
3209
  }
2362
3210
  when(
2363
3211
  lastResolution,
2364
3212
  () => outstanding--,
2365
- () => outstanding--
3213
+ (error) => {
3214
+ outstanding--;
3215
+ hadIndexingErrors = true;
3216
+ logger.error(error);
3217
+ }
2366
3218
  );
2367
3219
  if (workerData && workerData.restartNumber !== manageThreads.restartNumber) {
2368
3220
  interrupted = true;
2369
3221
  }
2370
- if (interrupted) {
2371
- try {
2372
- await lastResolution;
2373
- } catch {
2374
- // already counted and logged by the rejection handler above
3222
+ if (++indexed % 100 === 0 || interrupted) {
3223
+ // occasionally update our progress so if we crash, we can resume
3224
+ for (const attribute of attributes) {
3225
+ attribute.lastIndexedKey = key;
3226
+ Table.dbisDB.put(attribute.key, attribute);
2375
3227
  }
2376
- await checkpointing;
2377
- await persistCheckpoint(key);
2378
- return;
2379
- }
2380
- if (atInterval && indexed >= nextCheckpointRecord && performance.now() >= nextCheckpointAt) {
2381
- nextCheckpointAt = performance.now() + indexingCheckpointPeriodMs;
2382
- nextCheckpointRecord = indexed + indexingCheckpointMinRecords;
2383
- await checkpointing;
2384
- checkpointing = when(
2385
- lastResolution,
2386
- () => persistCheckpoint(key),
2387
- () => {}
2388
- );
3228
+ if (interrupted) return;
2389
3229
  }
2390
3230
  if (outstanding > MAX_OUTSTANDING_INDEXING) await lastResolution;
2391
- if (atInterval || didSynchronousIndexing || outstanding > MIN_OUTSTANDING_INDEXING) await yieldEventTurn();
3231
+ else if (outstanding > MIN_OUTSTANDING_INDEXING)
3232
+ await new Promise((resolve) => setImmediate(resolve)); // yield event turn, don't want to use all computation
3233
+ else if (didSynchronousIndexing) await new Promise((resolve) => setImmediate(resolve)); // custom indexes (e.g. HNSW) index synchronously and never raise `outstanding`; without this yield a large backfill runs in a single event-loop turn, starving keepalive/replication and queries and never letting the isIndexing flag be observed
2392
3234
  }
2393
3235
  }
2394
- await checkpointing;
2395
- // Await the last pending put. If it rejects, that is also an indexing error (already counted by
2396
- // onIndexPutRejected); catching it here keeps it from escaping to the outer catch.
3236
+ // Await the last pending put. If it rejects, that is also an indexing error.
3237
+ // Note: the when() calls above already attach rejection handlers to each record's
3238
+ // last-put promise; this try-catch specifically handles the case where lastResolution
3239
+ // itself rejects (i.e. the very last put in the loop failed) which would otherwise
3240
+ // throw past the hadIndexingErrors check to the outer catch. The broader issue of
3241
+ // unhandled rejections from non-last puts in multi-value attributes is pre-existing
3242
+ // and out of scope for this fix.
2397
3243
  try {
2398
3244
  await lastResolution;
2399
3245
  } catch (error) {
@@ -2404,16 +3250,6 @@ async function runIndexing(Table, attributes, indicesToRemove) {
2404
3250
  // microtasks when their tracked promise settles) have a chance to set hadIndexingErrors
2405
3251
  // before we decide whether to mark indexing as complete.
2406
3252
  await new Promise((resolve) => setImmediate(resolve));
2407
- // the tail since the last checkpoint is not durable until flushed; announcing the index complete
2408
- // before that would outlive a crash that loses it
2409
- if (!hadIndexingErrors) {
2410
- try {
2411
- await flushIndexStores(Table.primaryStore.rootStore);
2412
- } catch (error) {
2413
- hadIndexingErrors = true;
2414
- logger.error(`Could not flush the indexes of ${Table.tableName} before marking them complete`, error);
2415
- }
2416
- }
2417
3253
  if (hadIndexingErrors) {
2418
3254
  // Some records failed to index. Persist the failure marker in the descriptor so
2419
3255
  // the next call to table() (including after a restart with a fresh PID) re-triggers
@@ -2442,12 +3278,9 @@ async function runIndexing(Table, attributes, indicesToRemove) {
2442
3278
  // update the attributes to indicate that we are finished
2443
3279
  for (const attribute of attributes) {
2444
3280
  delete attribute.lastIndexedKey;
2445
- delete attribute.checkpointCertified;
2446
3281
  delete attribute.indexingPID;
2447
3282
  delete attribute.indexingFailed;
2448
3283
  delete attribute.restartNumber;
2449
- delete attribute.indexingIncarnation;
2450
- delete attribute.indexingBuildId;
2451
3284
  attribute.dbi.isIndexing = false;
2452
3285
  // Also clear isIndexing on the currently-active dbi in Table.indices, which may
2453
3286
  // differ from attribute.dbi if a resetDatabases() call during this migration
@@ -2459,12 +3292,11 @@ async function runIndexing(Table, attributes, indicesToRemove) {
2459
3292
  await lastResolution;
2460
3293
  // now notify all the threads that we are done and the index is ready to use
2461
3294
  await signalling.signalSchemaChange(
2462
- new SchemaEventMsg(process.pid, 'indexing-finished', Table.databaseName, Table.tableName)
3295
+ new SchemaEventMsg(process.pid, 'indexing-finished', Table.databaseName, Table.tableName, undefined, branchPath)
2463
3296
  );
2464
3297
  logger.info(`Finished indexing ${Table.tableName} attributes`, attributes);
2465
3298
  }
2466
3299
  } catch (error) {
2467
- await checkpointing;
2468
3300
  // A worker shutting down closes its stores mid-backfill, so the range iterator or a
2469
3301
  // put throws (e.g. "Database not open" / "Iterator not initialized"). This is an
2470
3302
  // interruption, not a data error: the next worker generation re-runs the backfill via