@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
@@ -59,6 +59,15 @@ export interface OllamaBackendConfig {
59
59
  * support exists on some models but is uneven across the model catalog; we keep
60
60
  * the v1 portability guarantee honest and skip them here.
61
61
  */
62
+ const CAPABILITIES: ModelCapabilities = Object.freeze({
63
+ embed: true,
64
+ generate: true,
65
+ stream: true,
66
+ tools: false,
67
+ adapters: false,
68
+ structuredOutput: true,
69
+ });
70
+
62
71
  export class OllamaBackend implements ModelBackend {
63
72
  readonly name = 'ollama';
64
73
  readonly #origin: string;
@@ -74,7 +83,7 @@ export class OllamaBackend implements ModelBackend {
74
83
  }
75
84
 
76
85
  capabilities(): ModelCapabilities {
77
- return { embed: true, generate: true, stream: true, tools: false, adapters: false };
86
+ return CAPABILITIES;
78
87
  }
79
88
 
80
89
  async embed(input: string | string[], opts: BackendOpts<EmbedOpts>): Promise<ModelCallResult<Float32Array[]>> {
@@ -19,6 +19,7 @@
19
19
  import { setEmbedding, setGenerative } from '../../resources/models/backendRegistry.ts';
20
20
  import {
21
21
  assignFiniteTokenCount,
22
+ ChoiceScoringUnsupportedError,
22
23
  composeSignal,
23
24
  MAX_ERROR_BODY_BYTES,
24
25
  normalizeOrigin,
@@ -31,6 +32,7 @@ import { ServerError } from '../../utility/errors/hdbError.ts';
31
32
  import harperLogger from '../../utility/logging/harper_logger.ts';
32
33
  import type {
33
34
  BackendOpts,
35
+ ChoiceScores,
34
36
  EmbedOpts,
35
37
  GenerateChunk,
36
38
  GenerateInput,
@@ -40,6 +42,7 @@ import type {
40
42
  ModelBackend,
41
43
  ModelCallResult,
42
44
  ModelCapabilities,
45
+ ScoreChoicesOpts,
43
46
  ToolCall,
44
47
  ToolDef,
45
48
  TokenUsage,
@@ -71,6 +74,14 @@ const MAX_TOOL_CALL_ACCUMULATOR_ENTRIES = 128;
71
74
  // cap (1 MiB) plus the 128-entry cap still allows ~128 MiB accumulated; this cap
72
75
  // keeps any single stream well-bounded. Real responses use tens of KB.
73
76
  const MAX_TOTAL_TOOL_CALL_ARGS_CHARS = 8 * 1024 * 1024; // 8 MiB
77
+ // `top_logprobs` accepts at most 20, so one scoring request can cover at most 20 choices with a
78
+ // single-letter label each; more must be declined (#2838).
79
+ const MAX_SCORED_CHOICES = 20;
80
+ const CHOICE_LABELS = 'ABCDEFGHIJKLMNOPQRST';
81
+ // A scoring response is one content token with at most 20 alternatives, a few KiB. The general
82
+ // 256 MiB cap is sized for embedding batches; an object schema scores its fields concurrently,
83
+ // so a faulty endpoint must not be able to hold that much per call.
84
+ const MAX_SCORE_RESPONSE_BODY_BYTES = 1 << 20; // 1 MiB
74
85
 
75
86
  const log = harperLogger.forComponent('openai').conditional;
76
87
 
@@ -97,12 +108,28 @@ export interface OpenAIBackendConfig {
97
108
  * - `generate` → `POST {baseUrl}/chat/completions` (always chat shape)
98
109
  * - `generateStream` → same with `stream: true`; consumes SSE wire format and
99
110
  * yields `GenerateChunk` per delta.
111
+ * - `scoreChoices` → `POST {baseUrl}/chat/completions` with `logprobs` for one
112
+ * token, the choices labelled `A`..`T` (#2838).
100
113
  *
101
114
  * Capabilities advertise `tools: true` — first backend with native tool-call
102
115
  * support. `adapters: false` — OpenAI doesn't expose LoRA adapter selection
103
116
  * externally. `toolMode: 'return'` (Phase 1 default) is supported end-to-end;
104
117
  * `toolMode: 'auto'` is reserved for #612.
105
118
  */
119
+ const CAPABILITIES: ModelCapabilities = Object.freeze({
120
+ embed: true,
121
+ generate: true,
122
+ stream: true,
123
+ tools: true,
124
+ adapters: false,
125
+ scoreChoices: true,
126
+ structuredOutput: true,
127
+ maxScoredChoices: MAX_SCORED_CHOICES,
128
+ });
129
+ // The same shape for a model that refused `logprobs`; two frozen objects, so `capabilities()` allocates nothing.
130
+ const { maxScoredChoices: _scoringLimit, ...UNLIMITED } = CAPABILITIES;
131
+ const CAPABILITIES_WITHOUT_SCORING: ModelCapabilities = Object.freeze({ ...UNLIMITED, scoreChoices: false });
132
+
106
133
  export class OpenAIBackend implements ModelBackend {
107
134
  readonly name = 'openai';
108
135
  readonly #baseUrl: string;
@@ -115,6 +142,12 @@ export class OpenAIBackend implements ModelBackend {
115
142
  // (o-series, gpt-5 family) reject `max_tokens` in favour of `max_completion_tokens`;
116
143
  // OpenAI-compatible shims (vLLM, Ollama-compat, older gateways) only know `max_tokens`.
117
144
  readonly #isNativeOpenAI: boolean;
145
+ // Models this instance has seen refuse `logprobs`: a 400 naming the parameter (OpenAI's
146
+ // reasoning models), or an answer with no log-probability field at all (a compatible endpoint
147
+ // that ignores the parameter). Scoring stays off for them until a reload builds a new
148
+ // instance, and `capabilities()` says so for the configured model, so a decision against it
149
+ // votes without an attempt. An empty completion is this call's problem, not the model's.
150
+ readonly #logprobsRejected = new Set<string>();
118
151
 
119
152
  constructor(config: OpenAIBackendConfig = {}, fetchImpl: typeof fetch = fetch) {
120
153
  this.#apiKey = requireCredential(config.apiKey, 'OpenAI', 'apiKey', OpenAIBackendError);
@@ -131,7 +164,9 @@ export class OpenAIBackend implements ModelBackend {
131
164
  }
132
165
 
133
166
  capabilities(): ModelCapabilities {
134
- return { embed: true, generate: true, stream: true, tools: true, adapters: false };
167
+ return this.#defaultModel === undefined || !this.#logprobsRejected.has(this.#defaultModel)
168
+ ? CAPABILITIES
169
+ : CAPABILITIES_WITHOUT_SCORING;
135
170
  }
136
171
 
137
172
  async embed(input: string | string[], opts: BackendOpts<EmbedOpts>): Promise<ModelCallResult<Float32Array[]>> {
@@ -242,7 +277,71 @@ export class OpenAIBackend implements ModelBackend {
242
277
  }
243
278
  }
244
279
 
245
- async #post(path: string, body: object, callerSignal?: AbortSignal): Promise<Response> {
280
+ /**
281
+ * Score `choices` from the first completion token's alternatives (#2838). The choices are
282
+ * listed under single-letter labels and the model is asked for the letter alone, so every
283
+ * choice is one token and the `top_logprobs` list can cover the whole set. What this model or
284
+ * endpoint cannot do is declined with `ChoiceScoringUnsupportedError`, not failed: more than
285
+ * 20 choices, a 400 that refuses `logprobs`, a response with no log-probabilities, no content
286
+ * token or no alternatives, no label among them, or too much mass outside the list to know the
287
+ * leading label. A malformed alternative is a failure like any other bad response.
288
+ */
289
+ async scoreChoices(
290
+ input: GenerateInput,
291
+ choices: readonly string[],
292
+ opts: BackendOpts<ScoreChoicesOpts>
293
+ ): Promise<ModelCallResult<ChoiceScores>> {
294
+ const model = opts.model ?? this.#defaultModel;
295
+ requireModel(model, 'scoreChoices', OpenAIBackendError);
296
+ if (choices.length < 1 || choices.length > MAX_SCORED_CHOICES)
297
+ throw new ChoiceScoringUnsupportedError(
298
+ `OpenAI scoreChoices covers 1 to ${MAX_SCORED_CHOICES} choices per call; got ${choices.length}`
299
+ );
300
+ if (this.#logprobsRejected.has(model))
301
+ throw new ChoiceScoringUnsupportedError(
302
+ `OpenAI model '${model}' rejects log-probabilities; scoring stays off for it until the backend is reloaded`
303
+ );
304
+ const body: Record<string, unknown> = {
305
+ model,
306
+ messages: [...normalizeMessages(input), { role: 'user', content: labeledChoices(choices) }],
307
+ stream: false,
308
+ logprobs: true,
309
+ top_logprobs: MAX_SCORED_CHOICES,
310
+ };
311
+ if (this.#isNativeOpenAI) body.max_completion_tokens = 1;
312
+ else body.max_tokens = 1;
313
+ const res = await this.#post('/chat/completions', body, opts.signal, (status, error) => {
314
+ if (!rejectsLogprobs(status, error)) return undefined;
315
+ this.#logprobsRejected.add(model);
316
+ return new ChoiceScoringUnsupportedError(`OpenAI model '${model}' rejects log-probabilities (HTTP ${status})`);
317
+ });
318
+ const data = await readBoundedJson<OpenAIChatResponse>(
319
+ res,
320
+ 'OpenAI /chat/completions',
321
+ OpenAIBackendError,
322
+ MAX_SCORE_RESPONSE_BODY_BYTES
323
+ );
324
+ const choice = data.choices?.[0];
325
+ if (!choice) throw new OpenAIBackendError('OpenAI /chat/completions response missing choices[0]');
326
+ const usage: TokenUsage = {};
327
+ assignFiniteTokenCount(usage, 'promptTokens', data.usage?.prompt_tokens);
328
+ assignFiniteTokenCount(usage, 'completionTokens', data.usage?.completion_tokens);
329
+ // Only an answer that carries no log-probabilities shows the endpoint ignores the parameter;
330
+ // an empty or filtered completion says nothing about the model.
331
+ const answered = typeof choice.message?.content === 'string' && choice.message.content.length > 0;
332
+ const logLikelihoods = scoreLabels(choice.logprobs, choices.length, (reason, structural) => {
333
+ if (structural && answered) this.#logprobsRejected.add(model);
334
+ return new ChoiceScoringUnsupportedError(`OpenAI model '${model}' ${reason}`, usage);
335
+ });
336
+ return { status: 'completed', output: { logLikelihoods }, usage };
337
+ }
338
+
339
+ async #post(
340
+ path: string,
341
+ body: object,
342
+ callerSignal?: AbortSignal,
343
+ translate?: (status: number, error: OpenAIErrorEnvelope) => Error | undefined
344
+ ): Promise<Response> {
246
345
  const signal = composeSignal(callerSignal, this.#requestTimeoutMs);
247
346
  const headers: Record<string, string> = {
248
347
  'Content-Type': 'application/json',
@@ -262,35 +361,171 @@ export class OpenAIBackend implements ModelBackend {
262
361
  // doesn't leak request content. Cap length defensively against a
263
362
  // misbehaving compat shim. Falls back to status-only if the body
264
363
  // isn't JSON or doesn't have the envelope.
265
- throw new OpenAIBackendError(
266
- `OpenAI ${path} returned HTTP ${res.status}${await readErrorSuffix(res)}`,
267
- res.status
268
- );
364
+ const error = await readErrorEnvelope(res);
365
+ const translated = translate?.(res.status, error);
366
+ if (translated) throw translated;
367
+ throw new OpenAIBackendError(`OpenAI ${path} returned HTTP ${res.status}${errorSuffix(error)}`, res.status);
269
368
  }
270
369
  return res;
271
370
  }
272
371
  }
273
372
 
274
- async function readErrorSuffix(res: Response): Promise<string> {
373
+ interface OpenAIErrorEnvelope {
374
+ message?: string;
375
+ param?: string;
376
+ code?: string;
377
+ }
378
+
379
+ async function readErrorEnvelope(res: Response): Promise<OpenAIErrorEnvelope> {
275
380
  try {
276
- const body = await readBoundedJson<{ error?: { message?: unknown; type?: unknown } }>(
381
+ const body = await readBoundedJson<{ error?: { message?: unknown; param?: unknown; code?: unknown } }>(
277
382
  res,
278
383
  'OpenAI error response',
279
384
  OpenAIBackendError,
280
385
  MAX_ERROR_BODY_BYTES
281
386
  );
282
- const message = body?.error?.message;
283
- if (typeof message === 'string' && message.length > 0) {
284
- const truncated =
285
- message.length > MAX_UPSTREAM_ERROR_MESSAGE_CHARS
286
- ? message.slice(0, MAX_UPSTREAM_ERROR_MESSAGE_CHARS) + '…'
287
- : message;
288
- return `: ${truncated}`;
289
- }
290
- return '';
387
+ const error: OpenAIErrorEnvelope = {};
388
+ if (typeof body?.error?.message === 'string') error.message = body.error.message;
389
+ if (typeof body?.error?.param === 'string') error.param = body.error.param;
390
+ if (typeof body?.error?.code === 'string') error.code = body.error.code;
391
+ return error;
291
392
  } catch {
292
- return '';
393
+ return {};
394
+ }
395
+ }
396
+
397
+ function errorSuffix(error: OpenAIErrorEnvelope): string {
398
+ const message = error.message;
399
+ if (typeof message !== 'string' || message.length === 0) return '';
400
+ const truncated =
401
+ message.length > MAX_UPSTREAM_ERROR_MESSAGE_CHARS
402
+ ? message.slice(0, MAX_UPSTREAM_ERROR_MESSAGE_CHARS) + '…'
403
+ : message;
404
+ return `: ${truncated}`;
405
+ }
406
+
407
+ /**
408
+ * Whether a 400 says this model or endpoint does not take log-probabilities at all, as opposed to
409
+ * rejecting this request for another reason. OpenAI names the parameter (`param: 'logprobs'`);
410
+ * a compatible shim tends to say so only in the message, which must then both name the parameter
411
+ * and say it is unsupported, so a request-specific complaint that merely mentions it does not
412
+ * switch scoring off.
413
+ */
414
+ function rejectsLogprobs(status: number, error: OpenAIErrorEnvelope): boolean {
415
+ if (status !== 400) return false;
416
+ if (error.param === 'logprobs' || error.param === 'top_logprobs') return true;
417
+ const message = error.message ?? '';
418
+ return (
419
+ /\b(?:top_)?logprobs\b/i.test(message) &&
420
+ /\b(not supported|unsupported|not available|unavailable|disabled|not allowed|does not support|doesn't support)\b/i.test(
421
+ message
422
+ )
423
+ );
424
+ }
425
+
426
+ /**
427
+ * One line per choice under its label. A value that cannot sit on one line as itself (a line
428
+ * break or other control character, leading or trailing space, a quote or backslash, or nothing
429
+ * at all) is JSON-quoted, so no value can pose as a further option and values that differ only in
430
+ * whitespace stay distinct; a plain value never starts with a quote, so the two forms cannot
431
+ * collide.
432
+ */
433
+ function labeledChoices(choices: readonly string[]): string {
434
+ const lines = ['Choose exactly one option and reply with its letter only.'];
435
+ choices.forEach((choice, i) => lines.push(`${CHOICE_LABELS[i]}. ${plainOrQuoted(choice)}`));
436
+ return lines.join('\n');
437
+ }
438
+
439
+ // Anything that cannot sit on one line as itself. Built from code points so the source carries
440
+ // no raw line terminator, which would end a regex literal.
441
+ const LINE_TERMINATORS = String.fromCharCode(0x2028, 0x2029);
442
+ const NEEDS_QUOTING = new RegExp(`^$|^\\s|\\s$|["\\\\\\x00-\\x1f\\x7f${LINE_TERMINATORS}]`);
443
+ const LINE_TERMINATOR = new RegExp(`[${LINE_TERMINATORS}]`, 'g');
444
+
445
+ function plainOrQuoted(choice: string): string {
446
+ if (!NEEDS_QUOTING.test(choice)) return choice;
447
+ // JSON.stringify leaves the two Unicode line terminators unescaped; they must not break the line.
448
+ return JSON.stringify(choice).replace(LINE_TERMINATOR, (c) => `\\u${c.charCodeAt(0).toString(16)}`);
449
+ }
450
+
451
+ // How far below the smallest listed alternative an unlisted label sits when the list leaves no mass
452
+ // over: finite, so the vector normalizes.
453
+ const UNLISTED_MARGIN = Math.log(1e6);
454
+
455
+ /**
456
+ * One log-likelihood per label from the first content token's alternatives. Spellings of the same
457
+ * letter (`A`, ` A`, `a.`) combine by log-sum-exp, so a label's score is the mass of its listed
458
+ * spellings; spellings outside the list are unknown, bounded together by the mass the list leaves
459
+ * over. A label with no listed spelling is placed only when that leftover is smaller than the
460
+ * leading observed label's mass, so a wholly unlisted label can never be the chosen value;
461
+ * otherwise the call is declined rather than guessed. Unlisted labels then share the leftover
462
+ * equally, so together they never exceed it and each stays below the leader; when the list leaves
463
+ * nothing over they sit a fixed margin below its smallest entry. Every placed score is still an estimate: an observed label's own unlisted
464
+ * spellings can add up to the leftover to it, so two observed labels closer than that may be
465
+ * ordered wrongly. No label at all means the model was not answering with a letter, and the call
466
+ * is declined. A malformed entry is a bad response, not a decline. Messages never quote tokens,
467
+ * which are upstream text.
468
+ *
469
+ * `unsupported(reason, structural)`: `structural` marks a response with no log-probability field
470
+ * at all, which the caller may remember for the model; a missing or empty alternatives list is
471
+ * this call's.
472
+ */
473
+ function scoreLabels(
474
+ logprobs: OpenAIChatLogprobs | null | undefined,
475
+ count: number,
476
+ unsupported: (reason: string, structural: boolean) => Error
477
+ ): number[] {
478
+ if (logprobs == null || logprobs.content === undefined) throw unsupported('returned no log-probabilities', true);
479
+ const first = Array.isArray(logprobs.content) ? logprobs.content[0] : undefined;
480
+ if (!first) throw unsupported('returned no scored content token', false);
481
+ if (!Array.isArray(first.top_logprobs) || first.top_logprobs.length === 0)
482
+ throw unsupported('returned no alternatives for the scored token', false);
483
+ const seen = new Map<string, number>();
484
+ for (const alternative of [first, ...first.top_logprobs]) {
485
+ const token = alternative?.token;
486
+ const logprob = alternative?.logprob;
487
+ if (typeof token !== 'string' || typeof logprob !== 'number' || !Number.isFinite(logprob))
488
+ throw new OpenAIBackendError('OpenAI /chat/completions returned a malformed log-probability entry');
489
+ if (!seen.has(token)) seen.set(token, logprob);
293
490
  }
491
+ const scores: Array<number | undefined> = new Array(count).fill(undefined);
492
+ let smallestListed = Infinity;
493
+ let listedMass = 0;
494
+ for (const [token, logprob] of seen) {
495
+ smallestListed = Math.min(smallestListed, logprob);
496
+ listedMass += Math.exp(logprob);
497
+ const index = labelIndex(token);
498
+ if (index === undefined || index >= count) continue;
499
+ const current = scores[index];
500
+ scores[index] = current === undefined ? logprob : logAddExp(current, logprob);
501
+ }
502
+ const observed = scores.filter((score): score is number => score !== undefined);
503
+ if (observed.length === 0) throw unsupported('did not answer with one of the choice labels', false);
504
+ if (observed.length === count) return observed;
505
+ const leftover = Math.max(0, 1 - listedMass);
506
+ if (leftover >= Math.exp(Math.max(...observed)))
507
+ throw unsupported('left too much probability outside its listed alternatives to know the leading choice', false);
508
+ // The leftover is every token outside the list, so it bounds the unlisted labels together; each
509
+ // takes an equal share of it, because one token's worth understates a label whose spellings are
510
+ // all unlisted. A share of nothing sits a fixed margin below the list.
511
+ const share = leftover / (count - observed.length);
512
+ const floor = share > 0 ? Math.log(share) : smallestListed - UNLISTED_MARGIN;
513
+ return scores.map((score) => score ?? floor);
514
+ }
515
+
516
+ function labelIndex(token: string): number | undefined {
517
+ const letter = token
518
+ .trim()
519
+ .replace(/^[("'[]+|[.)\]:"',]+$/g, '')
520
+ .toUpperCase();
521
+ if (letter.length !== 1) return undefined;
522
+ const index = CHOICE_LABELS.indexOf(letter);
523
+ return index === -1 ? undefined : index;
524
+ }
525
+
526
+ function logAddExp(a: number, b: number): number {
527
+ const max = Math.max(a, b);
528
+ return max + Math.log(Math.exp(a - max) + Math.exp(b - max));
294
529
  }
295
530
 
296
531
  /**
@@ -588,10 +823,22 @@ interface OpenAIChatResponse {
588
823
  choices?: Array<{
589
824
  message?: { role: string; content?: string | null; tool_calls?: OpenAIToolCall[] };
590
825
  finish_reason?: string | null;
826
+ logprobs?: OpenAIChatLogprobs | null;
591
827
  }>;
592
828
  usage?: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number };
593
829
  }
594
830
 
831
+ interface OpenAIChatLogprobs {
832
+ content?: OpenAITokenLogprob[] | null;
833
+ }
834
+
835
+ /** One scored token position; fields are `unknown` because a compatible endpoint's shape is not trusted. */
836
+ interface OpenAITokenLogprob {
837
+ token?: unknown;
838
+ logprob?: unknown;
839
+ top_logprobs?: Array<{ token?: unknown; logprob?: unknown }> | null;
840
+ }
841
+
595
842
  interface OpenAIToolCall {
596
843
  id?: string;
597
844
  type?: 'function';
@@ -32,6 +32,7 @@ const { Resources } = require('../resources/Resources.ts');
32
32
  const {
33
33
  Application,
34
34
  prepareApplication,
35
+ componentPreparationBudgetMs,
35
36
  ASIDE_STAGING_DIR,
36
37
  DEPLOY_STAGING_DIR,
37
38
  dropComponentDirectory,
@@ -451,6 +452,8 @@ async function packageComponent(req) {
451
452
  *
452
453
  * A no-op on the main thread, and the operations API deploys there — so operator deploys are unvalidated
453
454
  * (#2315 step 2). What this guarantees is ORDER: where validation runs, a rejected candidate never goes live.
455
+ *
456
+ * Also a no-op on a worker that `freeze-after-load` froze after its boot load; see components/DESIGN.md.
454
457
  */
455
458
  // `componentLoader.setErrorReporter` is ONE process-global callback, so two components validating
456
459
  // concurrently on the same worker cross-attribute their failures: B installs its reporter while A is
@@ -460,6 +463,12 @@ async function packageComponent(req) {
460
463
  let validationChain = Promise.resolve();
461
464
 
462
465
  async function validateComponentLoads(candidateDirPath, emit) {
466
+ if (!isMainThread && require('../security/jsLoader.ts').laterLoadsMeetFrozenIntrinsics()) {
467
+ log.trace(
468
+ `Not load-validating ${path.basename(candidateDirPath)}: this worker's intrinsics are frozen after its boot load, so the load would not match the one a restarted worker performs`
469
+ );
470
+ return;
471
+ }
463
472
  const run = validationChain.then(
464
473
  () => validateComponentLoadsExclusive(candidateDirPath, emit),
465
474
  () => validateComponentLoadsExclusive(candidateDirPath, emit)
@@ -573,6 +582,32 @@ function logRestartOutcome(restart, what) {
573
582
  `The restart after ${what} was still running after ${RESTART_WAIT_CEILING_MS}ms; worker threads may still be running the previous code`
574
583
  );
575
584
  }
585
+
586
+ // A peer's validation load and swap, which have no allowance of their own to sum.
587
+ const PEER_DEPLOY_VALIDATION_MARGIN_MS = 10 * 60 * 1000;
588
+
589
+ /**
590
+ * How long the origin waits for each peer to answer a replicated deploy: every wait and command the peer is
591
+ * allowed for this request, each at its full allowance. That includes the preparation lock's wait: a peer already
592
+ * preparing the same component for another deploy holds this one there for a preparation budget before the lock
593
+ * re-checks the holder. The lock keeps waiting while that holder is alive, so a peer queued behind a longer
594
+ * preparation, or behind several, can take longer; so can one validating plugins whose configured timeouts outlast
595
+ * the margin. Such a peer is reported as not answering, which says nothing of its outcome.
596
+ */
597
+ function peerDeployAnswerTimeoutMs(req) {
598
+ const { RESTART_WAIT_CEILING_MS } = require('./awaitRestart.ts');
599
+ const payloadWaitMs = coerceTimeoutMs(req.deployment_timeout, DEFAULT_AWAIT_ROW_TIMEOUT_MS);
600
+ const installTimeoutMs = coerceTimeoutMs(req.install_timeout, undefined);
601
+ return Math.min(
602
+ payloadWaitMs * (req.credentials?.length ? 2 : 1) +
603
+ // the lock's wait on another deploy's preparation, then this deploy's own
604
+ 2 * componentPreparationBudgetMs(installTimeoutMs) +
605
+ PEER_DEPLOY_VALIDATION_MARGIN_MS +
606
+ (req.restart === true ? RESTART_WAIT_CEILING_MS : 0),
607
+ hdbTerms.MAX_SET_TIMEOUT_MS
608
+ );
609
+ }
610
+
576
611
  /**
577
612
  * Can deploy a component in multiple ways. If a 'package' is provided all it will do is write that package to
578
613
  * harperdb-config, when HDB is restarted the package will be installed in hdb/nodeModules. If a base64 encoded string is passed it
@@ -890,7 +925,10 @@ async function deployComponent(req) {
890
925
  // finish()'s single write; live SSE 'peer' events still fire below.
891
926
  recorder?.seal();
892
927
  emit('phase', { phase: 'replicate', status: 'start' });
893
- let response = await server.replication.replicateOperation(req, { onPeerResult });
928
+ let response = await server.replication.replicateOperation(req, {
929
+ onPeerResult,
930
+ timeoutMs: peerDeployAnswerTimeoutMs(req),
931
+ });
894
932
  emit('phase', { phase: 'replicate', status: 'done' });
895
933
  if (recorder && response?.replicated) {
896
934
  // Fallback path for replicators that don't honor onPeerResult: re-record the
@@ -1589,6 +1627,7 @@ exports.dropCustomFunctionProject = dropCustomFunctionProject;
1589
1627
  exports.packageComponent = packageComponent;
1590
1628
  exports.deployComponent = deployComponent;
1591
1629
  exports.unconfirmedStagingPeers = unconfirmedStagingPeers;
1630
+ exports.peerDeployAnswerTimeoutMs = peerDeployAnswerTimeoutMs;
1592
1631
  exports.getComponents = getComponents;
1593
1632
  exports.getComponentFile = getComponentFile;
1594
1633
  exports.setComponentFile = setComponentFile;
@@ -32,6 +32,9 @@ import * as env from '../utility/environment/environmentManager.ts';
32
32
  import { prepareRuntimeEnvConfig, hasPersistedEnvConfigState, discardConfigState } from './harperConfigEnvVars.ts';
33
33
  import { warnComponentEnvConfigVars, resolveConfiguredPath } from './componentEnvPrepass.ts';
34
34
  import { isStartableThreadHeapMemory } from '../server/threads/threadHeapMemory.ts';
35
+ import { fsyncTolerantSync, isUnsupportedSyncError } from '../utility/fsync.ts';
36
+
37
+ export { isUnsupportedSyncError } from '../utility/fsync.ts';
35
38
 
36
39
  const { DATABASES_PARAM_CONFIG, CONFIG_PARAMS, CONFIG_PARAM_MAP } = hdbTerms;
37
40
  const UNINIT_GET_CONFIG_ERR = 'Unable to get config value because config is uninitialized';
@@ -133,25 +136,6 @@ type AtomicWriteOptions = RenameRetryOptions & {
133
136
  durable?: boolean;
134
137
  };
135
138
 
136
- // Codes that mean "this platform or filesystem will not fsync this handle", as opposed to "the write did
137
- // not reach storage". Windows raises EPERM fsyncing perfectly healthy files and cannot open a directory
138
- // for fsync at all; network and overlay mounts return EINVAL or ENOTSUP. None of those say anything about
139
- // durability, and treating them as failures would fail every durable write on those platforms. EIO and
140
- // ENOSPC are not in the set on purpose.
141
- const UNSUPPORTED_SYNC_CODES = new Set(['EPERM', 'EINVAL', 'ENOTSUP', 'EOPNOTSUPP', 'EBADF', 'EISDIR']);
142
-
143
- export function isUnsupportedSyncError(error: unknown): boolean {
144
- return UNSUPPORTED_SYNC_CODES.has((error as NodeJS.ErrnoException)?.code ?? '');
145
- }
146
-
147
- function fsyncTolerantSync(fd: number) {
148
- try {
149
- fs.fsyncSync(fd);
150
- } catch (error) {
151
- if (!isUnsupportedSyncError(error)) throw error;
152
- }
153
- }
154
-
155
139
  // `flags` matters on Windows, which only flushes a handle opened for writing; a directory cannot be opened
156
140
  // for writing anywhere, and Windows cannot open one for fsync at all.
157
141
  function syncPathSync(targetPath: string, flags: 'r' | 'r+') {
@@ -40,6 +40,20 @@ System tables replicate by default. To opt out, add the name to `NON_REPLICATING
40
40
 
41
41
  If the table needs `audit: true`, set it both in the schema (for fresh installs) **and** on the `CreateTableObject` instance in the directive (for upgrades) — otherwise the two paths diverge.
42
42
 
43
+ **A replicated system table whose owner declares more than a name list** — a table-level `expiration`, an index — needs that declaration on every node, every boot. `hdb_oidc_token_use` is the case: `security/authn/oidc/tokenUseTable.ts` holds its one definition, and `bin/run.ts initialize()` applies it after the upgrade step and before worker threads start (skipped in read-only mode; a failure is logged and retried on the next start), as do the 5.3.0 directive and the exchange path. Each replay row carries its own expiry as record metadata, which replication applies on the receiving node; the table's stored `expiration` is what arms the cleanup scan that removes those rows once a node loads the table. Metadata rather than an `@expiresAt` attribute, because the attribute needs an index and a sweep of its own on every node, while reads and the cleanup scan already honor the metadata. `systemSchema.json` keeps only its primary key; the declaration completes the table on the first boot after install. Every other route to a copy leaves it partial, which is why the declaration cannot live only on the exchange path or in the directive:
44
+
45
+ - A node that has not upgraded yet gets any system table it lacks from a peer's replication handshake (`DB_SCHEMA`), created from a snapshot that carries each attribute's name, type and primary-key bit — never `indexed`, `expiresAt` or the table's `expiration` — and schema-defined like the peer's copy. From then on the handshake keeps that local definition and logs `Schema for '<db>.<table>' is defined locally, but attribute '<name>: <type>' from '<node>' does not match local attribute which does not exist` for every attribute a peer has and it lacks, so no peer repairs it; the directive that runs when that node upgrades finds the table already there.
46
+ - A node that never exercises the owner's code path (a passive cluster member for the OIDC exchange) never declares the rest, and the rows replicated to it are never removed.
47
+ - An install already on a pre-release of the directive's version never runs the directive again (the `compareVersions` caveat above): from `5.3.0-beta.2`, `getVersionsForUpgrade` selects nothing for `5.3.0`, `5.3.0-beta.3` or `5.3.1`.
48
+
49
+ A local `table()` declaration is authoritative, so repeating it repairs any of those shapes in place, keeping every row and its expiry, and is a no-op once the shape matches (measured 2026-09-25: under 1 ms; repairing a copy holding 100,000 replay rows, under 10 ms on either engine, since no row is rewritten and no index is built). Limits that come from the storage layer rather than this table:
50
+
51
+ - A thread that only loads the table scans daily (the load path keeps the default interval; only a declaring thread uses a quarter of the `expiration`), so a spent row can stay on disk up to a day past its expiry. Reads skip it meanwhile.
52
+ - Under `threads: 0` the main thread loads and declares the table before `startHTTPThreads` makes it worker 0. `scheduleCleanup` records an interval only once its thread has a worker index, so the first expiring write or declaration after that arms the scan; a single-threaded node that writes nothing after a restart does not scan until it does.
53
+ - An attribute a declaration drops keeps its index store on disk: `table()` looks that store up by table name rather than attribute name, so it never drops it. A node that served exchanges on 5.3.0-beta.2 keeps its old `expiresAt` index; after a restart nothing opens it.
54
+
55
+ The certificate verification tables (`hdb_certificate_cache`, `hdb_crl_cache`, `hdb_revoked_certificates`) follow the same pattern through `security/certificateVerification/verificationTables.ts`, applied in `initialize()` right after the OIDC table and by the verification path's getters. `ensureCertificateVerificationTables` also waits for the revoked table's index backfill and reads its outcome back from the catalog, since a failed backfill still settles. What their rows hold is in security/DESIGN.md.
56
+
43
57
  ## RocksDB backup/restore: the restore lock + marker protocol (`dataLayer/restoreMarker.ts`, `dataLayer/rocksdbBackup.ts`)
44
58
 
45
59
  The `restore_backup` operation restores a user database on a live server by closing it across all
@@ -109,11 +123,21 @@ Three non-obvious mechanics keep that safe:
109
123
  exited job worker leaves no residual handle to be mistaken for a live holder.
110
124
  - **`dropDatabase` and `restore_backup` serialize on the same lock, not a check-then-act probe.**
111
125
  A drop's `destroy()` interleaving with a restore's purge-and-copy on the same directory would gut
112
- a "successful" restore (or vice versa). `dropDatabase` therefore _acquires_ the restore lock
113
- (`acquireRestoreLock`, marker-less) for each RocksDB root store and holds it across the whole drop,
114
- releasing in a `finally`; a restore in progress makes the acquire fail with 409, and a leftover
115
- incomplete-restore marker (lock free, detected via `restoreMarkerPresent`, which — unlike
116
- `checkRestoreState` — is safe while this thread holds the lock) is refused rather than dropped over.
126
+ a "successful" restore (or vice versa). `dropDatabase` takes the restore lock for every RocksDB or
127
+ LMDB root and publishes a positional `.dropping` marker beside each root before deleting any of
128
+ them. A restore in progress makes the acquire fail with 409; `beginRestore` likewise refuses a
129
+ surviving drop marker. Each marker records the root store's blob identity and exact pinned blob
130
+ paths, so a retry removes what an already-marked handle owned even if `storage.blobPaths` changes.
131
+ An integrity digest makes damaged identity content fail closed instead of redirecting deletion.
132
+ Markers are removed only after every root and blob path has been removed.
133
+ Startup scans and cold opens reject the marked root and the rest of its logical database graph, so
134
+ a crash while publishing or canceling several markers cannot expose a partial database. Retrying
135
+ `drop_database` re-enumerates that graph and completes marker publication and deletion even when
136
+ the in-memory catalog is gone. A configured `databases.<name>.path` is the exclusive root of that
137
+ logical graph, not a container for independent databases: every physical database directly beneath
138
+ it is an alias-owned member, and dropping any alias drops the graph. A strict worker-close failure
139
+ similarly keeps that worker's physical roots fenced and remembered; a same-name drop retry
140
+ re-attempts those closes before marker publication or deletion.
117
141
  `database()`'s on-demand open still uses the read-only `throwIfBlockedByRestore` (a
118
142
  `create_table`/`create_schema` must not resurrect a half-purged directory as a fresh empty DB), but
119
143
  the destructive drop path now uses the exclusive lock so the race is closed, not merely narrowed.
@@ -1,6 +1,19 @@
1
1
  import searchValidator from '../../validation/searchValidator.ts';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { threadId } from 'node:worker_threads';
2
4
  import { handleHDBError, ClientError, hdbErrors } from '../../utility/errors/hdbError.ts';
3
- import { table, getDatabases, database, dropDatabase, type Table } from '../../resources/databases.ts';
5
+ import {
6
+ table,
7
+ getDatabases,
8
+ database,
9
+ databaseDropPreparationTargets,
10
+ dropDatabase,
11
+ type Table,
12
+ } from '../../resources/databases.ts';
13
+ import {
14
+ claimDatabaseDropPreparations,
15
+ releaseDatabaseDropPreparations,
16
+ } from '../../resources/databaseDropPreparation.ts';
4
17
  import insertUpdateValidate from './bridgeUtility/insertUpdateValidate.js';
5
18
  import SearchObject from '../SearchObject.ts';
6
19
  import {
@@ -186,8 +199,31 @@ export class ResourceBridge extends BridgeMethods {
186
199
  }
187
200
 
188
201
  async dropSchema(dropSchemaObj) {
189
- await dropDatabase(dropSchemaObj.schema);
190
- signalling.signalSchemaChange(new SchemaEventMsg(process.pid, OPERATIONS_ENUM.DROP_SCHEMA, dropSchemaObj.schema));
202
+ const preparationId = randomUUID();
203
+ const { rootPaths } = databaseDropPreparationTargets(dropSchemaObj.schema);
204
+ const completion = () => {
205
+ const message: any = new SchemaEventMsg(process.pid, OPERATIONS_ENUM.DROP_SCHEMA, dropSchemaObj.schema);
206
+ message.dropPreparationId = preparationId;
207
+ message.dropPreparationOwnerThreadId = threadId;
208
+ message.dropPreparationRootPaths = rootPaths;
209
+ return message;
210
+ };
211
+ const preparation: any = completion();
212
+ preparation.prepareDrop = true;
213
+ claimDatabaseDropPreparations(rootPaths, preparationId, threadId, dropSchemaObj.schema);
214
+ try {
215
+ await signalling.signalSchemaChangeToPeers(preparation);
216
+ await dropDatabase(dropSchemaObj.schema, rootPaths);
217
+ } finally {
218
+ // The first round reaches current peers before local release; the second catches a worker whose
219
+ // startup snapshot inherited the fence. Cleanup-proven job workers are already exiting.
220
+ await signalling.signalSchemaChange(completion(), {
221
+ peersFirst: true,
222
+ includeJobWorkers: 'active',
223
+ peerRounds: 2,
224
+ });
225
+ releaseDatabaseDropPreparations(rootPaths, preparationId);
226
+ }
191
227
  }
192
228
 
193
229
  async updateRecords(updateObj) {