@harperfast/harper 5.3.0-beta.3 → 5.3.0-beta.4

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 (311) hide show
  1. package/bin/cliOperations.ts +25 -3
  2. package/bin/run.ts +18 -0
  3. package/components/Application.ts +52 -78
  4. package/components/DESIGN.md +76 -3
  5. package/components/Scope.ts +10 -4
  6. package/components/anthropic/index.ts +10 -1
  7. package/components/bedrock/index.ts +10 -1
  8. package/components/componentLoader.ts +23 -14
  9. package/components/mcp/tools/operations.ts +3 -1
  10. package/components/ollama/index.ts +10 -1
  11. package/components/openai/index.ts +265 -18
  12. package/components/operations.js +40 -1
  13. package/config/configUtils.ts +3 -19
  14. package/dataLayer/DESIGN.md +29 -5
  15. package/dataLayer/harperBridge/ResourceBridge.ts +39 -3
  16. package/dataLayer/restoreMarker.ts +250 -14
  17. package/dataLayer/schema.ts +2 -7
  18. package/dataLayer/schemaDescribe.ts +10 -0
  19. package/dist/bin/cliOperations.js +28 -3
  20. package/dist/bin/cliOperations.js.map +1 -1
  21. package/dist/bin/run.js +19 -0
  22. package/dist/bin/run.js.map +1 -1
  23. package/dist/components/Application.d.ts +8 -2
  24. package/dist/components/Application.js +43 -69
  25. package/dist/components/Application.js.map +1 -1
  26. package/dist/components/Scope.d.ts +1 -1
  27. package/dist/components/Scope.js +9 -4
  28. package/dist/components/Scope.js.map +1 -1
  29. package/dist/components/anthropic/index.d.ts +0 -10
  30. package/dist/components/anthropic/index.js +9 -1
  31. package/dist/components/anthropic/index.js.map +1 -1
  32. package/dist/components/bedrock/index.d.ts +0 -12
  33. package/dist/components/bedrock/index.js +9 -1
  34. package/dist/components/bedrock/index.js.map +1 -1
  35. package/dist/components/componentLoader.js +23 -14
  36. package/dist/components/componentLoader.js.map +1 -1
  37. package/dist/components/mcp/tools/operations.js +1 -1
  38. package/dist/components/mcp/tools/operations.js.map +1 -1
  39. package/dist/components/ollama/index.d.ts +0 -13
  40. package/dist/components/ollama/index.js +9 -1
  41. package/dist/components/ollama/index.js.map +1 -1
  42. package/dist/components/openai/index.d.ts +11 -15
  43. package/dist/components/openai/index.js +224 -12
  44. package/dist/components/openai/index.js.map +1 -1
  45. package/dist/components/operations.d.ts +9 -0
  46. package/dist/components/operations.js +32 -2
  47. package/dist/components/operations.js.map +1 -1
  48. package/dist/config/configUtils.d.ts +1 -2
  49. package/dist/config/configUtils.js +7 -22
  50. package/dist/config/configUtils.js.map +1 -1
  51. package/dist/dataLayer/harperBridge/ResourceBridge.js +29 -2
  52. package/dist/dataLayer/harperBridge/ResourceBridge.js.map +1 -1
  53. package/dist/dataLayer/restoreMarker.d.ts +25 -3
  54. package/dist/dataLayer/restoreMarker.js +213 -13
  55. package/dist/dataLayer/restoreMarker.js.map +1 -1
  56. package/dist/dataLayer/schema.js +1 -4
  57. package/dist/dataLayer/schema.js.map +1 -1
  58. package/dist/dataLayer/schemaDescribe.js +10 -0
  59. package/dist/dataLayer/schemaDescribe.js.map +1 -1
  60. package/dist/index.d.ts +1 -1
  61. package/dist/index.js.map +1 -1
  62. package/dist/json/systemSchema.json +28 -0
  63. package/dist/resources/DatabaseTransaction.d.ts +21 -1
  64. package/dist/resources/DatabaseTransaction.js +147 -8
  65. package/dist/resources/DatabaseTransaction.js.map +1 -1
  66. package/dist/resources/PrimaryRocksDatabase.js +1 -1
  67. package/dist/resources/PrimaryRocksDatabase.js.map +1 -1
  68. package/dist/resources/Table.d.ts +31 -3
  69. package/dist/resources/Table.js +397 -79
  70. package/dist/resources/Table.js.map +1 -1
  71. package/dist/resources/auditStore.js +16 -10
  72. package/dist/resources/auditStore.js.map +1 -1
  73. package/dist/resources/blob.d.ts +1 -0
  74. package/dist/resources/blob.js +14 -5
  75. package/dist/resources/blob.js.map +1 -1
  76. package/dist/resources/branchDatabase.js +3 -3
  77. package/dist/resources/branchDatabase.js.map +1 -1
  78. package/dist/resources/databaseDropPreparation.d.ts +20 -0
  79. package/dist/resources/databaseDropPreparation.js +106 -0
  80. package/dist/resources/databaseDropPreparation.js.map +1 -0
  81. package/dist/resources/databases.d.ts +26 -21
  82. package/dist/resources/databases.js +1040 -204
  83. package/dist/resources/databases.js.map +1 -1
  84. package/dist/resources/derivedIndexRegistry.d.ts +23 -0
  85. package/dist/resources/derivedIndexRegistry.js +92 -0
  86. package/dist/resources/derivedIndexRegistry.js.map +1 -1
  87. package/dist/resources/derivedIndexRuntime.d.ts +10 -0
  88. package/dist/resources/derivedIndexRuntime.js +136 -25
  89. package/dist/resources/derivedIndexRuntime.js.map +1 -1
  90. package/dist/resources/derivedIndexes.d.ts +39 -0
  91. package/dist/resources/derivedIndexes.js +733 -0
  92. package/dist/resources/derivedIndexes.js.map +1 -0
  93. package/dist/resources/fullTextSchema.d.ts +8 -0
  94. package/dist/resources/fullTextSchema.js +42 -0
  95. package/dist/resources/fullTextSchema.js.map +1 -1
  96. package/dist/resources/fullTextSchemaLifecycle.d.ts +0 -5
  97. package/dist/resources/fullTextSchemaLifecycle.js +0 -9
  98. package/dist/resources/fullTextSchemaLifecycle.js.map +1 -1
  99. package/dist/resources/graphql.js +101 -9
  100. package/dist/resources/graphql.js.map +1 -1
  101. package/dist/resources/indexes/hnswDerivedIndex.d.ts +0 -10
  102. package/dist/resources/indexes/hnswDerivedIndex.js +0 -230
  103. package/dist/resources/indexes/hnswDerivedIndex.js.map +1 -1
  104. package/dist/resources/indexes/nativeFullTextDerivedIndexLifecycle.d.ts +3 -0
  105. package/dist/resources/indexes/nativeFullTextDerivedIndexLifecycle.js +57 -8
  106. package/dist/resources/indexes/nativeFullTextDerivedIndexLifecycle.js.map +1 -1
  107. package/dist/resources/models/Models.d.ts +26 -3
  108. package/dist/resources/models/Models.js +256 -13
  109. package/dist/resources/models/Models.js.map +1 -1
  110. package/dist/resources/models/TestBackend.d.ts +3 -1
  111. package/dist/resources/models/TestBackend.js +35 -1
  112. package/dist/resources/models/TestBackend.js.map +1 -1
  113. package/dist/resources/models/analyticsTable.d.ts +2 -2
  114. package/dist/resources/models/analyticsTable.js +4 -2
  115. package/dist/resources/models/analyticsTable.js.map +1 -1
  116. package/dist/resources/models/backendHelpers.d.ts +24 -0
  117. package/dist/resources/models/backendHelpers.js +18 -1
  118. package/dist/resources/models/backendHelpers.js.map +1 -1
  119. package/dist/resources/models/backendRegistry.d.ts +10 -21
  120. package/dist/resources/models/backendRegistry.js +74 -18
  121. package/dist/resources/models/backendRegistry.js.map +1 -1
  122. package/dist/resources/models/bootstrap.d.ts +7 -0
  123. package/dist/resources/models/bootstrap.js +36 -4
  124. package/dist/resources/models/bootstrap.js.map +1 -1
  125. package/dist/resources/models/decideHook.d.ts +33 -0
  126. package/dist/resources/models/decideHook.js +105 -0
  127. package/dist/resources/models/decideHook.js.map +1 -0
  128. package/dist/resources/models/decision.d.ts +64 -0
  129. package/dist/resources/models/decision.js +507 -0
  130. package/dist/resources/models/decision.js.map +1 -0
  131. package/dist/resources/models/decisionStore.d.ts +134 -0
  132. package/dist/resources/models/decisionStore.js +350 -0
  133. package/dist/resources/models/decisionStore.js.map +1 -0
  134. package/dist/resources/models/embedHook.d.ts +66 -8
  135. package/dist/resources/models/embedHook.js +228 -61
  136. package/dist/resources/models/embedHook.js.map +1 -1
  137. package/dist/resources/models/generativeDecision.d.ts +58 -0
  138. package/dist/resources/models/generativeDecision.js +315 -0
  139. package/dist/resources/models/generativeDecision.js.map +1 -0
  140. package/dist/resources/models/types.d.ts +232 -4
  141. package/dist/security/authn/oidc/tokenExchange.js +3 -33
  142. package/dist/security/authn/oidc/tokenExchange.js.map +1 -1
  143. package/dist/security/authn/oidc/tokenUseTable.d.ts +9 -0
  144. package/dist/security/authn/oidc/tokenUseTable.js +28 -0
  145. package/dist/security/authn/oidc/tokenUseTable.js.map +1 -0
  146. package/dist/security/certificateVerification/certificateVerificationSource.d.ts +0 -1
  147. package/dist/security/certificateVerification/certificateVerificationSource.js +3 -3
  148. package/dist/security/certificateVerification/certificateVerificationSource.js.map +1 -1
  149. package/dist/security/certificateVerification/crlVerification.js +77 -165
  150. package/dist/security/certificateVerification/crlVerification.js.map +1 -1
  151. package/dist/security/certificateVerification/types.d.ts +0 -3
  152. package/dist/security/certificateVerification/verificationTables.d.ts +19 -0
  153. package/dist/security/certificateVerification/verificationTables.js +125 -0
  154. package/dist/security/certificateVerification/verificationTables.js.map +1 -0
  155. package/dist/security/certificateVerification/verificationUtils.d.ts +1 -1
  156. package/dist/security/certificateVerification/verificationUtils.js +6 -31
  157. package/dist/security/certificateVerification/verificationUtils.js.map +1 -1
  158. package/dist/security/jsLoader.d.ts +5 -0
  159. package/dist/security/jsLoader.js +8 -0
  160. package/dist/security/jsLoader.js.map +1 -1
  161. package/dist/server/Server.d.ts +1 -0
  162. package/dist/server/Server.js.map +1 -1
  163. package/dist/server/http.js +2 -2
  164. package/dist/server/http.js.map +1 -1
  165. package/dist/server/itc/serverHandlers.js +21 -2
  166. package/dist/server/itc/serverHandlers.js.map +1 -1
  167. package/dist/server/jobs/jobOwnership.d.ts +25 -0
  168. package/dist/server/jobs/jobOwnership.js +127 -0
  169. package/dist/server/jobs/jobOwnership.js.map +1 -0
  170. package/dist/server/jobs/jobProcess.js +9 -2
  171. package/dist/server/jobs/jobProcess.js.map +1 -1
  172. package/dist/server/jobs/jobs.js +18 -5
  173. package/dist/server/jobs/jobs.js.map +1 -1
  174. package/dist/server/loadRootComponents.js +10 -0
  175. package/dist/server/loadRootComponents.js.map +1 -1
  176. package/dist/server/serverHelpers/contentTypes.js +11 -1
  177. package/dist/server/serverHelpers/contentTypes.js.map +1 -1
  178. package/dist/server/serverHelpers/serverHandlers.js +12 -2
  179. package/dist/server/serverHelpers/serverHandlers.js.map +1 -1
  180. package/dist/server/threads/itc.d.ts +4 -1
  181. package/dist/server/threads/itc.js +29 -6
  182. package/dist/server/threads/itc.js.map +1 -1
  183. package/dist/server/threads/manageThreads.d.ts +12 -4
  184. package/dist/server/threads/manageThreads.js +241 -119
  185. package/dist/server/threads/manageThreads.js.map +1 -1
  186. package/dist/server/threads/threadServer.d.ts +1 -0
  187. package/dist/server/threads/threadServer.js +15 -1
  188. package/dist/server/threads/threadServer.js.map +1 -1
  189. package/dist/server/threads/windowsProcessTree.d.ts +91 -0
  190. package/dist/server/threads/windowsProcessTree.js +361 -0
  191. package/dist/server/threads/windowsProcessTree.js.map +1 -0
  192. package/dist/server/throttle.d.ts +1 -1
  193. package/dist/server/throttle.js +8 -1
  194. package/dist/server/throttle.js.map +1 -1
  195. package/dist/upgrade/directives/5-3-0.js +53 -46
  196. package/dist/upgrade/directives/5-3-0.js.map +1 -1
  197. package/dist/utility/errors/hdbError.d.ts +10 -1
  198. package/dist/utility/errors/hdbError.js +55 -11
  199. package/dist/utility/errors/hdbError.js.map +1 -1
  200. package/dist/utility/fsync.d.ts +2 -0
  201. package/dist/utility/fsync.js +20 -0
  202. package/dist/utility/fsync.js.map +1 -0
  203. package/dist/utility/globalSchema.d.ts +18 -0
  204. package/dist/utility/hdbTerms.d.ts +3 -0
  205. package/dist/utility/hdbTerms.js +3 -0
  206. package/dist/utility/hdbTerms.js.map +1 -1
  207. package/dist/utility/signalling.d.ts +7 -1
  208. package/dist/utility/signalling.js +27 -3
  209. package/dist/utility/signalling.js.map +1 -1
  210. package/dist/utility/when.d.ts +1 -0
  211. package/dist/utility/when.js +21 -0
  212. package/dist/utility/when.js.map +1 -1
  213. package/dist/validation/configValidator.js +30 -3
  214. package/dist/validation/configValidator.js.map +1 -1
  215. package/index.ts +13 -0
  216. package/json/systemSchema.json +28 -0
  217. package/npm-shrinkwrap.json +151 -55
  218. package/package.json +14 -13
  219. package/resources/DESIGN.md +80 -100
  220. package/resources/DatabaseTransaction.ts +157 -10
  221. package/resources/PrimaryRocksDatabase.ts +2 -2
  222. package/resources/Table.ts +494 -101
  223. package/resources/auditStore.ts +18 -10
  224. package/resources/blob.ts +13 -6
  225. package/resources/branchDatabase.ts +3 -3
  226. package/resources/databaseDropPreparation.ts +120 -0
  227. package/resources/databases.ts +1155 -205
  228. package/resources/derivedIndexRegistry.ts +122 -0
  229. package/resources/derivedIndexRuntime.ts +157 -30
  230. package/resources/derivedIndexes.ts +856 -0
  231. package/resources/fullTextSchema.ts +55 -0
  232. package/resources/fullTextSchemaLifecycle.ts +0 -15
  233. package/resources/graphql.ts +107 -12
  234. package/resources/indexes/DESIGN.md +7 -0
  235. package/resources/indexes/hnswDerivedIndex.ts +0 -260
  236. package/resources/indexes/nativeFullTextDerivedIndexLifecycle.ts +72 -9
  237. package/resources/models/DESIGN.md +39 -0
  238. package/resources/models/Models.ts +361 -18
  239. package/resources/models/TestBackend.ts +56 -1
  240. package/resources/models/analyticsTable.ts +6 -4
  241. package/resources/models/backendHelpers.ts +17 -0
  242. package/resources/models/backendRegistry.ts +76 -21
  243. package/resources/models/bootstrap.ts +57 -7
  244. package/resources/models/decideHook.ts +135 -0
  245. package/resources/models/decision.ts +505 -0
  246. package/resources/models/decisionStore.ts +419 -0
  247. package/resources/models/embedHook.ts +272 -55
  248. package/resources/models/generativeDecision.ts +459 -0
  249. package/resources/models/types.ts +231 -4
  250. package/security/DESIGN.md +22 -2
  251. package/security/authn/oidc/tokenExchange.ts +6 -34
  252. package/security/authn/oidc/tokenUseTable.ts +27 -0
  253. package/security/certificateVerification/certificateVerificationSource.ts +2 -3
  254. package/security/certificateVerification/crlVerification.ts +96 -174
  255. package/security/certificateVerification/types.ts +0 -3
  256. package/security/certificateVerification/verificationTables.ts +124 -0
  257. package/security/certificateVerification/verificationUtils.ts +8 -32
  258. package/security/jsLoader.ts +9 -1
  259. package/server/DESIGN.md +27 -1
  260. package/server/Server.ts +2 -1
  261. package/server/http.ts +4 -2
  262. package/server/itc/serverHandlers.js +29 -1
  263. package/server/jobs/jobOwnership.ts +89 -0
  264. package/server/jobs/jobProcess.ts +8 -2
  265. package/server/jobs/jobs.ts +15 -4
  266. package/server/loadRootComponents.js +11 -0
  267. package/server/serverHelpers/contentTypes.ts +13 -1
  268. package/server/serverHelpers/serverHandlers.js +11 -2
  269. package/server/threads/itc.js +32 -6
  270. package/server/threads/manageThreads.js +268 -122
  271. package/server/threads/threadServer.js +13 -1
  272. package/server/threads/windowsProcessTree.ts +409 -0
  273. package/server/throttle.ts +11 -1
  274. package/studio/web/assets/{Chat-CveGbxe6.js → Chat-Bq60AO-g.js} +1 -1
  275. package/studio/web/assets/{FloatingChat-IpCFgYWL.js → FloatingChat-D4560T0p.js} +3 -3
  276. package/studio/web/assets/{apiToken-CS8fFTgL.js → apiToken-BEvbFPMc.js} +1 -1
  277. package/studio/web/assets/{applications-D7F4Jopl.js → applications-CSUVTH4E.js} +1 -1
  278. package/studio/web/assets/{cssMode-Daw_oLPq.js → cssMode-DJyc9mHj.js} +1 -1
  279. package/studio/web/assets/{editor-B3Bs1zWX.js → editor-DDIsQfj6.js} +1 -1
  280. package/studio/web/assets/{html-CDaQwWiE.js → html-g1BJHxLy.js} +1 -1
  281. package/studio/web/assets/{htmlMode-Cb6emO4U.js → htmlMode-Vh5IglCQ.js} +1 -1
  282. package/studio/web/assets/{index-B4g2Fu_2.js → index-DGRAzvO4.js} +5 -5
  283. package/studio/web/assets/{index.lazy-NwhvF1q1.js → index.lazy-vgSic7et.js} +1 -1
  284. package/studio/web/assets/{javascript-2_86gB9Q.js → javascript-5VIolqf7.js} +1 -1
  285. package/studio/web/assets/{jsonMode-DX-i86uQ.js → jsonMode-DG4zQraI.js} +1 -1
  286. package/studio/web/assets/{languageServices-B85WN-Ej.js → languageServices-t5FBpX-i.js} +1 -1
  287. package/studio/web/assets/{lspLanguageFeatures-JCWHIsKg.js → lspLanguageFeatures-DgVnNPh-.js} +1 -1
  288. package/studio/web/assets/{notifications-D9ejzt52.js → notifications-BeWcX04m.js} +1 -1
  289. package/studio/web/assets/{notifications-B7hjKhai.js → notifications-DUGWrWoO.js} +1 -1
  290. package/studio/web/assets/{profile-D8B6No3F.js → profile-BvUEDBzW.js} +1 -1
  291. package/studio/web/assets/{regions-DFazIfTK.js → regions-CB-_2m7M.js} +1 -1
  292. package/studio/web/assets/{register-CwFLviLF.js → register-1w1Zr3b2.js} +2 -2
  293. package/studio/web/assets/{setComponentFile-VnJ80SSJ.js → setComponentFile-z4-qJCbA.js} +1 -1
  294. package/studio/web/assets/{setup-BYQxFy-C.js → setup-CS50uJ4x.js} +2 -2
  295. package/studio/web/assets/{status-Cn5XjmsL.js → status-CI0wOdOE.js} +1 -1
  296. package/studio/web/assets/{toggleHighContrast-g9xIh6TX.js → toggleHighContrast-BsAE4jPe.js} +1 -1
  297. package/studio/web/assets/{tsMode-EC-n0Wqc.js → tsMode-D83Gg3cq.js} +1 -1
  298. package/studio/web/assets/{typescript-DHmM9JCO.js → typescript-Dfp-GrNq.js} +1 -1
  299. package/studio/web/assets/{useEntityRestURL-BUdnVSVv.js → useEntityRestURL-BKetlINf.js} +1 -1
  300. package/studio/web/assets/{workers-BVl9bBB-.js → workers-CCvmDLH3.js} +1 -1
  301. package/studio/web/assets/{xml-C4_sqOkX.js → xml-06pZp00U.js} +1 -1
  302. package/studio/web/assets/{yaml-CVkbDqtW.js → yaml-iSnWMVlV.js} +1 -1
  303. package/studio/web/index.html +1 -1
  304. package/upgrade/directives/5-3-0.ts +61 -54
  305. package/utility/DESIGN.md +5 -1
  306. package/utility/errors/hdbError.ts +56 -8
  307. package/utility/fsync.ts +16 -0
  308. package/utility/hdbTerms.ts +3 -0
  309. package/utility/signalling.ts +33 -4
  310. package/utility/when.ts +22 -0
  311. package/validation/configValidator.ts +36 -7
@@ -4,8 +4,6 @@ The Resource layer is Harper's universal abstraction: all queryable/mutable thin
4
4
 
5
5
  **Read this when:** you're touching the read/write path, authorization, subscriptions, or table CRUD semantics.
6
6
 
7
- See also: `../DESIGN.md` for cross-cutting non-obvious internals (RecordObject prototype, `getFromSource` timing, blob orphan cleanup).
8
-
9
7
  > **Navigation convention.** This guide references code by **symbol name** (e.g. `_writeUpdate`) and by **section marker** (e.g. `// #section: write-path-internals`). Jump in your editor via go-to-symbol, or `grep` for the section marker. Line numbers drift; symbols and section markers don't.
10
8
 
11
9
  ---
@@ -61,23 +59,23 @@ Static methods are protocol entry points (each wrapped in `transactional()`); in
61
59
 
62
60
  One giant `makeTable()` factory that returns a `TableResource extends Resource` class. The file is divided into the sections below; each is anchored by a `// #section: <name>` marker — grep for the marker (or use VS Code's go-to-symbol within the section) to land directly.
63
61
 
64
- | Section marker | Contents |
65
- | -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
66
- | `#section: setup-and-factory` | `makeTable(options)` entry, attribute parsing & primary-key detection, replication wiring, `class Updatable` (RecordObject prototype: `getUpdatedTime`, `getExpiresAt`, `addTo`, `subtractFrom`). Ends where `class TableResource` opens. |
67
- | `#section: static-config` | Static configuration properties: `name`, `primaryStore`, `auditStore`, `primaryKey`, `indices`, `audit`, `databasePath`, `attributes`, `replicate`, `sealed`, `splitSegments`, `getResidencyById`, `dbisDB`, `schemaDefined`, `expirationMS`. |
68
- | `#section: resource-registry` | `sourcedFrom()` (cache/source hierarchy — the largest static), `isCaching`, `shouldRevalidateEvents`, `getResource()`, `_updateResource`, `ensureLoaded()`. |
69
- | `#section: lifecycle-admin` | `getNewId()` (UUID / autoincrement / prefix / time-based strategies), `setTTLExpiration`, residency (`getResidencyRecord`, `setResidency`, `setResidencyById`, `getResidency`), `enableAuditing`, `coerceId`, `dropTable`. |
70
- | `#section: read-path` | `get()` overloads & impl. |
71
- | `#section: authz-hooks` | `allowRead`, `allowUpdate`, `allowCreate`, `allowDelete`. |
72
- | `#section: write-path-public` | `update()`, `save()`, `addTo()`, `subtractFrom()`, `getMetadata`, `getRecord`, `getChanges`, `_setChanges`, `setRecord`, `invalidate()`, `operation()`, `put()`, `create()`, `patch()`. |
73
- | `#section: write-path-internals` | **`_writeUpdate()` — the central write routine** (versioning, conflict resolution, audit, residency, replication metadata, blob orphan tracking). The `write.skipped` flag mentioned in `../DESIGN.md` is set in this method's early-return paths. Also `_writeInvalidate`, `_writeRelocate`, `_recordRelocate`, `evict()`, `lock()`/`unlock()` (record locks — see `../DESIGN.md`), `delete()`, `_writeDelete`. |
74
- | `isPlainOptions` | _(after the class)_ argument-position helper for `lock()` (distinguishes `lock(options)` from `lock(target, options)`) |
75
- | `#section: search-query` | `search()` (the query engine — index selection, filter evaluation), `transformToOrderedSelect` (select-clause ordering), `transformEntryForSelect` (record → response shape). |
76
- | `#section: pub-sub` | `subscribe()` (subscription request handling, replay, cursor management), `subscribeOnThisThread`, `doesExist()`, `publish()`, `_writePublish()`. |
77
- | `#section: validation` | `validate(record, patch?)` — schema enforcement, computed attributes, attribute coercion. |
78
- | `#section: stats-admin` | `getUpdatedTime`, `addAttributes`, `removeAttributes`, `getSize`, `getAuditSize`, `getStorageStats`, `getRecordCount`, `updatedAttributes` (schema diff machinery). |
79
- | `#section: computed-history` | `setComputedAttribute`, `deleteHistory`, `getHistory` (generator), `getHistoryOfRecord`, `clear`, `cleanup`, `_readTxnForContext`. |
80
- | _(after the class)_ | `getFromSource()` — cache miss → source load (see `../DESIGN.md` for the resolve-before-commit timing trap); local helpers (`coerceType`, `isDescendantId`, etc.). |
62
+ | Section marker | Contents |
63
+ | -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
64
+ | `#section: setup-and-factory` | `makeTable(options)` entry, attribute parsing & primary-key detection, replication wiring, `class Updatable` (RecordObject prototype: `getUpdatedTime`, `getExpiresAt`, `addTo`, `subtractFrom`). Ends where `class TableResource` opens. |
65
+ | `#section: static-config` | Static configuration properties: `name`, `primaryStore`, `auditStore`, `primaryKey`, `indices`, `audit`, `databasePath`, `attributes`, `replicate`, `sealed`, `splitSegments`, `getResidencyById`, `dbisDB`, `schemaDefined`, `expirationMS`. |
66
+ | `#section: resource-registry` | `sourcedFrom()` (cache/source hierarchy — the largest static), `isCaching`, `shouldRevalidateEvents`, `getResource()`, `_updateResource`, `ensureLoaded()`. |
67
+ | `#section: lifecycle-admin` | `getNewId()` (UUID / autoincrement / prefix / time-based strategies), `setTTLExpiration`, residency (`getResidencyRecord`, `setResidency`, `setResidencyById`, `getResidency`), `enableAuditing`, `coerceId`, `dropTable`. |
68
+ | `#section: read-path` | `get()` overloads & impl. |
69
+ | `#section: authz-hooks` | `allowRead`, `allowUpdate`, `allowCreate`, `allowDelete`. |
70
+ | `#section: write-path-public` | `update()`, `save()`, `addTo()`, `subtractFrom()`, `getMetadata`, `getRecord`, `getChanges`, `_setChanges`, `setRecord`, `invalidate()`, `operation()`, `put()`, `create()`, `patch()`. |
71
+ | `#section: write-path-internals` | **`_writeUpdate()` — the central write routine** (versioning, conflict resolution, audit, residency, replication metadata, blob orphan tracking). The `write.skipped` flag ("Blob orphan cleanup" below) is set in this method's early-return paths. Also `_writeInvalidate`, `_writeRelocate`, `_recordRelocate`, `evict()`, `lock()`/`unlock()` (record locks — see `record-locks.md`), `delete()`, `_writeDelete`. |
72
+ | `isPlainOptions` | _(after the class)_ argument-position helper for `lock()` (distinguishes `lock(options)` from `lock(target, options)`) |
73
+ | `#section: search-query` | `search()` (the query engine — index selection, filter evaluation), `transformToOrderedSelect` (select-clause ordering), `transformEntryForSelect` (record → response shape). |
74
+ | `#section: pub-sub` | `subscribe()` (subscription request handling, replay, cursor management), `subscribeOnThisThread`, `doesExist()`, `publish()`, `_writePublish()`. |
75
+ | `#section: validation` | `validate(record, patch?)` — schema enforcement, computed attributes, attribute coercion. |
76
+ | `#section: stats-admin` | `getUpdatedTime`, `addAttributes`, `removeAttributes`, `getSize`, `getAuditSize`, `getStorageStats`, `getRecordCount`, `updatedAttributes` (schema diff machinery). |
77
+ | `#section: computed-history` | `setComputedAttribute`, `deleteHistory`, `getHistory` (generator), `getHistoryOfRecord`, `clear`, `cleanup`, `_readTxnForContext`. |
78
+ | _(after the class)_ | `getFromSource()` — cache miss → source load (see "getFromSource() timing" below for the resolve-before-commit trap); local helpers (`coerceType`, `isDescendantId`, etc.). |
81
79
 
82
80
  ---
83
81
 
@@ -94,7 +92,7 @@ One giant `makeTable()` factory that returns a `TableResource extends Resource`
94
92
  | Where is record-level TTL evaluated? | `Table.ts → setTTLExpiration` (`#section: lifecycle-admin`); `Updatable.getExpiresAt` (`#section: setup-and-factory`). Stored expiry metadata is resolved in the `_writeUpdate` commit closure: `options.expiresAt ?? context.expiresAt ?? (record @expiresAt field, if finite &amp; ≥ 0) ?? table default`. This metadata drives read-hiding + the cleanup sweep. The `@expiresAt` attribute is authoritative for **direct** put/patch only; cache/source fills persist via `recordUpdater` and derive expiry from `sourceContext.expiresAt` (source freshness / table default), not the field. |
95
93
  | Why does `search()` hide a row that's past its TTL but not yet swept? | `Table.ts → transformEntryForSelect` unconditionally treats `entry.expiresAt < Date.now()` as gone (lazy eviction on read) — correct for a SELECT, but a mutation locating rows to overwrite needs the opposite: pass `target.includeExpired = true` (read by the SQL engine's `runUpdate`/`runDelete` via `SqlEngineContext.includeExpiredRows`) to treat such a row as a live match, matching the leniency a direct by-id `put`/`patch` already has (they skip this check entirely, since `Resource.patch`'s static options don't request `ensureLoaded`). |
96
94
  | How are residencies enforced (replication)? | `Table.ts → #section: lifecycle-admin` (residency block: `getResidencyRecord`, `setResidency`, `setResidencyById`, `getResidency`) |
97
- | How is the RecordObject prototype applied? | `RecordEncoder.ts` (see `../DESIGN.md`) |
95
+ | How is the RecordObject prototype applied? | `RecordEncoder.ts` (see "RecordObject prototype and entryMap" below) |
98
96
  | Where is the per-request transaction stored? | `transaction.ts` + `contextStorage` (AsyncLocalStorage) |
99
97
  | What is in `context.transaction` when no scope owns one? | Whatever the last table call left there. A completed scope leaves `RELEASED_TRANSACTION` (`DatabaseTransaction.ts`), which reads latest and no-ops on commit. The next table call finds no transaction and `Table.ts → txnForContext` installs an `ImmediateTransaction` in the slot. It reports `open === OPEN`, but `transaction()` and `Resource`'s dispatcher gate on `isJoinableScope` — OPEN _and_ staging its writes for a later commit — so they never join it and start their own scope instead (#2292); `txnForContext` keeps returning it for reads and for writes that reach no wrapper, and those commit per write. It is also the only transaction that opens its native handle inside its own `commit()` (its `getReadTxn` never opens one), which is why `commit()` re-reads `this.transaction` after its save loop (#2288). |
100
98
  | How do I find a transaction that is holding write intents and is never reaped? | Three log surfaces, joined by the native transaction id (`longLivedTransactions.ts`, #2471). `runLongLivedTransactionSweep()` runs on the **main thread only** — rocksdb-js's registry is process-global, so one sweep sees every worker's handles — and names any handle open past `storage.longTransactionReportThreshold` (default `5m`, `0` disables all three), including handles no `DatabaseTransaction` owns. Changing the threshold clears the accrued backoff, so a handle already under observation is re-measured against the new value. `reportIfLongLived()` in each worker's monitor reports every link holding its own handle under that link's own id, adding `startedFrom`, the table, and the state keeping it alive (`source-apply`, `replay`, `commit-phase`, `active` when written in the current or preceding monitor tick), which is what the reap branches below it deliberately do not do. Attribution lines are capped at 10 per monitor tick; a summary names deferred holders, whose due backoff remains intact for a later tick. Attribution suppression is keyed on database path, native id, and handle-open time, so a reattached descriptor id cannot inherit its predecessor's backoff. The two stuck-commit logs (`checkOverloaded()` and `abandonCommitAfterDeadline()`) append holder candidates from **every** database, the commit's own first and then by age — the verification table is one process-global slot array, so a holder in another database parks this commit at the same rate — never filtered by age, because a coordinated retry can park on a transaction younger than itself. |
@@ -209,13 +207,12 @@ first and a throw from it is what stops the prune.
209
207
  | `Table.deleteHistory` | LMDB (`RocksTransactionLogStore.remove()` is a no-op, so it must NOT raise) |
210
208
  | `delete_transaction_logs_before` whole-database branch (`ResourceBridge.ts`) | RocksDB |
211
209
 
212
- Seven things that are easy to get wrong here:
210
+ Things that are easy to get wrong here:
213
211
 
214
212
  - **The floor cannot be derived from the surviving log.** For four of the five paths the oldest
215
213
  surviving entry would do, because they prune a database-wide time prefix. `Table.deleteHistory`
216
- does not: it removes one table's entries out of a database-scoped log, so a sibling's entry
217
- survives _below_ the newest entry it removed. Measured, LMDB: highest removed `…147.797`, oldest
218
- surviving `…143.309` — a log-derived floor would have certified a cursor at `…145`.
214
+ removes one table's entries from a database-scoped log, so a sibling's entry survives _below_ the
215
+ newest entry it removed, and a floor taken from that survivor certifies cursors over removed history.
219
216
  - **The record's presence is the trust marker.** `Symbol.for('audit-floor')` is a different key from
220
217
  `last-removed`, which is still live and still maintained by the LMDB retention loop (#2338 hardened
221
218
  its write path and added tests for the retry-carry — do not remove it). They coexist because they
@@ -368,7 +365,7 @@ Tests: `../unitTests/resources/defineResource.test.js`, `../unitTests/resources/
368
365
  - **The override makes the access-control decision.** No `allow*` predicate runs for it. An override that is not meant to be public must check authorization itself.
369
366
  - **FK-side relationship accessors (`relationship.from`) are a synchronous contract** (v4 parity). The resolvers built in `Table.updatedAttributes` must read through `getSync`/`getEntry` (which block on a RocksDB block-cache miss, like v4's LMDB page faults) — never `get()`, whose `async: true` path returns a Promise on a cache miss and would leak an intermittent value-or-Promise to `record.<relation>` in user code. Pinned by the "relationship property access is synchronous" test in `../unitTests/resources/query.test.js`, which stubs `store.get` to throw.
370
367
  - **Known gap, not covered by that test:** the `.to` side (one-to-many/many-to-many, resolved via `relatedTable.search(...).asArray`) is synchronous for a local read, but `asArray` can still hand back a Promise when the search lands on a **caching** table whose row needs source revalidation — `transformEntryForSelect` returns `loadingFromSource.then(transform)` for an expired/invalidated entry. Same bug class this PR closes on the FK side; not yet fixed here.
371
- - When adding a new early-return path inside a commit handler in `_writeUpdate`, follow the blob-cleanup protocol documented in `../DESIGN.md` ("Blob orphan cleanup").
368
+ - When adding a new early-return path inside a commit handler in `_writeUpdate`, follow the blob-cleanup protocol in "Blob orphan cleanup" below.
372
369
  - If you add a new top-level section to `Table.ts`, drop a `// #section: <name>` marker at its start and add a row to the section map above.
373
370
  - Tests for this layer live in `../unitTests/resources/`.
374
371
 
@@ -564,28 +561,15 @@ Without this, every re-delivery appended another entry under the origin's log ke
564
561
 
565
562
  ## A second sequential save() on the same ImmediateTransaction context must chain on `operation.innerCommit`
566
563
 
567
- Two `update()`+`save()` cycles on the _same resource instance_, outside an explicit `transaction()`,
568
- reuse the same `ImmediateTransaction` object even after its first cycle has closed it (`this.open =
569
- CLOSED`). The second `save()` re-enters `ImmediateTransaction.save()` with `isCommitting` false, so it
570
- calls `this.commit()` again; that `commit()`'s own sweep loop calls `this.save(newWrite, ...)` — a
571
- **polymorphic re-dispatch to `ImmediateTransaction.save()`**, now with `isCommitting` true, which takes
572
- the `super.save(operation, transaction, true)` branch with no handle to forward (a CLOSED context has
573
- none) and so creates its own
574
- brand-new `RocksTransaction` and immediately commits it, stashing the real commit promise on
575
- `operation.innerCommit`. But the outer `commit()`'s sweep loop discards the return value of
576
- `this.save(operation, ...)` for every write it processes — that's fine when the write commits inline,
577
- but this reused-context write's real work happens in a _third_, more deeply nested `commit()` call
578
- (triggered by `immediateCommit` inside the nested `save()`), whose promise never propagates back through
579
- any of the enclosing calls. `Table.save()`'s ordinary `#savingOperation` path used to just return
580
- `#saveOperation(operation)`'s result directly — which can resolve before that nested native commit
581
- actually settles, so a caller's `await resource.save()` can return before the write is durable (a real,
582
- if narrow, race: `LockTest.get()` immediately after can read the pre-write value). The lock-writable hold
583
- branch already avoided this by explicitly returning `operation.innerCommit` after its own recursive
584
- `save()`; the ordinary path now does the same — `when(this.#saveOperation(operation), () =>
585
- operation.innerCommit)`. `operation.innerCommit` is `undefined` when a write commits inline (no
586
- immediateCommit), so this is safe for the common case. Found via record-lock scoped-lock staging
587
- (harper#483), which is what first made this reused-closed-context pattern reachable for an ordinary
588
- resource, but the gap is general to `Table.save()`, not lock-specific.
564
+ Two `update()`+`save()` cycles on one resource instance outside `transaction()` reuse one
565
+ `ImmediateTransaction` after its first cycle has closed it. The second write then commits through a
566
+ nested `commit()` on a fresh native transaction (the `immediateCommit` branch of
567
+ `DatabaseTransaction.save()`), whose promise the outer commit's sweep loop discards; that branch
568
+ stashes it on `operation.innerCommit`, which is `undefined` for a write that commits inline. Both
569
+ `Table.save()` branches — the ordinary `#savingOperation` path and the lock-writable hold path — wait
570
+ on it, or `await resource.save()` returns before the write is durable and an immediate read sees the
571
+ old value. The gap is general to `Table.save()`, not lock-specific. Exercised by `recordLock.test.js`
572
+ "scoped lock outside transaction() persists across sequential saves until unlock()".
589
573
 
590
574
  ## A commit retry re-saves into the transaction it is retrying; `ImmediateTransaction.save()` must forward it
591
575
 
@@ -704,7 +688,7 @@ The additive-only rule above repairs the _consumer_ of a partial peer snapshot;
704
688
 
705
689
  ## Subscription version selection (`Table.ts`)
706
690
 
707
- `Table.subscribe` resolves audit events through `eventFromAudit` for both replay and live delivery: unless `includeSuperseded` (defaulting to `rawEvents`) is enabled, record mutations must match the current primary version; an absent primary entry cannot establish a current version and is skipped. Publishing advances that primary version too: a put followed by a publish replays the message but not the put under the default rule. Messages and control events are independent. Version filtering precedes historical reconstruction and `previousCount` acceptance, while audit cursors advance over rejected entries. Single-record replay retains its bounded history walk because it also contains independent published messages; only an accepted replay event suppresses the current snapshot, and `eventFilter` also applies to that fallback. Default replay reads the current primary entry for each mutation. Opting into superseded full records retains audit-history reconstruction costs and retention limits, including patch reconstruction on durable MQTT live delivery.
691
+ `Table.subscribe` resolves audit events through `eventFromAudit` for both replay and live delivery: unless `includeSuperseded` (defaulting to `rawEvents`) is enabled, record mutations must match the current primary version; an absent primary entry cannot establish a current version and is skipped. Publishing advances that primary version too: a put followed by a publish replays the message but not the put under the default rule. Messages and control events are independent. Version filtering precedes historical reconstruction and `previousCount` acceptance, while audit cursors advance over rejected entries. Single-record replay retains its bounded history walk because it also contains independent published messages; only an accepted replay event suppresses the current snapshot, and `eventFilter` also applies to that fallback. Default replay reads the current primary entry for each mutation. Opting into superseded full records retains audit-history reconstruction costs and retention limits, including patch reconstruction on durable MQTT live delivery. Collection `startTime` replay leaves `subscription.startTime` — the exclusive resume cursor `DurableSubscriptionsSession.saveSubscriptions` persists — never past a key with an unhandled in-table record: RocksDB gives every record of a transaction one `txnLogKey`, so the cursor moves to a key only on reaching the next key or finishing, and an early return (failed send, close at a yield or drain) leaves it up to one transaction back, which the resume re-sends (`subscriptionReplay.test.js`, "cursor after replay stops early"). The cursor counts events handed to the subscription queue, not consumed ones; `close()` discards the queue.
708
692
 
709
693
  ## Audit-store `'committed'` notification batching (`transactionBroadcast.ts`)
710
694
 
@@ -718,20 +702,27 @@ The cross-thread subscription path (default `crossThreads`) drives every `Table.
718
702
  ## Audit-entry removal loops must track every `removeAuditEntry()`/`removeEntry()` promise
719
703
 
720
704
  `scheduleAuditCleanup` (`auditStore.ts`) and `Table.deleteHistory` (`Table.ts`, the LMDB path behind
721
- `delete_transaction_logs_before`) both iterate a range of audit records and remove each one. Both were
722
- originally written as `completion = removeAuditEntry(auditStore, auditRecord)` inside the loop, awaiting
723
- only the final iteration's promise afterward. Any rejection from a non-last iteration was silently
724
- discarded — the promise reference was overwritten before it could be awaited or caught — and surfaced
725
- later as an unhandled rejection instead, with no logging to explain it. Any loop that removes
726
- audit/primary-store entries in a batch must attach a rejection handler to every removal immediately
727
- and drain all tracked promises before returning — never stash a per-iteration promise in an outer
728
- variable to await only the last one. `Table.deleteHistory` allows up to 1,000 LMDB removals in flight
705
+ `delete_transaction_logs_before`) both iterate a range of audit records and remove each one. Any loop
706
+ that removes audit/primary-store entries in a batch must attach a rejection handler to every removal
707
+ immediately and drain all tracked promises before returning — never stash a per-iteration promise in
708
+ an outer variable to await only the last one: an overwritten promise's rejection is never awaited or
709
+ caught, and surfaces later as an unhandled rejection with no log to explain it. `Table.deleteHistory`
710
+ allows up to 1,000 LMDB removals in flight
729
711
  (ten for RocksDB) so storage writes batch without growing an unbounded pending
730
712
  set. Live removals are tracked in a `Set`, and each one removes itself and wakes at most one parked
731
713
  producer when it settles, so any completion releases the loop. In these removal loops, do not repeatedly
732
714
  race the live set: each race attaches another reaction to every long-pending removal. Both phases drain
733
715
  their tracked removals before settling, including when iteration throws. `scheduleAuditCleanup` remains
734
- sequential because it is an automatic background loop.
716
+ sequential because it is an automatic background loop, and it ends a pass at its first failed removal
717
+ rather than continuing past it: its `deleted` count (the backoff input) and the `last-removed` marker it
718
+ writes then cover a contiguous removed prefix — nothing above that marker was removed by the pass — and
719
+ the next pass retries the failed entry first. Past a failure there is no key the marker can truthfully
720
+ record, and counting failures as progress re-armed an all-failing pass at 10 ms. The guarantee is
721
+ pass-local: the marker is written after its removals, `deleteHistory` never writes it, and a marker an
722
+ older build persisted past a failed entry is not repaired; completeness is the audit floor's question.
723
+ Stopping costs liveness only for an entry that fails on every pass, which is why `removeAuditEntry` must
724
+ fail only when `auditStore.remove()` does (below). Regression: `auditLog.test.js` "ends a pass at a failed
725
+ removal".
735
726
 
736
727
  Individual removal failures are logged and excluded from the returned count, but a purge that attempted
737
728
  at least one removal and completed none rejects with the first error after both phases have drained.
@@ -749,9 +740,11 @@ followed by an unconditional remove: a record recreated between those operations
749
740
  `removeAuditEntry` has a second, nested version of the same hazard: for a `'delete'`-type audit record it
750
741
  also invokes a per-table delete callback (`addDeleteRemovalCallback`) that removes the corresponding
751
742
  primary-store tombstone. That callback's promise must be returned and joined with the audit-store
752
- removal (currently via `Promise.all`, with the callback's own rejection caught and logged separately so
753
- a failed tombstone cleanup doesn't get misreported as a failed audit-entry removal) — otherwise the
754
- tombstone removal is fire-and-forget and the same detached-rejection hazard reappears one level down.
743
+ removal (currently via `Promise.all`, with the callback's own rejection — and a throwing tombstone
744
+ lookup, such as lmdb-js `getEntry(undefined)` for an undecodable recordId — caught and logged through
745
+ `warnContained`, so neither a failed tombstone cleanup nor a throwing log sink gets misreported as a failed
746
+ audit-entry removal) — otherwise the tombstone removal is fire-and-forget and the same detached-rejection
747
+ hazard reappears one level down.
755
748
  A tombstone whose cleanup fails this way is not swept automatically — `scheduleAuditCleanup`'s automatic
756
749
  pass never retries it, since the audit entry that would have triggered a retry is already gone. It sits
757
750
  in the primary store until an operator runs `delete_transaction_logs_before` with `cleanup_deleted_records: true`.
@@ -779,11 +772,9 @@ of the exported `scheduleAuditCleanup`, because a store-wide segment purge loopi
779
772
  duplicated work. If a future change passes `skipThreadCheck: true` at the registration site, that
780
773
  backstop — not the registration — becomes the thing keeping the loop single.
781
774
 
782
- Both re-arm guards are **Rocks-only**, deliberately: the LMDB arm keeps `origin/main`'s unconditional
783
- re-arm, so it neither yields to an already-pending pass (a pressure-armed 100ms pass can be cancelled
784
- and replaced by the idle backoff) nor restricts itself to one worker. Those are pre-existing LMDB
785
- behaviours, not invariants this section establishes — don't read the paragraphs above as
786
- engine-independent.
775
+ Both re-arm guards are **Rocks-only**. The LMDB arm re-arms unconditionally, so it neither yields to
776
+ an already-pending pass (a pressure-armed 100ms pass can be cancelled and replaced by the idle
777
+ backoff) nor restricts itself to one worker — pre-existing LMDB behavior, not an invariant.
787
778
 
788
779
  Two things a purge does **not** need to coordinate, both load-bearing for the continuous cadence.
789
780
  Unlinking a segment a consumer has mapped is safe **on POSIX**: the inode outlives the unlink, and the
@@ -808,11 +799,11 @@ number into its write instruction synchronously and the native writer consumes i
808
799
  (`node_modules/lmdb/write.js`), so a pass suspended inside `await removeAuditEntry()` still has a
809
800
  delete pending against the primary and audit DBIs, and LMDB forbids closing a DBI an existing
810
801
  transaction has modified. `dropDatabase()` and the legacy arm of `Table.dropTable()` await it.
811
- `closeDatabase()` and branch `close()` are synchronous and cannot; what covers them is that every
812
- environment touch remaining in a resumed pass — cursor advance, cursor release, marker write, re-arm —
813
- re-checks `rootStore.status`, plus the fact that their production callers reach them only for RocksDB
814
- stores, whose pass is one synchronous `purgeLogs()` call with nothing suspended mid-removal.
815
- `resetDatabases()` closes LMDB roots with no retirement call at all, so that re-check is a routine
802
+ `closeDatabase()` and branch `close()` close commit admission, then drain tracked Rocks transactions,
803
+ table maintenance, audit cleanup, and derived-index work before closing stores. Every environment
804
+ touch remaining in a resumed cleanup pass — cursor advance, cursor release, marker write, re-arm —
805
+ also re-checks `rootStore.status`. `resetDatabases()` closes LMDB roots with no retirement call at all,
806
+ so that re-check is a routine
816
807
  path rather than a defensive one.
817
808
 
818
809
  The last-removed marker is retained until it commits. A rejected write is logged and carried to the
@@ -824,13 +815,7 @@ were already removed.
824
815
 
825
816
  When a blob attribute is created from a Node `Readable` (e.g. `createBlob(stream)` then `row.payload_blob = blob; await table.put(row)`), the put does **not** wait for the underlying stream to fully drain into the file before resolving. Internally `saveBlob` kicks off a `writeBlobWithStream` pipeline whose `storageInfo.saving` promise is tracked separately. The put resolves once encoding has captured the blob reference; the bytes finish writing concurrently.
826
817
 
827
- Consequence for callers that wrap the source in a hashing `Transform`: calling `hash.digest('hex')` after `await table.put()` is unsafe — more `chunk.update()` calls can still fire as the stream drains, producing `Error [ERR_CRYPTO_HASH_FINALIZED]: Digest already called`. Options:
828
-
829
- - Buffer first, then hash + put (what `components/deploymentRecorder.ts` does for Slice A — small payloads only).
830
- - Hash via Transform while extraction reads the stream, and only finalize the hash on the Transform's `'end'` event before any second put with the final hash.
831
- - Await `storageInfo.saving` directly if you have a handle to the FileBackedBlob (the cleanest path for streaming).
832
-
833
- Future agents touching `components/deploymentRecorder.ts` for Slice B's streaming variant should pick one of the latter two patterns.
818
+ Consequence for callers that wrap the source in a hashing `Transform`: calling `hash.digest('hex')` after `await table.put()` is unsafe — more `chunk.update()` calls can still fire as the stream drains, producing `Error [ERR_CRYPTO_HASH_FINALIZED]: Digest already called`. Finalize the hash only after the source has ended and the blob's `storageInfo.saving` has settled, as `components/deploymentRecorder.ts`'s streaming ingest does (`await Promise.all([putDone, tapDone, saving])` before `digest`). Its in-memory sources, and its capped fallback for a missing deployment table, buffer and hash before the put instead.
834
819
 
835
820
  ## Table drops, the `dropping` tombstone, and ghost tables
836
821
 
@@ -847,29 +832,24 @@ silently re-opened with create-if-missing on the next start, which resurrects "d
847
832
 
848
833
  ## The exclusive `update-attributes` lock is a bounded synchronous wait, and drop-then-recreate needs the column-family eviction fix (`Table.ts`)
849
834
 
850
- Two related traps: the create/schema-update path's exclusive `update-attributes` lock is a
851
- synchronous bounded wait (`acquireUpdateAttributesLock` in `Table.ts`: brief hot spin, then
852
- `Atomics.wait` backoff, retryable `ServerError` after the 10s `UPDATE_ATTRIBUTES_LOCK_TIMEOUT` — harper#2251; it used to be
853
- an unbounded `while (!tryLock()) {}` spin that pinned a worker core forever if the holder never
854
- released). Release is structural — `table()` releases in a single `finally` and `dropTable` uses
855
- `withUpdateAttributesLock` — so a throw inside the locked window cannot leak the lock (regression
856
- suite: `unitTests/resources/updateAttributesLock.test.js`). Because the acquire can now throw,
857
- `table()` takes the RocksDB lock _before_ it mutates the live `Table` (attributes, class metadata,
858
- index handles): losing the race then leaves this worker's in-memory schema exactly as it found it,
859
- and moving any mutation above that acquire reintroduces schema drift the catalog never saw. LMDB
860
- keeps the lazy acquire — its `exclusiveLock()` is an environment-wide write transaction that cannot
861
- time out, so taking it eagerly would stall every write to the database on an unchanged reload. A
862
- successful acquire that waited past `UPDATE_ATTRIBUTES_LOCK_SLOW_WAIT` (1s) warns once, since
863
- contention is otherwise invisible until it becomes a timeout. The locked
864
- sections MUST stay synchronous: the wait blocks the event loop, so an awaited operation inside
865
- one would stall a concurrent acquirer to its deadline. And dropping then recreating a
866
- same-named table within one process requires @harperfast/rocksdb-js >= the column-family
867
- eviction fix (2.1.0 / rocksdb-js#647): older bindings keep the dropped column family's
868
- by-name registry entry alive whenever other worker threads hold handles, so the recreate
869
- silently reuses a dangling handle and every write fails with "Invalid column family specified
870
- in write batch", poisoning the whole database env until restart. The regression suite for all
871
- of this is `unitTests/resources/dropTableGhost.test.js` (it fails by design on pre-fix
872
- bindings).
835
+ The create/schema-update path's exclusive `update-attributes` lock is a synchronous bounded wait
836
+ (`acquireUpdateAttributesLock` in `Table.ts`: brief hot spin, then `Atomics.wait` backoff, retryable
837
+ `ServerError` after the 10s `UPDATE_ATTRIBUTES_LOCK_TIMEOUT`, harper#2251). Release is structural —
838
+ `table()` releases in a single `finally` and `dropTable` uses `withUpdateAttributesLock` — so a throw
839
+ inside the locked window cannot leak the lock (`unitTests/resources/updateAttributesLock.test.js`).
840
+ Because the acquire can throw, `table()` takes the RocksDB lock _before_ it mutates the live `Table`
841
+ (attributes, class metadata, index handles): losing the race then leaves this worker's in-memory
842
+ schema exactly as it found it, and moving any mutation above that acquire reintroduces schema drift
843
+ the catalog never saw. LMDB keeps the lazy acquire — its `exclusiveLock()` is an environment-wide
844
+ write transaction that cannot time out, so taking it eagerly would stall every write to the database
845
+ on an unchanged reload. A successful acquire that waited past `UPDATE_ATTRIBUTES_LOCK_SLOW_WAIT` (1s)
846
+ warns once, since contention is otherwise invisible until it becomes a timeout. The locked sections
847
+ MUST stay synchronous: the wait blocks the event loop, so an awaited operation inside one would stall
848
+ a concurrent acquirer to its deadline. Dropping then recreating a same-named table within one process
849
+ depends on rocksdb-js's column-family eviction fix (≥ 2.1.0, rocksdb-js#647; `package.json` pins a
850
+ later release): without it the recreate reuses a dangling handle and every write fails with "Invalid
851
+ column family specified in write batch". Regression suite: `unitTests/resources/dropTableGhost.test.js`,
852
+ which fails by design on pre-fix bindings.
873
853
 
874
854
  ## RocksDB transaction log purges are database-wide only (`ResourceBridge.deleteTransactionLogsBefore`)
875
855
 
@@ -1,14 +1,19 @@
1
1
  import { cleanupUnusedBlobs, collectRetainedFileIds } from './blob.ts';
2
2
  import { Transaction as LMDBTransaction } from 'lmdb';
3
3
  import { getNextMonotonicTime } from '../utility/lmdb/commonUtility.ts';
4
- import { ServerError, TransactionCommitConflictTimeoutError } from '../utility/errors/hdbError.ts';
4
+ import {
5
+ DatabaseClosingError,
6
+ DatabaseDrainTimeoutError,
7
+ ServerError,
8
+ TransactionCommitConflictTimeoutError,
9
+ } from '../utility/errors/hdbError.ts';
5
10
  import { lockNotHeldError, type RecordLockHandle } from './recordLock.ts';
6
11
  import * as harperLogger from '../utility/logging/harper_logger.ts';
7
12
  import type { Context, Id } from './ResourceInterface.ts';
8
13
  import * as envMngr from '../utility/environment/environmentManager.ts';
9
14
  import { CONFIG_PARAMS } from '../utility/hdbTerms.ts';
10
15
  import { convertToMS } from '../utility/common_utils.ts';
11
- import { when } from '../utility/when.ts';
16
+ import { settleBeforeDeadline, when } from '../utility/when.ts';
12
17
  import { setTimeout as delay } from 'node:timers/promises';
13
18
  import { Transaction as RocksTransaction, type Store as RocksStore, constants } from '@harperfast/rocksdb-js';
14
19
  const RETRY_NOW_VALUE = constants.RETRY_NOW_VALUE;
@@ -65,6 +70,7 @@ interface OutstandingCommit {
65
70
  start: number;
66
71
  prev: OutstandingCommit | undefined;
67
72
  next: OutstandingCommit | undefined;
73
+ commitResolution: Promise<number | void>;
68
74
  // Identity for the one-time checkOverloaded() log below (harper#2001) — otherwise a stuck commit
69
75
  // gives no indication of which database/table/resource to investigate. Snapshotted at arm time
70
76
  // (not read from `this.writes`/`this.startedFrom` lazily off the DatabaseTransaction object)
@@ -74,6 +80,7 @@ interface OutstandingCommit {
74
80
  // so that every outstanding commit carries its own identity and its own `logged` flag: a second
75
81
  // commit that is still stuck once the first settles becomes the new oldest and logs on its own.
76
82
  store: any;
83
+ rootStore: any;
77
84
  startedFrom: { resourceName: string; method: string } | undefined;
78
85
  nativeTransaction: any;
79
86
  logged: boolean;
@@ -81,6 +88,10 @@ interface OutstandingCommit {
81
88
  let oldestOutstandingCommit: OutstandingCommit | undefined;
82
89
  let newestOutstandingCommit: OutstandingCommit | undefined;
83
90
  let outstandingCommitCount = 0;
91
+ const suspendedDatabaseCommits = new WeakMap<object, number>();
92
+ const permanentlySuspended = Symbol('permanentlySuspendedDatabaseCommits');
93
+ let suspendedDatabaseRootCount = 0;
94
+ let hasPermanentlySuspendedDatabaseRoots = false;
84
95
  // Caps the stuck-commit log (checkOverloaded() below) to at most one line per this interval across
85
96
  // the whole thread, regardless of how many distinct commits individually cross the threshold — see
86
97
  // the comment at the log site for why a per-commit-only dedup isn't enough under sustained overload.
@@ -101,11 +112,12 @@ function allowStuckCommitLog(site: 'shed' | 'abandon', now: number): boolean {
101
112
  function describeCommitIdentity(
102
113
  store: any,
103
114
  startedFrom: { resourceName: string; method: string } | undefined,
104
- nativeTransaction: any
115
+ nativeTransaction: any,
116
+ rootStore = store?.rootStore
105
117
  ): string {
106
118
  const nativeTransactionId = nativeTransaction?.id;
107
119
  return (
108
- `from table: ${store?.rootStore?.databaseName ?? '?'}.${store?.name ?? '?'}` +
120
+ `from table: ${rootStore?.databaseName ?? '?'}.${store?.name ?? '?'}` +
109
121
  (nativeTransactionId !== undefined ? ` (transaction ${nativeTransactionId})` : '') +
110
122
  (startedFrom?.resourceName
111
123
  ? `, started from ${startedFrom.resourceName}${startedFrom.method ? '.' + startedFrom.method : ''}`
@@ -121,9 +133,9 @@ function describeCommitIdentity(
121
133
  // from its own submission, keeping the overload window per-attempt rather than cumulative over a
122
134
  // retry ladder. `.then(untrack, untrack)` also marks an ERR_BUSY rejection handled, so this
123
135
  // tracking never surfaces as an unhandled rejection alongside the caller's own handler.
124
- // Exported only so unit tests can drive the list with controllable promises: the unlink order that
125
- // matters (a middle or tail node settling first) cannot be forced through real writes, and a node
126
- // left linked would 503 every write on this thread forever. commit() below is the sole caller.
136
+ // Exported so raw RocksDB writers and unit tests share the same queue. The unlink order that matters
137
+ // (a middle or tail node settling first) cannot be forced through real writes, and a node left linked
138
+ // would 503 every write on this thread forever.
127
139
  // Also the single source for write-queue-depth accounting (getTransactionQueueDepths below): every
128
140
  // native commit this function tracks is, by definition, exactly the write-queue backlog — see the
129
141
  // comment there for why that used to be a second, separately-maintained counter.
@@ -133,7 +145,8 @@ export function trackOutstandingCommit(
133
145
  commitResolution: Promise<number | void>,
134
146
  store?: any,
135
147
  startedFrom?: { resourceName: string; method: string },
136
- nativeTransaction?: any
148
+ nativeTransaction?: any,
149
+ rootStore = store?.rootStore
137
150
  ): void {
138
151
  // Guards against a future caller passing a non-Promise: today commit() always hands this a real
139
152
  // Promise, but an unguarded link here would leave a node permanently wedged in the list (503ing
@@ -143,7 +156,9 @@ export function trackOutstandingCommit(
143
156
  start: performance.now(),
144
157
  prev: newestOutstandingCommit,
145
158
  next: undefined,
159
+ commitResolution,
146
160
  store,
161
+ rootStore,
147
162
  startedFrom,
148
163
  nativeTransaction,
149
164
  logged: false,
@@ -172,6 +187,120 @@ export function trackOutstandingCommit(
172
187
  commitResolution.then(untrack, untrack);
173
188
  }
174
189
 
190
+ export function databaseCommitsSuspended(rootStore: object | undefined): boolean {
191
+ if (rootStore == null) return false;
192
+ if ((rootStore as any).status === 'closed') return true;
193
+ if (suspendedDatabaseRootCount === 0 && !hasPermanentlySuspendedDatabaseRoots) return false;
194
+ if ((rootStore as any)[permanentlySuspended]) return true;
195
+ return suspendedDatabaseRootCount > 0 && (suspendedDatabaseCommits.get(rootStore) ?? 0) > 0;
196
+ }
197
+
198
+ /** Keep abandoned root wrappers fenced without retaining the process-wide active-fence counter. */
199
+ export function permanentlySuspendDatabaseCommits(rootStores: Iterable<object>): void {
200
+ for (const rootStore of rootStores) {
201
+ if ((rootStore as any)[permanentlySuspended]) continue;
202
+ const suspension = suspendedDatabaseCommits.get(rootStore) ?? 0;
203
+ if (suspension > 0) suspendedDatabaseRootCount--;
204
+ suspendedDatabaseCommits.delete(rootStore);
205
+ (rootStore as any)[permanentlySuspended] = true;
206
+ hasPermanentlySuspendedDatabaseRoots = true;
207
+ }
208
+ }
209
+
210
+ export function getSuspendedDatabaseRootCount(): number {
211
+ return suspendedDatabaseRootCount;
212
+ }
213
+
214
+ export function commitTrackedRocksTransaction(
215
+ transaction: RocksTransaction,
216
+ store: any,
217
+ rootStore = store?.rootStore
218
+ ): Promise<number | void> {
219
+ if (databaseCommitsSuspended(rootStore)) {
220
+ try {
221
+ transaction.abort();
222
+ } catch {}
223
+ return Promise.reject(new DatabaseClosingError(rootStore?.databaseName ?? rootStore?.path ?? 'unknown'));
224
+ }
225
+ let commitResolution: Promise<number | void>;
226
+ try {
227
+ commitResolution = transaction.commit() as Promise<number | void>;
228
+ } catch (error) {
229
+ return Promise.reject(error);
230
+ }
231
+ trackOutstandingCommit(commitResolution, store, undefined, transaction, rootStore);
232
+ return commitResolution;
233
+ }
234
+
235
+ let databaseCommitDrainTimeoutMilliseconds = 120_000;
236
+
237
+ export function getDatabaseCommitDrainTimeoutMilliseconds(): number {
238
+ return databaseCommitDrainTimeoutMilliseconds;
239
+ }
240
+
241
+ export function setDatabaseCommitDrainTimeoutMilliseconds(timeoutMilliseconds: number): number {
242
+ const previous = databaseCommitDrainTimeoutMilliseconds;
243
+ databaseCommitDrainTimeoutMilliseconds = timeoutMilliseconds;
244
+ return previous;
245
+ }
246
+
247
+ /**
248
+ * Stop new native commits for these database roots and wait for submissions already in flight.
249
+ * The returned release is reference-counted and idempotent so overlapping lifecycle operations
250
+ * cannot reopen commit admission underneath each other.
251
+ */
252
+ export function suspendDatabaseCommits(rootStores: Iterable<object>): {
253
+ waitForDrain(options?: { deadline?: number; databaseName?: string }): Promise<void>;
254
+ release(): void;
255
+ } {
256
+ const roots = [...new Set(rootStores)];
257
+ const rootSet = new Set(roots);
258
+ for (const rootStore of roots) {
259
+ if ((rootStore as any)[permanentlySuspended]) continue;
260
+ const count = suspendedDatabaseCommits.get(rootStore) ?? 0;
261
+ if (count === 0) suspendedDatabaseRootCount++;
262
+ suspendedDatabaseCommits.set(rootStore, count + 1);
263
+ }
264
+ let released = false;
265
+ return {
266
+ async waitForDrain(options = {}) {
267
+ const timeoutMilliseconds =
268
+ options.deadline === undefined
269
+ ? databaseCommitDrainTimeoutMilliseconds
270
+ : Math.max(0, options.deadline - Date.now());
271
+ const deadline = options.deadline ?? Date.now() + timeoutMilliseconds;
272
+ for (;;) {
273
+ const pending: Promise<number | void>[] = [];
274
+ for (let outstanding = oldestOutstandingCommit; outstanding; outstanding = outstanding.next) {
275
+ if (rootSet.has(outstanding.rootStore)) pending.push(outstanding.commitResolution);
276
+ }
277
+ if (pending.length === 0) return;
278
+ await settleBeforeDeadline(
279
+ pending,
280
+ deadline,
281
+ () =>
282
+ new DatabaseDrainTimeoutError(
283
+ options.databaseName ?? (roots[0] as any)?.databaseName ?? (roots[0] as any)?.path ?? 'unknown',
284
+ timeoutMilliseconds
285
+ )
286
+ );
287
+ }
288
+ },
289
+ release() {
290
+ if (released) return;
291
+ released = true;
292
+ for (const rootStore of roots) {
293
+ if ((rootStore as any)[permanentlySuspended]) continue;
294
+ const count = suspendedDatabaseCommits.get(rootStore) ?? 0;
295
+ if (count <= 1) {
296
+ suspendedDatabaseCommits.delete(rootStore);
297
+ suspendedDatabaseRootCount--;
298
+ } else suspendedDatabaseCommits.set(rootStore, count - 1);
299
+ }
300
+ },
301
+ };
302
+ }
303
+
175
304
  /**
176
305
  * How many write commits are outstanding on this thread and how long the oldest has been waiting
177
306
  * (`oldestAgeMs` is undefined when none is). `oldestAgeMs` is exactly the value checkOverloaded()
@@ -1027,11 +1156,12 @@ export class DatabaseTransaction implements Transaction {
1027
1156
  describeCommitIdentity(
1028
1157
  oldestOutstandingCommit.store,
1029
1158
  oldestOutstandingCommit.startedFrom,
1030
- oldestOutstandingCommit.nativeTransaction
1159
+ oldestOutstandingCommit.nativeTransaction,
1160
+ oldestOutstandingCommit.rootStore
1031
1161
  ) +
1032
1162
  `.` +
1033
1163
  describeHolderCandidates(
1034
- oldestOutstandingCommit.store?.rootStore?.path,
1164
+ oldestOutstandingCommit.rootStore?.path,
1035
1165
  oldestOutstandingCommit.nativeTransaction?.id
1036
1166
  ) +
1037
1167
  ` Further record updates and publishes from new application requests on this thread ` +
@@ -1398,6 +1528,7 @@ export class DatabaseTransaction implements Transaction {
1398
1528
  }
1399
1529
  // with options.transaction set this is a retry round — the save loop above already
1400
1530
  // re-staged the writes into it
1531
+ this.assertDatabaseCommitAllowed(transaction);
1401
1532
  commitResolution = transaction.commit() as Promise<void>;
1402
1533
  recordCommitLatency(commitResolution, performance.now());
1403
1534
  // Write-queue-depth accounting for this replay commit happens uniformly below, via
@@ -1457,6 +1588,7 @@ export class DatabaseTransaction implements Transaction {
1457
1588
  // getReadTxn), so commit() can resolve to RETRY_NOW_VALUE. That
1458
1589
  // sentinel (a number) is why commitResolution is typed
1459
1590
  // Promise<number | void>; it is handled in the resolve callback below.
1591
+ this.assertDatabaseCommitAllowed(transaction);
1460
1592
  commitResolution = transaction.commit();
1461
1593
  // Record how long this commit stays outstanding (submit → settle) as a distribution
1462
1594
  // metric. This is the same clock the overload check uses (trackOutstandingCommit
@@ -1795,6 +1927,20 @@ export class DatabaseTransaction implements Transaction {
1795
1927
  this.writesAbandoned = false;
1796
1928
  }
1797
1929
 
1930
+ private assertDatabaseCommitAllowed(transaction: RocksTransaction): void {
1931
+ const rootStore = this.writes[0]?.store?.rootStore;
1932
+ if (!databaseCommitsSuspended(rootStore)) return;
1933
+ const retryable = !this.root && !this.snapshotFree;
1934
+ if (this.transaction === transaction) this.detachOwnedTransaction();
1935
+ abortNativeTransaction(transaction, 'aborting a transaction while its database is closing');
1936
+ try {
1937
+ this.abort();
1938
+ } catch (error) {
1939
+ harperLogger.debug?.('cleaning up a transaction while its database is closing', error);
1940
+ }
1941
+ throw new DatabaseClosingError(rootStore?.databaseName ?? rootStore?.path ?? 'unknown', retryable);
1942
+ }
1943
+
1798
1944
  abort(): void {
1799
1945
  while (this.readTxnsUsed > 0) this.doneReadTxn(); // release the read snapshot when we abort, we assume we don't need it
1800
1946
  // Defensively release any native handle whose reference bookkeeping was already consumed.
@@ -1967,6 +2113,7 @@ export class DatabaseTransaction implements Transaction {
1967
2113
  }
1968
2114
  directCommitSync(): void {
1969
2115
  const transaction = this.transaction;
2116
+ if (transaction) this.assertDatabaseCommitAllowed(transaction);
1970
2117
  try {
1971
2118
  transaction?.commitSync();
1972
2119
  } catch (error) {