@harperfast/harper 5.2.13 → 5.3.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (655) hide show
  1. package/agent/mcpTools.ts +1 -1
  2. package/agent/session.ts +25 -14
  3. package/bin/cliOperations.ts +58 -9
  4. package/bin/copyDb.ts +282 -59
  5. package/bin/deploySetup.ts +16 -5
  6. package/bin/harper.ts +1 -1
  7. package/bin/help.ts +4 -1
  8. package/bin/lite.ts +4 -1
  9. package/bin/restart.ts +120 -4
  10. package/bin/run.ts +6 -11
  11. package/bin/upgrade.js +7 -3
  12. package/bin/workloadIdentity.ts +119 -0
  13. package/components/Application.ts +3436 -242
  14. package/components/ApplicationScope.ts +8 -0
  15. package/components/EntryHandler.ts +59 -39
  16. package/components/OptionsWatcher.ts +440 -98
  17. package/components/RuntimeModuleTracker.ts +38 -7
  18. package/components/Scope.ts +56 -15
  19. package/components/awaitRestart.ts +84 -0
  20. package/components/componentLoader.ts +381 -32
  21. package/components/componentPreparationLock.ts +16 -5
  22. package/components/deploymentOperations.ts +4 -1
  23. package/components/deploymentRecorder.ts +9 -2
  24. package/components/mcp/adapters/harperHttp.ts +4 -0
  25. package/components/mcp/listChanged.ts +4 -0
  26. package/components/mcp/toolRegistry.ts +2 -0
  27. package/components/mcp/tools/operations.ts +9 -0
  28. package/components/mcp/tools/schemas/operationDescriptions.ts +2 -2
  29. package/components/operations.js +537 -113
  30. package/components/operationsValidation.js +98 -3
  31. package/components/packageComponent.ts +25 -1
  32. package/components/requestRestart.ts +11 -0
  33. package/components/status/ComponentStatusRegistry.ts +59 -0
  34. package/config/RootConfigWatcher.ts +240 -40
  35. package/config/configReadRetry.ts +62 -0
  36. package/config/configUtils.ts +357 -48
  37. package/config/harperConfigEnvVars.ts +170 -27
  38. package/config/parseConfigFile.ts +34 -0
  39. package/config/readConfigFileSync.ts +44 -0
  40. package/config/watcherArming.ts +59 -0
  41. package/config-root.schema.json +33 -0
  42. package/dataLayer/blobBackup.ts +160 -50
  43. package/dataLayer/delete.ts +6 -1
  44. package/dataLayer/harperBridge/ResourceBridge.ts +80 -10
  45. package/dataLayer/harperBridge/lmdbBridge/lmdbMethods/DeleteAuditLogsBeforeResults.js +3 -1
  46. package/dataLayer/hdbInfoController.ts +34 -1
  47. package/dataLayer/insert.ts +44 -1
  48. package/dataLayer/rocksdbBackup.ts +53 -10
  49. package/dataLayer/schema.ts +11 -1
  50. package/dataLayer/schemaDescribe.ts +8 -1
  51. package/dist/agent/mcpTools.js +1 -1
  52. package/dist/agent/mcpTools.js.map +1 -1
  53. package/dist/agent/session.d.ts +22 -0
  54. package/dist/agent/session.js +26 -15
  55. package/dist/agent/session.js.map +1 -1
  56. package/dist/bin/cliOperations.js +61 -9
  57. package/dist/bin/cliOperations.js.map +1 -1
  58. package/dist/bin/copyDb.d.ts +12 -1
  59. package/dist/bin/copyDb.js +248 -60
  60. package/dist/bin/copyDb.js.map +1 -1
  61. package/dist/bin/deploySetup.d.ts +2 -0
  62. package/dist/bin/deploySetup.js +11 -3
  63. package/dist/bin/deploySetup.js.map +1 -1
  64. package/dist/bin/harper.js +1 -1
  65. package/dist/bin/harper.js.map +1 -1
  66. package/dist/bin/help.js +4 -1
  67. package/dist/bin/help.js.map +1 -1
  68. package/dist/bin/lite.js +4 -1
  69. package/dist/bin/lite.js.map +1 -1
  70. package/dist/bin/restart.js +91 -6
  71. package/dist/bin/restart.js.map +1 -1
  72. package/dist/bin/run.js +4 -10
  73. package/dist/bin/run.js.map +1 -1
  74. package/dist/bin/upgrade.js +4 -3
  75. package/dist/bin/upgrade.js.map +1 -1
  76. package/dist/bin/workloadIdentity.d.ts +18 -0
  77. package/dist/bin/workloadIdentity.js +100 -0
  78. package/dist/bin/workloadIdentity.js.map +1 -0
  79. package/dist/components/Application.d.ts +235 -17
  80. package/dist/components/Application.js +3038 -238
  81. package/dist/components/Application.js.map +1 -1
  82. package/dist/components/ApplicationScope.d.ts +8 -0
  83. package/dist/components/ApplicationScope.js +7 -0
  84. package/dist/components/ApplicationScope.js.map +1 -1
  85. package/dist/components/EntryHandler.js +26 -10
  86. package/dist/components/EntryHandler.js.map +1 -1
  87. package/dist/components/OptionsWatcher.d.ts +4 -0
  88. package/dist/components/OptionsWatcher.js +440 -99
  89. package/dist/components/OptionsWatcher.js.map +1 -1
  90. package/dist/components/RuntimeModuleTracker.js +40 -6
  91. package/dist/components/RuntimeModuleTracker.js.map +1 -1
  92. package/dist/components/Scope.js +52 -13
  93. package/dist/components/Scope.js.map +1 -1
  94. package/dist/components/awaitRestart.d.ts +33 -0
  95. package/dist/components/awaitRestart.js +61 -0
  96. package/dist/components/awaitRestart.js.map +1 -0
  97. package/dist/components/componentLoader.d.ts +38 -1
  98. package/dist/components/componentLoader.js +313 -24
  99. package/dist/components/componentLoader.js.map +1 -1
  100. package/dist/components/componentPreparationLock.d.ts +5 -0
  101. package/dist/components/componentPreparationLock.js +14 -6
  102. package/dist/components/componentPreparationLock.js.map +1 -1
  103. package/dist/components/deploymentOperations.js +4 -1
  104. package/dist/components/deploymentOperations.js.map +1 -1
  105. package/dist/components/deploymentRecorder.d.ts +4 -2
  106. package/dist/components/deploymentRecorder.js +1 -0
  107. package/dist/components/deploymentRecorder.js.map +1 -1
  108. package/dist/components/mcp/adapters/harperHttp.js +4 -0
  109. package/dist/components/mcp/adapters/harperHttp.js.map +1 -1
  110. package/dist/components/mcp/listChanged.js +5 -0
  111. package/dist/components/mcp/listChanged.js.map +1 -1
  112. package/dist/components/mcp/toolRegistry.d.ts +1 -0
  113. package/dist/components/mcp/toolRegistry.js.map +1 -1
  114. package/dist/components/mcp/tools/operations.d.ts +5 -0
  115. package/dist/components/mcp/tools/operations.js +9 -0
  116. package/dist/components/mcp/tools/operations.js.map +1 -1
  117. package/dist/components/mcp/tools/schemas/operationDescriptions.js +2 -2
  118. package/dist/components/mcp/tools/schemas/operationDescriptions.js.map +1 -1
  119. package/dist/components/operations.d.ts +28 -0
  120. package/dist/components/operations.js +476 -113
  121. package/dist/components/operations.js.map +1 -1
  122. package/dist/components/operationsValidation.js +97 -3
  123. package/dist/components/operationsValidation.js.map +1 -1
  124. package/dist/components/packageComponent.js +24 -0
  125. package/dist/components/packageComponent.js.map +1 -1
  126. package/dist/components/requestRestart.d.ts +1 -0
  127. package/dist/components/requestRestart.js +7 -0
  128. package/dist/components/requestRestart.js.map +1 -1
  129. package/dist/components/status/ComponentStatusRegistry.d.ts +0 -4
  130. package/dist/components/status/ComponentStatusRegistry.js +63 -0
  131. package/dist/components/status/ComponentStatusRegistry.js.map +1 -1
  132. package/dist/config/RootConfigWatcher.d.ts +9 -1
  133. package/dist/config/RootConfigWatcher.js +228 -37
  134. package/dist/config/RootConfigWatcher.js.map +1 -1
  135. package/dist/config/configReadRetry.d.ts +8 -0
  136. package/dist/config/configReadRetry.js +62 -0
  137. package/dist/config/configReadRetry.js.map +1 -0
  138. package/dist/config/configUtils.d.ts +11 -2
  139. package/dist/config/configUtils.js +304 -47
  140. package/dist/config/configUtils.js.map +1 -1
  141. package/dist/config/harperConfigEnvVars.d.ts +16 -0
  142. package/dist/config/harperConfigEnvVars.js +162 -25
  143. package/dist/config/harperConfigEnvVars.js.map +1 -1
  144. package/dist/config/parseConfigFile.d.ts +4 -0
  145. package/dist/config/parseConfigFile.js +35 -0
  146. package/dist/config/parseConfigFile.js.map +1 -0
  147. package/dist/config/readConfigFileSync.d.ts +1 -0
  148. package/dist/config/readConfigFileSync.js +47 -0
  149. package/dist/config/readConfigFileSync.js.map +1 -0
  150. package/dist/config/watcherArming.d.ts +15 -0
  151. package/dist/config/watcherArming.js +59 -0
  152. package/dist/config/watcherArming.js.map +1 -0
  153. package/dist/dataLayer/blobBackup.d.ts +49 -20
  154. package/dist/dataLayer/blobBackup.js +139 -50
  155. package/dist/dataLayer/blobBackup.js.map +1 -1
  156. package/dist/dataLayer/delete.js +1 -1
  157. package/dist/dataLayer/delete.js.map +1 -1
  158. package/dist/dataLayer/harperBridge/ResourceBridge.d.ts +14 -1
  159. package/dist/dataLayer/harperBridge/ResourceBridge.js +72 -12
  160. package/dist/dataLayer/harperBridge/ResourceBridge.js.map +1 -1
  161. package/dist/dataLayer/harperBridge/lmdbBridge/lmdbMethods/DeleteAuditLogsBeforeResults.d.ts +3 -1
  162. package/dist/dataLayer/harperBridge/lmdbBridge/lmdbMethods/DeleteAuditLogsBeforeResults.js +3 -1
  163. package/dist/dataLayer/harperBridge/lmdbBridge/lmdbMethods/DeleteAuditLogsBeforeResults.js.map +1 -1
  164. package/dist/dataLayer/hdbInfoController.d.ts +10 -0
  165. package/dist/dataLayer/hdbInfoController.js +30 -1
  166. package/dist/dataLayer/hdbInfoController.js.map +1 -1
  167. package/dist/dataLayer/insert.d.ts +9 -1
  168. package/dist/dataLayer/insert.js +30 -0
  169. package/dist/dataLayer/insert.js.map +1 -1
  170. package/dist/dataLayer/rocksdbBackup.d.ts +2 -2
  171. package/dist/dataLayer/rocksdbBackup.js +45 -8
  172. package/dist/dataLayer/rocksdbBackup.js.map +1 -1
  173. package/dist/dataLayer/schema.js +8 -0
  174. package/dist/dataLayer/schema.js.map +1 -1
  175. package/dist/dataLayer/schemaDescribe.js +8 -1
  176. package/dist/dataLayer/schemaDescribe.js.map +1 -1
  177. package/dist/index.d.ts +2 -1
  178. package/dist/index.js +4 -1
  179. package/dist/index.js.map +1 -1
  180. package/dist/json/systemSchema.json +55 -0
  181. package/dist/resources/DatabaseTransaction.d.ts +77 -0
  182. package/dist/resources/DatabaseTransaction.js +586 -54
  183. package/dist/resources/DatabaseTransaction.js.map +1 -1
  184. package/dist/resources/LMDBTransaction.d.ts +2 -1
  185. package/dist/resources/LMDBTransaction.js +43 -6
  186. package/dist/resources/LMDBTransaction.js.map +1 -1
  187. package/dist/resources/PrimaryRocksDatabase.d.ts +1 -0
  188. package/dist/resources/PrimaryRocksDatabase.js +49 -5
  189. package/dist/resources/PrimaryRocksDatabase.js.map +1 -1
  190. package/dist/resources/RecordEncoder.d.ts +20 -0
  191. package/dist/resources/RecordEncoder.js +110 -9
  192. package/dist/resources/RecordEncoder.js.map +1 -1
  193. package/dist/resources/RequestTarget.d.ts +2 -0
  194. package/dist/resources/RequestTarget.js.map +1 -1
  195. package/dist/resources/Resource.js +117 -24
  196. package/dist/resources/Resource.js.map +1 -1
  197. package/dist/resources/ResourceInterface.d.ts +28 -1
  198. package/dist/resources/ResourceInterface.js.map +1 -1
  199. package/dist/resources/RocksIndexStore.d.ts +6 -1
  200. package/dist/resources/RocksIndexStore.js +24 -9
  201. package/dist/resources/RocksIndexStore.js.map +1 -1
  202. package/dist/resources/RocksTransactionLogStore.d.ts +24 -1
  203. package/dist/resources/RocksTransactionLogStore.js +160 -49
  204. package/dist/resources/RocksTransactionLogStore.js.map +1 -1
  205. package/dist/resources/Table.d.ts +165 -10
  206. package/dist/resources/Table.js +1805 -303
  207. package/dist/resources/Table.js.map +1 -1
  208. package/dist/resources/analytics/write.js +10 -3
  209. package/dist/resources/analytics/write.js.map +1 -1
  210. package/dist/resources/auditStore.d.ts +181 -2
  211. package/dist/resources/auditStore.js +640 -29
  212. package/dist/resources/auditStore.js.map +1 -1
  213. package/dist/resources/blob.d.ts +129 -9
  214. package/dist/resources/blob.js +992 -114
  215. package/dist/resources/blob.js.map +1 -1
  216. package/dist/resources/branchDatabase.d.ts +48 -0
  217. package/dist/resources/branchDatabase.js +892 -0
  218. package/dist/resources/branchDatabase.js.map +1 -0
  219. package/dist/resources/crdt.js +50 -12
  220. package/dist/resources/crdt.js.map +1 -1
  221. package/dist/resources/dataLoader.js +3 -4
  222. package/dist/resources/dataLoader.js.map +1 -1
  223. package/dist/resources/databases.d.ts +157 -14
  224. package/dist/resources/databases.js +1462 -325
  225. package/dist/resources/databases.js.map +1 -1
  226. package/dist/resources/defineTable.d.ts +10 -2
  227. package/dist/resources/defineTable.js +9 -1
  228. package/dist/resources/defineTable.js.map +1 -1
  229. package/dist/resources/derivedIndexRegistry.d.ts +5 -0
  230. package/dist/resources/derivedIndexRegistry.js +68 -0
  231. package/dist/resources/derivedIndexRegistry.js.map +1 -0
  232. package/dist/resources/derivedIndexRuntime.d.ts +215 -0
  233. package/dist/resources/derivedIndexRuntime.js +2027 -0
  234. package/dist/resources/derivedIndexRuntime.js.map +1 -0
  235. package/dist/resources/graphql.d.ts +1 -1
  236. package/dist/resources/graphql.js +52 -16
  237. package/dist/resources/graphql.js.map +1 -1
  238. package/dist/resources/indexes/HierarchicalNavigableSmallWorld.d.ts +103 -9
  239. package/dist/resources/indexes/HierarchicalNavigableSmallWorld.js +854 -41
  240. package/dist/resources/indexes/HierarchicalNavigableSmallWorld.js.map +1 -1
  241. package/dist/resources/indexes/hnswDerivedIndex.d.ts +67 -0
  242. package/dist/resources/indexes/hnswDerivedIndex.js +464 -0
  243. package/dist/resources/indexes/hnswDerivedIndex.js.map +1 -0
  244. package/dist/resources/indexes/hnswPlaneBinding.d.ts +65 -0
  245. package/dist/resources/indexes/hnswPlaneBinding.js +91 -0
  246. package/dist/resources/indexes/hnswPlaneBinding.js.map +1 -0
  247. package/dist/resources/longLivedTransactions.d.ts +71 -0
  248. package/dist/resources/longLivedTransactions.js +358 -0
  249. package/dist/resources/longLivedTransactions.js.map +1 -0
  250. package/dist/resources/models/backendRegistry.d.ts +26 -0
  251. package/dist/resources/models/backendRegistry.js +60 -2
  252. package/dist/resources/models/backendRegistry.js.map +1 -1
  253. package/dist/resources/models/bootstrap.d.ts +33 -1
  254. package/dist/resources/models/bootstrap.js +416 -31
  255. package/dist/resources/models/bootstrap.js.map +1 -1
  256. package/dist/resources/nodeIdMapping.d.ts +5 -0
  257. package/dist/resources/nodeIdMapping.js +49 -0
  258. package/dist/resources/nodeIdMapping.js.map +1 -1
  259. package/dist/resources/recordLock.d.ts +123 -0
  260. package/dist/resources/recordLock.js +315 -0
  261. package/dist/resources/recordLock.js.map +1 -0
  262. package/dist/resources/recordLockCoordinator.d.ts +557 -0
  263. package/dist/resources/recordLockCoordinator.js +2565 -0
  264. package/dist/resources/recordLockCoordinator.js.map +1 -0
  265. package/dist/resources/replayLogs.d.ts +14 -1
  266. package/dist/resources/replayLogs.js +157 -24
  267. package/dist/resources/replayLogs.js.map +1 -1
  268. package/dist/resources/replayLogsGuards.d.ts +94 -7
  269. package/dist/resources/replayLogsGuards.js +111 -7
  270. package/dist/resources/replayLogsGuards.js.map +1 -1
  271. package/dist/resources/replicatedApplyFailure.d.ts +16 -0
  272. package/dist/resources/replicatedApplyFailure.js +63 -0
  273. package/dist/resources/replicatedApplyFailure.js.map +1 -0
  274. package/dist/resources/scheduler/scheduler.js +3 -3
  275. package/dist/resources/scheduler/scheduler.js.map +1 -1
  276. package/dist/resources/search.d.ts +10 -4
  277. package/dist/resources/search.js +283 -37
  278. package/dist/resources/search.js.map +1 -1
  279. package/dist/resources/tracked.d.ts +5 -1
  280. package/dist/resources/tracked.js +74 -23
  281. package/dist/resources/tracked.js.map +1 -1
  282. package/dist/resources/transactionBroadcast.d.ts +1 -1
  283. package/dist/resources/transactionBroadcast.js +2 -2
  284. package/dist/resources/transactionBroadcast.js.map +1 -1
  285. package/dist/security/auth.js +61 -30
  286. package/dist/security/auth.js.map +1 -1
  287. package/dist/security/authn/oidc/claims.d.ts +22 -0
  288. package/dist/security/authn/oidc/claims.js +71 -0
  289. package/dist/security/authn/oidc/claims.js.map +1 -0
  290. package/dist/security/authn/oidc/identityToken.d.ts +27 -0
  291. package/dist/security/authn/oidc/identityToken.js +111 -0
  292. package/dist/security/authn/oidc/identityToken.js.map +1 -0
  293. package/dist/security/authn/oidc/jwks.d.ts +25 -0
  294. package/dist/security/authn/oidc/jwks.js +261 -0
  295. package/dist/security/authn/oidc/jwks.js.map +1 -0
  296. package/dist/security/authn/oidc/providers/generic.d.ts +13 -0
  297. package/dist/security/authn/oidc/providers/generic.js +34 -0
  298. package/dist/security/authn/oidc/providers/generic.js.map +1 -0
  299. package/dist/security/authn/oidc/providers/githubActions.d.ts +11 -0
  300. package/dist/security/authn/oidc/providers/githubActions.js +129 -0
  301. package/dist/security/authn/oidc/providers/githubActions.js.map +1 -0
  302. package/dist/security/authn/oidc/providers/index.d.ts +37 -0
  303. package/dist/security/authn/oidc/providers/index.js +24 -0
  304. package/dist/security/authn/oidc/providers/index.js.map +1 -0
  305. package/dist/security/authn/oidc/tokenExchange.d.ts +12 -0
  306. package/dist/security/authn/oidc/tokenExchange.js +306 -0
  307. package/dist/security/authn/oidc/tokenExchange.js.map +1 -0
  308. package/dist/security/authn/oidc/trustPolicyOperations.d.ts +49 -0
  309. package/dist/security/authn/oidc/trustPolicyOperations.js +358 -0
  310. package/dist/security/authn/oidc/trustPolicyOperations.js.map +1 -0
  311. package/dist/security/authn/oidc/types.d.ts +38 -0
  312. package/dist/security/authn/oidc/types.js +6 -0
  313. package/dist/security/authn/oidc/types.js.map +1 -0
  314. package/dist/security/certificateVerification/index.js +40 -11
  315. package/dist/security/certificateVerification/index.js.map +1 -1
  316. package/dist/security/certificateVerification/trustedIssuers.d.ts +24 -0
  317. package/dist/security/certificateVerification/trustedIssuers.js +79 -0
  318. package/dist/security/certificateVerification/trustedIssuers.js.map +1 -0
  319. package/dist/security/certificateVerification/types.d.ts +1 -0
  320. package/dist/security/credentialProvenance.d.ts +35 -0
  321. package/dist/security/credentialProvenance.js +51 -0
  322. package/dist/security/credentialProvenance.js.map +1 -0
  323. package/dist/security/credentialRejection.d.ts +4 -0
  324. package/dist/security/credentialRejection.js +24 -0
  325. package/dist/security/credentialRejection.js.map +1 -0
  326. package/dist/security/deferredAuthentication.d.ts +36 -0
  327. package/dist/security/deferredAuthentication.js +70 -0
  328. package/dist/security/deferredAuthentication.js.map +1 -0
  329. package/dist/security/impersonation.d.ts +21 -0
  330. package/dist/security/impersonation.js +108 -9
  331. package/dist/security/impersonation.js.map +1 -1
  332. package/dist/security/jsLoader.d.ts +6 -0
  333. package/dist/security/jsLoader.js +77 -15
  334. package/dist/security/jsLoader.js.map +1 -1
  335. package/dist/security/keys.js +301 -71
  336. package/dist/security/keys.js.map +1 -1
  337. package/dist/security/operationScope.d.ts +21 -0
  338. package/dist/security/operationScope.js +36 -0
  339. package/dist/security/operationScope.js.map +1 -0
  340. package/dist/security/permissionsTranslator.js +21 -0
  341. package/dist/security/permissionsTranslator.js.map +1 -1
  342. package/dist/security/tokenAuthentication.d.ts +19 -1
  343. package/dist/security/tokenAuthentication.js +191 -10
  344. package/dist/security/tokenAuthentication.js.map +1 -1
  345. package/dist/security/user.js +4 -3
  346. package/dist/security/user.js.map +1 -1
  347. package/dist/server/DurableSubscriptionsSession.d.ts +2 -2
  348. package/dist/server/DurableSubscriptionsSession.js +67 -10
  349. package/dist/server/DurableSubscriptionsSession.js.map +1 -1
  350. package/dist/server/REST.js +106 -2
  351. package/dist/server/REST.js.map +1 -1
  352. package/dist/server/graphqlQuerying.js +4 -0
  353. package/dist/server/graphqlQuerying.js.map +1 -1
  354. package/dist/server/http.d.ts +16 -1
  355. package/dist/server/http.js +122 -17
  356. package/dist/server/http.js.map +1 -1
  357. package/dist/server/itc/serverHandlers.js +8 -1
  358. package/dist/server/itc/serverHandlers.js.map +1 -1
  359. package/dist/server/jobs/jobProcess.js +6 -2
  360. package/dist/server/jobs/jobProcess.js.map +1 -1
  361. package/dist/server/jobs/jobs.js +4 -1
  362. package/dist/server/jobs/jobs.js.map +1 -1
  363. package/dist/server/liveSubscriptionAuth.d.ts +26 -4
  364. package/dist/server/liveSubscriptionAuth.js +105 -39
  365. package/dist/server/liveSubscriptionAuth.js.map +1 -1
  366. package/dist/server/loadRootComponents.js +49 -10
  367. package/dist/server/loadRootComponents.js.map +1 -1
  368. package/dist/server/mqtt.d.ts +2 -0
  369. package/dist/server/mqtt.js +165 -30
  370. package/dist/server/mqtt.js.map +1 -1
  371. package/dist/server/nodeName.d.ts +2 -0
  372. package/dist/server/nodeName.js +107 -23
  373. package/dist/server/nodeName.js.map +1 -1
  374. package/dist/server/serverHelpers/Headers.d.ts +27 -0
  375. package/dist/server/serverHelpers/Headers.js +145 -1
  376. package/dist/server/serverHelpers/Headers.js.map +1 -1
  377. package/dist/server/serverHelpers/NodeAdapterResponse.d.ts +48 -0
  378. package/dist/server/serverHelpers/NodeAdapterResponse.js +220 -0
  379. package/dist/server/serverHelpers/NodeAdapterResponse.js.map +1 -0
  380. package/dist/server/serverHelpers/Request.d.ts +5 -10
  381. package/dist/server/serverHelpers/Request.js +38 -136
  382. package/dist/server/serverHelpers/Request.js.map +1 -1
  383. package/dist/server/serverHelpers/contentTypes.d.ts +11 -0
  384. package/dist/server/serverHelpers/contentTypes.js +221 -40
  385. package/dist/server/serverHelpers/contentTypes.js.map +1 -1
  386. package/dist/server/serverHelpers/deployValidationState.d.ts +3 -0
  387. package/dist/server/serverHelpers/deployValidationState.js +9 -19
  388. package/dist/server/serverHelpers/deployValidationState.js.map +1 -1
  389. package/dist/server/serverHelpers/operationAuthorizationState.d.ts +12 -0
  390. package/dist/server/serverHelpers/operationAuthorizationState.js +24 -2
  391. package/dist/server/serverHelpers/operationAuthorizationState.js.map +1 -1
  392. package/dist/server/serverHelpers/registeredOperations.d.ts +5 -4
  393. package/dist/server/serverHelpers/registeredOperations.js +74 -21
  394. package/dist/server/serverHelpers/registeredOperations.js.map +1 -1
  395. package/dist/server/serverHelpers/requestSanitization.d.ts +11 -0
  396. package/dist/server/serverHelpers/requestSanitization.js +20 -0
  397. package/dist/server/serverHelpers/requestSanitization.js.map +1 -0
  398. package/dist/server/serverHelpers/serverHandlers.js +6 -3
  399. package/dist/server/serverHelpers/serverHandlers.js.map +1 -1
  400. package/dist/server/serverHelpers/serverUtilities.d.ts +18 -0
  401. package/dist/server/serverHelpers/serverUtilities.js +178 -29
  402. package/dist/server/serverHelpers/serverUtilities.js.map +1 -1
  403. package/dist/server/serverHelpers/sharedMessageEncoding.d.ts +67 -0
  404. package/dist/server/serverHelpers/sharedMessageEncoding.js +280 -0
  405. package/dist/server/serverHelpers/sharedMessageEncoding.js.map +1 -0
  406. package/dist/server/serverHelpers/uwsServer.js +19 -1
  407. package/dist/server/serverHelpers/uwsServer.js.map +1 -1
  408. package/dist/server/static.js +24 -28
  409. package/dist/server/static.js.map +1 -1
  410. package/dist/server/storageReclamation.d.ts +5 -0
  411. package/dist/server/storageReclamation.js +17 -1
  412. package/dist/server/storageReclamation.js.map +1 -1
  413. package/dist/server/threads/isolatedApplications.d.ts +47 -0
  414. package/dist/server/threads/isolatedApplications.js +171 -0
  415. package/dist/server/threads/isolatedApplications.js.map +1 -0
  416. package/dist/server/threads/itc.d.ts +7 -2
  417. package/dist/server/threads/itc.js +5 -1
  418. package/dist/server/threads/itc.js.map +1 -1
  419. package/dist/server/threads/logRotationTransport.d.ts +1 -0
  420. package/dist/server/threads/logRotationTransport.js +33 -0
  421. package/dist/server/threads/logRotationTransport.js.map +1 -0
  422. package/dist/server/threads/manageThreads.d.ts +83 -3
  423. package/dist/server/threads/manageThreads.js +769 -75
  424. package/dist/server/threads/manageThreads.js.map +1 -1
  425. package/dist/server/threads/socketRouter.d.ts +1 -0
  426. package/dist/server/threads/socketRouter.js +277 -31
  427. package/dist/server/threads/socketRouter.js.map +1 -1
  428. package/dist/server/threads/threadHeapMemory.d.ts +2 -0
  429. package/dist/server/threads/threadHeapMemory.js +31 -0
  430. package/dist/server/threads/threadHeapMemory.js.map +1 -0
  431. package/dist/server/threads/threadServer.js +76 -22
  432. package/dist/server/threads/threadServer.js.map +1 -1
  433. package/dist/sqlEngine/config.d.ts +1 -3
  434. package/dist/sqlEngine/config.js +19 -16
  435. package/dist/sqlEngine/config.js.map +1 -1
  436. package/dist/sqlTranslator/index.d.ts +1 -1
  437. package/dist/sqlTranslator/index.js +30 -7
  438. package/dist/sqlTranslator/index.js.map +1 -1
  439. package/dist/upgrade/directives/5-3-0.d.ts +7 -0
  440. package/dist/upgrade/directives/5-3-0.js +148 -0
  441. package/dist/upgrade/directives/5-3-0.js.map +1 -0
  442. package/dist/upgrade/directives/directivesController.js +2 -1
  443. package/dist/upgrade/directives/directivesController.js.map +1 -1
  444. package/dist/utility/OperationFunctionCaller.js +2 -1
  445. package/dist/utility/OperationFunctionCaller.js.map +1 -1
  446. package/dist/utility/common_utils.d.ts +16 -0
  447. package/dist/utility/common_utils.js +32 -6
  448. package/dist/utility/common_utils.js.map +1 -1
  449. package/dist/utility/componentNames.d.ts +8 -0
  450. package/dist/utility/componentNames.js +12 -1
  451. package/dist/utility/componentNames.js.map +1 -1
  452. package/dist/utility/environment/environmentManager.js +3 -6
  453. package/dist/utility/environment/environmentManager.js.map +1 -1
  454. package/dist/utility/environment/systemInformation.d.ts +1 -0
  455. package/dist/utility/environment/systemInformation.js +1 -0
  456. package/dist/utility/environment/systemInformation.js.map +1 -1
  457. package/dist/utility/errors/commonErrors.d.ts +2 -0
  458. package/dist/utility/errors/commonErrors.js +2 -0
  459. package/dist/utility/errors/commonErrors.js.map +1 -1
  460. package/dist/utility/errors/hdbError.d.ts +33 -0
  461. package/dist/utility/errors/hdbError.js +58 -1
  462. package/dist/utility/errors/hdbError.js.map +1 -1
  463. package/dist/utility/globalSchema.d.ts +18 -0
  464. package/dist/utility/hdbTerms.d.ts +18 -0
  465. package/dist/utility/hdbTerms.js +20 -2
  466. package/dist/utility/hdbTerms.js.map +1 -1
  467. package/dist/utility/logging/harper_logger.d.ts +2 -0
  468. package/dist/utility/logging/harper_logger.js +286 -30
  469. package/dist/utility/logging/harper_logger.js.map +1 -1
  470. package/dist/utility/logging/logGenerationCoordinator.d.ts +35 -0
  471. package/dist/utility/logging/logGenerationCoordinator.js +184 -0
  472. package/dist/utility/logging/logGenerationCoordinator.js.map +1 -0
  473. package/dist/utility/logging/logRotation.d.ts +46 -0
  474. package/dist/utility/logging/logRotation.js +365 -0
  475. package/dist/utility/logging/logRotation.js.map +1 -0
  476. package/dist/utility/logging/logRotator.d.ts +1 -1
  477. package/dist/utility/logging/logRotator.js +192 -85
  478. package/dist/utility/logging/logRotator.js.map +1 -1
  479. package/dist/utility/nodeIdentity.d.ts +9 -0
  480. package/dist/utility/nodeIdentity.js +58 -0
  481. package/dist/utility/nodeIdentity.js.map +1 -0
  482. package/dist/utility/npmUtilities.js +11 -7
  483. package/dist/utility/npmUtilities.js.map +1 -1
  484. package/dist/utility/operationPermissions.d.ts +3 -1
  485. package/dist/utility/operationPermissions.js +16 -1
  486. package/dist/utility/operationPermissions.js.map +1 -1
  487. package/dist/utility/operation_authorization.d.ts +10 -7
  488. package/dist/utility/operation_authorization.js +212 -41
  489. package/dist/utility/operation_authorization.js.map +1 -1
  490. package/dist/utility/watchPath.d.ts +29 -0
  491. package/dist/utility/watchPath.js +68 -0
  492. package/dist/utility/watchPath.js.map +1 -0
  493. package/dist/utility/watcherFallback.d.ts +41 -0
  494. package/dist/utility/watcherFallback.js +153 -0
  495. package/dist/utility/watcherFallback.js.map +1 -1
  496. package/dist/validation/configValidator.d.ts +12 -0
  497. package/dist/validation/configValidator.js +205 -75
  498. package/dist/validation/configValidator.js.map +1 -1
  499. package/dist/validation/installValidator.js +12 -0
  500. package/dist/validation/installValidator.js.map +1 -1
  501. package/dist/validation/validationWrapper.d.ts +11 -0
  502. package/dist/validation/validationWrapper.js +16 -3
  503. package/dist/validation/validationWrapper.js.map +1 -1
  504. package/index.ts +8 -0
  505. package/json/systemSchema.json +55 -0
  506. package/npm-shrinkwrap.json +286 -194
  507. package/package.json +15 -7
  508. package/resources/DESIGN.md +219 -52
  509. package/resources/DatabaseTransaction.ts +657 -52
  510. package/resources/LMDBTransaction.ts +46 -6
  511. package/resources/PrimaryRocksDatabase.ts +46 -7
  512. package/resources/RecordEncoder.ts +124 -9
  513. package/resources/RequestTarget.ts +2 -0
  514. package/resources/Resource.ts +114 -22
  515. package/resources/ResourceInterface.ts +31 -0
  516. package/resources/RocksIndexStore.ts +30 -9
  517. package/resources/RocksTransactionLogStore.ts +188 -51
  518. package/resources/Table.ts +1960 -301
  519. package/resources/analytics/write.ts +10 -3
  520. package/resources/auditStore.ts +647 -32
  521. package/resources/blob.ts +1029 -110
  522. package/resources/branchDatabase.ts +941 -0
  523. package/resources/crdt.ts +76 -12
  524. package/resources/dataLoader.ts +3 -4
  525. package/resources/databases.ts +1584 -320
  526. package/resources/defineTable.ts +18 -2
  527. package/resources/derivedIndexRegistry.ts +56 -0
  528. package/resources/derivedIndexRuntime.ts +2292 -0
  529. package/resources/graphql.ts +72 -17
  530. package/resources/indexes/HierarchicalNavigableSmallWorld.ts +911 -48
  531. package/resources/indexes/hnswDerivedIndex.ts +531 -0
  532. package/resources/indexes/hnswPlaneBinding.ts +174 -0
  533. package/resources/longLivedTransactions.ts +360 -0
  534. package/resources/models/backendRegistry.ts +84 -2
  535. package/resources/models/bootstrap.ts +473 -28
  536. package/resources/nodeIdMapping.ts +50 -0
  537. package/resources/recordLock.ts +419 -0
  538. package/resources/recordLockCoordinator.ts +3043 -0
  539. package/resources/replayLogs.ts +152 -26
  540. package/resources/replayLogsGuards.ts +171 -8
  541. package/resources/replicatedApplyFailure.ts +77 -0
  542. package/resources/scheduler/scheduler.ts +4 -4
  543. package/resources/search.ts +288 -46
  544. package/resources/tracked.ts +73 -22
  545. package/resources/transactionBroadcast.ts +3 -3
  546. package/security/auth.ts +68 -29
  547. package/security/authn/oidc/claims.ts +72 -0
  548. package/security/authn/oidc/identityToken.ts +129 -0
  549. package/security/authn/oidc/jwks.ts +260 -0
  550. package/security/authn/oidc/providers/generic.ts +40 -0
  551. package/security/authn/oidc/providers/githubActions.ts +137 -0
  552. package/security/authn/oidc/providers/index.ts +52 -0
  553. package/security/authn/oidc/tokenExchange.ts +300 -0
  554. package/security/authn/oidc/trustPolicyOperations.ts +343 -0
  555. package/security/authn/oidc/types.ts +41 -0
  556. package/security/certificateVerification/index.ts +54 -13
  557. package/security/certificateVerification/trustedIssuers.ts +76 -0
  558. package/security/certificateVerification/types.ts +1 -0
  559. package/security/credentialProvenance.ts +47 -0
  560. package/security/credentialRejection.ts +22 -0
  561. package/security/deferredAuthentication.ts +71 -0
  562. package/security/impersonation.ts +117 -12
  563. package/security/jsLoader.ts +83 -18
  564. package/security/keys.ts +298 -72
  565. package/security/operationScope.ts +33 -0
  566. package/security/permissionsTranslator.js +23 -0
  567. package/security/tokenAuthentication.ts +233 -12
  568. package/security/user.ts +4 -3
  569. package/server/DESIGN.md +194 -16
  570. package/server/DurableSubscriptionsSession.ts +71 -11
  571. package/server/REST.ts +115 -4
  572. package/server/graphqlQuerying.ts +4 -0
  573. package/server/http.ts +133 -20
  574. package/server/itc/serverHandlers.js +8 -1
  575. package/server/jobs/jobProcess.ts +8 -2
  576. package/server/jobs/jobs.ts +4 -1
  577. package/server/liveSubscriptionAuth.ts +129 -46
  578. package/server/loadRootComponents.js +50 -8
  579. package/server/mqtt.ts +179 -38
  580. package/server/nodeName.ts +103 -21
  581. package/server/serverHelpers/Headers.ts +135 -0
  582. package/server/serverHelpers/NodeAdapterResponse.ts +221 -0
  583. package/server/serverHelpers/Request.ts +33 -131
  584. package/server/serverHelpers/contentTypes.ts +217 -36
  585. package/server/serverHelpers/deployValidationState.ts +24 -13
  586. package/server/serverHelpers/operationAuthorizationState.ts +34 -3
  587. package/server/serverHelpers/registeredOperations.ts +79 -22
  588. package/server/serverHelpers/requestSanitization.ts +15 -0
  589. package/server/serverHelpers/serverHandlers.js +6 -3
  590. package/server/serverHelpers/serverUtilities.ts +232 -40
  591. package/server/serverHelpers/sharedMessageEncoding.ts +307 -0
  592. package/server/serverHelpers/uwsServer.ts +17 -2
  593. package/server/static.ts +23 -29
  594. package/server/storageReclamation.ts +15 -2
  595. package/server/threads/isolatedApplications.ts +157 -0
  596. package/server/threads/itc.js +11 -1
  597. package/server/threads/logRotationTransport.ts +40 -0
  598. package/server/threads/manageThreads.js +771 -66
  599. package/server/threads/socketRouter.ts +291 -30
  600. package/server/threads/threadHeapMemory.ts +26 -0
  601. package/server/threads/threadServer.js +73 -22
  602. package/sqlTranslator/index.ts +31 -8
  603. package/studio/web/assets/{Chat-4RrB5134.js → Chat-D3j-1yY1.js} +1 -1
  604. package/studio/web/assets/{FloatingChat-omlNMDcJ.js → FloatingChat-BxJGYcfB.js} +3 -3
  605. package/studio/web/assets/{apiToken-Bke3wvfZ.js → apiToken-CT55oWOe.js} +1 -1
  606. package/studio/web/assets/{applications-DvFDYJgK.js → applications-D9Ct9_vm.js} +1 -1
  607. package/studio/web/assets/{cssMode-C1vRa7zh.js → cssMode-DV8H7VwA.js} +1 -1
  608. package/studio/web/assets/{editor-3XRWEDWX.js → editor-uatc0unt.js} +1 -1
  609. package/studio/web/assets/{html-CTY5tdMr.js → html-Bm6D6paN.js} +1 -1
  610. package/studio/web/assets/{htmlMode-CG1vSD9t.js → htmlMode-CEn7tpLG.js} +1 -1
  611. package/studio/web/assets/{index-Dfpeofdu.js → index-BIXW6Pu4.js} +5 -5
  612. package/studio/web/assets/{index.lazy-B1VOIv-t.js → index.lazy-UI7L-Vrk.js} +1 -1
  613. package/studio/web/assets/{javascript-CJeJzGnI.js → javascript-CJ0G3AFZ.js} +1 -1
  614. package/studio/web/assets/{jsonMode-CYPBwM82.js → jsonMode-DQADAYEa.js} +1 -1
  615. package/studio/web/assets/{languageServices-IKH4GUHl.js → languageServices-CAQJXWcI.js} +1 -1
  616. package/studio/web/assets/{lspLanguageFeatures-BC8_gxKG.js → lspLanguageFeatures-CCQ8P5sY.js} +1 -1
  617. package/studio/web/assets/{notifications-BfKBpYcq.js → notifications-BbxTU6Aw.js} +1 -1
  618. package/studio/web/assets/{notifications-DFbArTfC.js → notifications-Cvb3P1lB.js} +1 -1
  619. package/studio/web/assets/{profile-Q4-T6c-S.js → profile-Yyb7gsvL.js} +1 -1
  620. package/studio/web/assets/{regions-CtkV0xje.js → regions-OgjGHlU5.js} +1 -1
  621. package/studio/web/assets/{register-1ZZuMsiA.js → register-6qwNEOY3.js} +2 -2
  622. package/studio/web/assets/{setComponentFile-ZWLcWv5X.js → setComponentFile-BilDMtgB.js} +1 -1
  623. package/studio/web/assets/{setup-DWprJyJy.js → setup-J6qJ7OIU.js} +2 -2
  624. package/studio/web/assets/{status--aNm8isn.js → status-0RWGcfyD.js} +1 -1
  625. package/studio/web/assets/{toggleHighContrast-Cq_lt3XD.js → toggleHighContrast-BIn-vErT.js} +1 -1
  626. package/studio/web/assets/{tsMode-pjgytARx.js → tsMode-DgUXku4d.js} +1 -1
  627. package/studio/web/assets/{typescript-Cdg0mqUh.js → typescript-C9orXcsM.js} +1 -1
  628. package/studio/web/assets/{useEntityRestURL-RZhaY8Rn.js → useEntityRestURL-BEoXXbUB.js} +1 -1
  629. package/studio/web/assets/{workers-BgoXIqQe.js → workers-JVzSDmgx.js} +1 -1
  630. package/studio/web/assets/{xml-BwSeDMiP.js → xml-Cq-S8S4X.js} +1 -1
  631. package/studio/web/assets/{yaml-DotCUG5l.js → yaml-sfoRdh1M.js} +1 -1
  632. package/studio/web/index.html +1 -1
  633. package/upgrade/directives/5-3-0.ts +132 -0
  634. package/upgrade/directives/directivesController.ts +2 -1
  635. package/utility/OperationFunctionCaller.ts +2 -1
  636. package/utility/common_utils.ts +30 -5
  637. package/utility/componentNames.ts +12 -0
  638. package/utility/environment/environmentManager.ts +3 -7
  639. package/utility/environment/systemInformation.ts +7 -0
  640. package/utility/errors/commonErrors.ts +4 -0
  641. package/utility/errors/hdbError.ts +57 -0
  642. package/utility/hdbTerms.ts +19 -0
  643. package/utility/logging/harper_logger.ts +278 -26
  644. package/utility/logging/logGenerationCoordinator.ts +196 -0
  645. package/utility/logging/logRotation.ts +367 -0
  646. package/utility/logging/logRotator.ts +213 -81
  647. package/utility/nodeIdentity.ts +45 -0
  648. package/utility/npmUtilities.ts +12 -8
  649. package/utility/operationPermissions.ts +18 -1
  650. package/utility/operation_authorization.ts +231 -42
  651. package/utility/watchPath.ts +63 -0
  652. package/utility/watcherFallback.ts +148 -0
  653. package/validation/configValidator.ts +215 -75
  654. package/validation/installValidator.ts +15 -0
  655. package/validation/validationWrapper.ts +18 -4
@@ -41,7 +41,10 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
41
41
  return (mod && mod.__esModule) ? mod : { "default": mod };
42
42
  };
43
43
  Object.defineProperty(exports, "__esModule", { value: true });
44
- exports.EVICTED = exports.INVALIDATED = void 0;
44
+ exports.EVICTED = exports.INVALIDATED = exports.UPDATE_ATTRIBUTES_LOCK_SLOW_WAIT = exports.UPDATE_ATTRIBUTES_LOCK_TIMEOUT = void 0;
45
+ exports.acquireUpdateAttributesLock = acquireUpdateAttributesLock;
46
+ exports.releaseUpdateAttributesLock = releaseUpdateAttributesLock;
47
+ exports.withUpdateAttributesLock = withUpdateAttributesLock;
45
48
  exports.ignoreAlreadyDropped = ignoreAlreadyDropped;
46
49
  exports.freezeRecord = freezeRecord;
47
50
  exports.frozenRecordView = frozenRecordView;
@@ -50,6 +53,7 @@ exports.coerceType = coerceType;
50
53
  const hdbTerms_ts_1 = require("../utility/hdbTerms.js");
51
54
  const node_vm_1 = require("node:vm");
52
55
  const node_crypto_1 = require("node:crypto");
56
+ const node_perf_hooks_1 = require("node:perf_hooks");
53
57
  const commonUtility_ts_1 = require("../utility/lmdb/commonUtility.js");
54
58
  const nodeIdMapping_ts_1 = require("./nodeIdMapping.js");
55
59
  const lodash_1 = __importDefault(require("lodash"));
@@ -58,12 +62,15 @@ const lmdbProcessRows_js_1 = __importDefault(require("../dataLayer/harperBridge/
58
62
  const Resource_ts_1 = require("./Resource.js");
59
63
  const when_ts_1 = require("../utility/when.js");
60
64
  const DatabaseTransaction_ts_1 = require("./DatabaseTransaction.js");
65
+ const recordLock_ts_1 = require("./recordLock.js");
66
+ const nodeName_ts_1 = require("../server/nodeName.js");
61
67
  const envMngr = __importStar(require("../utility/environment/environmentManager.js"));
62
68
  const transactionBroadcast_ts_1 = require("./transactionBroadcast.js");
63
69
  const hdbError_ts_1 = require("../utility/errors/hdbError.js");
64
70
  const signalling = __importStar(require("../utility/signalling.js"));
65
71
  const itc_js_1 = require("../server/threads/itc.js");
66
72
  const databases_ts_1 = require("./databases.js");
73
+ const replicatedApplyFailure_ts_1 = require("./replicatedApplyFailure.js");
67
74
  const search_ts_1 = require("./search.js");
68
75
  const logger_ts_1 = require("../utility/logging/logger.js");
69
76
  const staticResourceDispatch_ts_1 = require("./staticResourceDispatch.js");
@@ -72,6 +79,8 @@ const transaction_ts_1 = require("./transaction.js");
72
79
  const ordered_binary_1 = require("ordered-binary");
73
80
  const manageThreads_js_1 = require("../server/threads/manageThreads.js");
74
81
  const auditStore_ts_1 = require("./auditStore.js");
82
+ const derivedIndexRegistry_ts_1 = require("./derivedIndexRegistry.js");
83
+ const recordLockCoordinator_ts_1 = require("./recordLockCoordinator.js");
75
84
  const embedHook_ts_1 = require("./models/embedHook.js");
76
85
  const common_utils_ts_1 = require("../utility/common_utils.js");
77
86
  const RecordEncoder_ts_1 = require("./RecordEncoder.js");
@@ -92,8 +101,14 @@ const { sortBy } = lodash_1.default;
92
101
  const { validateAttribute } = lmdbProcessRows_js_1.default;
93
102
  const NULL_WITH_TIMESTAMP = new Uint8Array(9);
94
103
  NULL_WITH_TIMESTAMP[8] = 0xc0; // null
104
+ const sourceWriteTypes = new Set(['put', 'patch', 'delete', 'publish', 'message', 'invalidate', 'relocate']);
105
+ const isSourceWriteType = (type) => sourceWriteTypes.has(type);
106
+ const SOURCE_APPLY_POSITION = Symbol('sourceApplyPosition');
95
107
  const UNCACHEABLE_TIMESTAMP = Infinity; // we use this when dynamic content is accessed that we can't safely cache, and this prevents earlier timestamps from change the "last" modification
108
+ const MAX_DATE_TIMESTAMP = 8.64e15;
96
109
  const RECORD_PRUNING_INTERVAL = 60000; // one minute
110
+ const MAX_CONCURRENT_HISTORY_REMOVALS = 10;
111
+ const MAX_CONCURRENT_LMDB_HISTORY_REMOVALS = 1000;
97
112
  // RocksDB-only: number of eviction/tombstone removals coalesced into a single transaction commit.
98
113
  // Each evict otherwise pays a full transaction commit, so batching amortizes that cost. LMDB already
99
114
  // coalesces async writes per event turn (eventTurnBatching), so it keeps the per-record path.
@@ -102,6 +117,20 @@ const EVICTION_BATCH_SIZE = 100;
102
117
  // letting an unbounded number of open transactions (and their snapshots) accumulate.
103
118
  const MAX_INFLIGHT_EVICTION_BATCHES = 4;
104
119
  const CACHEABLE_STATUS_CODES = new Set([200, 203, 204, 206, 300, 301, 308, 404, 405, 410, 414, 501]);
120
+ // Guardrails for `Prefer: count=exact`: once the requested page has been collected, counting the rest
121
+ // of the match set is bounded by BOTH a row cap and a wall-clock budget, so a paginated read can't turn
122
+ // into an unbounded scan. Exceeding either reports an unknown total (Content-Range `.../*`) rather than
123
+ // truncating the page. These bound the count tail, not the page itself; a genuinely expensive query
124
+ // (large filtered full-scan, in-memory sort) should still be gated by config before broad exposure.
125
+ const MAX_EXACT_COUNT_SCAN = 1_000_000;
126
+ const MAX_EXACT_COUNT_MS = 1_000;
127
+ // Largest page a `Prefer: count=` request will materialize. A request whose limit exceeds this (or is
128
+ // not a finite, non-negative integer, e.g. `limit(Infinity)`/`limit(foo)`) falls through to the normal
129
+ // streaming path with no count, so a count request can't be coerced into buffering an unbounded page.
130
+ const MAX_COUNT_PAGE = 10_000;
131
+ // How often the exact-count drain yields to the macrotask queue (must be a power of two for the bit-mask
132
+ // check). Keeps a large scan from monopolizing the event loop without adding a yield per row.
133
+ const COUNT_YIELD_INTERVAL = 2_048;
105
134
  // Smallest forward sample `getRecordCount` will extrapolate a record rate from; below it the scan runs
106
135
  // to completion and reports an exact count.
107
136
  const MIN_ESTIMATOR_SAMPLE = 1_000;
@@ -119,6 +148,58 @@ function usableCount(estimate) {
119
148
  envMngr.initSync();
120
149
  const LMDB_PREFETCH_WRITES = envMngr.get(hdbTerms_ts_1.CONFIG_PARAMS.STORAGE_PREFETCHWRITES);
121
150
  const LOCK_TIMEOUT = 10000;
151
+ // This bounds schema-lock acquisition; LOCK_TIMEOUT bounds in-flight record writes during a drop.
152
+ exports.UPDATE_ATTRIBUTES_LOCK_TIMEOUT = 10000;
153
+ const UPDATE_ATTRIBUTES_LOCK = 'update-attributes';
154
+ // Contention is otherwise only visible once it becomes a timeout (harper#2251).
155
+ exports.UPDATE_ATTRIBUTES_LOCK_SLOW_WAIT = 1000;
156
+ // raw ASCII bytes are ordered-binary's encoding of the string, so this addresses the same native
157
+ // lock as string-keyed tryLock/unlock calls
158
+ const updateAttributesLockKey = Buffer.from(UPDATE_ATTRIBUTES_LOCK);
159
+ const lockWait = new Int32Array(new SharedArrayBuffer(4));
160
+ /** The wait blocks the event loop, so the locked section must stay synchronous. */
161
+ function acquireUpdateAttributesLock(rootStore, scopeDescription, timeout = exports.UPDATE_ATTRIBUTES_LOCK_TIMEOUT) {
162
+ if (rootStore.tryLock(updateAttributesLockKey))
163
+ return;
164
+ const startTime = node_perf_hooks_1.performance.now();
165
+ let waitTime = 1;
166
+ while (!rootStore.tryLock(updateAttributesLockKey)) {
167
+ const elapsed = node_perf_hooks_1.performance.now() - startTime;
168
+ if (elapsed >= timeout) {
169
+ throw new hdbError_ts_1.UpdateAttributesLockTimeoutError(`Timed out after ${Math.round(elapsed)}ms waiting for the exclusive '${UPDATE_ATTRIBUTES_LOCK}' lock on ${scopeDescription}; the lock holder did not release it before the deadline, so this schema/attribute update cannot proceed`);
170
+ }
171
+ if (elapsed >= 2) {
172
+ Atomics.wait(lockWait, 0, 0, Math.min(waitTime, timeout - elapsed));
173
+ if (waitTime < 16)
174
+ waitTime *= 2;
175
+ }
176
+ }
177
+ const waited = node_perf_hooks_1.performance.now() - startTime;
178
+ // The caller cannot register its release until we return, so a throw here would leak the lock
179
+ // with no `finally` able to reach it.
180
+ if (waited >= exports.UPDATE_ATTRIBUTES_LOCK_SLOW_WAIT)
181
+ try {
182
+ logger_ts_1.logger.warn?.(`Acquired the exclusive '${UPDATE_ATTRIBUTES_LOCK}' lock on ${scopeDescription} after waiting ${Math.round(waited)}ms; this worker's event loop was blocked for that wait, and a holder that runs past ${exports.UPDATE_ATTRIBUTES_LOCK_TIMEOUT}ms fails the update outright`);
183
+ }
184
+ catch { }
185
+ }
186
+ function releaseUpdateAttributesLock(rootStore) {
187
+ rootStore.unlock(updateAttributesLockKey);
188
+ }
189
+ function withUpdateAttributesLock(rootStore, scopeDescription, callback) {
190
+ acquireUpdateAttributesLock(rootStore, scopeDescription);
191
+ try {
192
+ const result = callback();
193
+ if (typeof result?.then === 'function') {
194
+ Promise.resolve(result).catch((error) => logger_ts_1.logger.error?.(`Async update-attributes callback rejected after its lock was released (${scopeDescription})`, error));
195
+ throw new TypeError(`withUpdateAttributesLock callback must be synchronous (${scopeDescription}); asynchronous work may continue after the lock is released`);
196
+ }
197
+ return result;
198
+ }
199
+ finally {
200
+ releaseUpdateAttributesLock(rootStore);
201
+ }
202
+ }
122
203
  // Tolerate a redundant column family drop. Drops are broadcast to every worker
123
204
  // thread and each holds its own handle to the same underlying family, so a
124
205
  // concurrent worker may already have dropped it; the storage engine reports
@@ -349,9 +430,49 @@ function detectCyclicEnumerable(start) {
349
430
  function chainKeyForId(id) {
350
431
  return typeof id === 'string' ? 's' + id : 'k' + (0, DatabaseTransaction_ts_1.writeKeyId)(id);
351
432
  }
433
+ /** Normalizes a passed `context` argument as `transactional()` does; undefined means fall back to ambient. */
434
+ function contextArgument(context) {
435
+ if (!context || (0, DatabaseTransaction_ts_1.isReleasedTransaction)(context))
436
+ return undefined;
437
+ const resolved = context.getContext?.() || context;
438
+ return resolved instanceof DatabaseTransaction_ts_1.DatabaseTransaction ? { transaction: resolved } : resolved;
439
+ }
440
+ /** The cluster round never ran for a node-scoped handle, so no peer ever deferred to it. */
441
+ function scopeViolation(handle, resolved, databaseName) {
442
+ if (resolved.scope !== 'cluster' || handle.clusterTsR !== undefined)
443
+ return undefined;
444
+ // The same predicate lock() fails closed on, not the transport alone: a coalesced caller re-checks
445
+ // this after its wait, and a transport unregistered during that wait leaves the database still
446
+ // clustered while the lookup answers undefined. Only the implicit Phase 0 case falls through.
447
+ if (!resolved.scopeRequested && !(0, recordLockCoordinator_ts_1.isClusterLockRequired)(databaseName) && !(0, recordLockCoordinator_ts_1.getClusterLockTransport)(databaseName))
448
+ return undefined;
449
+ return new hdbError_ts_1.ClientError('This transaction already holds a node-scoped lock on this record, so a cluster-scoped lock cannot be taken on top of it', 409);
450
+ }
451
+ /** Distinguishes bare lock options from a record target (id, URL, {id:...}). */
452
+ function isPlainOptions(value) {
453
+ return (typeof value === 'object' &&
454
+ value !== null &&
455
+ !Array.isArray(value) &&
456
+ !(value instanceof URLSearchParams) &&
457
+ value.id === undefined);
458
+ }
459
+ // Lets a transport push a received control entry straight to the right coordinator without
460
+ // importing Table (which would be a cycle through databases.ts).
461
+ (0, recordLockCoordinator_ts_1.setLockCoordinatorResolver)((database, tableName) => databases_ts_1.databases[database]?.[tableName]?.lockCoordinator, (database, tableName) => databases_ts_1.databases[database]?.[tableName]?.admittingCoordinator, (database, tableName) => {
462
+ const Table = databases_ts_1.databases[database]?.[tableName];
463
+ if (typeof Table?.writeLockControlEntry !== 'function')
464
+ return undefined;
465
+ return (entry) => Table.writeLockControlEntry(entry);
466
+ });
352
467
  function makeTable(options) {
353
- const { primaryKey, indices, tableId, tableName, primaryStore, databasePath, databaseName, auditStore, schemaDefined, dbisDB: dbisDb, sealed, splitSegments, replicate, description, hidden, cacheControl, } = options;
468
+ var _a;
469
+ const { primaryKey, indices, tableId, tableName, primaryStore, databasePath, databaseName, auditStore, schemaDefined, dbisDB: dbisDb, sealed, splitSegments, replicate, description, hidden, cacheControl, isBranch, } = options;
354
470
  let { expirationMS: expirationMs, evictionMS: evictionMs, audit, trackDeletes } = options;
471
+ // Set when the TTL exists only on this thread: either application code configured it at runtime, or
472
+ // an isolated application's schema was declared here. Hydrating persisted metadata does not set it:
473
+ // dedicated workers open unrelated shared tables too, whose scan remains owned by the pool.
474
+ let ttlConfiguredByApplication = false;
475
+ let ttlFromLoad = false; // true only around the creation-time call below
355
476
  evictionMs ??= 0;
356
477
  // Eviction without explicit expiration means expiration:0. Apply at construction so
357
478
  // describe_all sees it on every worker, not just ones that ran setTTLExpiration.
@@ -363,7 +484,11 @@ function makeTable(options) {
363
484
  if (!properties)
364
485
  properties = (0, jsonSchemaTypes_ts_1.projectAttributesToProperties)(attributes);
365
486
  const updateRecord = (0, RecordEncoder_ts_1.recordUpdater)(primaryStore, tableId, auditStore);
487
+ // Created on first cluster-scoped lock() or first arriving control entry, and only while a
488
+ // transport is registered for this database.
489
+ let lockCoordinator;
366
490
  let warnedNullSourcePut = false; // latched: one warn per table per worker (see _writeUpdate)
491
+ let warnedFutureSourceVersion = false; // likewise (see getFromSource)
367
492
  let sourceLoad; // if a source has a load function (replicator), record it here
368
493
  let hasSourceGet;
369
494
  let primaryKeyAttribute;
@@ -397,6 +522,10 @@ function makeTable(options) {
397
522
  let cleanupPriority = 0;
398
523
  let lastCleanupInterval;
399
524
  let cleanupTimer;
525
+ let recordExpirationInterval;
526
+ // a reclamation pass awaits a scheduled cleanup, which only settles from its timer
527
+ const pendingCleanupResolvers = new Set();
528
+ let disposed = false;
400
529
  // true once a table-level expiration/eviction/scanInterval has armed the periodic cleanup scan at setup
401
530
  let expirationScanScheduled = false;
402
531
  // set on the first expiring write so the unscheduled-expiration warning is evaluated at most once per table
@@ -445,10 +574,11 @@ function makeTable(options) {
445
574
  const MAX_PREFETCH_BUNDLE = 6;
446
575
  if (audit)
447
576
  addDeleteRemoval();
448
- (0, storageReclamation_ts_1.onStorageReclamation)(primaryStore.path, (priority) => {
577
+ const reclamationHandler = (priority) => {
449
578
  if (hasSourceGet)
450
579
  return scheduleCleanup(priority);
451
- });
580
+ };
581
+ (0, storageReclamation_ts_1.onStorageReclamation)(primaryStore.path, reclamationHandler);
452
582
  class Updatable extends tracked_ts_1.GenericTrackedObject {
453
583
  getUpdatedTime() {
454
584
  return RecordEncoder_ts_1.entryMap.get(this.getRecord())?.version;
@@ -547,12 +677,104 @@ function makeTable(options) {
547
677
  },
548
678
  });
549
679
  }
680
+ function resolveAuditHead(id, version, nodeId, refs) {
681
+ if (!refs?.length)
682
+ return { txnLogKey: version, nodeId };
683
+ const visited = new Set();
684
+ function findHead(candidateRefs) {
685
+ if (!candidateRefs)
686
+ return;
687
+ const pending = candidateRefs.slice().reverse();
688
+ while (pending.length > 0) {
689
+ const ref = pending.pop();
690
+ const identity = `${ref.nodeId ?? 0}:${ref.version}`;
691
+ if (visited.has(identity))
692
+ continue;
693
+ visited.add(identity);
694
+ const entry = auditStore.getSync(ref.version, tableId, id, ref.nodeId);
695
+ if (!entry)
696
+ continue;
697
+ if (entry.version === version && (nodeId == null || (entry.nodeId ?? 0) === nodeId))
698
+ return { txnLogKey: ref.version, nodeId: ref.nodeId };
699
+ const previousRefs = entry.previousAdditionalAuditRefs;
700
+ if (previousRefs) {
701
+ for (let index = previousRefs.length - 1; index >= 0; index--)
702
+ pending.push(previousRefs[index]);
703
+ }
704
+ }
705
+ }
706
+ const referencedHead = findHead(refs);
707
+ if (referencedHead)
708
+ return referencedHead;
709
+ if (version != null) {
710
+ const directHead = auditStore.getSync(version, tableId, id, nodeId);
711
+ if (directHead?.version === version && (nodeId == null || (directHead.nodeId ?? 0) === nodeId))
712
+ return { txnLogKey: version, nodeId };
713
+ }
714
+ return { txnLogKey: version, nodeId };
715
+ }
716
+ // Canonical-source applies (sourceApply), replay and replication notifications are never shed;
717
+ // dropping one would advance the source cursor past a write that never landed.
718
+ function assertDerivedIndexAdmission(options, transaction) {
719
+ if (options?.isNotification || transaction?.sourceApply || transaction?.isReplay)
720
+ return;
721
+ const reason = (0, derivedIndexRegistry_ts_1.derivedIndexWriteRejection)(auditStore, tableId);
722
+ if (reason)
723
+ throw new hdbError_ts_1.DerivedIndexLagError(reason);
724
+ }
725
+ function stageDerivedIndexEviction(transaction, id, version) {
726
+ if (!(0, derivedIndexRegistry_ts_1.hasDerivedIndexRegistration)(auditStore, tableId))
727
+ return;
728
+ const nodeId = (0, nodeIdMapping_ts_1.getThisNodeId)(auditStore) ?? 0;
729
+ auditStore.put(null, {
730
+ type: 'evict',
731
+ tableId,
732
+ recordId: id,
733
+ version,
734
+ nodeId,
735
+ extendedType: auditStore_ts_1.LOCAL_ONLY,
736
+ }, { transaction, nodeId });
737
+ }
550
738
  class TableResource extends Resource_ts_1.Resource {
551
739
  #record; // the stored/frozen record from the database and stored in the cache (should not be modified directly)
552
740
  #changes; // the changes to the record that have been made (should not be modified directly)
553
741
  #version; // version of the record
554
742
  #entry; // the entry from the database
555
743
  #savingOperation; // operation for the record is currently being saved
744
+ #lockHandle; // the record lock acquired by lock() — scoped or hold
745
+ #lockWritable; // set by #reloadLocked to let save() stage lock-writable updates
746
+ #writeGeneration;
747
+ [tracked_ts_1.ASSERT_TRACKED_WRITABLE](generation = this.#writeGeneration) {
748
+ if (!generation)
749
+ return;
750
+ if (generation.internalWrites > 0)
751
+ return;
752
+ if (generation !== this.#writeGeneration || generation.closed)
753
+ throw new hdbError_ts_1.ClientError('Can not modify an update instance after it has been saved; call update() again', 409);
754
+ }
755
+ [tracked_ts_1.GET_TRACKED_WRITE_GENERATION]() {
756
+ return (this.#writeGeneration ??= { closed: false, internalWrites: 0 });
757
+ }
758
+ /**
759
+ * Shared guard: if this instance is lock-writable but the handle is gone (expired or
760
+ * released), throw 409 before staging any write. Covers update/invalidate/relocate/delete
761
+ * in addition to the save() path. Every lock-writable instance carries its own handle in
762
+ * #lockHandle (scoped and hold alike), so we never need to search the registry here.
763
+ */
764
+ #assertLiveHandle(id, allowClosed = false) {
765
+ if (!allowClosed && this.#writeGeneration?.closed && (0, DatabaseTransaction_ts_1.writeKeyId)(id) === (0, DatabaseTransaction_ts_1.writeKeyId)(this.getId()))
766
+ this[tracked_ts_1.ASSERT_TRACKED_WRITABLE]();
767
+ if (!this.#lockWritable)
768
+ return;
769
+ const handle = this.#lockHandle;
770
+ // Off-key writes through the same resource instance are ordinary; only guard the
771
+ // exact key the lock was acquired for.
772
+ if (handle.keyId !== (0, DatabaseTransaction_ts_1.writeKeyId)(id))
773
+ return;
774
+ if (handle.isExpired()) {
775
+ throw (0, recordLock_ts_1.lockNotHeldError)(handle);
776
+ }
777
+ }
556
778
  // #section: static-config
557
779
  static name = tableName; // for display/debugging purposes
558
780
  static primaryStore = primaryStore;
@@ -561,6 +783,7 @@ function makeTable(options) {
561
783
  static tableName = tableName;
562
784
  static tableId = tableId;
563
785
  static indices = indices;
786
+ static derivedIndexRuntime;
564
787
  static audit = audit;
565
788
  static databasePath = databasePath;
566
789
  static databaseName = databaseName;
@@ -646,10 +869,62 @@ function makeTable(options) {
646
869
  (async () => {
647
870
  let userRoleUpdate = false;
648
871
  let lastSequenceId;
872
+ let pendingApplyFailures;
873
+ const reportDroppedWrite = (event, context, error) => {
874
+ const position = event === context ? context[SOURCE_APPLY_POSITION] : (event.timestamp ?? context[SOURCE_APPLY_POSITION]);
875
+ const notification = (0, replicatedApplyFailure_ts_1.notifyReplicatedApplyFailure)(databaseName, {
876
+ nodeId: event.nodeId ?? context.nodeId,
877
+ table: event.table ?? context.table,
878
+ localTime: event.localTime ?? context.localTime,
879
+ }, position, error, tableName);
880
+ pendingApplyFailures = pendingApplyFailures
881
+ ? Promise.all([pendingApplyFailures, notification]).then(noop)
882
+ : notification;
883
+ return notification;
884
+ };
885
+ /** Cluster lock coordination entries (harper#483 Phase 1) describe no record. */
886
+ const applyLockControlEvent = (event, context) => {
887
+ const entry = (0, recordLockCoordinator_ts_1.decodeLockControlPayload)(event.type, event.value);
888
+ if (!entry) {
889
+ logger_ts_1.logger.warn?.('discarding a malformed record lock control entry from', event.nodeId, event.type);
890
+ return reportDroppedWrite(event, context, new Error('Malformed record lock control entry'));
891
+ }
892
+ const target = event.table ? databases_ts_1.databases[databaseName]?.[event.table] : _a;
893
+ try {
894
+ // The audit header's nodeId is the origin, translated on receive and preserved across
895
+ // relays. The payload's own names are peer-supplied and prove nothing. Rebuild the id
896
+ // map on a miss rather than waiting out the negative-cache window: a dropped release
897
+ // leaves the key's home holding its grant until the delegation's own deadline, and
898
+ // control entries are far too rare to drive the store.
899
+ //
900
+ // Inside the guard, not before it: that rebuild reads the audit store, and a throw
901
+ // there would escape this sink and stall the apply loop for every later entry — the §8
902
+ // rule that a receive boundary settles its callers and keeps admission closed.
903
+ const author = (0, nodeIdMapping_ts_1.getNodeNameForId)(auditStore, event.nodeId, true);
904
+ if (!author) {
905
+ logger_ts_1.logger.warn?.('discarding a record lock control entry whose origin node could not be resolved');
906
+ return reportDroppedWrite(event, context, new Error('Record lock control origin could not be resolved'));
907
+ }
908
+ // The coordinator getter fails closed on an unusable node identity. That is right for
909
+ // an acquire and wrong here: rejecting out of this sink stalls the apply loop for
910
+ // every later entry rather than dropping one.
911
+ // `admittingCoordinator`, because `lockCoordinator` answers undefined while a transport
912
+ // is momentarily unregistered — and this sink runs off the replication stream, not off
913
+ // that transport. Dropping a peer's clean-handoff release there leaves the home holding
914
+ // its grant for the delegation's whole deadline.
915
+ target?.admittingCoordinator?.applyEntry(entry, author, event.timestamp);
916
+ }
917
+ catch (error) {
918
+ logger_ts_1.logger.warn?.('dropping a record lock control entry: the coordinator is unavailable', error);
919
+ return reportDroppedWrite(event, context, error);
920
+ }
921
+ };
649
922
  // perform the write of an individual write event
650
923
  const writeUpdate = async (event, context) => {
924
+ if ((0, auditStore_ts_1.isLockControlType)(event.type))
925
+ return applyLockControlEvent(event, context);
651
926
  const value = event.value;
652
- const Table = event.table ? databases_ts_1.databases[databaseName][event.table] : TableResource;
927
+ const Table = event.table ? databases_ts_1.databases[databaseName][event.table] : _a;
653
928
  if (databaseName === hdbTerms_ts_1.SYSTEM_SCHEMA_NAME &&
654
929
  (event.table === hdbTerms_ts_1.SYSTEM_TABLE_NAMES.ROLE_TABLE_NAME || event.table === hdbTerms_ts_1.SYSTEM_TABLE_NAMES.USER_TABLE_NAME)) {
655
930
  userRoleUpdate = true;
@@ -666,6 +941,9 @@ function makeTable(options) {
666
941
  ensureLoaded: false,
667
942
  nodeId: event.nodeId,
668
943
  viaNodeId: event.viaNodeId,
944
+ // the origin's record version, stored as-is so every replica holds the version the
945
+ // origin holds; the transaction's own timestamp stays the origin's log key
946
+ version: event.version,
669
947
  // use per-event expiresAt: batched txn context only holds the first event's expiration
670
948
  expiresAt: event.expiresAt,
671
949
  // bulk base-copy snapshot frame: apply current-state directly, without an audit/transaction-log
@@ -675,6 +953,15 @@ function makeTable(options) {
675
953
  async: true,
676
954
  };
677
955
  const id = event.id;
956
+ if (!isSourceWriteType(event.type)) {
957
+ logger_ts_1.logger.error?.('Unknown operation', event.type, event.id);
958
+ const notification = reportDroppedWrite(event, context, new Error('Unknown source operation'));
959
+ if (event.finished)
960
+ await event.finished;
961
+ return notification;
962
+ }
963
+ if (Table && event.type === 'put' && value == null && !shouldRevalidateEvents)
964
+ await reportDroppedWrite(event, context, new Error('Source-applied put has no record content'));
678
965
  const resource = await Table.getResource(id, context, options);
679
966
  if (event.finished)
680
967
  await event.finished;
@@ -696,15 +983,18 @@ function makeTable(options) {
696
983
  return resource._writeInvalidate(id, value, options);
697
984
  case 'relocate':
698
985
  return resource._writeRelocate(id, options);
699
- default:
700
- logger_ts_1.logger.error?.('Unknown operation', event.type, event.id);
701
986
  }
702
987
  };
703
988
  /** Keeps the writes to any one key in arrival order; see DESIGN.md (harper#2211). */
704
989
  const stageWrite = (event, context) => {
990
+ // A grant must not queue behind whatever the key it names is doing.
991
+ if ((0, auditStore_ts_1.isLockControlType)(event.type) ||
992
+ !isSourceWriteType(event.type) ||
993
+ (event.type === 'put' && event.value == null && !shouldRevalidateEvents))
994
+ return writeUpdate(event, context);
705
995
  let chainKey;
706
996
  try {
707
- const Table = event.table ? databases_ts_1.databases[databaseName][event.table] : TableResource;
997
+ const Table = event.table ? databases_ts_1.databases[databaseName][event.table] : _a;
708
998
  const id = event.id ?? (event.value ? event.value[Table?.primaryKey] : undefined);
709
999
  if (id != null && typeof id !== 'symbol')
710
1000
  chainKey = `${event.table ?? tableName} ${chainKeyForId(id)}`;
@@ -745,14 +1035,18 @@ function makeTable(options) {
745
1035
  omitCurrent: true,
746
1036
  };
747
1037
  const subscribeOnThisThread = source.subscribeOnThisThread
748
- ? source.subscribeOnThisThread((0, manageThreads_js_1.getWorkerIndex)(), subscriptionOptions)
749
- : (0, manageThreads_js_1.getWorkerIndex)() === 0;
1038
+ ? source.subscribeOnThisThread((0, manageThreads_js_1.applicationWorkerIndex)(), subscriptionOptions)
1039
+ : (0, manageThreads_js_1.runsApplicationCodeSingletons)(); // set up by the defining application's code, so it runs where that code does
750
1040
  const subscription = hasSubscribe && subscribeOnThisThread && (await source.subscribe?.(subscriptionOptions));
751
1041
  if (subscription) {
752
1042
  let txnInProgress;
753
1043
  // we listen for events by iterating through the async iterator provided by the subscription
754
1044
  for await (const event of subscription) {
1045
+ let failureEvent = event;
1046
+ let failurePosition;
1047
+ let applied = false;
755
1048
  try {
1049
+ failurePosition = event?.timestamp;
756
1050
  if (!event || typeof event !== 'object') {
757
1051
  logger_ts_1.logger.error?.('Bad subscription event', event);
758
1052
  continue;
@@ -760,6 +1054,7 @@ function makeTable(options) {
760
1054
  const firstWrite = event.type === 'transaction' ? event.writes[0] : event;
761
1055
  if (!firstWrite) {
762
1056
  logger_ts_1.logger.error?.('Bad subscription event', event);
1057
+ await (0, replicatedApplyFailure_ts_1.notifyReplicatedApplyFailure)(databaseName, event, failurePosition, new Error('Subscription transaction has no writes'), tableName);
763
1058
  continue;
764
1059
  }
765
1060
  event.source = source;
@@ -768,11 +1063,16 @@ function makeTable(options) {
768
1063
  // there is no re-subscribe / sequence-id-resume path to recover it. Mark the context so the
769
1064
  // commit retries such conflicts without a cap (see DatabaseTransaction commit).
770
1065
  event.sourceApply = true;
1066
+ event[SOURCE_APPLY_POSITION] = failurePosition;
771
1067
  if (event.type === 'end_txn') {
772
1068
  // Capture the in-progress transaction in a stable local: the loop variable is reset
773
1069
  // once this transaction completes (below), but the seq-id closure and the commit await
774
1070
  // still need to reference it afterward.
775
1071
  const committingTxn = txnInProgress;
1072
+ if (committingTxn) {
1073
+ failureEvent = committingTxn;
1074
+ failurePosition = committingTxn[SOURCE_APPLY_POSITION];
1075
+ }
776
1076
  committingTxn?.resolve();
777
1077
  let updateRecordedSequenceId;
778
1078
  if (event.localTime && lastSequenceId !== event.localTime) {
@@ -855,6 +1155,7 @@ function makeTable(options) {
855
1155
  let committed;
856
1156
  try {
857
1157
  committed = committingTxn ? await committingTxn.committed : undefined;
1158
+ applied = true;
858
1159
  if (event.onCommit) {
859
1160
  // the onCommit callback can be async and carry associated work (e.g. blob
860
1161
  // transfer); wait for it too before recording the sequence id. Pass the commit
@@ -891,6 +1192,7 @@ function makeTable(options) {
891
1192
  // than rethrow) so the current beginTxn still starts a fresh transaction with
892
1193
  // correct boundaries instead of having its writes applied as standalone ones.
893
1194
  logger_ts_1.logger.error?.('source-applied transaction commit failed during apply', error);
1195
+ await (0, replicatedApplyFailure_ts_1.notifyReplicatedApplyFailure)(databaseName, txnInProgress, txnInProgress[SOURCE_APPLY_POSITION], error, tableName);
894
1196
  }
895
1197
  finally {
896
1198
  // Clear it regardless of outcome so a rejected commit isn't re-awaited on the
@@ -904,7 +1206,9 @@ function makeTable(options) {
904
1206
  continue;
905
1207
  }
906
1208
  }
907
- // use the version as the transaction timestamp
1209
+ // A source that reports no log position of its own (no `timestamp`) has only one clock,
1210
+ // so its record version doubles as the apply transaction's timestamp. A replication
1211
+ // receiver always sets `timestamp` from the origin's log key and never reaches this.
908
1212
  if (!event.timestamp && event.version)
909
1213
  event.timestamp = event.version;
910
1214
  const commitResolution = (0, transaction_ts_1.transaction)(event, () => {
@@ -978,6 +1282,7 @@ function makeTable(options) {
978
1282
  // standalone write: backpressure on the commit before pulling the next event,
979
1283
  // and pass the commit resolution through to the callback.
980
1284
  const committed = commitResolution ? await commitResolution : undefined;
1285
+ applied = true;
981
1286
  await event.onCommit(committed);
982
1287
  }
983
1288
  }
@@ -988,6 +1293,15 @@ function makeTable(options) {
988
1293
  }
989
1294
  catch (error) {
990
1295
  logger_ts_1.logger.error?.('error in subscription handler', error);
1296
+ if (!applied)
1297
+ await (0, replicatedApplyFailure_ts_1.notifyReplicatedApplyFailure)(databaseName, failureEvent, failurePosition, error, tableName);
1298
+ }
1299
+ finally {
1300
+ while (pendingApplyFailures) {
1301
+ const notification = pendingApplyFailures;
1302
+ pendingApplyFailures = undefined;
1303
+ await notification;
1304
+ }
991
1305
  }
992
1306
  }
993
1307
  }
@@ -1005,7 +1319,7 @@ function makeTable(options) {
1005
1319
  /** Indicates if the events should be revalidated when they are received. By default we do this if the get
1006
1320
  * method is overriden */
1007
1321
  static get shouldRevalidateEvents() {
1008
- return this.prototype.get !== TableResource.prototype.get;
1322
+ return this.prototype.get !== _a.prototype.get;
1009
1323
  }
1010
1324
  /**
1011
1325
  * Gets a resource instance, as defined by the Resource class, adding the table-specific handling
@@ -1041,7 +1355,7 @@ function makeTable(options) {
1041
1355
  }
1042
1356
  return loadLocalRecord(id, request, { transaction: readTxn, ensureLoaded: resourceOptions?.ensureLoaded }, sync, (entry) => {
1043
1357
  if (entry) {
1044
- TableResource._updateResource(this, entry);
1358
+ _a._updateResource(this, entry);
1045
1359
  }
1046
1360
  else
1047
1361
  this.#record = null;
@@ -1059,7 +1373,7 @@ function makeTable(options) {
1059
1373
  if (loadingFromSource) {
1060
1374
  txn?.disregardReadTxn(); // this could take some time, so don't keep the transaction open if possible
1061
1375
  return (0, when_ts_1.when)(loadingFromSource, (entry) => {
1062
- TableResource._updateResource(this, entry);
1376
+ _a._updateResource(this, entry);
1063
1377
  return this;
1064
1378
  });
1065
1379
  }
@@ -1251,15 +1565,24 @@ function makeTable(options) {
1251
1565
  * This also informs the scheduling for record eviction.
1252
1566
  * @param opts Time in seconds until records expire, or an options object with `expiration`, `eviction`,
1253
1567
  * and `scanInterval` (all in seconds, all optional). Number form preserves any previously configured
1254
- * eviction/scanInterval; object form replaces all three.
1568
+ * eviction/scanInterval; object form replaces all three. An internal schema ownership-only call with
1569
+ * none of those values preserves the settings already loaded from the catalog.
1255
1570
  */
1256
1571
  static setTTLExpiration(opts) {
1257
1572
  if (opts == null || (typeof opts !== 'number' && typeof opts !== 'object'))
1258
1573
  throw new Error('Invalid expiration value type');
1574
+ const declaredHere = typeof opts === 'object' && opts.fromSchema;
1575
+ const isolatedApplicationOwner = declaredHere && opts.isolatedApplicationOwner;
1576
+ const preserveLoadedConfiguration = declaredHere && opts.expiration === undefined && opts.eviction === undefined && opts.scanInterval === undefined;
1577
+ if (((!ttlFromLoad && !declaredHere) || isolatedApplicationOwner) && !ttlConfiguredByApplication) {
1578
+ ttlConfiguredByApplication = true;
1579
+ // the scan owner may have changed with this: re-evaluate even if the interval did not
1580
+ lastCleanupInterval = undefined;
1581
+ }
1259
1582
  if (typeof opts === 'number') {
1260
1583
  expirationMs = opts * 1000;
1261
1584
  }
1262
- else {
1585
+ else if (!preserveLoadedConfiguration) {
1263
1586
  // `??` so an explicit 0 is treated as the user's chosen value, not as "missing"
1264
1587
  expirationMs = (opts.expiration ?? 0) * 1000;
1265
1588
  evictionMs = (opts.eviction ?? 0) * 1000;
@@ -1267,10 +1590,19 @@ function makeTable(options) {
1267
1590
  }
1268
1591
  if (expirationMs < 0)
1269
1592
  throw new Error('Expiration can not be negative');
1270
- // default to one quarter of the total expiration+eviction window
1271
- cleanupInterval = cleanupInterval || (expirationMs + evictionMs) / 4;
1272
- expirationScanScheduled = true;
1273
- scheduleCleanup();
1593
+ if (!preserveLoadedConfiguration) {
1594
+ // default to one quarter of the total expiration+eviction window
1595
+ cleanupInterval = cleanupInterval || (expirationMs + evictionMs) / 4;
1596
+ expirationScanScheduled = true;
1597
+ }
1598
+ // Re-evaluate an existing table-level scan after an ownership-only declaration, but do not
1599
+ // create the default daily cleanup timer for a table that has only an @expiresAt field.
1600
+ if (!preserveLoadedConfiguration || expirationScanScheduled || evictionMs)
1601
+ scheduleCleanup();
1602
+ // @expiresAt has its own interval rather than the cleanup timer above. Arm it whenever a live
1603
+ // declaration introduces the attribute, including after this application already claimed TTL.
1604
+ if (expiresAtProperty && !recordExpirationInterval)
1605
+ runRecordExpirationEviction();
1274
1606
  }
1275
1607
  static getResidencyRecord(id) {
1276
1608
  // getSync (not get): callers consume the result synchronously (e.g. residency.includes(...) in a
@@ -1279,7 +1611,7 @@ function makeTable(options) {
1279
1611
  return dbisDb.getSync([Symbol.for('residency_by_id'), id]);
1280
1612
  }
1281
1613
  static setResidency(getResidency) {
1282
- TableResource.getResidency =
1614
+ _a.getResidency =
1283
1615
  getResidency &&
1284
1616
  ((record, context) => {
1285
1617
  try {
@@ -1292,7 +1624,7 @@ function makeTable(options) {
1292
1624
  });
1293
1625
  }
1294
1626
  static setResidencyById(getResidencyById) {
1295
- TableResource.getResidencyById =
1627
+ _a.getResidencyById =
1296
1628
  getResidencyById &&
1297
1629
  ((id) => {
1298
1630
  try {
@@ -1305,8 +1637,8 @@ function makeTable(options) {
1305
1637
  });
1306
1638
  }
1307
1639
  static getResidency(record, context) {
1308
- if (TableResource.getResidencyById) {
1309
- return TableResource.getResidencyById(record[primaryKey]);
1640
+ if (_a.getResidencyById) {
1641
+ return _a.getResidencyById(record[primaryKey]);
1310
1642
  }
1311
1643
  let count = replicateToCount;
1312
1644
  if (context.replicateTo != undefined) {
@@ -1348,7 +1680,7 @@ function makeTable(options) {
1348
1680
  return; // already enabled
1349
1681
  audit = true;
1350
1682
  addDeleteRemoval();
1351
- TableResource.audit = true;
1683
+ _a.audit = true;
1352
1684
  }
1353
1685
  /**
1354
1686
  * Coerce the id as a string to the correct type for the primary key
@@ -1360,7 +1692,57 @@ function makeTable(options) {
1360
1692
  return null;
1361
1693
  return coerceType(id, primaryKeyAttribute);
1362
1694
  }
1695
+ /**
1696
+ * A branch's Table classes deliberately carry the BASE's logical database name so an
1697
+ * application's schema and code resolve unchanged (harper#643). That makes every schema
1698
+ * mutation resolve against the global catalog — a `dropTable()` through a branch would delete
1699
+ * the live base table. Reads and writes are per-branch and unaffected; DDL is refused until a
1700
+ * branch owns a schema identity of its own.
1701
+ */
1702
+ static assertSchemaMutable(operation) {
1703
+ if (!isBranch)
1704
+ return;
1705
+ const error = new Error(`Cannot ${operation} through a branched database: '${tableName}' resolves to the schema of base ` +
1706
+ `database '${databaseName}', so the change would apply to the base rather than the branch`);
1707
+ error.statusCode = 400;
1708
+ throw error;
1709
+ }
1363
1710
  static async dropTable() {
1711
+ _a.assertSchemaMutable('drop a table');
1712
+ const rootStore = primaryStore.rootStore;
1713
+ if (databaseName === databasePath &&
1714
+ rootStore instanceof rocksdb_js_1.RocksDatabase &&
1715
+ dbisDb.put !== dbisDb.putSync)
1716
+ throw new Error(`Cannot drop ${databaseName}.${_a.tableName}: the catalog store's put is asynchronous, so the drop tombstone cannot be made durable before the column families are dropped`);
1717
+ // Release post-commit derived-index delivery before any destructive work: the runner's
1718
+ // backend must have quiesced before its stores and native file are destroyed, and a
1719
+ // same-name recreate must not race an owner still applying to the old generation.
1720
+ const derivedIndexRuntime = _a.derivedIndexRuntime;
1721
+ const restoreDerivedIndexesAfterFailedDrop = () => {
1722
+ try {
1723
+ _a.derivedIndexRuntime = derivedIndexRuntime?.restoreAfterFailedDrop?.();
1724
+ }
1725
+ catch (restoreError) {
1726
+ _a.derivedIndexRuntime = undefined;
1727
+ logger_ts_1.logger.error?.(`Could not restore derived indexes after failed drop of ${databaseName}.${_a.tableName}`, restoreError);
1728
+ }
1729
+ };
1730
+ try {
1731
+ await derivedIndexRuntime?.close(true);
1732
+ }
1733
+ catch (error) {
1734
+ restoreDerivedIndexesAfterFailedDrop();
1735
+ throw error;
1736
+ }
1737
+ const abortStaleDrop = () => {
1738
+ derivedIndexRuntime?.completeDrop?.(false);
1739
+ _a.derivedIndexRuntime = undefined;
1740
+ _a.cleanup();
1741
+ if (databases_ts_1.databases[databaseName]?.[tableName] === _a)
1742
+ delete databases_ts_1.databases[databaseName][tableName];
1743
+ };
1744
+ let dropIdentityConfirmed = databaseName !== databasePath;
1745
+ let primaryCatalogKey = _a.tableName + '/';
1364
1746
  if (databaseName === databasePath) {
1365
1747
  // Persist a drop tombstone on the primary catalog entry BEFORE any
1366
1748
  // destructive work. If the process dies or a column family drop fails
@@ -1368,9 +1750,21 @@ function makeTable(options) {
1368
1750
  // the next startup (or a same-name create) completes the drop via
1369
1751
  // completeInterruptedDrop in databases.ts instead of resurrecting
1370
1752
  // the table.
1371
- const primaryCatalogKey = TableResource.tableName + '/';
1372
- const primaryMeta = dbisDb.getSync(primaryCatalogKey);
1373
- if (primaryMeta && !primaryMeta.dropping) {
1753
+ let tombstoneWrite;
1754
+ const writeTombstone = () => {
1755
+ let primaryMeta = dbisDb.getSync(primaryCatalogKey);
1756
+ if (!primaryMeta && primaryKey) {
1757
+ const legacyPrimaryKey = `${_a.tableName}/${primaryKey}`;
1758
+ const legacyPrimaryMeta = dbisDb.getSync(legacyPrimaryKey);
1759
+ if (legacyPrimaryMeta?.isPrimaryKey) {
1760
+ primaryCatalogKey = legacyPrimaryKey;
1761
+ primaryMeta = legacyPrimaryMeta;
1762
+ }
1763
+ }
1764
+ if (!primaryMeta || (primaryMeta.tableId != null && primaryMeta.tableId !== tableId))
1765
+ return false;
1766
+ if (primaryMeta.dropping)
1767
+ return true;
1374
1768
  primaryMeta.dropping = true;
1375
1769
  // Stamps this drop's identity so the interrupted-drop retry budget in
1376
1770
  // databases.ts can be scoped to THIS drop rather than the table name: a
@@ -1380,14 +1774,33 @@ function makeTable(options) {
1380
1774
  // the budget by generation instead makes the new drop's tombstone carry
1381
1775
  // its own fresh key regardless of what any worker last observed.
1382
1776
  primaryMeta.dropGeneration = (0, node_crypto_1.randomUUID)();
1383
- // put is rebound to putSync on RocksDB stores; on LMDB it returns
1384
- // a promise, so await it to make the tombstone durable before the
1385
- // destructive work below
1386
- const tombstoneWrite = dbisDb.put(primaryCatalogKey, primaryMeta);
1387
- if (tombstoneWrite?.then)
1388
- await tombstoneWrite;
1777
+ tombstoneWrite = dbisDb.put(primaryCatalogKey, primaryMeta);
1778
+ return true;
1779
+ };
1780
+ try {
1781
+ if (rootStore instanceof rocksdb_js_1.RocksDatabase) {
1782
+ // withUpdateAttributesLock's locked section cannot be held across an await, so a durable
1783
+ // tombstone depends on put being rebound to putSync for RocksDB primary stores.
1784
+ dropIdentityConfirmed = withUpdateAttributesLock(rootStore, `drop table '${databaseName}.${_a.tableName}'`, writeTombstone);
1785
+ }
1786
+ else {
1787
+ rootStore.transactionSync(() => {
1788
+ dropIdentityConfirmed = writeTombstone();
1789
+ });
1790
+ if (typeof tombstoneWrite?.then === 'function')
1791
+ await tombstoneWrite;
1792
+ }
1389
1793
  }
1794
+ catch (error) {
1795
+ restoreDerivedIndexesAfterFailedDrop();
1796
+ throw error;
1797
+ }
1798
+ }
1799
+ if (!dropIdentityConfirmed) {
1800
+ abortStaleDrop();
1801
+ return;
1390
1802
  }
1803
+ _a.derivedIndexRuntime = undefined;
1391
1804
  // A get() against a sourcedFrom table resolves to its caller before the resolved
1392
1805
  // record's cache write has committed (see getFromSource) - the write lands "in the
1393
1806
  // background" for latency reasons. Flip this BEFORE removing the table from the
@@ -1400,7 +1813,8 @@ function makeTable(options) {
1400
1813
  // family drops below. If a drop fails past this point the table stays
1401
1814
  // invisible, and the tombstone guarantees the drop completes on the
1402
1815
  // next startup (or on a same-name create).
1403
- delete databases_ts_1.databases[databaseName][tableName];
1816
+ if (databases_ts_1.databases[databaseName]?.[tableName] === _a)
1817
+ delete databases_ts_1.databases[databaseName][tableName];
1404
1818
  // The above stops new source-fill writes from starting, but a write from a get()
1405
1819
  // that already returned to its caller may still be in flight. Dropping the column
1406
1820
  // families out from under that write is a genuine invariant violation, not just a
@@ -1431,14 +1845,21 @@ function makeTable(options) {
1431
1845
  ]);
1432
1846
  clearTimeout(timer);
1433
1847
  if (result === timedOut) {
1848
+ derivedIndexRuntime?.completeDrop?.();
1434
1849
  throw new Error(`dropTable() timed out after ${LOCK_TIMEOUT}ms waiting for ${pending.length} in-flight source-populated cache write(s) on ${tableName} to settle; refusing to drop the column families out from under a write that may still be staged. The drop tombstone is durable, so this will be retried on the next load.`);
1435
1850
  }
1436
1851
  }
1437
- for (const entry of primaryStore.getRange({ versions: true, snapshot: false, lazy: true })) {
1438
- if (entry.metadataFlags & auditStore_ts_1.HAS_BLOBS && entry.value) {
1439
- (0, blob_ts_1.deleteBlobsInObject)(entry.value);
1852
+ try {
1853
+ for (const entry of primaryStore.getRange({ versions: true, snapshot: false, lazy: true })) {
1854
+ if (entry.metadataFlags & auditStore_ts_1.HAS_BLOBS && entry.value) {
1855
+ (0, blob_ts_1.deleteBlobsInObject)(entry.value);
1856
+ }
1440
1857
  }
1441
1858
  }
1859
+ catch (error) {
1860
+ derivedIndexRuntime?.completeDrop?.();
1861
+ throw error;
1862
+ }
1442
1863
  if (databaseName === databasePath) {
1443
1864
  // part of a database.
1444
1865
  // Drop the column families, then remove the catalog metadata - never
@@ -1455,66 +1876,94 @@ function makeTable(options) {
1455
1876
  // same-name create completes the interrupted drop and writes fresh
1456
1877
  // catalog rows, and clobbering those would orphan the new table.
1457
1878
  const removeTombstonedCatalog = () => {
1458
- const currentPrimary = dbisDb.getSync(TableResource.tableName + '/');
1459
- if (!currentPrimary?.dropping)
1879
+ const currentPrimary = dbisDb.getSync(primaryCatalogKey);
1880
+ if (!currentPrimary?.dropping || (currentPrimary.tableId != null && currentPrimary.tableId !== tableId))
1460
1881
  return false;
1461
1882
  for (const attribute of attributes) {
1462
- dbisDb.remove(TableResource.tableName + '/' + attribute.name);
1883
+ dbisDb.remove(_a.tableName + '/' + attribute.name);
1463
1884
  }
1464
- dbisDb.remove(TableResource.tableName + '/');
1885
+ dbisDb.remove(primaryCatalogKey);
1465
1886
  return true;
1466
1887
  };
1467
- const rootStore = primaryStore.rootStore;
1468
1888
  if (rootStore instanceof rocksdb_js_1.RocksDatabase) {
1469
1889
  // Serialize the drops + catalog removal against a concurrent
1470
1890
  // same-name create (and completeInterruptedDrop) under the database's
1471
1891
  // 'update-attributes' exclusive lock - the same lock the create path
1472
- // holds. It is a synchronous spin lock that blocks the event loop, so
1892
+ // holds. It is a synchronous lock wait that blocks the event loop, so
1473
1893
  // the locked section MUST stay synchronous: drop with dropSync (as
1474
1894
  // completeInterruptedDrop does), never an awaited drop(), or a
1475
- // concurrent create's spin would deadlock waiting on a drop that the
1476
- // blocked event loop can never resolve.
1477
- while (!rootStore.tryLock('update-attributes')) { }
1478
- let removed = false;
1895
+ // concurrent create's wait would be stuck on a drop that the blocked
1896
+ // event loop can never resolve, burning its full deadline before failing.
1897
+ let removed;
1479
1898
  try {
1480
- for (const attribute of attributes) {
1481
- const index = indices[attribute.name];
1482
- if (index)
1483
- try {
1484
- index.dropSync();
1485
- }
1486
- catch (error) {
1487
- ignoreAlreadyDropped(error);
1488
- }
1489
- }
1490
- try {
1491
- primaryStore.dropSync();
1492
- }
1493
- catch (error) {
1494
- ignoreAlreadyDropped(error);
1495
- }
1496
- removed = removeTombstonedCatalog();
1899
+ removed = withUpdateAttributesLock(rootStore, `table '${databaseName}.${tableName}'`, () => {
1900
+ const currentPrimary = dbisDb.getSync(primaryCatalogKey);
1901
+ if (!currentPrimary?.dropping || (currentPrimary.tableId != null && currentPrimary.tableId !== tableId))
1902
+ return false;
1903
+ for (const attribute of attributes) {
1904
+ const index = indices[attribute.name];
1905
+ if (index)
1906
+ try {
1907
+ index.customIndex?.resetDerivedStorage?.();
1908
+ index.dropSync();
1909
+ }
1910
+ catch (error) {
1911
+ ignoreAlreadyDropped(error);
1912
+ }
1913
+ }
1914
+ try {
1915
+ primaryStore.dropSync();
1916
+ }
1917
+ catch (error) {
1918
+ ignoreAlreadyDropped(error);
1919
+ }
1920
+ return removeTombstonedCatalog();
1921
+ });
1922
+ if (removed)
1923
+ await dbisDb.committed;
1497
1924
  }
1498
- finally {
1499
- rootStore.unlock('update-attributes');
1925
+ catch (error) {
1926
+ derivedIndexRuntime?.completeDrop?.();
1927
+ throw error;
1928
+ }
1929
+ if (!removed) {
1930
+ abortStaleDrop();
1931
+ return;
1500
1932
  }
1501
- if (removed)
1502
- await dbisDb.committed;
1503
1933
  }
1504
1934
  else {
1505
1935
  // LMDB: no shared column-family double-drop, and its engine lock is
1506
1936
  // transactional rather than this spin lock, so keep the awaited drop
1507
1937
  // plus the same tombstone-guarded catalog removal.
1508
- const drops = [];
1509
- for (const attribute of attributes) {
1510
- const index = indices[attribute.name];
1511
- if (index)
1512
- drops.push(index.drop().catch(ignoreAlreadyDropped));
1938
+ let removed;
1939
+ try {
1940
+ const currentPrimary = dbisDb.getSync(primaryCatalogKey);
1941
+ if (!currentPrimary?.dropping || (currentPrimary.tableId != null && currentPrimary.tableId !== tableId)) {
1942
+ abortStaleDrop();
1943
+ return;
1944
+ }
1945
+ const drops = [];
1946
+ for (const attribute of attributes) {
1947
+ const index = indices[attribute.name];
1948
+ if (index) {
1949
+ index.customIndex?.resetDerivedStorage?.();
1950
+ drops.push(index.drop().catch(ignoreAlreadyDropped));
1951
+ }
1952
+ }
1953
+ drops.push(primaryStore.drop().catch(ignoreAlreadyDropped));
1954
+ await Promise.all(drops);
1955
+ removed = removeTombstonedCatalog();
1956
+ if (removed)
1957
+ await dbisDb.committed;
1958
+ }
1959
+ catch (error) {
1960
+ derivedIndexRuntime?.completeDrop?.();
1961
+ throw error;
1962
+ }
1963
+ if (!removed) {
1964
+ abortStaleDrop();
1965
+ throw new Error(`Could not complete drop of ${databaseName}.${tableName}: a replacement table became current while the LMDB stores were being dropped`);
1513
1966
  }
1514
- drops.push(primaryStore.drop().catch(ignoreAlreadyDropped));
1515
- await Promise.all(drops);
1516
- if (removeTombstonedCatalog())
1517
- await dbisDb.committed;
1518
1967
  }
1519
1968
  }
1520
1969
  else {
@@ -1522,11 +1971,18 @@ function makeTable(options) {
1522
1971
  // assigns `primaryStore.auditStore` — openAuditStore() assigns `rootStore.auditStore`, and
1523
1972
  // this is the reference makeTable() was handed. Awaited so a pass suspended mid-removal has
1524
1973
  // released the primary DBI before it is closed and unlinked.
1525
- await auditStore?.stopAuditCleanup?.();
1526
- (0, storageReclamation_ts_1.removeStorageReclamation)(primaryStore.path);
1527
- await primaryStore.close();
1528
- node_fs_1.default.unlinkSync(primaryStore.path);
1974
+ try {
1975
+ await auditStore?.stopAuditCleanup?.();
1976
+ (0, storageReclamation_ts_1.removeStorageReclamation)(primaryStore.path);
1977
+ await primaryStore.close();
1978
+ node_fs_1.default.unlinkSync(primaryStore.path);
1979
+ }
1980
+ catch (error) {
1981
+ derivedIndexRuntime?.completeDrop?.();
1982
+ throw error;
1983
+ }
1529
1984
  }
1985
+ derivedIndexRuntime?.completeDrop?.();
1530
1986
  signalling.signalSchemaChange(new itc_js_1.SchemaEventMsg(process.pid, hdbTerms_ts_1.OPERATIONS_ENUM.DROP_TABLE, databaseName, tableName));
1531
1987
  }
1532
1988
  // #section: read-path
@@ -1554,7 +2010,7 @@ function makeTable(options) {
1554
2010
  estimatedRecordRange: undefined,
1555
2011
  };
1556
2012
  if (this.getContext()?.includeExpensiveRecordCountEstimates) {
1557
- return TableResource.getRecordCount().then((recordCount) => {
2013
+ return _a.getRecordCount().then((recordCount) => {
1558
2014
  description.recordCount = recordCount.recordCount;
1559
2015
  description.estimatedRecordRange = recordCount.estimatedRange;
1560
2016
  return description;
@@ -1779,12 +2235,18 @@ function makeTable(options) {
1779
2235
  else {
1780
2236
  id = requestTargetToId(target);
1781
2237
  }
2238
+ if (this.#writeGeneration?.closed) {
2239
+ this.#changes = undefined;
2240
+ this.#writeGeneration = undefined;
2241
+ }
2242
+ this.#assertLiveHandle(id, true);
1782
2243
  const context = this.getContext();
1783
2244
  const envTxn = txnForContext(context);
1784
2245
  if (!envTxn)
1785
2246
  throw new Error('Can not update a table resource outside of a transaction');
1786
2247
  // record in the list of updating records so it can be written to the database when we commit
1787
- if (updates === false) {
2248
+ // `false` is the patch-cancel sentinel, not a record root — but only incrementally.
2249
+ if (updates === false && !fullUpdate) {
1788
2250
  // TODO: Remove from transaction
1789
2251
  return this;
1790
2252
  }
@@ -1832,22 +2294,98 @@ function makeTable(options) {
1832
2294
  });
1833
2295
  }
1834
2296
  }
1835
- return (0, when_ts_1.when)(this._writeUpdate(id, this.#changes, fullUpdate), () => this);
2297
+ // Keep absent changes distinguishable from an explicit empty patch: framework-created
2298
+ // post/publish updates do not necessarily mutate or save the instance.
2299
+ // A supplied root must reach validation as itself, not as the staged changes (harper#1298).
2300
+ const recordRoot = updates === undefined ? this.#changes : updates;
2301
+ return (0, when_ts_1.when)(this._writeUpdate(id, recordRoot, fullUpdate), () => this);
1836
2302
  }
1837
2303
  /**
1838
2304
  * Save any changes into this instance to the current transaction
1839
2305
  */
1840
2306
  save() {
2307
+ const operation = this.#savingOperation;
2308
+ if (!this.#lockWritable &&
2309
+ this.#writeGeneration?.closed &&
2310
+ (!operation || operation.writeGeneration === this.#writeGeneration))
2311
+ return;
2312
+ this.#assertLiveHandle(operation?.key ?? this.getId()); // a write through a released or expired lock never lands
2313
+ if ((!operation || operation.dropped) && this.#lockWritable && this.#lockHandle?.hold) {
2314
+ // A held lock's record stages its update here rather than at lock() time: it is often
2315
+ // written after the acquiring transaction has already completed, which would have
2316
+ // dropped an update staged then. Nothing set means nothing to stage — a held-but-untouched
2317
+ // id stays untouched. Scoped locks do not take this branch: #reloadLocked stages their
2318
+ // TransactionWrite at lock() time (exactly like update()), so #savingOperation is always
2319
+ // set for a live scoped lock and the ordinary path below applies.
2320
+ // Verify the hold is still alive: if #lockWritable is set but the handle expired or was
2321
+ // released between lock acquisition and this save(), throw 409 rather than silently
2322
+ // committing stale data. Every lock-writable instance carries its own handle.
2323
+ const saveHandle = this.#lockHandle;
2324
+ if (saveHandle.isExpired()) {
2325
+ throw (0, recordLock_ts_1.lockNotHeldError)(saveHandle);
2326
+ }
2327
+ const changes = this.#changes;
2328
+ if (changes && Object.keys(changes).length > 0) {
2329
+ this.#savingOperation = null;
2330
+ return (0, when_ts_1.when)(this._writeUpdate(this.getId(), changes, false), () => {
2331
+ const op = this.#savingOperation;
2332
+ if (op?.dropped) {
2333
+ this.#changes = undefined;
2334
+ return;
2335
+ }
2336
+ // Clear #savingOperation so the next sequential save() enters the lock-writable
2337
+ // path and creates a fresh write (otherwise a non-null #savingOperation makes
2338
+ // save() take the #saveOperation branch with an already-committed write, which
2339
+ // is a no-op, silently dropping the new change).
2340
+ // op.innerCommit is the real native-transaction commit Promise set on the
2341
+ // immediateCommit path in DatabaseTransaction.save(); await it to ensure
2342
+ // durability before resolving to the caller.
2343
+ if (op?.saved) {
2344
+ this.#savingOperation = null;
2345
+ return op?.innerCommit;
2346
+ }
2347
+ // op.saved = false means addWrite deferred the save; #saveOperation commits it
2348
+ // synchronously but ImmediateTransaction.save() returns undefined while the
2349
+ // inner rocksdb commit is still pending — return innerCommit so the caller
2350
+ // actually waits for durability.
2351
+ return (0, when_ts_1.when)(this.save(), () => op?.innerCommit);
2352
+ });
2353
+ }
2354
+ // No changes: nothing to stage. A dropped operation (detached at a scoped→hold
2355
+ // upgrade — see detachScopedUpgradeWrite) must not fall through to the ordinary
2356
+ // #saveOperation path below with its now-detached reference.
2357
+ if (!operation || operation.dropped) {
2358
+ this.#savingOperation = null;
2359
+ return;
2360
+ }
2361
+ }
1841
2362
  if (this.#savingOperation) {
2363
+ const operation = this.#savingOperation;
2364
+ this.#savingOperation = null;
2365
+ // A write that lands via a nested immediateCommit (e.g. a second sequential save() on
2366
+ // the same ImmediateTransaction context, once the first has already closed it) sets
2367
+ // operation.innerCommit to the real native-commit promise, but the commit() sweep loop
2368
+ // that triggers it discards its own return value — #saveOperation()'s result can
2369
+ // resolve before that native commit actually settles. Chain on innerCommit (as the
2370
+ // lock-writable hold branch above already does) so callers awaiting save() see the
2371
+ // write durably land, not just the outer (possibly premature) resolution.
2372
+ let result;
1842
2373
  try {
1843
- return this.#saveOperation(this.#savingOperation);
2374
+ result = this.#saveOperation(operation);
1844
2375
  }
1845
- finally {
1846
- this.#savingOperation = null;
2376
+ catch (error) {
2377
+ if (!operation.saved)
2378
+ this.#savingOperation = operation;
2379
+ throw error;
1847
2380
  }
2381
+ const innerCommit = operation.innerCommit;
2382
+ return innerCommit ? (0, when_ts_1.when)(innerCommit, () => result) : result;
1848
2383
  }
1849
2384
  }
1850
2385
  #saveOperation(operation) {
2386
+ // LMDB validates staged writes at transaction commit, so bind a lazy update to the
2387
+ // generation selected by save() before another update can replace its changes.
2388
+ operation.captureChanges?.();
1851
2389
  const transaction = txnForContext(this.getContext());
1852
2390
  const holder = operation.stagedIn;
1853
2391
  // never-drop-on-conflict lives on the transaction and would not travel with the write, so an
@@ -1865,13 +2403,26 @@ function makeTable(options) {
1865
2403
  // merge and index diff would be relative to a record that may never land.
1866
2404
  operation.priorWrite = undefined;
1867
2405
  operation.deferSave = false;
1868
- return (0, when_ts_1.when)(transaction.addWrite(operation), () => operation.promise ?? operation.result);
2406
+ const result = (0, when_ts_1.when)(transaction.addWrite(operation), () => operation.promise ?? operation.result);
2407
+ this.#closeWriteChain(operation);
2408
+ return result;
1869
2409
  }
1870
2410
  const owner = holder ?? transaction;
1871
- if (owner.save)
1872
- return owner.save(operation) || operation.promise || operation.result;
2411
+ if (owner.save) {
2412
+ const result = owner.save(operation) || operation.promise || operation.result;
2413
+ this.#closeWriteChain(operation);
2414
+ return result;
2415
+ }
2416
+ }
2417
+ #closeWriteChain(operation) {
2418
+ const owner = operation.stagedIn;
2419
+ for (let write = operation; write && !write.instanceClosed; write = write.priorWrite) {
2420
+ if (write === operation || owner?.ownedWrites?.has(write))
2421
+ (0, DatabaseTransaction_ts_1.closeWriteInstance)(write);
2422
+ }
1873
2423
  }
1874
2424
  addTo(property, value) {
2425
+ this[tracked_ts_1.ASSERT_TRACKED_WRITABLE]();
1875
2426
  if (typeof value === 'number' || typeof value === 'bigint') {
1876
2427
  if (this.#savingOperation?.fullUpdate)
1877
2428
  this.set(property, (+this.getProperty(property) || 0) + value);
@@ -1923,17 +2474,23 @@ function makeTable(options) {
1923
2474
  });
1924
2475
  }
1925
2476
  _writeInvalidate(id, partialRecord, options) {
2477
+ this.#assertLiveHandle(id);
1926
2478
  const context = this.getContext();
1927
2479
  checkValidId(id);
1928
2480
  const transaction = txnForContext(this.getContext());
2481
+ assertDerivedIndexAdmission(options, transaction);
1929
2482
  const write = {
1930
2483
  key: id,
1931
2484
  store: primaryStore,
1932
2485
  invalidated: true,
1933
2486
  entry: this.#entry,
2487
+ recordVersion: options?.version,
2488
+ lockHandle: this.#lockHandle && this.#lockHandle.keyId === (0, DatabaseTransaction_ts_1.writeKeyId)(id) ? this.#lockHandle : undefined,
2489
+ reloadCommitBase: true,
1934
2490
  commit: (txnTime, existingEntry, _retry, transaction) => {
2491
+ const txnLogKey = isRocksDB && options?.version != null ? (transaction?.getTimestamp?.() ?? txnTime) : txnTime;
1935
2492
  write.skipped = false; // reset on each retry; cleanup happens after commit if still true
1936
- if (precedesExistingVersion(txnTime, existingEntry, options?.nodeId) <= 0) {
2493
+ if (precedesExistingVersion(txnTime, existingEntry, options?.nodeId) < 0) {
1937
2494
  write.skipped = true;
1938
2495
  return;
1939
2496
  }
@@ -1954,7 +2511,13 @@ function makeTable(options) {
1954
2511
  viaNodeId: options?.viaNodeId,
1955
2512
  transaction,
1956
2513
  tableToTrack: tableName,
2514
+ recordVersion: txnTime,
2515
+ additionalAuditRefs: isRocksDB && audit && txnLogKey !== txnTime
2516
+ ? [{ version: txnLogKey, nodeId: options?.nodeId }]
2517
+ : undefined,
1957
2518
  }, 'invalidate');
2519
+ if (write.trackRecordVersion)
2520
+ write.recordVersionApplied = true;
1958
2521
  // TODO: recordDeletion?
1959
2522
  },
1960
2523
  };
@@ -1962,21 +2525,27 @@ function makeTable(options) {
1962
2525
  transaction.addWrite(write);
1963
2526
  }
1964
2527
  _writeRelocate(id, options) {
2528
+ this.#assertLiveHandle(id);
1965
2529
  const context = this.getContext();
1966
2530
  checkValidId(id);
1967
2531
  const transaction = txnForContext(this.getContext());
1968
- transaction.addWrite({
2532
+ assertDerivedIndexAdmission(options, transaction);
2533
+ const write = {
1969
2534
  key: id,
1970
2535
  store: primaryStore,
1971
2536
  invalidated: true,
1972
2537
  entry: this.#entry,
2538
+ recordVersion: options?.version,
2539
+ lockHandle: this.#lockHandle && this.#lockHandle.keyId === (0, DatabaseTransaction_ts_1.writeKeyId)(id) ? this.#lockHandle : undefined,
2540
+ reloadCommitBase: true,
1973
2541
  before: this.constructor.source?.relocate && !context?.source
1974
2542
  ? this.constructor.source.relocate.bind(this.constructor.source, id, undefined, context)
1975
2543
  : undefined,
1976
2544
  commit: (txnTime, existingEntry, _retry, transaction) => {
1977
- if (precedesExistingVersion(txnTime, existingEntry, options?.nodeId) <= 0)
2545
+ const txnLogKey = isRocksDB && options?.version != null ? (transaction?.getTimestamp?.() ?? txnTime) : txnTime;
2546
+ if (precedesExistingVersion(txnTime, existingEntry, options?.nodeId) < 0)
1978
2547
  return;
1979
- const residency = TableResource.getResidencyRecord(options.residencyId);
2548
+ const residency = _a.getResidencyRecord(options.residencyId);
1980
2549
  let metadata = 0;
1981
2550
  let newRecord = null;
1982
2551
  const existingRecord = existingEntry?.value;
@@ -2000,9 +2569,16 @@ function makeTable(options) {
2000
2569
  viaNodeId: options?.viaNodeId,
2001
2570
  expiresAt: options.expiresAt,
2002
2571
  transaction,
2572
+ recordVersion: txnTime,
2573
+ additionalAuditRefs: isRocksDB && audit && txnLogKey !== txnTime
2574
+ ? [{ version: txnLogKey, nodeId: options?.nodeId }]
2575
+ : undefined,
2003
2576
  }, 'relocate', false, null);
2577
+ if (write.trackRecordVersion)
2578
+ write.recordVersionApplied = true;
2004
2579
  },
2005
- });
2580
+ };
2581
+ transaction.addWrite(write);
2006
2582
  }
2007
2583
  /**
2008
2584
  * Record the relocation of an entry (when a record is moved to a different node), return true if it is now located locally
@@ -2055,9 +2631,8 @@ function makeTable(options) {
2055
2631
  if (primaryStore.hasLock(id, entry.version))
2056
2632
  return;
2057
2633
  }
2058
- // evictions never go in the audit log, so we can not record a deletion entry for the eviction
2059
- // as there is no corresponding audit entry and it would never get cleaned up. So we must simply
2060
- // removed the entry entirely, but first cleanup indices
2634
+ // Eviction is not a canonical delete. Indexed caching tables add a local-only control entry so
2635
+ // their derived indexes can remove the resident projection without exposing a delete event.
2061
2636
  let lmdbCompletion;
2062
2637
  if (primaryStore.ifVersion) {
2063
2638
  // lmdb: the index cleanup and the record removal are both version-guarded optimistic writes.
@@ -2071,6 +2646,7 @@ function makeTable(options) {
2071
2646
  }
2072
2647
  else {
2073
2648
  updateIndices(id, existingRecord, null, options);
2649
+ stageDerivedIndexEviction(transaction, id, existingVersion);
2074
2650
  (0, RecordEncoder_ts_1.removeEntry)(primaryStore, entry ?? primaryStore.getEntry(id), options);
2075
2651
  }
2076
2652
  committed = true;
@@ -2121,10 +2697,368 @@ function makeTable(options) {
2121
2697
  }
2122
2698
  }
2123
2699
  /**
2124
- * This is intended to acquire a lock on a record from the whole cluster.
2700
+ * Static entry point: `Table.lock(id, options?, context?)` — creates an instance in the given,
2701
+ * ambient, or a fresh context and delegates to the instance lock(). This shadows Resource.static
2702
+ * lock so that both callers share the same transaction link (required for cross-instance upgrade
2703
+ * detection). lock() is an in-process API with no authorization hook of its own; it is not
2704
+ * protocol-dispatched, so no allowUpdate/allowCreate check runs on acquisition.
2705
+ *
2706
+ * Dropping the trailing `context` leaks the key: the bare `{}` fallback is an
2707
+ * ImmediateTransaction, which releases no record locks.
2708
+ */
2709
+ static async lock(target, options, context) {
2710
+ if (!isRocksDB)
2711
+ throw new hdbError_ts_1.ClientError('Record locks are not supported on LMDB', 501);
2712
+ if (options === undefined && isPlainOptions(target)) {
2713
+ options = target;
2714
+ target = undefined;
2715
+ }
2716
+ const id = target != null ? requestTargetToId(target) : null;
2717
+ const resolvedContext = contextArgument(context) ?? transaction_ts_1.contextStorage.getStore() ?? {};
2718
+ const resource = new _a(id, resolvedContext);
2719
+ return resource.lock(target, options);
2720
+ }
2721
+ /**
2722
+ * Acquire an exclusive lock on this record (or on `target`'s) and return it ready for updates
2723
+ * (harper#483, Phase 0: exclusive across every worker thread of this node). The lock is held
2724
+ * in process memory only — no durable writes. Phase 0 contract: lock() is mutually exclusive
2725
+ * with other lock() calls on the same key; plain writes (put/patch/delete/create) are never
2726
+ * gated or blocked. The generation expires after `lease` if it is never released.
2727
+ *
2728
+ * Transaction-scoped (default): write through the returned record (or the table's static verbs
2729
+ * in the same transaction), and the commit or abort releases it. `{ hold: true }`: the lock
2730
+ * outlives the transaction; write through the returned record and release with `unlock()`, or
2731
+ * let the lease expire.
2732
+ */
2733
+ // async so option/id validation rejects rather than throwing past a caller's `.catch()`; the
2734
+ // body still runs to completion synchronously, which is what keeps concurrent lock() calls
2735
+ // on one key coalescing instead of racing to tryLock.
2736
+ async lock(target, options) {
2737
+ if (!isRocksDB)
2738
+ throw new hdbError_ts_1.ClientError('Record locks are not supported on LMDB', 501);
2739
+ if (options === undefined && isPlainOptions(target)) {
2740
+ options = target;
2741
+ target = undefined;
2742
+ }
2743
+ const id = target != null ? requestTargetToId(target) : this.getId();
2744
+ checkValidId(id);
2745
+ this.#assertLiveHandle(id);
2746
+ const resolved = (0, recordLock_ts_1.resolveLockOptions)(options);
2747
+ const context = this.getContext();
2748
+ const link = txnForContext(context);
2749
+ const keyId = (0, DatabaseTransaction_ts_1.writeKeyId)(id);
2750
+ // Before the re-entrant paths, not after: a transaction that already holds this key
2751
+ // node-scoped would otherwise be handed that handle back for an explicit cluster request,
2752
+ // while the same request on a fresh key fails closed.
2753
+ if (resolved.scope === 'cluster' &&
2754
+ (resolved.scopeRequested || (0, recordLockCoordinator_ts_1.isClusterLockRequired)(databaseName)) &&
2755
+ !(0, recordLockCoordinator_ts_1.getClusterLockTransport)(databaseName))
2756
+ return Promise.reject(new hdbError_ts_1.LockUnavailableError(`Cluster-scoped record locks are not available on ${databaseName}: no record lock transport is registered`));
2757
+ const held = this.#lockHandle;
2758
+ if (held && !held.isExpired() && held.keyId === keyId) {
2759
+ // Re-entrant: upgrade to hold if requested, then preserve staged changes.
2760
+ const violation = scopeViolation(held, resolved, databaseName);
2761
+ if (violation)
2762
+ return Promise.reject(violation);
2763
+ if (resolved.hold && !held.hold) {
2764
+ held.upgradeToHold(resolved.lease);
2765
+ // The scoped phase eagerly staged a TransactionWrite (see #reloadLocked); hold
2766
+ // staging is deferred and explicit-save-only, so an unsaved scoped write left
2767
+ // dangling here would otherwise auto-commit at the transaction sweep and clobber
2768
+ // whatever the hold write lands. detachScopedUpgradeWrite marks it .dropped so a
2769
+ // later save() on this instance falls through to the hold branch instead of the
2770
+ // dead #savingOperation reference.
2771
+ detachScopedUpgradeWrite(link, keyId, held);
2772
+ }
2773
+ return Promise.resolve(this.#reloadLocked(id, undefined, true));
2774
+ }
2775
+ const scoped = link.recordLockFor(primaryStore, keyId);
2776
+ if (scoped && !scoped.isExpired()) {
2777
+ const violation = scopeViolation(scoped, resolved, databaseName);
2778
+ if (violation)
2779
+ return Promise.reject(violation);
2780
+ if (resolved.hold && !scoped.hold) {
2781
+ // Upgrade scoped → hold: flip the existing handle object to hold mode so every
2782
+ // instance that already references this handle stays valid. Retiring and creating a
2783
+ // new handle would invalidate those other references (their save() would then throw
2784
+ // 409 against a released handle). The native key stays locked throughout.
2785
+ scoped.upgradeToHold(resolved.lease);
2786
+ detachScopedUpgradeWrite(link, keyId, scoped);
2787
+ return Promise.resolve(this.#reloadLocked(id, scoped, true));
2788
+ }
2789
+ // Already held with the same type: re-entrant return. Preserve any staged changes.
2790
+ return Promise.resolve(this.#reloadLocked(id, scoped, true));
2791
+ }
2792
+ // Cluster scope needs a registered transport. An EXPLICIT { scope: 'cluster' } without one is
2793
+ // a caller asking for a guarantee this node cannot make, so it fails closed rather than
2794
+ // silently returning the node-local lock; the default keeps Phase 0 behavior, which is what
2795
+ // a build with no replication has anyway.
2796
+ // The getter fails closed on an unusable node identity, and lock() answers with a promise.
2797
+ let coordinator;
2798
+ try {
2799
+ coordinator = resolved.scope === 'node' ? undefined : _a.lockCoordinator;
2800
+ }
2801
+ catch (error) {
2802
+ return Promise.reject(error);
2803
+ }
2804
+ const key = (0, recordLock_ts_1.lockAttemptKey)(tableId, id);
2805
+ // Coalesce concurrent lock() calls for the same key inside one link so they don't
2806
+ // self-block: Promise.all([T.lock(id), T.lock(id)]) would otherwise have both calls
2807
+ // reach tryLock before either registers, making the second park against the first.
2808
+ const pending = link.pendingLockFor(primaryStore, keyId);
2809
+ if (pending) {
2810
+ // Wait for the in-flight acquisition, then take the re-entrant path as if
2811
+ // recordLockFor had found it. If the first attempt timed out, re-enter so
2812
+ // the second caller gets its own timeout.
2813
+ // The follower waits on the leader's acquisition, but only for its own timeout.
2814
+ let followerTimer;
2815
+ const followerTimedOut = Symbol('follower timeout');
2816
+ // Why the leader failed, so the follower can report that instead of inventing contention
2817
+ // when its own budget runs out. A leader 503 means the guarantee could not be established
2818
+ // at all; retrying is still right (the condition may clear) but 423 at the end is not.
2819
+ let leaderFailure;
2820
+ const followerStart = Date.now();
2821
+ const followerDeadline = new Promise((_, reject) => {
2822
+ followerTimer = setTimeout(() => reject(followerTimedOut), resolved.timeout).unref();
2823
+ });
2824
+ // Try again on this caller's own terms with the budget it has left.
2825
+ const retryOnRemainingBudget = () => {
2826
+ // The enclosing transaction ended while we were parked. A retry re-resolves the
2827
+ // context, which no longer points at this link, so the handle it acquired would be
2828
+ // registered on a fresh transaction that no commit or abort ever releases — the
2829
+ // same abandonment the leader's own post-acquisition guard below rejects.
2830
+ if (link.open === DatabaseTransaction_ts_1.TRANSACTION_STATE.CLOSED && !link.saveCommits)
2831
+ throw new hdbError_ts_1.ServerError('Transaction was closed while waiting for a record lock', 500);
2832
+ const remaining = resolved.timeout - (Date.now() - followerStart);
2833
+ if (remaining <= 0)
2834
+ throw leaderFailure ?? new hdbError_ts_1.ClientError(`Record is locked and was not released in time`, 423);
2835
+ // Carry the scope only if the caller named it: spreading the resolved options would turn
2836
+ // a defaulted 'cluster' into an explicit one, which is fail-closed when no transport is
2837
+ // registered.
2838
+ return this.lock(target, {
2839
+ lease: resolved.lease,
2840
+ timeout: remaining,
2841
+ hold: resolved.hold,
2842
+ scope: resolved.scopeRequested ? resolved.scope : undefined,
2843
+ });
2844
+ };
2845
+ return Promise.race([pending, followerDeadline]).then(() => {
2846
+ clearTimeout(followerTimer);
2847
+ const acquired = link.recordLockFor(primaryStore, keyId);
2848
+ if (acquired && !acquired.isExpired()) {
2849
+ const violation = scopeViolation(acquired, resolved, databaseName);
2850
+ if (violation)
2851
+ throw violation;
2852
+ if (resolved.hold && !acquired.hold) {
2853
+ detachScopedUpgradeWrite(link, keyId, acquired);
2854
+ acquired.upgradeToHold(resolved.lease);
2855
+ }
2856
+ return this.#reloadLocked(id, acquired, true);
2857
+ }
2858
+ return retryOnRemainingBudget();
2859
+ }, (error) => {
2860
+ clearTimeout(followerTimer);
2861
+ // A follower that simply ran out of its own wait was waiting on another caller in this
2862
+ // process, which is the contention 423 describes. But if the LEADER failed for a reason
2863
+ // that is not contention, that reason is the true one — keep it and report it if the
2864
+ // retries below also run out, rather than ending on a 423 for a key nobody held.
2865
+ if (error === followerTimedOut)
2866
+ throw new hdbError_ts_1.ClientError(`Record is locked and was not released in time`, 423);
2867
+ if (error instanceof hdbError_ts_1.LockUnavailableError)
2868
+ leaderFailure = error;
2869
+ return retryOnRemainingBudget();
2870
+ });
2871
+ }
2872
+ const pendingPromise = (0, recordLock_ts_1.acquireRecordKey)(link, primaryStore, key, keyId, resolved.timeout, resolved.lease, resolved.hold);
2873
+ const clusterStart = node_perf_hooks_1.performance.now();
2874
+ // What a follower waits on must span the cluster round and registration, not just the native
2875
+ // acquire. Waking it at the native hand-off leaves it in a window where the key is held but no
2876
+ // handle is registered, so it retries and parks on the leader's own lock for its full timeout
2877
+ // — inside a transaction that cannot finish until it gives up.
2878
+ const acquisition = pendingPromise.then(async (handle) => {
2879
+ const closedWhileWaiting = () => link.open === DatabaseTransaction_ts_1.TRANSACTION_STATE.CLOSED && !link.saveCommits;
2880
+ if (closedWhileWaiting()) {
2881
+ // The transaction was aborted while this call waited; nothing would ever release the handle.
2882
+ handle.release();
2883
+ throw new hdbError_ts_1.ServerError('Transaction was closed while waiting for a record lock', 500);
2884
+ }
2885
+ // Anything that fails from here must give the native key back, or it becomes a lock this
2886
+ // caller does not know it owns.
2887
+ // Re-resolved, not the snapshot taken before `acquireRecordKey`: that wait can run the
2888
+ // caller's whole timeout, long enough for harper-pro to register the transport on this
2889
+ // worker. Using the snapshot would take the native key alone and hand back a node-scoped
2890
+ // handle while a peer that already had the transport is granted the same key.
2891
+ try {
2892
+ if (resolved.scope !== 'node')
2893
+ coordinator = _a.lockCoordinator ?? coordinator;
2894
+ }
2895
+ catch (error) {
2896
+ // The getter fails closed on an unusable node identity, and that has to reach the caller
2897
+ // the same way it does before the wait. Swallowing it let an implicit cluster lock fall
2898
+ // through to node-local authority — the one outcome failing closed exists to prevent —
2899
+ // because `coordinator` is still whatever it was, including undefined.
2900
+ handle.release();
2901
+ throw error;
2902
+ }
2903
+ if (coordinator) {
2904
+ try {
2905
+ // Not a 423 when the budget is gone, and not a skip either: the native wait can consume
2906
+ // the whole timeout, and `acquire` with no wait left still admits from a live delegation
2907
+ // or a local grant without sending anything. Only if it cannot does the caller learn the
2908
+ // guarantee was unavailable — which is not the same as the key being held.
2909
+ const remaining = Math.max(0, resolved.timeout - (node_perf_hooks_1.performance.now() - clusterStart));
2910
+ const round = await coordinator.acquire(id, resolved.lease, remaining);
2911
+ // Resolved through the getter rather than captured, so a transport swap between
2912
+ // acquisition and release reaches the coordinator that now owns the delegation.
2913
+ if (!handle.joinClusterRound(round.tsR, resolved.lease, round.mintedMono, () => _a.admittingCoordinator?.release(id, round.admissionId))) {
2914
+ // The round completed inside its lease but the lease elapsed before the handle
2915
+ // could take it. The coordinator still holds it, and only this call knows the
2916
+ // hold was never handed out.
2917
+ // The getter, not the captured coordinator: after a transport swap the captured one no
2918
+ // longer owns this admission, so releasing through it would be a silent no-op.
2919
+ // `.then`, not `Promise.resolve(release())`: the call can throw synchronously, and that
2920
+ // throw would escape the catch and replace the 423 below with an internal error.
2921
+ Promise.resolve()
2922
+ .then(() => _a.admittingCoordinator?.release(id, round.admissionId))
2923
+ .catch(noop);
2924
+ // 503, not 423: the home granted this key to US and the lease elapsed before the handle
2925
+ // could take it, so nobody ever held it. The coordinator classifies the same thing the
2926
+ // same way — see its `timeout` denial.
2927
+ throw new hdbError_ts_1.LockUnavailableError(`A cluster record lock on ${databaseName}.${tableName} was granted after its lease had elapsed`);
2928
+ }
2929
+ // A recall must be able to fence a write this handle staged and then unlocked, so
2930
+ // the coordinator needs a way to revoke it — see LockCoordinator.registerAdmission.
2931
+ // The getter again: a swap during the acquisition moved this admission to the
2932
+ // successor, and registering on the predecessor would revoke a handle that is fine.
2933
+ _a.admittingCoordinator?.registerAdmission(round.admissionId, () => handle.revokeLease());
2934
+ }
2935
+ catch (error) {
2936
+ handle.release();
2937
+ throw error;
2938
+ }
2939
+ if (closedWhileWaiting()) {
2940
+ handle.release();
2941
+ throw new hdbError_ts_1.ServerError('Transaction was closed while waiting for a record lock', 500);
2942
+ }
2943
+ }
2944
+ link.registerRecordLock(handle);
2945
+ if (link.saveCommits && context?.timestamp)
2946
+ handle.noteCandidateFloor(context.timestamp);
2947
+ if (link.open === DatabaseTransaction_ts_1.TRANSACTION_STATE.OPEN && !link.saveCommits) {
2948
+ // Explicit transaction() (not ImmediateTransaction): pin the clock to
2949
+ // acquiredAt when no writes have been staged yet. When writes already
2950
+ // exist, leave the clock alone (ordering is best-effort; write held records
2951
+ // in their own transaction for the guarantee). ImmediateTransaction is
2952
+ // excluded (saveCommits=true) — its clock is never pinned in lock();
2953
+ // each save() stamps from the handle's committed version floor instead.
2954
+ if (link.writes.length === 0 && !link.timestamp) {
2955
+ link.timestamp = handle.acquiredAt;
2956
+ }
2957
+ if (!resolved.hold && link.transaction) {
2958
+ // Scoped lock: the read snapshot may predate the lock; drop it so the
2959
+ // scope reads what it locked. Hold locks use acquiredAt directly and
2960
+ // do not update the read snapshot.
2961
+ // The timestamp guard matches DatabaseTransaction's own setTimestamp calls: a
2962
+ // deferred update() write leaves the clock at 0, which rocksdb-js rejects.
2963
+ if (link.writes.length === 0 && link.readTxnsUsed <= 1) {
2964
+ link.releaseReadTxn();
2965
+ link.snapshotFree = true;
2966
+ }
2967
+ else if (link.timestamp)
2968
+ link.transaction.setTimestamp(link.timestamp);
2969
+ }
2970
+ }
2971
+ // ImmediateTransaction: no clock pinning in lock(); save() stamps each write
2972
+ // from the committed handle floor for both scoped and hold handles.
2973
+ return handle;
2974
+ });
2975
+ link.registerPendingLock(primaryStore, keyId, acquisition);
2976
+ return acquisition.then((handle) => {
2977
+ link.unregisterPendingLock(primaryStore, keyId);
2978
+ return this.#reloadLocked(id, handle);
2979
+ }, (error) => {
2980
+ link.unregisterPendingLock(primaryStore, keyId);
2981
+ throw error;
2982
+ });
2983
+ }
2984
+ #reloadLocked(id, holdHandle, preserveChanges = false) {
2985
+ // For freshness, read the committed entry (snapshot-free) so a hold lock sees concurrent
2986
+ // committed writes rather than a stale snapshot. A write earlier in THIS explicit
2987
+ // transaction has not landed in that committed entry yet (harper#1968: Harper defers an
2988
+ // explicit transaction's writes until the writing call actually runs them), so pull the
2989
+ // current value the same way a chained write picks up its basis (priorStagedWrite): the
2990
+ // record comes from the prior staged write, the rest of the entry (version, audit chain,
2991
+ // blob metadata) stays the pre-transaction one.
2992
+ const link = txnForContext(this.getContext());
2993
+ let entryForReload = primaryStore.getEntry(id);
2994
+ if (link.open === DatabaseTransaction_ts_1.TRANSACTION_STATE.OPEN) {
2995
+ const keyId = (0, DatabaseTransaction_ts_1.writeKeyId)(id);
2996
+ const tailWrite = link.writesByKey?.get(primaryStore)?.get(keyId);
2997
+ const priorStaged = tailWrite && (tailWrite.stagedEntry !== undefined ? tailWrite : (0, DatabaseTransaction_ts_1.priorStagedWrite)(tailWrite));
2998
+ if (priorStaged?.stagedEntry !== undefined) {
2999
+ entryForReload = entryForReload
3000
+ ? { ...entryForReload, value: priorStaged.stagedEntry.value }
3001
+ : { value: priorStaged.stagedEntry.value };
3002
+ if (entryForReload.value && typeof entryForReload.value === 'object') {
3003
+ // Register the merged entry in entryMap so getUpdatedTime() works.
3004
+ RecordEncoder_ts_1.entryMap.set(entryForReload.value, entryForReload);
3005
+ }
3006
+ }
3007
+ }
3008
+ if ((0, DatabaseTransaction_ts_1.writeKeyId)(id) !== (0, DatabaseTransaction_ts_1.writeKeyId)(this.getId())) {
3009
+ // lock(target) where target differs from this record: return a separate instance.
3010
+ const fresh = new this.constructor(id, this.getContext());
3011
+ _a._updateResource(fresh, entryForReload);
3012
+ if (holdHandle != null) {
3013
+ fresh.#lockHandle = holdHandle;
3014
+ // Do not clear this.#lockHandle: the original instance keeps its own lock on its
3015
+ // own id; the fresh instance owns the lock on the target id independently.
3016
+ }
3017
+ fresh.#lockWritable = true;
3018
+ // Scoped (not hold) stages exactly like update(): create the TransactionWrite now so
3019
+ // save() is the ordinary #savingOperation path. Hold keeps deferred staging (the
3020
+ // acquiring transaction may commit before the holder ever writes).
3021
+ if (!fresh.#lockHandle.hold)
3022
+ fresh._writeUpdate(id, fresh.#changes, false);
3023
+ return fresh;
3024
+ }
3025
+ // Store the handle for both scoped and hold locks; undefined (re-entrant hold fast-path)
3026
+ // must not clear a handle already set.
3027
+ if (holdHandle != null)
3028
+ this.#lockHandle = holdHandle;
3029
+ _a._updateResource(this, entryForReload);
3030
+ // Preserve staged changes when upgrading the same instance from scoped to hold so that
3031
+ // set() calls made under the scoped lock survive the reload.
3032
+ if (!preserveChanges)
3033
+ this.#changes = undefined;
3034
+ this.#lockWritable = true;
3035
+ // Scoped (not hold): stage now, same as update() would. Skip if a write from an earlier
3036
+ // lock() cycle on this instance is still pending (re-entrant call before its save()).
3037
+ if (!this.#lockHandle.hold && !this.#savingOperation)
3038
+ this._writeUpdate(id, this.#changes, false);
3039
+ return this;
3040
+ }
3041
+ /**
3042
+ * Release the lock this instance holds. Resolves true when this call cleared the native key lock.
3043
+ * Works for both held (`{ hold: true }`) and transaction-scoped locks. After unlock() the
3044
+ * instance is no longer lock-writable; writes through it require a fresh lock.
2125
3045
  */
2126
- lock() {
2127
- throw new Error('Not yet implemented');
3046
+ unlock() {
3047
+ // Always clear the local lock-writable state so subsequent writes on this instance are
3048
+ // ungated, regardless of whether the handle was already released.
3049
+ const handle = this.#lockHandle;
3050
+ this.#lockHandle = undefined;
3051
+ this.#lockWritable = false;
3052
+ if (!handle || handle.released)
3053
+ return Promise.resolve(false);
3054
+ const link = txnForContext(this.getContext());
3055
+ // A scoped lock staged its write at lock() time; released before commit, that write must not
3056
+ // run into the released-handle guard at the sweep.
3057
+ if (this.#savingOperation && !this.#savingOperation.saved && this.#savingOperation.lockHandle === handle)
3058
+ this.#savingOperation = null;
3059
+ detachScopedUpgradeWrite(link, (0, DatabaseTransaction_ts_1.writeKeyId)(this.getId()), handle);
3060
+ link.unregisterRecordLock(handle);
3061
+ return Promise.resolve(handle.release());
2128
3062
  }
2129
3063
  static operation(operation, context) {
2130
3064
  operation.table ||= tableName;
@@ -2240,8 +3174,11 @@ function makeTable(options) {
2240
3174
  // a notification that a write has already occurred in the canonical data source, we need to update our
2241
3175
  // local copy
2242
3176
  _writeUpdate(id, recordUpdate, fullUpdate, options) {
3177
+ this.#assertLiveHandle(id);
2243
3178
  const context = this.getContext();
2244
3179
  const transaction = txnForContext(context);
3180
+ const replaying = transaction.isReplay === true;
3181
+ assertDerivedIndexAdmission(options, transaction);
2245
3182
  checkValidId(id);
2246
3183
  if (fullUpdate && recordUpdate == null && options?.isNotification) {
2247
3184
  // A source/replication-applied put must carry the record; these applies skip record
@@ -2256,6 +3193,16 @@ function makeTable(options) {
2256
3193
  }
2257
3194
  return;
2258
3195
  }
3196
+ let captureChanges;
3197
+ if (recordUpdate === undefined) {
3198
+ let captured = false;
3199
+ captureChanges = () => {
3200
+ if (!captured) {
3201
+ captured = true;
3202
+ recordUpdate = this.#changes;
3203
+ }
3204
+ };
3205
+ }
2259
3206
  const entry = this.#entry ?? primaryStore.getEntry(id, { transaction: transaction.getReadTxn() });
2260
3207
  const writeToSource = () => {
2261
3208
  if (!this.constructor.source || context?.source)
@@ -2277,16 +3224,31 @@ function makeTable(options) {
2277
3224
  }
2278
3225
  }
2279
3226
  };
3227
+ const receiverId = this.getId();
3228
+ const closesReceiver = !this.isCollection &&
3229
+ !isSearchTarget(receiverId) &&
3230
+ (id === receiverId || (0, DatabaseTransaction_ts_1.writeKeyId)(id) === (0, DatabaseTransaction_ts_1.writeKeyId)(receiverId));
2280
3231
  const write = {
2281
3232
  key: id,
2282
3233
  store: primaryStore,
2283
3234
  entry,
2284
3235
  nodeName: context?.nodeName,
2285
3236
  fullUpdate,
3237
+ chainsStagedState: true,
3238
+ // copy-apply rows keep their pre-read base: one read per row, healed by the post-copy replay
3239
+ reloadCommitBase: options?.isCopyApply !== true,
2286
3240
  deferSave: true,
3241
+ // the origin's record version on an applied write; absent for a locally-originated one
3242
+ recordVersion: options?.version,
3243
+ // Include the lock handle (if any) so the expired-handle guard in
3244
+ // DatabaseTransaction.save() can throw 409 when the lease has lapsed.
3245
+ // Only attach the hold handle when it covers exactly this key; off-key writes
3246
+ // are ordinary and must not carry an unrelated hold's handle.
3247
+ lockHandle: this.#lockHandle && this.#lockHandle.keyId === (0, DatabaseTransaction_ts_1.writeKeyId)(id) ? this.#lockHandle : undefined,
3248
+ writeGeneration: !this.#lockWritable && closesReceiver ? this[tracked_ts_1.GET_TRACKED_WRITE_GENERATION]() : undefined,
3249
+ captureChanges,
2287
3250
  validate: (txnTime, committedBy = transaction) => {
2288
- if (!recordUpdate)
2289
- recordUpdate = this.#changes;
3251
+ write.captureChanges?.();
2290
3252
  if (fullUpdate || (recordUpdate && (0, tracked_ts_1.hasChanges)(this.#changes === recordUpdate ? this : recordUpdate))) {
2291
3253
  if (!context?.source) {
2292
3254
  committedBy.checkOverloaded();
@@ -2336,10 +3298,13 @@ function makeTable(options) {
2336
3298
  : txnTime;
2337
3299
  }
2338
3300
  if (createdTimeProperty) {
2339
- if (entry?.value) {
3301
+ // the reloaded commit base, not the pre-read one: a full PUT racing a create
3302
+ // would otherwise stamp a fresh created time over the real one
3303
+ const base = write.entry;
3304
+ if (base?.value) {
2340
3305
  if (fullUpdate || recordUpdate[createdTimeProperty.name]) {
2341
3306
  // make sure to retain original created time
2342
- recordUpdate[createdTimeProperty.name] = entry?.value[createdTimeProperty.name];
3307
+ recordUpdate[createdTimeProperty.name] = base.value[createdTimeProperty.name];
2343
3308
  }
2344
3309
  }
2345
3310
  else {
@@ -2408,6 +3373,7 @@ function makeTable(options) {
2408
3373
  this.#savingOperation = null;
2409
3374
  write.stagedIn = undefined; // nothing may pin this write's transaction past its commit
2410
3375
  let omitLocalRecord = false;
3376
+ const txnLogKey = isRocksDB && options?.version != null ? (transaction?.getTimestamp?.() ?? txnTime) : txnTime;
2411
3377
  // we use optimistic locking to only commit if the existing record state still holds true.
2412
3378
  // this is superior to using an async transaction since it doesn't require JS execution
2413
3379
  // during the write transaction.
@@ -2468,8 +3434,8 @@ function makeTable(options) {
2468
3434
  // best-effort keyed lookup in the capped block below — see #1148. precedesExistingVersion(...)
2469
3435
  // === 0 is the identity tie: same version AND same node (the local node is id 0, so an undefined
2470
3436
  // options?.nodeId resolves to the same 0 the ref stored).
2471
- if (existingEntry.additionalAuditRefs?.some((ref) => ref.version === txnTime &&
2472
- precedesExistingVersion(txnTime, { version: txnTime, localTime: txnTime, key: id, nodeId: ref.nodeId }, options?.nodeId) === 0)) {
3437
+ if (existingEntry.additionalAuditRefs?.some((ref) => ref.version === txnLogKey &&
3438
+ precedesExistingVersion(txnTime, { version: txnTime, localTime: txnLogKey, key: id, nodeId: ref.nodeId }, options?.nodeId) === 0)) {
2473
3439
  write.skipped = true;
2474
3440
  return; // out-of-order write already folded into this record
2475
3441
  }
@@ -2497,10 +3463,10 @@ function makeTable(options) {
2497
3463
  if (!oldestRetainedAuditTimeResolved) {
2498
3464
  oldestRetainedAuditTimeResolved = true;
2499
3465
  // getRange yields ascending by audit-log key, so the first entry is the oldest retained.
2500
- // Mirror replicationConnection's retention check and the cleanup key basis (localTime ??
2501
- // version). Fall back to the nominal time-based purge floor when the log is empty/unavailable.
3466
+ // Mirror replicationConnection's retention check and the cleanup key basis (`txnLogKey`).
3467
+ // Fall back to the nominal time-based purge floor when the log is empty/unavailable.
2502
3468
  for (const entry of auditStore.getRange({ start: 1, log: options?.nodeId })) {
2503
- oldestRetainedAuditTime = entry.localTime ?? entry.version;
3469
+ oldestRetainedAuditTime = entry.txnLogKey;
2504
3470
  break;
2505
3471
  }
2506
3472
  oldestRetainedAuditTime ??= Date.now() - auditStore_ts_1.auditRetention;
@@ -2513,43 +3479,67 @@ function makeTable(options) {
2513
3479
  // depth-cap block. This is the same keyed lookup that block performs, hoisted ahead of the walk.
2514
3480
  // It is what catches transitive/proxied re-deliveries: they arrive buried below the record head
2515
3481
  // (so replication's head-tie fast-skip can't see them) yet are exact duplicates. Keyed by nodeId,
2516
- // so it is correct across multiple source nodes. RocksDB-only: LMDB audit entries are keyed by
2517
- // local audit time, not version, so this version-keyed lookup doesn't apply there (LMDB keeps the
2518
- // exact unbounded walk). A miss (the keyed lookup can lag a back-to-back re-delivery — #1137)
3482
+ // so it is correct across multiple source nodes. The lookup key is this write's LOG key, not its
3483
+ // record version — a replication apply commits under the origin's log key while storing the
3484
+ // origin's version, and only the log key addresses the entry (harper#2412).
3485
+ // RocksDB-only: LMDB audit entries are keyed by local audit time, so this lookup doesn't apply
3486
+ // there (LMDB keeps the exact unbounded walk). A miss (the keyed lookup can lag a back-to-back re-delivery — #1137)
2519
3487
  // simply falls through to the walk, so this never changes correctness; the additionalAuditRefs
2520
3488
  // check above remains the read-your-writes guard. Never when this write staged in a prior
2521
3489
  // failed attempt: that attempt already appended this write's own audit entry, so the lookup
2522
3490
  // would find it and skip the write as "already applied" when the record was never committed.
2523
3491
  // A recommit of the same transaction survived that skip only because the old write batch
2524
3492
  // still carried the put; a fresh-transaction replay (ERR_TRY_AGAIN) would drop the write.
2525
- if (isRocksDB && !stagedOwnAuditEntry && dedupVersionCouldBeRetained(txnTime)) {
2526
- const priorAudit = auditStore.get(txnTime, tableId, id, options?.nodeId);
3493
+ if (isRocksDB && !replaying && !stagedOwnAuditEntry && dedupVersionCouldBeRetained(txnLogKey)) {
3494
+ const priorAudit = auditStore.get(txnLogKey, tableId, id, options?.nodeId);
2527
3495
  if (priorAudit &&
2528
- priorAudit.version === txnTime &&
2529
- precedesExistingVersion(txnTime, { version: txnTime, localTime: txnTime, key: id, nodeId: priorAudit.nodeId }, options?.nodeId) === 0) {
3496
+ priorAudit.txnLogKey === txnLogKey &&
3497
+ precedesExistingVersion(txnTime, { version: txnTime, localTime: txnLogKey, key: id, nodeId: priorAudit.nodeId }, options?.nodeId) === 0) {
2530
3498
  write.skipped = true;
2531
3499
  return; // duplicate already applied; avoid the resequencing walk
2532
3500
  }
2533
3501
  }
2534
3502
  // incremental CRDT updates are only available with audit logging on
2535
- let localTime = existingEntry.localTime;
3503
+ const initialAuditHead = isRocksDB
3504
+ ? resolveAuditHead(id, existingEntry.version, existingEntry.nodeId, existingEntry.additionalAuditRefs)
3505
+ : { txnLogKey: existingEntry.localTime, nodeId: existingEntry.nodeId };
3506
+ let localTime = initialAuditHead.txnLogKey;
2536
3507
  let auditedVersion = existingEntry.version;
2537
3508
  logger_ts_1.logger.debug?.('Applying CRDT update to record with id: ', id, 'txn time', new Date(txnTime), 'applying later update from:', new Date(auditedVersion), 'local recorded time', new Date(localTime));
2538
- let nodeId = existingEntry.nodeId;
3509
+ let nodeId = initialAuditHead.nodeId;
2539
3510
  const succeedingUpdates = []; // record the "future" updates, as we need to apply the updates in reverse order
2540
3511
  const auditRefsToVisit = existingEntry.additionalAuditRefs
2541
3512
  ? existingEntry.additionalAuditRefs.map((ref) => ({ localTime: ref.version, nodeId: ref.nodeId }))
2542
3513
  : [];
2543
- // Collect any existing audit refs that should be preserved (those older than current transaction)
3514
+ // Out-of-order merges retain every existing branch head; per-origin log keys are not globally ordered.
2544
3515
  if (existingEntry.additionalAuditRefs) {
2545
3516
  for (const ref of existingEntry.additionalAuditRefs) {
2546
- if (ref.version <= txnTime) {
2547
- additionalAuditRefs.push(ref);
2548
- }
3517
+ additionalAuditRefs.push(ref);
2549
3518
  }
2550
3519
  }
2551
3520
  let addedAuditRef = false;
2552
3521
  let nextRef;
3522
+ const visitedAuditRefs = new Set();
3523
+ const queuePreviousAuditRefs = (auditRecord) => {
3524
+ const previousRefs = auditRecord.previousAdditionalAuditRefs;
3525
+ if (previousRefs) {
3526
+ for (const ref of previousRefs) {
3527
+ auditRefsToVisit.push({ localTime: ref.version, nodeId: ref.nodeId });
3528
+ logger_ts_1.logger.debug?.('Adding audit ref from audit record to visit queue', {
3529
+ version: ref.version,
3530
+ nodeId: ref.nodeId,
3531
+ });
3532
+ }
3533
+ }
3534
+ };
3535
+ const advanceToPreviousAudit = (auditRecord) => {
3536
+ const previousRefs = auditRecord.previousAdditionalAuditRefs;
3537
+ const previousHead = isRocksDB && previousRefs?.length
3538
+ ? resolveAuditHead(id, auditRecord.previousVersion, auditRecord.previousNodeId, previousRefs)
3539
+ : { txnLogKey: auditRecord.previousVersion, nodeId: auditRecord.previousNodeId };
3540
+ localTime = previousHead.txnLogKey;
3541
+ nodeId = previousHead.nodeId;
3542
+ };
2553
3543
  let walkSteps = 0;
2554
3544
  let auditWalkCapped = false;
2555
3545
  // Early-out residual: as we walk the chain newest-first, fold each succeeding patch into a
@@ -2568,17 +3558,21 @@ function makeTable(options) {
2568
3558
  // appended this write's own audit entry, so the lookup would match it while the record was
2569
3559
  // never committed (see the up-front keyed dedup above).
2570
3560
  const isReDeliveredDuplicate = () => {
2571
- if (stagedOwnAuditEntry)
3561
+ if (replaying || stagedOwnAuditEntry)
2572
3562
  return false;
2573
- if (!dedupVersionCouldBeRetained(txnTime))
2574
- return false; // pre-retention version — skip the end-of-log scan (best-effort; see above)
2575
- const duplicate = auditStore.get(txnTime, tableId, id, options?.nodeId);
3563
+ if (!dedupVersionCouldBeRetained(txnLogKey))
3564
+ return false; // pre-retention log key — skip the end-of-log scan (best-effort; see above)
3565
+ const duplicate = auditStore.get(txnLogKey, tableId, id, options?.nodeId);
2576
3566
  return (duplicate &&
2577
- duplicate.version === txnTime &&
2578
- precedesExistingVersion(txnTime, { version: txnTime, localTime: txnTime, key: id, nodeId: duplicate.nodeId }, options?.nodeId) === 0);
3567
+ duplicate.txnLogKey === txnLogKey &&
3568
+ precedesExistingVersion(txnTime, { version: txnTime, localTime: txnLogKey, key: id, nodeId: duplicate.nodeId }, options?.nodeId) === 0);
2579
3569
  };
2580
3570
  do {
2581
3571
  while (localTime > txnTime || (auditedVersion >= txnTime && localTime > 0)) {
3572
+ const auditIdentity = `${nodeId ?? 0}:${localTime}`;
3573
+ if (visitedAuditRefs.has(auditIdentity))
3574
+ break;
3575
+ visitedAuditRefs.add(auditIdentity);
2582
3576
  // Bound the walk only for RocksDB, where the OOM was observed (issue #1114): each step
2583
3577
  // is a transaction-log range scan + msgpackr decode, and the per-node logs can be huge.
2584
3578
  // LMDB audit entries are keyed by local audit time (not version), so the duplicate
@@ -2590,19 +3584,39 @@ function makeTable(options) {
2590
3584
  const auditRecord = auditStore.get(localTime, tableId, id, nodeId);
2591
3585
  if (!auditRecord)
2592
3586
  break;
3587
+ queuePreviousAuditRefs(auditRecord);
3588
+ if (isRocksDB &&
3589
+ !replaying &&
3590
+ !stagedOwnAuditEntry &&
3591
+ localTime === txnLogKey &&
3592
+ precedesExistingVersion(txnTime, { version: txnTime, localTime: txnLogKey, key: id, nodeId: auditRecord.nodeId }, options?.nodeId) === 0) {
3593
+ write.skipped = true;
3594
+ return;
3595
+ }
2593
3596
  auditedVersion = auditRecord.version;
2594
3597
  if (auditedVersion >= txnTime) {
2595
3598
  if (auditedVersion === txnTime) {
2596
3599
  precedesExisting = precedesExistingVersion(txnTime, { version: auditedVersion, localTime: localTime, key: id, nodeId: auditRecord.nodeId }, options?.nodeId);
2597
3600
  if (precedesExisting === 0) {
2598
- logger_ts_1.logger.debug?.('The transaction time is equal to the existing version, treating as duplicate', id);
2599
- write.skipped = true;
2600
- return; // treat a tie as a duplicate and drop it
3601
+ if (isRocksDB && localTime !== txnLogKey) {
3602
+ // Same origin and record version, but a distinct write. Its per-origin log key
3603
+ // orders the otherwise non-unique record clock without comparing keys across origins.
3604
+ precedesExisting = txnLogKey > localTime ? 1 : -1;
3605
+ }
3606
+ else if (replaying || stagedOwnAuditEntry) {
3607
+ // The log entry being replayed (or staged by this write's failed attempt) is
3608
+ // the write itself, not proof that its primary-store mutation committed.
3609
+ precedesExisting = 1;
3610
+ }
3611
+ else {
3612
+ logger_ts_1.logger.debug?.('The transaction time and log key match the existing write, treating as duplicate', id);
3613
+ write.skipped = true;
3614
+ return;
3615
+ }
2601
3616
  }
2602
3617
  if (precedesExisting > 0) {
2603
3618
  // if the existing version is older, we can skip this update
2604
- localTime = auditRecord.previousVersion;
2605
- nodeId = auditRecord.previousNodeId;
3619
+ advanceToPreviousAudit(auditRecord);
2606
3620
  continue;
2607
3621
  }
2608
3622
  }
@@ -2637,23 +3651,16 @@ function makeTable(options) {
2637
3651
  }
2638
3652
  if (!addedAuditRef && isRocksDB) {
2639
3653
  addedAuditRef = true;
2640
- // Add a reference to this older audit record if we had out-of-order writes
2641
- additionalAuditRefs.push({ version: txnTime, nodeId: options?.nodeId });
3654
+ // Add a reference to this older audit record if we had out-of-order writes. The stored
3655
+ // value is a LOG key, not a record version: every consumer follows it straight into
3656
+ // `auditStore.get` (see the `auditRefsToVisit` mapping above and below), and on an
3657
+ // applied write those two clocks differ.
3658
+ additionalAuditRefs.push({ version: txnLogKey, nodeId: options?.nodeId });
2642
3659
  logger_ts_1.logger.debug?.('Adding additional audit ref for out-of-order write', {
2643
- version: txnTime,
3660
+ txnLogKey,
2644
3661
  nodeId: options?.nodeId,
2645
3662
  });
2646
3663
  }
2647
- // Collect any additional audit refs from this audit record to traverse other branches
2648
- if (auditRecord.previousAdditionalAuditRefs) {
2649
- for (const ref of auditRecord.previousAdditionalAuditRefs) {
2650
- auditRefsToVisit.push({ localTime: ref.version, nodeId: ref.nodeId });
2651
- logger_ts_1.logger.debug?.('Adding audit ref from audit record to visit queue', {
2652
- version: ref.version,
2653
- nodeId: ref.nodeId,
2654
- });
2655
- }
2656
- }
2657
3664
  // Every field of this write is overwritten by newer writes, and there is no alternate
2658
3665
  // audit branch left to scan, so it is fully superseded — the same outcome as walking to
2659
3666
  // the end and taking the `writeCommit(false)` escape below, reached without paying the rest
@@ -2671,8 +3678,7 @@ function makeTable(options) {
2671
3678
  }
2672
3679
  return writeCommit(false);
2673
3680
  }
2674
- localTime = auditRecord.previousVersion;
2675
- nodeId = auditRecord.previousNodeId;
3681
+ advanceToPreviousAudit(auditRecord);
2676
3682
  }
2677
3683
  // Check if we need to scan additional audit refs from this record
2678
3684
  if (auditWalkCapped)
@@ -2766,15 +3772,15 @@ function makeTable(options) {
2766
3772
  if (recordToStore && recordToStore.getRecord)
2767
3773
  throw new Error('Can not assign a record to a record, check for circular references');
2768
3774
  if (residencyId == undefined) {
2769
- if (entry?.residencyId)
2770
- context.previousResidency = TableResource.getResidencyRecord(entry.residencyId);
2771
- const residency = residencyFromFunction(TableResource.getResidency(recordToStore, context));
3775
+ if (existingEntry?.residencyId)
3776
+ context.previousResidency = _a.getResidencyRecord(existingEntry.residencyId);
3777
+ const residency = residencyFromFunction(_a.getResidency(recordToStore, context));
2772
3778
  if (residency) {
2773
3779
  if (!residency.includes(server.hostname)) {
2774
3780
  // if we aren't in the residency list, specify that our local record should be omitted or be partial
2775
3781
  auditRecordToStore ??= recordToStore;
2776
3782
  omitLocalRecord = true;
2777
- if (TableResource.getResidencyById) {
3783
+ if (_a.getResidencyById) {
2778
3784
  // complete omission of the record that doesn't belong here
2779
3785
  recordToStore = undefined;
2780
3786
  }
@@ -2844,7 +3850,17 @@ function makeTable(options) {
2844
3850
  }
2845
3851
  })());
2846
3852
  updateIndices(id, existingRecord, recordToStore, transaction && { transaction });
3853
+ // Preserve an addressable audit head when the record and log clocks diverge.
3854
+ if (isRocksDB && audit && !isCopyApply && txnLogKey !== txnTime) {
3855
+ const headIndex = additionalAuditRefs.findIndex((ref) => ref.version === txnLogKey && (ref.nodeId ?? 0) === (options?.nodeId ?? 0));
3856
+ if (headIndex > 0)
3857
+ additionalAuditRefs.unshift(additionalAuditRefs.splice(headIndex, 1)[0]);
3858
+ else if (headIndex < 0)
3859
+ additionalAuditRefs.unshift({ version: txnLogKey, nodeId: options?.nodeId });
3860
+ }
2847
3861
  writeCommit(true);
3862
+ if (write.trackRecordVersion)
3863
+ write.recordVersionApplied = true;
2848
3864
  if (expiresAt >= 0) {
2849
3865
  scheduleCleanup(); // arm for replicated writes too, not just local-context writes
2850
3866
  // A runtime per-record expiresAt on a table with no table-level expiration/eviction, no expiresAt
@@ -2871,6 +3887,8 @@ function makeTable(options) {
2871
3887
  user: context?.user,
2872
3888
  residencyId,
2873
3889
  expiresAt,
3890
+ recordVersion: txnTime,
3891
+ recordNodeId: precedesExisting < 0 ? existingEntry?.nodeId : options?.nodeId,
2874
3892
  nodeId: options?.nodeId,
2875
3893
  viaNodeId: options?.viaNodeId,
2876
3894
  originatingOperation: context?.originatingOperation,
@@ -2908,7 +3926,7 @@ function makeTable(options) {
2908
3926
  // calls the backend, and a tracked-instance mutation (update(id,{}); row.source=…;
2909
3927
  // save()) that sets the source via accessors after update() won't re-embed. A
2910
3928
  // resource-layer re-embed is the proper fix; tracked as a follow-up.
2911
- const embedBefore = (0, embedHook_ts_1.buildEmbedBefore)(recordUpdate, context, options, TableResource.embedAttributes, TableResource.userEmbedders);
3929
+ const embedBefore = (0, embedHook_ts_1.buildEmbedBefore)(recordUpdate, context, options, _a.embedAttributes, _a.userEmbedders);
2912
3930
  const proceed = () => {
2913
3931
  // On a source/replication apply (`isNotification`), the record's already-saved blobs were
2914
3932
  // received out-of-band for THIS write, so track them for skip/abort cleanup (harper-pro#406).
@@ -2964,8 +3982,10 @@ function makeTable(options) {
2964
3982
  return Boolean(this.#record);
2965
3983
  }
2966
3984
  _writeDelete(id, options) {
3985
+ this.#assertLiveHandle(id);
2967
3986
  const context = this.getContext();
2968
3987
  const transaction = txnForContext(context);
3988
+ assertDerivedIndexAdmission(options, transaction);
2969
3989
  checkValidId(id);
2970
3990
  const entry = this.#entry ?? primaryStore.getEntry(id, { transaction: transaction.getReadTxn() });
2971
3991
  const write = {
@@ -2973,7 +3993,10 @@ function makeTable(options) {
2973
3993
  store: primaryStore,
2974
3994
  entry,
2975
3995
  chainsStagedState: true,
3996
+ reloadCommitBase: true,
2976
3997
  nodeName: context?.nodeName,
3998
+ recordVersion: options?.version,
3999
+ lockHandle: this.#lockHandle && this.#lockHandle.keyId === (0, DatabaseTransaction_ts_1.writeKeyId)(id) ? this.#lockHandle : undefined,
2977
4000
  before: this.constructor.source?.delete && !context?.source
2978
4001
  ? this.constructor.source.delete.bind(this.constructor.source, id, undefined, context)
2979
4002
  : undefined,
@@ -2985,10 +4008,11 @@ function makeTable(options) {
2985
4008
  const priorStagedOp = (0, DatabaseTransaction_ts_1.priorStagedWrite)(write);
2986
4009
  const priorStaged = priorStagedOp?.stagedEntry;
2987
4010
  const existingRecord = priorStaged ? priorStaged.value : existingEntry?.value;
4011
+ const txnLogKey = isRocksDB && options?.version != null ? (transaction?.getTimestamp?.() ?? txnTime) : txnTime;
2988
4012
  if (retry) {
2989
4013
  if (context && existingEntry?.version > (context.lastModified || 0))
2990
4014
  context.lastModified = existingEntry.version;
2991
- TableResource._updateResource(this, existingEntry);
4015
+ _a._updateResource(this, existingEntry);
2992
4016
  }
2993
4017
  // a strictly newer record exists locally, so this delete loses. An earlier write in this
2994
4018
  // transaction can never trip this guard — it shares this transaction's timestamp and
@@ -3006,6 +4030,10 @@ function makeTable(options) {
3006
4030
  viaNodeId: options?.viaNodeId,
3007
4031
  transaction,
3008
4032
  tableToTrack: tableName,
4033
+ recordVersion: txnTime,
4034
+ additionalAuditRefs: isRocksDB && audit && txnLogKey !== txnTime
4035
+ ? [{ version: txnLogKey, nodeId: options?.nodeId }]
4036
+ : undefined,
3009
4037
  }, 'delete');
3010
4038
  if (!audit || isRocksDB)
3011
4039
  scheduleCleanup();
@@ -3015,6 +4043,8 @@ function makeTable(options) {
3015
4043
  (0, RecordEncoder_ts_1.removeEntry)(primaryStore, existingEntry, isRocksDB && transaction ? { transaction } : undefined);
3016
4044
  }
3017
4045
  write.stagedEntry = { value: undefined }; // the key holds no record for the rest of this transaction
4046
+ if (write.trackRecordVersion)
4047
+ write.recordVersionApplied = true;
3018
4048
  // the removal supersedes the nearest record an earlier write in this transaction stored
3019
4049
  // (older ones were already marked by their staged successors), so its saved blobs are
3020
4050
  // cleaned up post-commit unless its audit entry references them
@@ -3274,7 +4304,7 @@ function makeTable(options) {
3274
4304
  // Note, that we do allow users to disable condition re-ordering, in case they have knowledge of a preferred
3275
4305
  // order for their query.
3276
4306
  if (conditions.length > 1 && operator !== 'or')
3277
- return sortBy(conditions, (0, search_ts_1.estimateCondition)(TableResource));
4307
+ return sortBy(conditions, (0, search_ts_1.estimateCondition)(_a));
3278
4308
  else
3279
4309
  return conditions;
3280
4310
  }
@@ -3321,8 +4351,13 @@ function makeTable(options) {
3321
4351
  else if (conditions.length === 0 && !target.allowFullScan)
3322
4352
  throw (0, hdbError_ts_1.handleHDBError)(new Error(), `${Array.isArray(attribute_name) ? attribute_name.join('.') : attribute_name} is not indexed and not combined with any other conditions`, 404);
3323
4353
  }
3324
- if (orderAlignedCondition)
4354
+ if (orderAlignedCondition) {
3325
4355
  orderAlignedCondition.descending = Boolean(sort.descending);
4356
+ if (orderAlignedCondition.maxIndexLagMilliseconds === undefined)
4357
+ orderAlignedCondition.maxIndexLagMilliseconds = sort.maxIndexLagMilliseconds;
4358
+ if (orderAlignedCondition.waitForIndexMilliseconds === undefined)
4359
+ orderAlignedCondition.waitForIndexMilliseconds = sort.waitForIndexMilliseconds;
4360
+ }
3326
4361
  }
3327
4362
  }
3328
4363
  conditions = orderConditions(conditions, operator);
@@ -3351,6 +4386,10 @@ function makeTable(options) {
3351
4386
  }
3352
4387
  }
3353
4388
  const select = target.select;
4389
+ // Whether the caller supplied real filter conditions — read from the raw request, NOT the
4390
+ // planner-augmented `conditions` (which by now may carry a synthetic `sort` pseudo-condition and
4391
+ // injected full-scan condition). Used to pick the count-estimate source below.
4392
+ const hasUserConditions = Array.isArray(target.conditions) && target.conditions.length > 0;
3354
4393
  if (conditions.length === 0) {
3355
4394
  conditions = [{ attribute: primaryKey, comparator: 'greater_than', value: true }];
3356
4395
  }
@@ -3387,35 +4426,156 @@ function makeTable(options) {
3387
4426
  const recordAccess = boundRowFilter || typeof target.vectorFilter === 'function'
3388
4427
  ? { rowFilter: boundRowFilter, vectorFilter: target.vectorFilter }
3389
4428
  : undefined;
3390
- const entries = (0, search_ts_1.executeConditions)(conditions, operator, TableResource, readTxn, target, context, (results, filters) => transformToEntries(results, select, context, readTxn, filters), filtered, recordAccess);
3391
- const ensure_loaded = target.ensureLoaded !== false;
3392
- // The guards inside executeConditions evaluate the
3393
- // LOCAL record, but on a caching table transformEntryForSelect may then revalidate an
3394
- // expired/invalidated row from source and return a DIFFERENT record. The explicit row filter
3395
- // must hold on the record actually returned, so it is re-checked
3396
- // there, after materialization (the earlier evaluation stays as a prune that also bounds HNSW
3397
- // traversal). vectorFilter and condition filters intentionally keep the local-record
3398
- // semantics all query filters have on caching tables.
3399
- //
3400
- // A row that is past its TTL but not yet swept by the background eviction
3401
- // scan is still physically present. A write that is about to overwrite it
3402
- // anyway (e.g. the SQL engine locating UPDATE/DELETE targets) needs to see
3403
- // it as a match — the same leniency a direct by-id put/patch already gets,
3404
- // since those never run the ensureLoaded-gated freshness check this transform
3405
- // otherwise applies unconditionally to every read.
3406
- const includeExpired = target.includeExpired === true;
3407
- const transformToRecord = TableResource.transformEntryForSelect(select, context, readTxn, filtered, ensure_loaded, true, boundRowFilter, includeExpired, postOrdering);
3408
- let results = TableResource.transformToOrderedSelect(entries, select, postOrdering, context, readTxn, transformToRecord);
3409
- // apply any offset/limit after all the sorting and filtering
3410
- if (target.offset || target.limit !== undefined)
3411
- results = results.slice(target.offset, target.limit !== undefined ? (target.offset || 0) + target.limit : undefined);
3412
- results.onDone = () => {
3413
- results.onDone = null; // ensure that it isn't called twice
4429
+ try {
4430
+ const entries = (0, search_ts_1.executeConditions)(conditions, operator, _a, readTxn, target, context, (results, filters) => transformToEntries(results, select, context, readTxn, filters), filtered, recordAccess);
4431
+ const ensure_loaded = target.ensureLoaded !== false;
4432
+ // The guards inside executeConditions evaluate the
4433
+ // LOCAL record, but on a caching table transformEntryForSelect may then revalidate an
4434
+ // expired/invalidated row from source and return a DIFFERENT record. The explicit row filter
4435
+ // must hold on the record actually returned, so it is re-checked
4436
+ // there, after materialization (the earlier evaluation stays as a prune that also bounds HNSW
4437
+ // traversal). vectorFilter and condition filters intentionally keep the local-record
4438
+ // semantics all query filters have on caching tables.
4439
+ //
4440
+ // A row that is past its TTL but not yet swept by the background eviction
4441
+ // scan is still physically present. A write that is about to overwrite it
4442
+ // anyway (e.g. the SQL engine locating UPDATE/DELETE targets) needs to see
4443
+ // it as a match — the same leniency a direct by-id put/patch already gets,
4444
+ // since those never run the ensureLoaded-gated freshness check this transform
4445
+ // otherwise applies unconditionally to every read.
4446
+ const includeExpired = target.includeExpired === true;
4447
+ const transformToRecord = _a.transformEntryForSelect(select, context, readTxn, filtered, ensure_loaded, true, boundRowFilter, includeExpired, postOrdering);
4448
+ let results = _a.transformToOrderedSelect(entries, select, postOrdering, context, readTxn, transformToRecord);
4449
+ const offset = target.offset || 0;
4450
+ const end = target.limit !== undefined ? offset + target.limit : undefined;
4451
+ // `Prefer: count=` (REST pagination): materialize the requested page and attach a total record
4452
+ // count so the HTTP layer can emit a Content-Range. `exact` drains the full matched set once,
4453
+ // windowing the page in the same pass; `estimated` returns just the page plus a cheap planner/
4454
+ // table estimate. Opt-in only — the default streaming path below is untouched.
4455
+ //
4456
+ // Requires a bounded page AND window. Counting is a pagination feature; both the limit and the
4457
+ // offset must be finite, non-negative integers, the limit no larger than MAX_COUNT_PAGE, and the
4458
+ // window (offset + limit) no larger than MAX_EXACT_COUNT_SCAN. Anything else — a missing/
4459
+ // oversized/non-finite/negative limit or offset (a bare collection GET, limit(Infinity),
4460
+ // limit(foo), limit(-5,10)) or a deep-page window past the scan budget — falls through to the
4461
+ // normal streaming path with no count. This bounds the offset too: without it a huge offset would
4462
+ // postpone the exact guardrail (which only engages past the page) until that offset was scanned.
4463
+ const pageLimit = target.limit;
4464
+ if (target.count &&
4465
+ Number.isInteger(pageLimit) &&
4466
+ pageLimit >= 0 &&
4467
+ pageLimit <= MAX_COUNT_PAGE &&
4468
+ Number.isInteger(offset) &&
4469
+ offset >= 0 &&
4470
+ offset + pageLimit <= MAX_EXACT_COUNT_SCAN) {
4471
+ const wantExact = target.count === 'exact';
4472
+ const pageEnd = offset + pageLimit;
4473
+ const countStart = node_perf_hooks_1.performance.now();
4474
+ // A custom-index (vector/HNSW) traversal returns a bounded, approximate candidate set whose size is
4475
+ // chosen from `minResults` (offset + limit), so `scanned` over it tracks the requested page size, not
4476
+ // the true match count — the same query at limit(5) vs limit(200) would otherwise advertise two
4477
+ // different `count=exact` totals. Any query whose execution touches a custom index is affected: a
4478
+ // custom-index sort (its aligned pseudo-condition lands in `conditions`), a custom-index threshold
4479
+ // filter (an HNSW `lt`/`le` is the same minResults-widened traversal as a sort), or an opaque vector
4480
+ // filter. Report the total as unavailable for those rather than advertising it as count=exact
4481
+ // (mirroring how the estimated branch below bails to null for an opaque row/vector filter). A vector
4482
+ // sort applied as in-memory post-ordering leaves no custom-index condition here and stays exact.
4483
+ const touchesCustomIndex = (conds) => conds.some((c) => {
4484
+ if (!c)
4485
+ return false;
4486
+ if (c.conditions)
4487
+ return touchesCustomIndex(c.conditions);
4488
+ const attr = Array.isArray(c.attribute) ? c.attribute[0] : (c.attribute ?? c[0]);
4489
+ return typeof attr === 'string' && Boolean(indices[attr]?.customIndex);
4490
+ });
4491
+ const approximateResultSet = typeof target.vectorFilter === 'function' || touchesCustomIndex(conditions);
4492
+ return (async () => {
4493
+ const page = [];
4494
+ let scanned = 0;
4495
+ let exact = true;
4496
+ try {
4497
+ for await (const record of results) {
4498
+ if (scanned >= offset && scanned < pageEnd)
4499
+ page.push(record);
4500
+ scanned++;
4501
+ // A store whose async iterator settles synchronously (the common indexed-scan case) would
4502
+ // otherwise let this drain spin as one uninterrupted microtask run, blocking the event loop
4503
+ // for the whole count. Yield to the macrotask queue periodically so concurrent requests and
4504
+ // I/O still make progress during a large exact scan.
4505
+ if ((scanned & (COUNT_YIELD_INTERVAL - 1)) === 0)
4506
+ await new Promise((resolve) => setImmediate(resolve));
4507
+ // The page window [offset, pageEnd) is always collected in full first — the guardrail
4508
+ // only ever abandons the running TOTAL, never truncates the page body.
4509
+ if (scanned >= pageEnd) {
4510
+ // `estimated` needs nothing past the page; an approximate (vector) exact total is going to
4511
+ // be reported unavailable anyway, so don't drain its tail for a number we won't publish.
4512
+ if (!wantExact || approximateResultSet)
4513
+ break;
4514
+ // `exact` keeps counting the tail, bounded by a row cap AND a time budget so a
4515
+ // large match set can't turn a bounded page fetch into an unbounded scan.
4516
+ if (scanned > MAX_EXACT_COUNT_SCAN || node_perf_hooks_1.performance.now() - countStart > MAX_EXACT_COUNT_MS) {
4517
+ exact = false;
4518
+ break;
4519
+ }
4520
+ }
4521
+ }
4522
+ }
4523
+ finally {
4524
+ // We own the iteration here (no results.onDone consumer), so release the read
4525
+ // transaction unconditionally — including when the drain throws — or the snapshot leaks.
4526
+ txn.doneReadTxn();
4527
+ }
4528
+ let total;
4529
+ if (wantExact) {
4530
+ // `scanned` is only an authoritative total when the iteration was exhaustive and deterministic;
4531
+ // an approximate (vector/HNSW) result set is neither, so report the total as unavailable.
4532
+ total = exact && !approximateResultSet ? scanned : null;
4533
+ }
4534
+ else if (boundRowFilter || typeof target.vectorFilter === 'function') {
4535
+ // An opaque row/vector filter shapes the result but isn't reflected in the index/condition
4536
+ // estimate; guessing would both mislead and disclose cardinality the filter hides.
4537
+ total = null;
4538
+ }
4539
+ else if (!hasUserConditions) {
4540
+ total = (0, search_ts_1.estimatedEntryCount)(primaryStore);
4541
+ }
4542
+ else {
4543
+ // Estimate from the real conditions only — drop the planner's synthetic `sort`
4544
+ // pseudo-condition, which otherwise contributes a bogus (entryCount/2) cardinality.
4545
+ const est = (0, search_ts_1.estimateCondition)(_a)({
4546
+ conditions: conditions.filter((c) => c.comparator !== 'sort'),
4547
+ operator: operator ? String(operator).toLowerCase() : 'and',
4548
+ });
4549
+ total = isFinite(est) ? Math.round(est) : null;
4550
+ }
4551
+ // For an estimate, never report a total below the last row actually returned — keeps the
4552
+ // Content-Range valid (start-end/total) when an estimate undershoots a non-empty page.
4553
+ // Exact totals are authoritative (and an empty page past the end must not be clamped up).
4554
+ if (!wantExact && total != null && page.length > 0 && total < offset + page.length) {
4555
+ total = offset + page.length;
4556
+ }
4557
+ page.recordCount = total;
4558
+ page.recordCountExact = wantExact && exact && !approximateResultSet;
4559
+ page.selectApplied = true;
4560
+ page.getColumns = getColumns;
4561
+ return page;
4562
+ })();
4563
+ }
4564
+ // apply any offset/limit after all the sorting and filtering
4565
+ if (target.offset || target.limit !== undefined)
4566
+ results = results.slice(offset, end);
4567
+ results.onDone = () => {
4568
+ results.onDone = null; // ensure that it isn't called twice
4569
+ txn.doneReadTxn();
4570
+ };
4571
+ results.selectApplied = true;
4572
+ results.getColumns = getColumns;
4573
+ return results;
4574
+ }
4575
+ catch (error) {
3414
4576
  txn.doneReadTxn();
3415
- };
3416
- results.selectApplied = true;
3417
- results.getColumns = getColumns;
3418
- return results;
4577
+ throw error;
4578
+ }
3419
4579
  }
3420
4580
  /**
3421
4581
  * This is responsible for ordering and select()ing the attributes/properties from returned entries
@@ -3431,10 +4591,18 @@ function makeTable(options) {
3431
4591
  if (sort) {
3432
4592
  // there might be some situations where we don't need to transform to entries for sorting, not sure
3433
4593
  entries = transformToEntries(entries, select, context, readTxn, null);
3434
- let ordered;
4594
+ // Sort keys are resolved as entries are collected, so comparison never dereferences a record: a
4595
+ // cached entry holds its record only weakly, and a re-read per comparison is what this avoids.
4596
+ const clauses = [];
4597
+ for (let order = sort; order; order = order.next)
4598
+ clauses.push(order);
4599
+ const clauseCount = clauses.length;
3435
4600
  // if we are doing post-ordering, we need to get records first, then sort them
3436
4601
  results.iterate = function (options) {
3437
- let sortedArrayIterator;
4602
+ let ordered;
4603
+ let orderedKeys;
4604
+ let sortedPositions;
4605
+ let sortedIndex;
3438
4606
  const dbIterator = options?.async && entries[Symbol.asyncIterator]
3439
4607
  ? entries[Symbol.asyncIterator]()
3440
4608
  : entries[Symbol.iterator]();
@@ -3443,26 +4611,34 @@ function makeTable(options) {
3443
4611
  let enqueuedEntryForNextGroup;
3444
4612
  let lastGroupingValue;
3445
4613
  let firstEntry = true;
3446
- function createComparator(order) {
3447
- const nextComparator = order.next && createComparator(order.next);
3448
- const descending = order.descending;
3449
- return (entryA, entryB) => {
3450
- const a = getAttributeValue(entryA, order.attribute, context, order);
3451
- const b = getAttributeValue(entryB, order.attribute, context, order);
3452
- const diff = descending
3453
- ? (0, ordered_binary_1.compareKeys)(convertToComparableKeys(b), convertToComparableKeys(a))
3454
- : (0, ordered_binary_1.compareKeys)(convertToComparableKeys(a), convertToComparableKeys(b));
3455
- if (diff === 0)
3456
- return nextComparator?.(entryA, entryB) || 0;
3457
- return diff;
3458
- };
4614
+ function collect(entry) {
4615
+ ordered.push(entry);
4616
+ for (let i = 0; i < clauseCount; i++) {
4617
+ const clause = clauses[i];
4618
+ orderedKeys[i].push(convertToComparableKeys(getAttributeValue(entry, clause.attribute, context, clause)));
4619
+ }
4620
+ }
4621
+ function comparePositions(positionA, positionB) {
4622
+ for (let i = 0; i < clauseCount; i++) {
4623
+ const keys = orderedKeys[i];
4624
+ const diff = clauses[i].descending
4625
+ ? (0, ordered_binary_1.compareKeys)(keys[positionB], keys[positionA])
4626
+ : (0, ordered_binary_1.compareKeys)(keys[positionA], keys[positionB]);
4627
+ if (diff !== 0)
4628
+ return diff;
4629
+ }
4630
+ return 0;
4631
+ }
4632
+ function nextSorted() {
4633
+ if (sortedIndex < sortedPositions.length)
4634
+ return { done: false, value: ordered[sortedPositions[sortedIndex++]] };
4635
+ return { done: true, value: undefined };
3459
4636
  }
3460
- const comparator = createComparator(sort);
3461
4637
  return {
3462
4638
  async next() {
3463
4639
  let iteration;
3464
- if (sortedArrayIterator) {
3465
- iteration = sortedArrayIterator.next();
4640
+ if (sortedPositions) {
4641
+ iteration = nextSorted();
3466
4642
  if (iteration.done) {
3467
4643
  if (dbDone) {
3468
4644
  if (results.onDone)
@@ -3476,8 +4652,11 @@ function makeTable(options) {
3476
4652
  };
3477
4653
  }
3478
4654
  ordered = [];
4655
+ orderedKeys = [];
4656
+ for (let i = 0; i < clauseCount; i++)
4657
+ orderedKeys.push([]);
3479
4658
  if (enqueuedEntryForNextGroup)
3480
- ordered.push(enqueuedEntryForNextGroup);
4659
+ collect(enqueuedEntryForNextGroup);
3481
4660
  // need to load all the entries into ordered
3482
4661
  do {
3483
4662
  iteration = await dbIterator.next();
@@ -3509,17 +4688,18 @@ function makeTable(options) {
3509
4688
  break;
3510
4689
  }
3511
4690
  }
3512
- // we store the value we will sort on, for fast sorting, and the entry so the records can be GC'ed if necessary
3513
- // before the sorting is completed
3514
- ordered.push(entry);
4691
+ collect(entry);
3515
4692
  }
3516
4693
  } while (true);
3517
4694
  if (sort.isGrouped) {
3518
4695
  // TODO: Return grouped results
3519
4696
  }
3520
- ordered.sort(comparator);
3521
- sortedArrayIterator = ordered[Symbol.iterator]();
3522
- iteration = sortedArrayIterator.next();
4697
+ sortedPositions = [];
4698
+ for (let i = 0; i < ordered.length; i++)
4699
+ sortedPositions.push(i);
4700
+ sortedPositions.sort(comparePositions);
4701
+ sortedIndex = 0;
4702
+ iteration = nextSorted();
3523
4703
  if (!iteration.done)
3524
4704
  return {
3525
4705
  value: await transformToRecord.call(this, iteration.value),
@@ -3723,12 +4903,12 @@ function makeTable(options) {
3723
4903
  if (resolver.directReturn)
3724
4904
  return callback(value, attribute_name);
3725
4905
  if (value && typeof value === 'object') {
3726
- const targetTable = resolver.definition?.tableClass || TableResource;
4906
+ const targetTable = resolver.definition?.tableClass || _a;
3727
4907
  if (!transformCache)
3728
4908
  transformCache = {};
3729
4909
  // Use the target table's own read transaction; each table's readTxn is
3730
4910
  // scoped to its RocksDB column family and cannot read another table's store.
3731
- const targetReadTxn = targetTable === TableResource ? readTxn : targetTable._readTxnForContext(context);
4911
+ const targetReadTxn = targetTable === _a ? readTxn : targetTable._readTxnForContext(context);
3732
4912
  const transform = transformCache[attribute_name] ||
3733
4913
  (transformCache[attribute_name] = targetTable.transformEntryForSelect(
3734
4914
  // if it is a simple string, there is no select for the next level,
@@ -3781,7 +4961,7 @@ function makeTable(options) {
3781
4961
  else {
3782
4962
  value = record[attribute_name];
3783
4963
  if (value && typeof value === 'object' && attribute_name !== attribute) {
3784
- const subTransform = TableResource.transformEntryForSelect(attribute.select || attribute, context, readTxn, null);
4964
+ const subTransform = _a.transformEntryForSelect(attribute.select || attribute, context, readTxn, null);
3785
4965
  // Plain JSON nested values: arrays project per-element so that
3786
4966
  // `select: [{ name: 'addresses', select: ['city'] }]` returns
3787
4967
  // `addresses: [{ city }, { city }]` rather than a single object.
@@ -3860,6 +5040,10 @@ function makeTable(options) {
3860
5040
  if (!auditStore)
3861
5041
  throw new Error('Can not subscribe to a table without an audit log');
3862
5042
  if (!audit) {
5043
+ // Turning auditing on is a schema write, and a branch's Table classes carry the base's
5044
+ // logical name: without this a subscribe through a branched application would enable
5045
+ // auditing on the live base table for every other consumer, with no DDL call involved.
5046
+ _a.assertSchemaMutable('enable auditing for a subscription');
3863
5047
  (0, databases_ts_1.table)({ table: tableName, database: databaseName, schemaDefined, attributes, audit: true });
3864
5048
  }
3865
5049
  const getFullRecord = !request.rawEvents;
@@ -3915,11 +5099,14 @@ function makeTable(options) {
3915
5099
  return evaluateFilter(rowFilter, event.value, 'rowFilter');
3916
5100
  }
3917
5101
  : null;
3918
- const subscription = (0, transactionBroadcast_ts_1.addSubscription)(TableResource, thisId, function (id, auditRecord, localTime, beginTxn) {
5102
+ const subscription = (0, transactionBroadcast_ts_1.addSubscription)(_a, thisId, function (id, auditRecord, txnLogKey, beginTxn) {
3919
5103
  if (dropDuringReplay)
3920
5104
  return;
3921
5105
  try {
3922
5106
  let type = auditRecord.type;
5107
+ // Ahead of the rawEvents branch, which forwards every type verbatim.
5108
+ if ((0, auditStore_ts_1.isLockControlType)(type))
5109
+ return;
3923
5110
  let value;
3924
5111
  if (type === 'message' || request.rawEvents) {
3925
5112
  // we only send the full message, this are individual messages that can be sent out of order
@@ -3942,8 +5129,7 @@ function makeTable(options) {
3942
5129
  // been written, so are fresh in memory.
3943
5130
  const entry = primaryStore.getEntry(id);
3944
5131
  if (entry) {
3945
- // staleness is a record-version comparison; auditRecord.version is the log key on RocksDB
3946
- if (entry.version !== (auditRecord.recordVersion ?? auditRecord.version))
5132
+ if (entry.version !== auditRecord.version)
3947
5133
  return; // out of order event, with old update, don't send anything
3948
5134
  value = entry.value;
3949
5135
  type = entry.metadataFlags & exports.INVALIDATED ? 'invalidate' : value ? 'put' : 'delete';
@@ -3954,7 +5140,7 @@ function makeTable(options) {
3954
5140
  }
3955
5141
  const event = {
3956
5142
  id,
3957
- localTime,
5143
+ localTime: txnLogKey,
3958
5144
  value,
3959
5145
  version: auditRecord.version,
3960
5146
  type,
@@ -4019,14 +5205,16 @@ function makeTable(options) {
4019
5205
  if (!isActive())
4020
5206
  return;
4021
5207
  }
4022
- if (auditRecord.tableId !== tableId)
5208
+ if (auditRecord.tableId !== tableId || auditRecord.type === 'evict')
5209
+ continue;
5210
+ if ((0, auditStore_ts_1.isLockControlType)(auditRecord.type))
4023
5211
  continue;
4024
5212
  const id = auditRecord.recordId;
4025
5213
  if (thisId == null || isDescendantId(thisId, id)) {
4026
- const value = auditRecord.getValue(primaryStore, getFullRecord, auditRecord.localTime);
5214
+ const value = auditRecord.getValue(primaryStore, getFullRecord, auditRecord.txnLogKey);
4027
5215
  if (!send({
4028
5216
  id,
4029
- localTime: auditRecord.localTime,
5217
+ localTime: auditRecord.txnLogKey,
4030
5218
  value,
4031
5219
  version: auditRecord.version,
4032
5220
  type: auditRecord.type,
@@ -4039,7 +5227,7 @@ function makeTable(options) {
4039
5227
  return;
4040
5228
  }
4041
5229
  }
4042
- subscription.startTime = auditRecord.localTime ?? auditRecord.version; // update so we don't double send
5230
+ subscription.startTime = auditRecord.txnLogKey; // update so we don't double send
4043
5231
  }
4044
5232
  }
4045
5233
  finally {
@@ -4059,7 +5247,9 @@ function makeTable(options) {
4059
5247
  return;
4060
5248
  }
4061
5249
  try {
4062
- if (auditRecord.tableId !== tableId)
5250
+ if (auditRecord.tableId !== tableId || auditRecord.type === 'evict')
5251
+ continue;
5252
+ if ((0, auditStore_ts_1.isLockControlType)(auditRecord.type))
4063
5253
  continue;
4064
5254
  const id = auditRecord.recordId;
4065
5255
  if (thisId == null || isDescendantId(thisId, id)) {
@@ -4073,10 +5263,10 @@ function makeTable(options) {
4073
5263
  logger_ts_1.logger.warn?.(`previousCount backfill on ${tableName} stopped after inspecting ${MAX_PREVIOUS_COUNT_SCAN} in-scope audit records without collecting ${request.previousCount} accepted event(s); returning ${history.length} instead`);
4074
5264
  break;
4075
5265
  }
4076
- const value = auditRecord.getValue(primaryStore, getFullRecord, auditRecord.localTime);
5266
+ const value = auditRecord.getValue(primaryStore, getFullRecord, auditRecord.txnLogKey);
4077
5267
  const historyEntry = {
4078
5268
  id,
4079
- localTime: auditRecord.localTime,
5269
+ localTime: auditRecord.txnLogKey,
4080
5270
  value,
4081
5271
  version: auditRecord.version,
4082
5272
  type: auditRecord.type,
@@ -4093,7 +5283,7 @@ function makeTable(options) {
4093
5283
  }
4094
5284
  }
4095
5285
  catch (error) {
4096
- logger_ts_1.logger.error?.('Error getting history entry', auditRecord.localTime, error);
5286
+ logger_ts_1.logger.error?.('Error getting history entry', auditRecord.txnLogKey, error);
4097
5287
  }
4098
5288
  }
4099
5289
  for (let i = history.length; i > 0;) {
@@ -4181,6 +5371,12 @@ function makeTable(options) {
4181
5371
  logger_ts_1.logger.trace?.('re-retrieved record', localTime, this.#entry?.localTime);
4182
5372
  localTime = entry?.localTime;
4183
5373
  }
5374
+ let nodeId = entry?.nodeId;
5375
+ if (isRocksDB && entry) {
5376
+ const head = resolveAuditHead(thisId, entry.version, nodeId, entry.additionalAuditRefs);
5377
+ localTime = head.txnLogKey;
5378
+ nodeId = head.nodeId;
5379
+ }
4184
5380
  logger_ts_1.logger.trace?.('Subscription from', startTime, 'from', thisId, localTime);
4185
5381
  if (startTime < localTime) {
4186
5382
  // start time specified, get the audit history for this record. Set startTime up
@@ -4191,7 +5387,6 @@ function makeTable(options) {
4191
5387
  const history = [];
4192
5388
  let inspected = 0;
4193
5389
  let nextTime = localTime;
4194
- let nodeId = entry?.nodeId;
4195
5390
  do {
4196
5391
  if (++recordsSinceYield >= REPLAY_YIELD_INTERVAL) {
4197
5392
  recordsSinceYield = 0;
@@ -4222,8 +5417,11 @@ function makeTable(options) {
4222
5417
  else if (!isActive())
4223
5418
  return;
4224
5419
  }
4225
- nextTime = auditRecord.previousVersion;
4226
- nodeId = auditRecord.previousNodeId;
5420
+ const previousHead = isRocksDB
5421
+ ? resolveAuditHead(thisId, auditRecord.previousVersion, auditRecord.previousNodeId, auditRecord.previousAdditionalAuditRefs)
5422
+ : { txnLogKey: auditRecord.previousVersion, nodeId: auditRecord.previousNodeId };
5423
+ nextTime = previousHead.txnLogKey;
5424
+ nodeId = previousHead.nodeId;
4227
5425
  }
4228
5426
  else
4229
5427
  break;
@@ -4418,6 +5616,7 @@ function makeTable(options) {
4418
5616
  store: primaryStore,
4419
5617
  entry: this.#entry,
4420
5618
  nodeName: context?.nodeName,
5619
+ recordVersion: options?.version,
4421
5620
  validate: () => {
4422
5621
  if (!context?.source) {
4423
5622
  transaction.checkOverloaded();
@@ -4482,6 +5681,108 @@ function makeTable(options) {
4482
5681
  });
4483
5682
  });
4484
5683
  }
5684
+ /**
5685
+ * Write one cluster record-lock control entry (harper#483 Phase 1). Not local-only: replicating
5686
+ * it IS the send.
5687
+ *
5688
+ * `recordId` must stay null. An entry carrying the locked key would share
5689
+ * `(version, tableId, recordId, nodeId)` with the holder's own first write, which is stamped at
5690
+ * exactly `ts_R`, and `RocksTransactionLogStore.getSync` answers with the FIRST entry at a
5691
+ * timestamp and key — so `_writeUpdate`'s keyed dedup would find this one and drop that write.
5692
+ * The payload goes in as bytes rather than through `recordUpdater`, which would run it through
5693
+ * schema projection and the table's shared structure dictionary.
5694
+ */
5695
+ static writeLockControlEntry(entry) {
5696
+ const encodedRecord = (0, recordLockCoordinator_ts_1.encodeLockControlPayload)(entry);
5697
+ const nodeId = (0, nodeIdMapping_ts_1.getThisNodeId)(auditStore) ?? 0;
5698
+ let position;
5699
+ // No entry pins its clock, the request included. `ts_R` is minted before the write, so pinning
5700
+ // to it can land the entry behind a peer's replication cursor if any write to this table
5701
+ // commits in between — the same hazard that rules it out for grants and releases, which are
5702
+ // written later still. The protocol reads `ts_R` from the payload, so the entry's own log key
5703
+ // never has to equal it.
5704
+ const context = {};
5705
+ return Promise.resolve((0, transaction_ts_1.transaction)(context, (txn) => {
5706
+ const tableTxn = txnForContext({ transaction: txn });
5707
+ tableTxn.addWrite({
5708
+ key: null,
5709
+ store: primaryStore,
5710
+ skipReplicationConfirmation: true,
5711
+ commit: (txnTime, _existingEntry, _retry, nativeTransaction) => {
5712
+ position = txnTime;
5713
+ return auditStore[isRocksDB ? 'putSync' : 'put'](null, {
5714
+ version: txnTime,
5715
+ tableId,
5716
+ recordId: null,
5717
+ nodeId,
5718
+ type: entry.type,
5719
+ encodedRecord,
5720
+ extendedType: 0,
5721
+ // Zero, not the table's count: these bytes were packed by the private control `Packr`
5722
+ // and carry none of the table's structures. `RocksTransactionLogStore` raises the
5723
+ // per-(log, table) structure watermark from this field and flags the entry that does
5724
+ // it, so claiming the table's version would let a release take `HAS_STRUCTURE_UPDATE`
5725
+ // and leave the next real write at that version unflagged — a receiver that learns
5726
+ // structures only from flagged entries then decodes later records against a stale set
5727
+ // (harper#1348's class). A payload with no table structures cannot advance them.
5728
+ structureVersion: 0,
5729
+ }, { instructedWrite: true, transaction: nativeTransaction, nodeId, viaNodeId: nodeId });
5730
+ },
5731
+ });
5732
+ })).then(() => position);
5733
+ }
5734
+ /**
5735
+ * The coordinator that holds this node's admissions, transport or not. Releasing and registering
5736
+ * go here rather than through `lockCoordinator`, which answers undefined while a transport is
5737
+ * momentarily unregistered — and a release dropped on that answer leaves the key's home holding
5738
+ * its grant until the delegation's own deadline.
5739
+ */
5740
+ static get admittingCoordinator() {
5741
+ return lockCoordinator;
5742
+ }
5743
+ /**
5744
+ * This table's cluster lock coordinator, created on first use and only while a transport is
5745
+ * registered for the database. Nothing is allocated on the Phase 0 path.
5746
+ */
5747
+ static get lockCoordinator() {
5748
+ const transport = (0, recordLockCoordinator_ts_1.getClusterLockTransport)(databaseName);
5749
+ if (!transport) {
5750
+ // Deliberately NOT closed. harper-pro unregisters without a standalone claim during a
5751
+ // reconnect, and closing here would drop this node's record of the delegations it has
5752
+ // issued as a home — so the next registration would start empty and could grant a key
5753
+ // whose delegate is still admitting. The coordinator keeps ticking, its grants expire on
5754
+ // their own deadlines, and `isClusterLockRequired` is what fails an acquire closed in the
5755
+ // meantime. A genuine standalone claim clears the requirement and the coordinator with it.
5756
+ if (!(0, recordLockCoordinator_ts_1.isClusterLockRequired)(databaseName)) {
5757
+ lockCoordinator?.close();
5758
+ lockCoordinator = undefined;
5759
+ }
5760
+ return undefined;
5761
+ }
5762
+ if (lockCoordinator?.transport !== transport) {
5763
+ // The transport object changed, but this node's delegations and the handles they admitted
5764
+ // did not. The successor adopts that live authority in its constructor; the predecessor
5765
+ // is closed afterwards so nothing is dropped in between. See LockCoordinatorOptions.adopt.
5766
+ const predecessor = lockCoordinator;
5767
+ lockCoordinator = new recordLockCoordinator_ts_1.LockCoordinator({
5768
+ database: databaseName,
5769
+ table: tableName,
5770
+ nodeId: (0, nodeName_ts_1.getThisNodeName)(),
5771
+ transport,
5772
+ adopt: predecessor,
5773
+ // Writing to the local transaction log IS the send, so a transport that only computes
5774
+ // the participant set gets core's writer.
5775
+ writeControl: transport.writeControl
5776
+ ? (entry) => transport.writeControl(tableName, entry)
5777
+ : (entry) => _a.writeLockControlEntry(entry),
5778
+ keyIdOf: DatabaseTransaction_ts_1.writeKeyId,
5779
+ nextTimestamp: () => primaryStore.getMonotonicTimestamp(),
5780
+ grantableAfterMono: transport.grantableAfterMono,
5781
+ });
5782
+ predecessor?.close();
5783
+ }
5784
+ return lockCoordinator;
5785
+ }
4485
5786
  // #section: validation
4486
5787
  validate(record, patch) {
4487
5788
  // Accumulate structured per-field issues so the 400 carries `{ path, code,
@@ -4631,6 +5932,7 @@ function makeTable(options) {
4631
5932
  return this.#version;
4632
5933
  }
4633
5934
  static async addAttributes(attributesToAdd) {
5935
+ _a.assertSchemaMutable('add attributes');
4634
5936
  const new_attributes = attributes.slice(0);
4635
5937
  for (const attribute of attributesToAdd) {
4636
5938
  if (!attribute.name)
@@ -4646,9 +5948,10 @@ function makeTable(options) {
4646
5948
  schemaDefined,
4647
5949
  attributes: new_attributes,
4648
5950
  });
4649
- return TableResource.indexingOperation;
5951
+ return _a.indexingOperation;
4650
5952
  }
4651
5953
  static async removeAttributes(names) {
5954
+ _a.assertSchemaMutable('remove attributes');
4652
5955
  const new_attributes = attributes.filter((attribute) => !names.includes(attribute.name));
4653
5956
  (0, databases_ts_1.table)({
4654
5957
  table: tableName,
@@ -4656,7 +5959,7 @@ function makeTable(options) {
4656
5959
  schemaDefined,
4657
5960
  attributes: new_attributes,
4658
5961
  });
4659
- return TableResource.indexingOperation;
5962
+ return _a.indexingOperation;
4660
5963
  }
4661
5964
  /**
4662
5965
  * Get the size of the table in bytes (based on amount of pages stored in the database)
@@ -4669,6 +5972,10 @@ function makeTable(options) {
4669
5972
  const stats = primaryStore.getStats();
4670
5973
  return (stats.treeBranchPageCount + stats.treeLeafPageCount + stats.overflowPages) * stats.pageSize;
4671
5974
  }
5975
+ /** Sizes of this table's durable record-structure dictionaries. */
5976
+ static getStructureCounts() {
5977
+ return primaryStore.encoder?.getStructureCounts?.();
5978
+ }
4672
5979
  static getAuditSize() {
4673
5980
  const stats = auditStore?.getStats();
4674
5981
  return (stats &&
@@ -4687,7 +5994,7 @@ function makeTable(options) {
4687
5994
  // iterate through the metadata entries to exclude their count and exclude the deletion counts
4688
5995
  const exactCount = options?.exactCount;
4689
5996
  const TIME_LIMIT = options?.timeLimit ?? 1000 / 2; // one second time limit, enforced by seeing if we are halfway through at 500ms
4690
- const start = performance.now();
5997
+ const start = node_perf_hooks_1.performance.now();
4691
5998
  let entryCount = 0;
4692
5999
  let remainderPhysical = 0;
4693
6000
  let estimator;
@@ -4711,7 +6018,7 @@ function makeTable(options) {
4711
6018
  // a table too small to reach the floor is small enough to finish exactly
4712
6019
  if (exactCount || entriesScanned < MIN_ESTIMATOR_SAMPLE)
4713
6020
  continue;
4714
- const now = performance.now();
6021
+ const now = node_perf_hooks_1.performance.now();
4715
6022
  if (now <= nextCheckAt)
4716
6023
  continue;
4717
6024
  nextCheckAt = now + TIME_LIMIT;
@@ -4877,6 +6184,7 @@ function makeTable(options) {
4877
6184
  // Refresh on every call: schema reload mutates `attributes` in place, so the
4878
6185
  // class-construction snapshot would otherwise go stale.
4879
6186
  this.embedAttributes = this.attributes.filter((a) => a?.embed);
6187
+ expiresAtProperty = this.attributes.find((attribute) => attribute.expiresAt);
4880
6188
  // Drop registry entries for attributes that are no longer `@embed`, so a dropped
4881
6189
  // directive doesn't leave a stale embedder or block a default refresh on re-add.
4882
6190
  const embedNames = new Set(this.embedAttributes.map((a) => a.name));
@@ -4925,7 +6233,7 @@ function makeTable(options) {
4925
6233
  const computed = attribute.computed;
4926
6234
  // Register the default embedder unless an author override is set. Sits outside
4927
6235
  // the resolver chain below so `@embed` fields still flow through auto-HNSW indexing.
4928
- if (attribute.embed && !TableResource.userSetEmbedders.has(attribute.name)) {
6236
+ if (attribute.embed && !_a.userSetEmbedders.has(attribute.name)) {
4929
6237
  this.userEmbedders[attribute.name] = (0, embedHook_ts_1.createDefaultEmbedder)(attribute.embed);
4930
6238
  }
4931
6239
  if (relationship) {
@@ -4943,7 +6251,7 @@ function makeTable(options) {
4943
6251
  const id = object[relationship.from ? relationship.from : primaryKey];
4944
6252
  const relatedTable = attribute.elements.definition.tableClass;
4945
6253
  if (returnEntry) {
4946
- return (0, search_ts_1.searchByIndex)({ attribute: relationship.to, value: id }, txnForContext(context).getReadTxn(), false, relatedTable, false).map((entry) => {
6254
+ return (0, search_ts_1.searchByIndex)({ attribute: relationship.to, value: id }, txnForContext(context).getReadTxn(), false, relatedTable, { allowFullScan: false }).map((entry) => {
4947
6255
  if (entry && entry.key !== undefined)
4948
6256
  return entry;
4949
6257
  return relatedTable.primaryStore.getEntry(entry, {
@@ -4991,7 +6299,7 @@ function makeTable(options) {
4991
6299
  const options = { transaction: txnForContext(context).getReadTxn() };
4992
6300
  const results = normalizedIds.map((id) => {
4993
6301
  const value = store[method](id, options);
4994
- if (TableResource.loadAsInstance === false)
6302
+ if (_a.loadAsInstance === false)
4995
6303
  freezeRecord(returnEntry ? value?.value : value);
4996
6304
  return value;
4997
6305
  });
@@ -5000,7 +6308,7 @@ function makeTable(options) {
5000
6308
  const value = definition.tableClass.primaryStore[returnEntry ? 'getEntry' : 'getSync'](ids, {
5001
6309
  transaction: txnForContext(context).getReadTxn(),
5002
6310
  });
5003
- if (TableResource.loadAsInstance === false)
6311
+ if (_a.loadAsInstance === false)
5004
6312
  freezeRecord(returnEntry ? value?.value : value);
5005
6313
  return value;
5006
6314
  };
@@ -5155,30 +6463,99 @@ function makeTable(options) {
5155
6463
  this.userSetEmbedders.add(attribute_name);
5156
6464
  }
5157
6465
  static async deleteHistory(endTime = 0, cleanupDeletedRecords = false) {
5158
- let completion;
6466
+ const maxConcurrentRemovals = isRocksDB ? MAX_CONCURRENT_HISTORY_REMOVALS : MAX_CONCURRENT_LMDB_HISTORY_REMOVALS;
6467
+ const inFlightRemovals = new Set();
6468
+ const removalSlotWaiters = [];
6469
+ let removalsAttempted = 0;
6470
+ let removalsSucceeded = 0;
6471
+ let firstRemovalError;
6472
+ function startRemoval(remove, errorMessage, onSuccess) {
6473
+ removalsAttempted++;
6474
+ const removal = new Promise((resolve) => resolve(remove()))
6475
+ .then(() => {
6476
+ removalsSucceeded++;
6477
+ onSuccess?.();
6478
+ }, (error) => {
6479
+ // capture before logging: a throwing logger must not cost us the error we may rethrow
6480
+ if (firstRemovalError === undefined)
6481
+ firstRemovalError = error;
6482
+ harper_logger_ts_1.default.warn(errorMessage, error);
6483
+ })
6484
+ .catch(() => undefined)
6485
+ .finally(() => {
6486
+ inFlightRemovals.delete(removal);
6487
+ removalSlotWaiters.shift()?.();
6488
+ });
6489
+ inFlightRemovals.add(removal);
6490
+ }
6491
+ function queueRemoval(remove, errorMessage, onSuccess) {
6492
+ if (inFlightRemovals.size >= maxConcurrentRemovals) {
6493
+ return new Promise((resolve) => {
6494
+ removalSlotWaiters.push(resolve);
6495
+ }).then(() => startRemoval(remove, errorMessage, onSuccess));
6496
+ }
6497
+ startRemoval(remove, errorMessage, onSuccess);
6498
+ }
6499
+ const drainRemovals = () => Promise.all(inFlightRemovals);
5159
6500
  let entriesDeleted = 0;
5160
- for (const auditRecord of auditStore.getRange({
5161
- start: 0,
5162
- end: endTime,
5163
- })) {
5164
- await rest(); // yield to other async operations
5165
- if (auditRecord.tableId !== tableId)
5166
- continue;
5167
- completion = (0, auditStore_ts_1.removeAuditEntry)(auditStore, auditRecord);
5168
- entriesDeleted++;
6501
+ // LMDB only: RocksTransactionLogStore.remove() is a no-op, so a RocksDB deleteHistory removes
6502
+ // nothing and must not claim it did.
6503
+ // A bound above everything reachable must not be recorded as the floor: the floor only rises
6504
+ // and a store with a record is never re-stamped, so it would never come down, for every table in
6505
+ // this database. `boundedAuditPruneEnd` clamps the cutoff to just above the newest key in the
6506
+ // log, and the scan below uses that same value as its range end, so the prune cannot remove an
6507
+ // entry the floor does not cover.
6508
+ let pruneEnd = endTime;
6509
+ if (!isRocksDB) {
6510
+ pruneEnd = (0, auditStore_ts_1.boundedAuditPruneEnd)(auditStore, endTime);
6511
+ (0, auditStore_ts_1.raiseAuditFloor)(auditStore, pruneEnd);
6512
+ }
6513
+ try {
6514
+ for (const auditRecord of auditStore.getRange({
6515
+ // must not be zero: 0 encodes to all zero bytes and so overlaps the symbol keys, as in
6516
+ // getHistory below
6517
+ start: 1,
6518
+ end: pruneEnd,
6519
+ })) {
6520
+ await rest(); // yield to other async operations
6521
+ if (auditRecord.tableId !== tableId)
6522
+ continue;
6523
+ const backpressure = queueRemoval(() => (0, auditStore_ts_1.removeAuditEntry)(auditStore, auditRecord), 'Error removing audit entry during deleteHistory', () => {
6524
+ entriesDeleted++;
6525
+ });
6526
+ if (backpressure)
6527
+ await backpressure;
6528
+ }
6529
+ }
6530
+ finally {
6531
+ await drainRemovals();
5169
6532
  }
5170
6533
  if (cleanupDeletedRecords) {
5171
6534
  // this is separate procedure we can do if the records are not being cleaned up by the audit log. This shouldn't
5172
6535
  // ever happen, but if there are cleanup failures for some reason, we can run this to clean up the records
5173
- for (const entry of primaryStore.getRange({ start: 0, versions: true })) {
5174
- const { value, localTime } = entry;
5175
- await rest(); // yield to other async operations
5176
- if (value === null && localTime < endTime) {
5177
- completion = (0, RecordEncoder_ts_1.removeEntry)(primaryStore, entry);
6536
+ try {
6537
+ for (const entry of primaryStore.getRange({ start: 0, versions: true })) {
6538
+ const { key, value, localTime, version } = entry;
6539
+ await rest(); // yield to other async operations
6540
+ const auditTime = isRocksDB && version != null
6541
+ ? resolveAuditHead(key, version, entry.nodeId, entry.additionalAuditRefs).txnLogKey
6542
+ : localTime;
6543
+ if (value === null && version != null && auditTime < pruneEnd) {
6544
+ const backpressure = queueRemoval(() => primaryStore.remove(key, version), 'Error removing deleted record during deleteHistory');
6545
+ if (backpressure)
6546
+ await backpressure;
6547
+ }
5178
6548
  }
5179
6549
  }
6550
+ finally {
6551
+ await drainRemovals();
6552
+ }
6553
+ }
6554
+ if (removalsAttempted > 0 && removalsSucceeded === 0) {
6555
+ // zero progress must not report the same success as "nothing was eligible" (see DESIGN.md);
6556
+ // partial failures stay best-effort, logged and excluded from the returned count
6557
+ throw firstRemovalError ?? new Error('Every removal attempted during deleteHistory failed');
5180
6558
  }
5181
- await completion;
5182
6559
  return entriesDeleted;
5183
6560
  }
5184
6561
  static async *getHistory(startTime = 0, endTime = Infinity) {
@@ -5187,14 +6564,15 @@ function makeTable(options) {
5187
6564
  end: endTime,
5188
6565
  })) {
5189
6566
  await rest(); // yield to other async operations
5190
- if (auditRecord.tableId !== tableId)
6567
+ if (auditRecord.tableId !== tableId || auditRecord.type === 'evict' || (0, auditStore_ts_1.isLockControlType)(auditRecord.type))
5191
6568
  continue;
5192
6569
  yield {
5193
6570
  id: auditRecord.recordId,
5194
- localTime: auditRecord.version,
6571
+ // Compatibility-facing LMDB history has always reported/grouped by record version.
6572
+ localTime: isRocksDB ? auditRecord.txnLogKey : auditRecord.version,
5195
6573
  version: auditRecord.version,
5196
6574
  type: auditRecord.type,
5197
- value: auditRecord.getValue(primaryStore, true, auditRecord.version),
6575
+ value: auditRecord.getValue(primaryStore, true, auditRecord.txnLogKey),
5198
6576
  user: auditRecord.user,
5199
6577
  operation: auditRecord.originatingOperation,
5200
6578
  };
@@ -5207,7 +6585,9 @@ function makeTable(options) {
5207
6585
  const entry = primaryStore.getEntry(id);
5208
6586
  if (!entry)
5209
6587
  return history;
5210
- let nextVersion = entry.localTime;
6588
+ let nextVersion = isRocksDB
6589
+ ? resolveAuditHead(id, entry.version, entry.nodeId, entry.additionalAuditRefs).txnLogKey
6590
+ : entry.localTime;
5211
6591
  if (!nextVersion)
5212
6592
  throw new Error('The entry does not have a local audit time');
5213
6593
  const count = 0;
@@ -5218,20 +6598,26 @@ function makeTable(options) {
5218
6598
  let highestPreviousVersion = 0;
5219
6599
  const start = nextVersion - auditWindow;
5220
6600
  for (const auditRecord of auditStore.getRange({ start, end: nextVersion + 0.001 })) {
5221
- if (auditRecord.tableId === tableId && (0, ordered_binary_1.compareKeys)(auditRecord.recordId, id) === 0) {
6601
+ if (auditRecord.tableId === tableId &&
6602
+ auditRecord.type !== 'evict' &&
6603
+ !(0, auditStore_ts_1.isLockControlType)(auditRecord.type) &&
6604
+ (0, ordered_binary_1.compareKeys)(auditRecord.recordId, id) === 0) {
5222
6605
  history.splice(insertionPoint, 0, {
5223
6606
  id: auditRecord.recordId,
5224
- localTime: auditRecord.version,
6607
+ localTime: isRocksDB ? auditRecord.txnLogKey : auditRecord.version,
5225
6608
  version: auditRecord.version,
5226
6609
  type: auditRecord.type,
5227
- // reconstruct each entry's record image as of its own version, not the audit
6610
+ // reconstruct each entry's record image as of its own log position, not the audit
5228
6611
  // window boundary (nextVersion), matching getHistory (issue #1330)
5229
- value: auditRecord.getValue(primaryStore, true, auditRecord.version),
6612
+ value: auditRecord.getValue(primaryStore, true, auditRecord.txnLogKey),
5230
6613
  user: auditRecord.user,
5231
6614
  operation: auditRecord.originatingOperation,
5232
6615
  });
5233
- if (auditRecord.previousVersion > highestPreviousVersion && auditRecord.previousVersion < start) {
5234
- highestPreviousVersion = auditRecord.previousVersion;
6616
+ const previousVersion = isRocksDB
6617
+ ? resolveAuditHead(id, auditRecord.previousVersion, auditRecord.previousNodeId, auditRecord.previousAdditionalAuditRefs).txnLogKey
6618
+ : auditRecord.previousVersion;
6619
+ if (previousVersion > highestPreviousVersion && previousVersion < start) {
6620
+ highestPreviousVersion = previousVersion;
5235
6621
  }
5236
6622
  }
5237
6623
  }
@@ -5246,17 +6632,26 @@ function makeTable(options) {
5246
6632
  const promises = [primaryStore.clear()];
5247
6633
  for (const key in indices) {
5248
6634
  const index = indices[key];
6635
+ index.customIndex?.resetDerivedStorage?.();
5249
6636
  promises.push(index.clearAsync ? index.clearAsync() : index.clear());
5250
6637
  }
5251
6638
  return Promise.all(promises);
5252
6639
  }
6640
+ /** Release everything makeTable() registered process-wide; the class must not be used afterwards. */
5253
6641
  static cleanup() {
6642
+ disposed = true;
6643
+ void _a.derivedIndexRuntime?.close();
6644
+ clearTimeout(cleanupTimer);
6645
+ settlePendingCleanup();
6646
+ clearInterval(recordExpirationInterval);
5254
6647
  deleteCallbackHandle?.remove();
6648
+ (0, storageReclamation_ts_1.removeStorageReclamationHandler)(primaryStore.path, reclamationHandler);
5255
6649
  }
5256
6650
  static _readTxnForContext(context) {
5257
6651
  return txnForContext(context).getReadTxn();
5258
6652
  }
5259
6653
  }
6654
+ _a = TableResource;
5260
6655
  const throttledCallToSource = (0, throttle_ts_1.throttle)(async (source, id, sourceContext, existingEntry) => {
5261
6656
  // call the data source if it exists and will fulfill our request for data
5262
6657
  if (source && source.get && (!source.get.reliesOnPrototype || source.prototype.get)) {
@@ -5270,11 +6665,24 @@ function makeTable(options) {
5270
6665
  }, () => {
5271
6666
  throw new hdbError_ts_1.ServerError('Service unavailable, exceeded request queue limit for resolving cache record', 503);
5272
6667
  });
5273
- TableResource.updatedAttributes(); // on creation, update accessors as well
5274
- if (expirationMs)
5275
- TableResource.setTTLExpiration(expirationMs / 1000);
5276
- if (expiresAtProperty)
5277
- runRecordExpirationEviction();
6668
+ try {
6669
+ TableResource.updatedAttributes(); // on creation, update accessors as well
6670
+ if (expirationMs) {
6671
+ ttlFromLoad = true;
6672
+ try {
6673
+ TableResource.setTTLExpiration(expirationMs / 1000);
6674
+ }
6675
+ finally {
6676
+ ttlFromLoad = false;
6677
+ }
6678
+ }
6679
+ if (expiresAtProperty && !recordExpirationInterval)
6680
+ runRecordExpirationEviction();
6681
+ }
6682
+ catch (error) {
6683
+ TableResource.cleanup();
6684
+ throw error;
6685
+ }
5278
6686
  return TableResource;
5279
6687
  function updateIndices(id, existingRecord, record, options) {
5280
6688
  let hasChanges;
@@ -5701,6 +7109,22 @@ function makeTable(options) {
5701
7109
  return transaction;
5702
7110
  }
5703
7111
  }
7112
+ /**
7113
+ * Detach an unsaved TransactionWrite that a scoped lock() eagerly staged (see #reloadLocked)
7114
+ * once its handle upgrades to hold: hold staging is deferred and explicit-save-only, so a
7115
+ * dangling scoped write would otherwise auto-commit at the transaction sweep and clobber
7116
+ * whatever the hold write lands. Marking it .dropped lets a later save() on the instance that
7117
+ * owns it (checked via #savingOperation === this write) fall through to the hold branch
7118
+ * instead of resolving a detached, dead reference.
7119
+ */
7120
+ function detachScopedUpgradeWrite(link, keyId, handle) {
7121
+ for (const write of link.writes) {
7122
+ if (write && !write.saved && write.lockHandle === handle && (0, DatabaseTransaction_ts_1.writeKeyId)(write.key) === keyId) {
7123
+ write.dropped = true;
7124
+ link.detachWrite(write);
7125
+ }
7126
+ }
7127
+ }
5704
7128
  function getAttributeValue(entry, attribute_name, context, sort) {
5705
7129
  if (!entry) {
5706
7130
  return;
@@ -5818,6 +7242,10 @@ function makeTable(options) {
5818
7242
  async function getFromSource(source, id, existingEntry, context, target) {
5819
7243
  const metadataFlags = existingEntry?.metadataFlags;
5820
7244
  const existingVersion = existingEntry?.version;
7245
+ const existingRecord = existingEntry?.value;
7246
+ const inheritedTimestamp = context?.timestamp || context?.transaction?.timestamp;
7247
+ const sourceTimestamp = inheritedTimestamp ||
7248
+ (isRocksDB ? primaryStore.getMonotonicTimestamp() : (0, commonUtility_ts_1.getNextMonotonicTime)());
5821
7249
  let whenResolved, timer;
5822
7250
  // We start by locking the record so that there is only one resolution happening at once;
5823
7251
  // if there is already a resolution in process, we want to use the results of that resolution
@@ -5852,10 +7280,8 @@ function makeTable(options) {
5852
7280
  }
5853
7281
  // lock acquired — this request will actually load from source
5854
7282
  setLoadedFromSource(target, true);
5855
- const existingRecord = existingEntry?.value;
5856
7283
  // it is important to remember that this is _NOT_ part of the current transaction; nothing is changing
5857
- // with the canonical data, we are simply fulfilling our local copy of the canonical data, but still don't
5858
- // want a timestamp later than the current transaction
7284
+ // with the canonical data, we are simply fulfilling our local copy of the canonical data.
5859
7285
  // we create a new context for the source, we want to determine the timestamp and don't want to
5860
7286
  // attribute this to the current user
5861
7287
  const sourceContext = {
@@ -5890,15 +7316,36 @@ function makeTable(options) {
5890
7316
  // belt to that suspenders, at the cost of a bounded wait on a merely slow source
5891
7317
  // before the drain's fail-closed timeout below.
5892
7318
  const commitPromise = (0, transaction_ts_1.transaction)(sourceContext, async (_txn) => {
5893
- const start = performance.now();
5894
- let updatedRecord;
7319
+ const start = node_perf_hooks_1.performance.now();
7320
+ let updatedRecord, assignCreatedTime, sourceVersion;
5895
7321
  let hasChanges, invalidated;
5896
7322
  try {
5897
7323
  updatedRecord = await throttledCallToSource(source, id, sourceContext, existingEntry);
5898
7324
  invalidated = metadataFlags & exports.INVALIDATED;
5899
- let version = sourceContext.lastModified || (invalidated && existingVersion);
5900
- hasChanges = invalidated || version > existingVersion || !existingRecord;
5901
- const resolveDuration = performance.now() - start;
7325
+ const reportedVersion = sourceContext.lastModified;
7326
+ const validReportedVersion = typeof reportedVersion === 'number' &&
7327
+ Number.isFinite(reportedVersion) &&
7328
+ reportedVersion > 0 &&
7329
+ reportedVersion <= MAX_DATE_TIMESTAMP;
7330
+ if (validReportedVersion) {
7331
+ // A record version is also this node's ordering token (precedesExistingVersion), so a
7332
+ // source-reported version ahead of local time would make every subsequent local write look
7333
+ // out-of-order and be discarded until wall-clock caught up — freezing the row. Honor what
7334
+ // the source reports, but never beyond now.
7335
+ const versionCeiling = Math.max(sourceTimestamp, Date.now());
7336
+ sourceVersion = Math.min(reportedVersion, versionCeiling);
7337
+ if (sourceVersion !== reportedVersion) {
7338
+ logger_ts_1.logger.trace?.(`Capping future source version for ${tableName} id ${id}: ${reportedVersion} -> ${sourceVersion}`);
7339
+ if (!warnedFutureSourceVersion) {
7340
+ warnedFutureSourceVersion = true;
7341
+ logger_ts_1.logger.warn?.(`The source for ${tableName} reported a lastModified ahead of local time (${new Date(reportedVersion).toISOString()}) for id ${id}; capping cached record versions at local time`);
7342
+ }
7343
+ }
7344
+ }
7345
+ else
7346
+ sourceVersion = sourceTimestamp;
7347
+ hasChanges = invalidated || (validReportedVersion && reportedVersion > existingVersion) || !existingRecord;
7348
+ const resolveDuration = node_perf_hooks_1.performance.now() - start;
5902
7349
  (0, write_ts_1.recordAction)(resolveDuration, 'cache-resolution', tableName, null, 'success');
5903
7350
  if (responseHeaders)
5904
7351
  (0, Headers_ts_1.appendHeader)(responseHeaders, 'Server-Timing', `cache-resolve;dur=${resolveDuration.toFixed(2)}`, true);
@@ -5913,7 +7360,7 @@ function makeTable(options) {
5913
7360
  if (status === 304) {
5914
7361
  // revalidation of our current cached record
5915
7362
  updatedRecord = existingRecord;
5916
- version = existingVersion;
7363
+ sourceVersion = existingVersion;
5917
7364
  }
5918
7365
  else if (!CACHEABLE_STATUS_CODES.has(status)) {
5919
7366
  // non-cacheable status - propagate to client without caching
@@ -5991,10 +7438,14 @@ function makeTable(options) {
5991
7438
  if (primaryKey && updatedRecord[primaryKey] !== id)
5992
7439
  updatedRecord[primaryKey] = id;
5993
7440
  }
7441
+ assignCreatedTime = createdTimeProperty && updatedRecord?.[createdTimeProperty.name] == null;
5994
7442
  resolved = true;
7443
+ const resolvedVersion = isRocksDB && updatedRecord && existingVersion != null
7444
+ ? Math.max(sourceVersion, existingVersion)
7445
+ : sourceVersion;
5995
7446
  const resolvedEntry = {
5996
7447
  key: id,
5997
- version,
7448
+ version: resolvedVersion,
5998
7449
  value: updatedRecord,
5999
7450
  expiresAt: sourceContext.expiresAt,
6000
7451
  metadataFlags: 0,
@@ -6043,7 +7494,7 @@ function makeTable(options) {
6043
7494
  catch (settlingError) {
6044
7495
  reject(error ?? settlingError);
6045
7496
  }
6046
- const resolveDuration = performance.now() - start;
7497
+ const resolveDuration = node_perf_hooks_1.performance.now() - start;
6047
7498
  (0, write_ts_1.recordAction)(resolveDuration, 'cache-resolution', tableName, null, 'fail');
6048
7499
  if (responseHeaders)
6049
7500
  (0, Headers_ts_1.appendHeader)(responseHeaders, 'Server-Timing', `cache-resolve;dur=${resolveDuration.toFixed(2)}`, true);
@@ -6062,16 +7513,28 @@ function makeTable(options) {
6062
7513
  const sourceWrite = {
6063
7514
  key: id,
6064
7515
  store: primaryStore,
6065
- entry: existingEntry,
7516
+ entry: undefined,
6066
7517
  nodeName: 'source',
6067
- commit: (txnTime, existingEntry, _retry, transaction) => {
7518
+ commit: (_txnTime, existingEntry, _retry, transaction) => {
6068
7519
  sourceWrite.skipped = false; // reset on each retry; cleanup happens after commit if still true
6069
- if (existingEntry?.version !== existingVersion) {
6070
- // don't do anything if the version has changed
7520
+ const racedVersion = existingEntry?.version;
7521
+ // A first fill may replace a record that raced it only when its candidate version strictly
7522
+ // orders after that record. The comparison has to be replica-independent, so a tie leaves the
7523
+ // raced record in place: precedesExistingVersion() would break the tie with *this* node's
7524
+ // name, and a fill from a shared source has no node identity of its own, so two replicas
7525
+ // resolving the same tie could keep different values at the same version.
7526
+ const replacesRacedRecord = racedVersion == null || sourceVersion > racedVersion;
7527
+ if (racedVersion !== existingVersion &&
7528
+ // Revalidations retain exact-CAS semantics; first fills use deterministic ordering.
7529
+ (existingVersion != null || !updatedRecord || !replacesRacedRecord)) {
7530
+ logger_ts_1.logger.trace?.(`Discarding resolved record from source with id: ${id}, source version: ${sourceVersion}, current version: ${racedVersion}`);
6071
7531
  sourceWrite.skipped = true;
6072
7532
  return;
6073
7533
  }
6074
- updateIndices(id, existingRecord, updatedRecord, transaction && { transaction });
7534
+ const currentRecord = existingEntry?.value;
7535
+ const recordVersion = isRocksDB && racedVersion != null ? Math.max(sourceVersion, racedVersion) : sourceVersion;
7536
+ const txnLogKey = isRocksDB ? transaction?.getTimestamp?.() : recordVersion;
7537
+ updateIndices(id, currentRecord, updatedRecord, transaction && { transaction });
6075
7538
  if (updatedRecord) {
6076
7539
  if (existingEntry) {
6077
7540
  context.previousResidency = TableResource.getResidencyRecord(existingEntry.residencyId);
@@ -6082,23 +7545,23 @@ function makeTable(options) {
6082
7545
  if (updatedTimeProperty) {
6083
7546
  updatedRecord[updatedTimeProperty.name] =
6084
7547
  updatedTimeProperty.type === 'Date'
6085
- ? new Date(txnTime)
7548
+ ? new Date(recordVersion)
6086
7549
  : updatedTimeProperty.type === 'String'
6087
- ? new Date(txnTime).toISOString()
6088
- : txnTime;
7550
+ ? new Date(recordVersion).toISOString()
7551
+ : recordVersion;
6089
7552
  }
6090
- if (createdTimeProperty && updatedRecord[createdTimeProperty.name] == null) {
6091
- const existingCreatedTime = existingEntry?.value?.[createdTimeProperty.name];
7553
+ if (assignCreatedTime) {
7554
+ const existingCreatedTime = currentRecord?.[createdTimeProperty.name];
6092
7555
  if (existingCreatedTime != null) {
6093
7556
  updatedRecord[createdTimeProperty.name] = existingCreatedTime;
6094
7557
  }
6095
7558
  else {
6096
7559
  updatedRecord[createdTimeProperty.name] =
6097
7560
  createdTimeProperty.type === 'Date'
6098
- ? new Date(txnTime)
7561
+ ? new Date(recordVersion)
6099
7562
  : createdTimeProperty.type === 'String'
6100
- ? new Date(txnTime).toISOString()
6101
- : txnTime;
7563
+ ? new Date(recordVersion).toISOString()
7564
+ : recordVersion;
6102
7565
  }
6103
7566
  }
6104
7567
  const residency = residencyFromFunction(TableResource.getResidency(updatedRecord, context));
@@ -6131,23 +7594,33 @@ function makeTable(options) {
6131
7594
  }
6132
7595
  residencyId = getResidencyId(residency);
6133
7596
  }
6134
- logger_ts_1.logger.trace?.(`Writing resolved record from source with id: ${id}, timestamp: ${new Date(txnTime).toISOString()}`);
7597
+ logger_ts_1.logger.trace?.(`Writing resolved record from source with id: ${id}, timestamp: ${new Date(recordVersion).toISOString()}`);
6135
7598
  // TODO: We are doing a double check for ifVersion that should probably be cleaned out
6136
- updateRecord(id, updatedRecord, existingEntry, txnTime, omitLocalRecord ? exports.INVALIDATED : 0, (audit && (hasChanges || omitLocalRecord)) || null, {
7599
+ const writeAudit = (audit && (hasChanges || omitLocalRecord)) || null;
7600
+ updateRecord(id, updatedRecord, existingEntry, recordVersion, omitLocalRecord ? exports.INVALIDATED : 0, writeAudit, {
6137
7601
  user: sourceContext?.user,
6138
7602
  expiresAt: sourceContext.expiresAt,
6139
7603
  residencyId,
6140
7604
  transaction,
6141
7605
  tableToTrack: tableName,
7606
+ additionalAuditRefs: writeAudit && txnLogKey !== recordVersion ? [{ version: txnLogKey, nodeId: 0 }] : undefined,
6142
7607
  }, 'put', Boolean(invalidated), auditRecord);
6143
7608
  // arm the eviction scanner, mirroring the .put() path
6144
7609
  if (sourceContext.expiresAt)
6145
7610
  scheduleCleanup();
6146
7611
  }
6147
7612
  else if (existingEntry) {
6148
- logger_ts_1.logger.trace?.(`Deleting resolved record from source with id: ${id}, timestamp: ${new Date(txnTime).toISOString()}`);
7613
+ logger_ts_1.logger.trace?.(`Deleting resolved record from source with id: ${id}, timestamp: ${new Date(recordVersion).toISOString()}`);
6149
7614
  if (audit || trackDeletes) {
6150
- updateRecord(id, null, existingEntry, txnTime, 0, (audit && hasChanges) || null, { user: sourceContext?.user, transaction, tableToTrack: tableName }, 'delete', Boolean(invalidated));
7615
+ updateRecord(id, null, existingEntry, recordVersion, 0, (audit && hasChanges) || null, {
7616
+ user: sourceContext?.user,
7617
+ transaction,
7618
+ tableToTrack: tableName,
7619
+ recordVersion,
7620
+ additionalAuditRefs: audit && hasChanges && txnLogKey !== recordVersion
7621
+ ? [{ version: txnLogKey, nodeId: 0 }]
7622
+ : undefined,
7623
+ }, 'delete', Boolean(invalidated));
6151
7624
  }
6152
7625
  else {
6153
7626
  (0, RecordEncoder_ts_1.removeEntry)(primaryStore, existingEntry, existingVersion);
@@ -6230,6 +7703,7 @@ function makeTable(options) {
6230
7703
  if (hasSourceGet && primaryStore.hasLock(item.key, entry.version))
6231
7704
  continue; // resolution in progress
6232
7705
  updateIndices(item.key, entry.value, null, options);
7706
+ stageDerivedIndexEviction(transaction, item.key, entry.version);
6233
7707
  }
6234
7708
  (0, RecordEncoder_ts_1.removeEntry)(primaryStore, entry, options);
6235
7709
  staged++;
@@ -6307,7 +7781,15 @@ function makeTable(options) {
6307
7781
  },
6308
7782
  };
6309
7783
  }
7784
+ function settlePendingCleanup() {
7785
+ for (const resolve of pendingCleanupResolvers)
7786
+ resolve();
7787
+ pendingCleanupResolvers.clear();
7788
+ }
6310
7789
  function scheduleCleanup(priority) {
7790
+ // a reclamation run may still hold this class's handler after cleanup(); a promise here would never settle
7791
+ if (disposed)
7792
+ return;
6311
7793
  let runImmediately = false;
6312
7794
  if (priority) {
6313
7795
  // run immediately if there is a big increase in priority
@@ -6319,13 +7801,23 @@ function makeTable(options) {
6319
7801
  if (cleanupInterval === lastCleanupInterval && !runImmediately)
6320
7802
  return;
6321
7803
  lastCleanupInterval = cleanupInterval;
6322
- if ((0, manageThreads_js_1.getWorkerIndex)() === (0, manageThreads_js_1.getWorkerCount)() - 1) {
7804
+ if ((0, manageThreads_js_1.ownsStoreMaintenance)(primaryStore.path) || (ttlConfiguredByApplication && (0, manageThreads_js_1.isDedicatedWorker)())) {
6323
7805
  // run on the last thread so we aren't overloading lower-numbered threads
6324
7806
  if (cleanupTimer)
6325
7807
  clearTimeout(cleanupTimer);
6326
- if (!cleanupInterval)
7808
+ if (!cleanupInterval) {
7809
+ // no replacement pass is being scheduled, so nothing is left to settle a superseded one
7810
+ settlePendingCleanup();
6327
7811
  return;
7812
+ }
7813
+ // This pass adopts the awaiters of the pass whose timer it just cleared: they settle when
7814
+ // this pass's scan completes, so a reclamation run is never told the storage was reclaimed
7815
+ // before any scan ran. It has to run now, though — that run blocks its whole path on the
7816
+ // promise, and the replacement's own slot can be a full interval out.
7817
+ if (pendingCleanupResolvers.size > 0)
7818
+ runImmediately = true;
6328
7819
  return new Promise((resolve) => {
7820
+ pendingCleanupResolvers.add(resolve);
6329
7821
  const startOfYear = new Date();
6330
7822
  startOfYear.setMonth(0);
6331
7823
  startOfYear.setDate(1);
@@ -6338,6 +7830,8 @@ function makeTable(options) {
6338
7830
  ? Date.now()
6339
7831
  : Math.ceil((Date.now() - startOfYear.getTime()) / nextInterval) * nextInterval + startOfYear.getTime();
6340
7832
  const startNextTimer = (nextScheduled) => {
7833
+ if (disposed)
7834
+ return;
6341
7835
  logger_ts_1.logger.trace?.(`Scheduled next cleanup scan at ${new Date(nextScheduled)}`);
6342
7836
  // noinspection JSVoidFunctionReturnValueUsed
6343
7837
  cleanupTimer = setTimeout(() => (lastEvictionCompletion = lastEvictionCompletion.then(async () => {
@@ -6346,8 +7840,11 @@ function makeTable(options) {
6346
7840
  const rootStore = primaryStore.rootStore;
6347
7841
  if (rootStore.status !== 'open') {
6348
7842
  clearTimeout(cleanupTimer);
7843
+ settlePendingCleanup();
6349
7844
  return;
6350
7845
  }
7846
+ // snapshot: an awaiter that arrives during this scan belongs to the pass that supersedes it
7847
+ const settling = [...pendingCleanupResolvers];
6351
7848
  const MAX_CLEANUP_CONCURRENCY = 50;
6352
7849
  const outstandingCleanupOperations = new Array(MAX_CLEANUP_CONCURRENCY);
6353
7850
  let cleanupIndex = 0;
@@ -6427,7 +7924,10 @@ function makeTable(options) {
6427
7924
  catch (error) {
6428
7925
  logger_ts_1.logger.warn?.(`Error in cleanup scan for ${tableName}:`, error);
6429
7926
  }
6430
- resolve(undefined);
7927
+ for (const settle of settling) {
7928
+ pendingCleanupResolvers.delete(settle);
7929
+ settle();
7930
+ }
6431
7931
  cleanupPriority = 0; // reset the priority
6432
7932
  })), Math.min(nextScheduled - Date.now(), hdbTerms_ts_1.MAX_SET_TIMEOUT_MS) // make sure it can fit in 32-bit signed number
6433
7933
  ).unref(); // don't let this prevent closing the thread
@@ -6438,17 +7938,19 @@ function makeTable(options) {
6438
7938
  }
6439
7939
  function addDeleteRemoval() {
6440
7940
  deleteCallbackHandle = auditStore?.addDeleteRemovalCallback(tableId, primaryStore, (id, version) => {
6441
- primaryStore.remove(id, version);
7941
+ return primaryStore.remove(id, version);
6442
7942
  });
6443
7943
  }
6444
7944
  function runRecordExpirationEviction() {
6445
7945
  // Periodically evict expired records, searching for records who expiresAt timestamp is before now
6446
- if ((0, manageThreads_js_1.getWorkerIndex)() === 0) {
7946
+ if ((0, manageThreads_js_1.ownsStoreExpiration)(primaryStore.path) || (ttlConfiguredByApplication && (0, manageThreads_js_1.isDedicatedWorker)())) {
6447
7947
  // we want to run the pruning of expired records on only one thread so we don't have conflicts in evicting
6448
- setInterval(async () => {
7948
+ recordExpirationInterval = setInterval(async () => {
6449
7949
  // go through each database and table and then search for expired entries
6450
7950
  // find any entries that are set to expire before now
6451
- if (runningRecordExpiration)
7951
+ // updatedAttributes() clears expiresAtProperty when a live redeclaration drops the directive,
7952
+ // and there is nothing left for this interval to scan by
7953
+ if (disposed || runningRecordExpiration || !expiresAtProperty)
6452
7954
  return;
6453
7955
  runningRecordExpiration = true;
6454
7956
  try {