@harperfast/harper 5.2.0 → 5.2.2

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 (308) hide show
  1. package/bin/copyDb.ts +40 -11
  2. package/bin/harper.ts +20 -51
  3. package/bin/help.ts +216 -0
  4. package/components/Application.ts +236 -46
  5. package/components/ApplicationScope.ts +26 -0
  6. package/components/EntryHandler.ts +410 -105
  7. package/components/RuntimeModuleTracker.ts +189 -0
  8. package/components/Scope.ts +68 -24
  9. package/components/componentLoader.ts +70 -16
  10. package/components/deployLifecycle.ts +119 -33
  11. package/components/deploymentRecorder.ts +102 -11
  12. package/components/mcp/tools/operations.ts +6 -0
  13. package/components/operations.js +8 -6
  14. package/config/configUtils.ts +78 -20
  15. package/config/harperConfigEnvVars.ts +114 -17
  16. package/config-root.schema.json +10 -0
  17. package/dataLayer/harperBridge/ResourceBridge.ts +26 -5
  18. package/dataLayer/harperBridge/lmdbBridge/lmdbUtility/initializePaths.js +4 -4
  19. package/dataLayer/hdbInfoController.ts +8 -0
  20. package/dataLayer/schemaDescribe.ts +2 -1
  21. package/dist/bin/copyDb.js +33 -9
  22. package/dist/bin/copyDb.js.map +1 -1
  23. package/dist/bin/harper.d.ts +6 -0
  24. package/dist/bin/harper.js +18 -50
  25. package/dist/bin/harper.js.map +1 -1
  26. package/dist/bin/help.d.ts +8 -0
  27. package/dist/bin/help.js +192 -0
  28. package/dist/bin/help.js.map +1 -0
  29. package/dist/components/Application.d.ts +16 -1
  30. package/dist/components/Application.js +210 -38
  31. package/dist/components/Application.js.map +1 -1
  32. package/dist/components/ApplicationScope.d.ts +7 -0
  33. package/dist/components/ApplicationScope.js +22 -0
  34. package/dist/components/ApplicationScope.js.map +1 -1
  35. package/dist/components/EntryHandler.d.ts +4 -4
  36. package/dist/components/EntryHandler.js +386 -95
  37. package/dist/components/EntryHandler.js.map +1 -1
  38. package/dist/components/RuntimeModuleTracker.d.ts +11 -0
  39. package/dist/components/RuntimeModuleTracker.js +189 -0
  40. package/dist/components/RuntimeModuleTracker.js.map +1 -0
  41. package/dist/components/Scope.d.ts +1 -0
  42. package/dist/components/Scope.js +69 -22
  43. package/dist/components/Scope.js.map +1 -1
  44. package/dist/components/componentLoader.js +69 -10
  45. package/dist/components/componentLoader.js.map +1 -1
  46. package/dist/components/deployLifecycle.d.ts +6 -2
  47. package/dist/components/deployLifecycle.js +109 -31
  48. package/dist/components/deployLifecycle.js.map +1 -1
  49. package/dist/components/deploymentRecorder.d.ts +6 -0
  50. package/dist/components/deploymentRecorder.js +94 -12
  51. package/dist/components/deploymentRecorder.js.map +1 -1
  52. package/dist/components/mcp/tools/operations.js +6 -0
  53. package/dist/components/mcp/tools/operations.js.map +1 -1
  54. package/dist/components/operations.js +8 -6
  55. package/dist/components/operations.js.map +1 -1
  56. package/dist/config/configUtils.d.ts +6 -0
  57. package/dist/config/configUtils.js +78 -18
  58. package/dist/config/configUtils.js.map +1 -1
  59. package/dist/config/harperConfigEnvVars.js +108 -16
  60. package/dist/config/harperConfigEnvVars.js.map +1 -1
  61. package/dist/dataLayer/harperBridge/ResourceBridge.js +12 -5
  62. package/dist/dataLayer/harperBridge/ResourceBridge.js.map +1 -1
  63. package/dist/dataLayer/harperBridge/lmdbBridge/lmdbUtility/initializePaths.js +4 -4
  64. package/dist/dataLayer/harperBridge/lmdbBridge/lmdbUtility/initializePaths.js.map +1 -1
  65. package/dist/dataLayer/hdbInfoController.js +4 -0
  66. package/dist/dataLayer/hdbInfoController.js.map +1 -1
  67. package/dist/dataLayer/schemaDescribe.js +2 -1
  68. package/dist/dataLayer/schemaDescribe.js.map +1 -1
  69. package/dist/resources/DatabaseTransaction.d.ts +57 -0
  70. package/dist/resources/DatabaseTransaction.js +284 -104
  71. package/dist/resources/DatabaseTransaction.js.map +1 -1
  72. package/dist/resources/ResourceInterface.d.ts +8 -2
  73. package/dist/resources/ResourceInterface.js.map +1 -1
  74. package/dist/resources/Resources.js +22 -4
  75. package/dist/resources/Resources.js.map +1 -1
  76. package/dist/resources/Table.d.ts +8 -6
  77. package/dist/resources/Table.js +158 -31
  78. package/dist/resources/Table.js.map +1 -1
  79. package/dist/resources/analytics/write.js +6 -6
  80. package/dist/resources/analytics/write.js.map +1 -1
  81. package/dist/resources/auditStore.js +27 -1
  82. package/dist/resources/auditStore.js.map +1 -1
  83. package/dist/resources/blob.d.ts +0 -1
  84. package/dist/resources/blob.js +15 -7
  85. package/dist/resources/blob.js.map +1 -1
  86. package/dist/resources/databases.d.ts +22 -5
  87. package/dist/resources/databases.js +118 -9
  88. package/dist/resources/databases.js.map +1 -1
  89. package/dist/resources/indexes/HierarchicalNavigableSmallWorld.d.ts +15 -2
  90. package/dist/resources/indexes/HierarchicalNavigableSmallWorld.js +128 -39
  91. package/dist/resources/indexes/HierarchicalNavigableSmallWorld.js.map +1 -1
  92. package/dist/resources/jsResource.d.ts +4 -26
  93. package/dist/resources/jsResource.js +5 -59
  94. package/dist/resources/jsResource.js.map +1 -1
  95. package/dist/resources/models/Models.d.ts +11 -1
  96. package/dist/resources/models/Models.js +10 -1
  97. package/dist/resources/models/Models.js.map +1 -1
  98. package/dist/resources/models/backendRegistry.d.ts +9 -0
  99. package/dist/resources/models/backendRegistry.js +10 -0
  100. package/dist/resources/models/backendRegistry.js.map +1 -1
  101. package/dist/resources/models/openaiStream.d.ts +16 -1
  102. package/dist/resources/models/openaiStream.js +113 -21
  103. package/dist/resources/models/openaiStream.js.map +1 -1
  104. package/dist/resources/models/v1/chatCompletions.d.ts +54 -0
  105. package/dist/resources/models/v1/chatCompletions.js +115 -0
  106. package/dist/resources/models/v1/chatCompletions.js.map +1 -0
  107. package/dist/resources/models/v1/embeddings.d.ts +11 -0
  108. package/dist/resources/models/v1/embeddings.js +71 -0
  109. package/dist/resources/models/v1/embeddings.js.map +1 -0
  110. package/dist/resources/models/v1/errors.d.ts +54 -0
  111. package/dist/resources/models/v1/errors.js +130 -0
  112. package/dist/resources/models/v1/errors.js.map +1 -0
  113. package/dist/resources/models/v1/index.d.ts +36 -0
  114. package/dist/resources/models/v1/index.js +75 -0
  115. package/dist/resources/models/v1/index.js.map +1 -0
  116. package/dist/resources/models/v1/models.d.ts +26 -0
  117. package/dist/resources/models/v1/models.js +44 -0
  118. package/dist/resources/models/v1/models.js.map +1 -0
  119. package/dist/resources/models/v1/translation.d.ts +133 -0
  120. package/dist/resources/models/v1/translation.js +298 -0
  121. package/dist/resources/models/v1/translation.js.map +1 -0
  122. package/dist/resources/roles.d.ts +1 -1
  123. package/dist/resources/roles.js +54 -7
  124. package/dist/resources/roles.js.map +1 -1
  125. package/dist/resources/search.d.ts +1 -1
  126. package/dist/resources/search.js +7 -3
  127. package/dist/resources/search.js.map +1 -1
  128. package/dist/security/jsLoader.js +84 -33
  129. package/dist/security/jsLoader.js.map +1 -1
  130. package/dist/security/role.js +4 -0
  131. package/dist/security/role.js.map +1 -1
  132. package/dist/security/superUserGuard.d.ts +7 -0
  133. package/dist/security/superUserGuard.js +23 -0
  134. package/dist/security/superUserGuard.js.map +1 -0
  135. package/dist/security/tokenAuthentication.d.ts +0 -1
  136. package/dist/security/tokenAuthentication.js +6 -2
  137. package/dist/security/tokenAuthentication.js.map +1 -1
  138. package/dist/security/user.d.ts +6 -1
  139. package/dist/security/user.js +23 -1
  140. package/dist/security/user.js.map +1 -1
  141. package/dist/server/http.d.ts +23 -2
  142. package/dist/server/http.js +99 -12
  143. package/dist/server/http.js.map +1 -1
  144. package/dist/server/operationsServer.d.ts +0 -2
  145. package/dist/server/operationsServer.js.map +1 -1
  146. package/dist/server/serverHelpers/multipartParser.js +9 -0
  147. package/dist/server/serverHelpers/multipartParser.js.map +1 -1
  148. package/dist/server/serverHelpers/operationAuthorizationState.d.ts +2 -0
  149. package/dist/server/serverHelpers/operationAuthorizationState.js +13 -0
  150. package/dist/server/serverHelpers/operationAuthorizationState.js.map +1 -0
  151. package/dist/server/serverHelpers/registeredOperations.d.ts +3 -2
  152. package/dist/server/serverHelpers/registeredOperations.js +14 -13
  153. package/dist/server/serverHelpers/registeredOperations.js.map +1 -1
  154. package/dist/server/serverHelpers/serverHandlers.js +19 -3
  155. package/dist/server/serverHelpers/serverHandlers.js.map +1 -1
  156. package/dist/server/serverHelpers/serverUtilities.d.ts +1 -1
  157. package/dist/server/serverHelpers/serverUtilities.js +10 -7
  158. package/dist/server/serverHelpers/serverUtilities.js.map +1 -1
  159. package/dist/server/static.js +57 -18
  160. package/dist/server/static.js.map +1 -1
  161. package/dist/server/storageReclamation.d.ts +17 -0
  162. package/dist/server/storageReclamation.js +90 -6
  163. package/dist/server/storageReclamation.js.map +1 -1
  164. package/dist/server/threads/manageThreads.js +8 -0
  165. package/dist/server/threads/manageThreads.js.map +1 -1
  166. package/dist/server/threads/socketRouter.js +20 -0
  167. package/dist/server/threads/socketRouter.js.map +1 -1
  168. package/dist/server/threads/threadServer.js +12 -0
  169. package/dist/server/threads/threadServer.js.map +1 -1
  170. package/dist/sqlEngine/diff/differential.js +7 -3
  171. package/dist/sqlEngine/diff/differential.js.map +1 -1
  172. package/dist/sqlEngine/parser/normalizer.js +37 -9
  173. package/dist/sqlEngine/parser/normalizer.js.map +1 -1
  174. package/dist/sqlTranslator/index.js +6 -1
  175. package/dist/sqlTranslator/index.js.map +1 -1
  176. package/dist/upgrade/upgradePrompt.d.ts +2 -2
  177. package/dist/upgrade/upgradePrompt.js +22 -3
  178. package/dist/upgrade/upgradePrompt.js.map +1 -1
  179. package/dist/utility/environment/environmentManager.d.ts +11 -0
  180. package/dist/utility/environment/environmentManager.js +154 -0
  181. package/dist/utility/environment/environmentManager.js.map +1 -1
  182. package/dist/utility/errors/commonErrors.d.ts +1 -0
  183. package/dist/utility/errors/commonErrors.js +1 -0
  184. package/dist/utility/errors/commonErrors.js.map +1 -1
  185. package/dist/utility/hdbTerms.d.ts +2 -0
  186. package/dist/utility/hdbTerms.js +2 -0
  187. package/dist/utility/hdbTerms.js.map +1 -1
  188. package/dist/utility/install/installer.js +32 -0
  189. package/dist/utility/install/installer.js.map +1 -1
  190. package/dist/utility/lmdb/environmentUtility.js +2 -1
  191. package/dist/utility/lmdb/environmentUtility.js.map +1 -1
  192. package/dist/utility/logging/harper_logger.d.ts +7 -0
  193. package/dist/utility/logging/harper_logger.js +57 -24
  194. package/dist/utility/logging/harper_logger.js.map +1 -1
  195. package/npm-shrinkwrap.json +208 -198
  196. package/package.json +10 -8
  197. package/resources/DESIGN.md +1 -0
  198. package/resources/DatabaseTransaction.ts +313 -102
  199. package/resources/ResourceInterface.ts +8 -2
  200. package/resources/Resources.ts +22 -4
  201. package/resources/Table.ts +398 -259
  202. package/resources/analytics/write.ts +22 -20
  203. package/resources/auditStore.ts +32 -1
  204. package/resources/blob.ts +15 -8
  205. package/resources/databases.ts +123 -12
  206. package/resources/indexes/HierarchicalNavigableSmallWorld.ts +119 -45
  207. package/resources/jsResource.ts +5 -62
  208. package/resources/models/Models.ts +14 -1
  209. package/resources/models/backendRegistry.ts +10 -0
  210. package/resources/models/openaiStream.ts +131 -19
  211. package/resources/models/v1/chatCompletions.ts +128 -0
  212. package/resources/models/v1/embeddings.ts +70 -0
  213. package/resources/models/v1/errors.ts +141 -0
  214. package/resources/models/v1/index.ts +72 -0
  215. package/resources/models/v1/models.ts +53 -0
  216. package/resources/models/v1/translation.ts +362 -0
  217. package/resources/roles.ts +67 -7
  218. package/resources/search.ts +8 -3
  219. package/security/jsLoader.ts +84 -30
  220. package/security/role.ts +7 -0
  221. package/security/superUserGuard.ts +20 -0
  222. package/security/tokenAuthentication.ts +6 -3
  223. package/security/user.ts +26 -1
  224. package/server/DESIGN.md +45 -34
  225. package/server/http.ts +100 -13
  226. package/server/operationsServer.ts +0 -2
  227. package/server/serverHelpers/multipartParser.ts +9 -0
  228. package/server/serverHelpers/operationAuthorizationState.ts +11 -0
  229. package/server/serverHelpers/registeredOperations.ts +19 -15
  230. package/server/serverHelpers/serverHandlers.js +20 -3
  231. package/server/serverHelpers/serverUtilities.ts +10 -7
  232. package/server/static.ts +75 -20
  233. package/server/storageReclamation.ts +104 -8
  234. package/server/threads/manageThreads.js +8 -0
  235. package/server/threads/socketRouter.ts +20 -0
  236. package/server/threads/threadServer.js +11 -0
  237. package/sqlTranslator/index.ts +6 -1
  238. package/static/defaultConfig.yaml +2 -0
  239. package/studio/web/assets/{Chat-DoVWScmq.js → Chat-BMUjPklt.js} +5 -5
  240. package/studio/web/assets/{Chat-DoVWScmq.js.map → Chat-BMUjPklt.js.map} +1 -1
  241. package/studio/web/assets/{FloatingChat-UZ2NsUOZ.js → FloatingChat-Cbghuxe5.js} +5 -5
  242. package/studio/web/assets/{FloatingChat-UZ2NsUOZ.js.map → FloatingChat-Cbghuxe5.js.map} +1 -1
  243. package/studio/web/assets/{apiToken-BUI_04o7.js → apiToken-CkgTQAqc.js} +2 -2
  244. package/studio/web/assets/{apiToken-BUI_04o7.js.map → apiToken-CkgTQAqc.js.map} +1 -1
  245. package/studio/web/assets/{applications-D03NA7wW.js → applications-WvF4NOTN.js} +13 -13
  246. package/studio/web/assets/applications-WvF4NOTN.js.map +1 -0
  247. package/studio/web/assets/chevron-right-anDJCGlp.js +2 -0
  248. package/studio/web/assets/chevron-right-anDJCGlp.js.map +1 -0
  249. package/studio/web/assets/index-CDeqVx71.css +1 -0
  250. package/studio/web/assets/index-Sevt3MVx.js +819 -0
  251. package/studio/web/assets/index-Sevt3MVx.js.map +1 -0
  252. package/studio/web/assets/index.lazy-Dg5YYPGg.js +14 -0
  253. package/studio/web/assets/{index.lazy-Dx3MpyDC.js.map → index.lazy-Dg5YYPGg.js.map} +1 -1
  254. package/studio/web/assets/{jsonMode-Cjj7dtlr.js → jsonMode-CBSEleE7.js} +2 -2
  255. package/studio/web/assets/{jsonMode-Cjj7dtlr.js.map → jsonMode-CBSEleE7.js.map} +1 -1
  256. package/studio/web/assets/languageServices-gxedeeTo.js +2 -0
  257. package/studio/web/assets/languageServices-gxedeeTo.js.map +1 -0
  258. package/studio/web/assets/{notifications-0edoFTsb.js → notifications-Ap84YyQp.js} +2 -2
  259. package/studio/web/assets/{notifications-0edoFTsb.js.map → notifications-Ap84YyQp.js.map} +1 -1
  260. package/studio/web/assets/notifications-vehqyOLM.js +2 -0
  261. package/studio/web/assets/{notifications-CwKhipK7.js.map → notifications-vehqyOLM.js.map} +1 -1
  262. package/studio/web/assets/{pollUnlessForbidden-HHdQZW1N.js → pollUnlessForbidden-BhzefVRn.js} +2 -2
  263. package/studio/web/assets/pollUnlessForbidden-BhzefVRn.js.map +1 -0
  264. package/studio/web/assets/{profile-DUfEPQtx.js → profile-DzDpRLHY.js} +2 -2
  265. package/studio/web/assets/{profile-DUfEPQtx.js.map → profile-DzDpRLHY.js.map} +1 -1
  266. package/studio/web/assets/queryClient-CgtkoiCY.js +2 -0
  267. package/studio/web/assets/queryClient-CgtkoiCY.js.map +1 -0
  268. package/studio/web/assets/regions-DpJ8s7iN.js +2 -0
  269. package/studio/web/assets/regions-DpJ8s7iN.js.map +1 -0
  270. package/studio/web/assets/register-BDy01-q3.js +3 -0
  271. package/studio/web/assets/register-BDy01-q3.js.map +1 -0
  272. package/studio/web/assets/register-CQHRwNP4.js +3 -0
  273. package/studio/web/assets/register-CQHRwNP4.js.map +1 -0
  274. package/studio/web/assets/{setComponentFile-DMPo4UjC.js → setComponentFile-D0aPcI3W.js} +2 -2
  275. package/studio/web/assets/{setComponentFile-DMPo4UjC.js.map → setComponentFile-D0aPcI3W.js.map} +1 -1
  276. package/studio/web/assets/setup-I15FAF9H.js +3 -0
  277. package/studio/web/assets/{setup-B56Oz1_u.js.map → setup-I15FAF9H.js.map} +1 -1
  278. package/studio/web/assets/status-BF1eJshJ.js +62 -0
  279. package/studio/web/assets/status-BF1eJshJ.js.map +1 -0
  280. package/studio/web/assets/{swagger-ui-react-lQrBxfwM.js → swagger-ui-react-DwSPXXhl.js} +2 -2
  281. package/studio/web/assets/{swagger-ui-react-lQrBxfwM.js.map → swagger-ui-react-DwSPXXhl.js.map} +1 -1
  282. package/studio/web/assets/{textarea-DhLMZ3PA.js → table-DpOjNQHt.js} +2 -10
  283. package/studio/web/assets/table-DpOjNQHt.js.map +1 -0
  284. package/studio/web/assets/{tsMode-CrHCRjTK.js → tsMode-Bu51A-VG.js} +2 -2
  285. package/studio/web/assets/{tsMode-CrHCRjTK.js.map → tsMode-Bu51A-VG.js.map} +1 -1
  286. package/studio/web/assets/{useEntityRestURL-DoaBMEvU.js → useEntityRestURL-DrV1zMrN.js} +2 -2
  287. package/studio/web/assets/{useEntityRestURL-DoaBMEvU.js.map → useEntityRestURL-DrV1zMrN.js.map} +1 -1
  288. package/studio/web/index.html +6 -7
  289. package/upgrade/upgradePrompt.ts +22 -3
  290. package/utility/environment/environmentManager.ts +152 -0
  291. package/utility/errors/commonErrors.ts +2 -0
  292. package/utility/hdbTerms.ts +2 -0
  293. package/utility/install/installer.ts +37 -0
  294. package/utility/lmdb/environmentUtility.ts +2 -1
  295. package/utility/logging/harper_logger.ts +57 -26
  296. package/studio/web/assets/applications-D03NA7wW.js.map +0 -1
  297. package/studio/web/assets/index-Bh_CNAHr.js +0 -809
  298. package/studio/web/assets/index-Bh_CNAHr.js.map +0 -1
  299. package/studio/web/assets/index-C8Mv9kHV.css +0 -1
  300. package/studio/web/assets/index.lazy-Dx3MpyDC.js +0 -14
  301. package/studio/web/assets/notifications-CwKhipK7.js +0 -2
  302. package/studio/web/assets/pollUnlessForbidden-HHdQZW1N.js.map +0 -1
  303. package/studio/web/assets/queryClient-CbA8wM7J.js +0 -2
  304. package/studio/web/assets/queryClient-CbA8wM7J.js.map +0 -1
  305. package/studio/web/assets/setup-B56Oz1_u.js +0 -3
  306. package/studio/web/assets/status-BAod7p3o.js +0 -62
  307. package/studio/web/assets/status-BAod7p3o.js.map +0 -1
  308. package/studio/web/assets/textarea-DhLMZ3PA.js.map +0 -1
@@ -397,26 +397,28 @@ function storeDBSizeMetrics(analyticsTable: Table, databases: Databases) {
397
397
  }
398
398
  }
399
399
 
400
- function storeVolumeMetrics(analyticsTable: Table, databases: Databases) {
401
- for (const [db, tables] of Object.entries(databases)) {
402
- try {
403
- const [firstTable] = Object.values(tables);
404
- const storageStats = firstTable?.getStorageStats();
405
- if (!storageStats) {
406
- continue;
400
+ async function storeVolumeMetrics(analyticsTable: Table, databases: Databases) {
401
+ await Promise.all(
402
+ Object.entries(databases).map(async ([db, tables]) => {
403
+ try {
404
+ const [firstTable] = Object.values(tables);
405
+ const storageStats = await firstTable?.getStorageStats();
406
+ if (!storageStats) {
407
+ return;
408
+ }
409
+ const metric = {
410
+ metric: METRIC.STORAGE_VOLUME,
411
+ database: db,
412
+ ...storageStats,
413
+ };
414
+ storeMetric(analyticsTable, metric);
415
+ log.trace?.(`db ${db} storage volume metrics: ${JSON.stringify(metric)}`);
416
+ } catch (error) {
417
+ // a table or db was deleted, could get an error here
418
+ log.warn?.(`Error getting DB volume metrics`, error);
407
419
  }
408
- const metric = {
409
- metric: METRIC.STORAGE_VOLUME,
410
- database: db,
411
- ...storageStats,
412
- };
413
- storeMetric(analyticsTable, metric);
414
- log.trace?.(`db ${db} storage volume metrics: ${JSON.stringify(metric)}`);
415
- } catch (error) {
416
- // a table or db was deleted, could get an error here
417
- log.warn?.(`Error getting DB volume metrics`, error);
418
- }
419
- }
420
+ })
421
+ );
420
422
  }
421
423
 
422
424
  // RocksDB stat names are kebab-case with a "rocksdb." prefix (e.g. "rocksdb.block.cache.hit");
@@ -1047,7 +1049,7 @@ async function aggregation(fromPeriod, toPeriod = 60000) {
1047
1049
  storeDBSizeMetrics(analyticsTable, databases);
1048
1050
 
1049
1051
  // database storage volume metrics
1050
- storeVolumeMetrics(analyticsTable, databases);
1052
+ await storeVolumeMetrics(analyticsTable, databases);
1051
1053
 
1052
1054
  // rocksdb engine stats (only for RocksDB-backed databases)
1053
1055
  const rocksDBPeriod = lastRocksDBStatsTime ? now - lastRocksDBStatsTime : undefined;
@@ -111,6 +111,10 @@ const MAX_DELETES_PER_CLEANUP = 1000;
111
111
  // setTimeout silently falls back to 1ms for delays past this, which would turn the backoff into the
112
112
  // hot loop it is meant to avoid — a `logging.auditRetention` over ~248 days reaches it via retention/10
113
113
  const MAX_CLEANUP_DELAY = 2 ** 31 - 1;
114
+ // separate mint/read latches so a legacy-entry read warn can't mask the still-minting signal;
115
+ // mint latch keyed per (table, type) so one entry type can't silence another's producer stack
116
+ const warnedBodylessMints = new Set<string>();
117
+ const warnedBodylessTables = new Set<number>();
114
118
  const FLOAT_TARGET = new Float64Array(1);
115
119
  const FLOAT_BUFFER = new Uint8Array(FLOAT_TARGET.buffer);
116
120
  let DEFAULT_AUDIT_CLEANUP_DELAY = 10000; // default delay of 10 seconds
@@ -416,10 +420,24 @@ export function createAuditEntry(auditRecord: AuditRecord, start = 0) {
416
420
  originatingOperation,
417
421
  previousAdditionalAuditRefs,
418
422
  } = auditRecord;
419
- const action = EVENT_TYPES[type];
423
+ let action = EVENT_TYPES[type];
420
424
  if (!action) {
421
425
  throw new Error(`Invalid audit entry type ${type}`);
422
426
  }
427
+ if (action & (HAS_RECORD | HAS_PARTIAL_RECORD) && !encodedRecord?.length) {
428
+ // Readers decode the remainder whenever HAS_RECORD is set, so an audit-only commit minted with
429
+ // no body must not advertise one (#2153). HAS_PARTIAL_RECORD is kept: it also drives
430
+ // record-history reconstruction, and the read path tolerates the empty body.
431
+ if (!warnedBodylessMints.has(`${tableId}:${type}`)) {
432
+ warnedBodylessMints.add(`${tableId}:${type}`);
433
+ // the Error's stack identifies which write path delivered the missing value
434
+ harperLogger.warn(
435
+ `Audit entry (${type}) for record ${recordId} in table ${tableId} has no record body`,
436
+ new Error('bodyless audit mint')
437
+ );
438
+ }
439
+ action &= ~HAS_RECORD;
440
+ }
423
441
  let position = start + 1;
424
442
  if (previousVersion) {
425
443
  if (previousVersion > 1) ENTRY_DATAVIEW.setFloat64(start, previousVersion);
@@ -621,6 +639,19 @@ export function readAuditEntry(buffer: Uint8Array, start = 0, end = undefined):
621
639
  },
622
640
  getValue(store, fullRecord?, auditTime?) {
623
641
  if (action & HAS_RECORD || (action & HAS_PARTIAL_RECORD && !fullRecord)) {
642
+ if (decoder.position >= (end ?? buffer.byteLength)) {
643
+ // Entry advertises a record but has no body (minted before #2153): nothing to decode, and
644
+ // return undefined rather than falling through — this branch means the caller asked for the
645
+ // entry's own content (full-record consumers with an auditTime never enter it for partials
646
+ // and still reconstruct below). Warn latched per table: this getter runs inside range scans.
647
+ if (!warnedBodylessTables.has(tableId)) {
648
+ warnedBodylessTables.add(tableId);
649
+ harperLogger.warn(
650
+ `Audit entry (${EVENT_TYPES[action & 0xf]}) for table ${tableId} advertises a record but has no body; treating as having no record`
651
+ );
652
+ }
653
+ return;
654
+ }
624
655
  if (!value) {
625
656
  value = decodeFromDatabase(
626
657
  // the audit value has no on-disk timestamp/metadata prefix (the audit entry carries
package/resources/blob.ts CHANGED
@@ -55,7 +55,6 @@ type StorageInfo = {
55
55
  recordId?: number;
56
56
  contentBuffer?: any;
57
57
  source?: Readable;
58
- storageBuffer?: Buffer;
59
58
  compress?: boolean;
60
59
  flush?: boolean;
61
60
  start?: number;
@@ -829,7 +828,7 @@ class FileBackedBlob extends (Blob as unknown as { new (): Blob }) implements Bl
829
828
  storageInfoForBlob.set(slicedBlob, slicedStorageInfo);
830
829
  if (this.size != undefined)
831
830
  slicedBlob.size = (end == undefined ? this.size : Math.min(end, this.size)) - (start ?? 0);
832
- } else if (sourceStorageInfo?.contentBuffer && !sourceStorageInfo.storageBuffer) {
831
+ } else if (sourceStorageInfo?.contentBuffer) {
833
832
  const slicedStorageInfo = {
834
833
  ...sourceStorageInfo,
835
834
  contentBuffer: sourceStorageInfo.contentBuffer.subarray(start, end),
@@ -1340,8 +1339,11 @@ function getNextStorageIndex(blobStoragePaths: string[], fileId: number) {
1340
1339
  }
1341
1340
  if (((blobStoragePaths as any).lastUpdated ?? 0) + 60000 < now) {
1342
1341
  (blobStoragePaths as any).lastUpdated = now;
1343
- // create a new frequency table based on the available space
1344
- createFrequencyTableForStoragePaths(blobStoragePaths);
1342
+ // create a new frequency table based on the available space; fire-and-forget, so a
1343
+ // transient stat/mkdir failure must not become an unhandled rejection
1344
+ createFrequencyTableForStoragePaths(blobStoragePaths).catch((error) => {
1345
+ logger.warn?.('Error creating storage path frequency table', error);
1346
+ });
1345
1347
  }
1346
1348
  const nextIndex = (blobStoragePaths as any).frequencyTable[fileId % FREQUENCY_TABLE_SIZE];
1347
1349
  return nextIndex;
@@ -1356,6 +1358,10 @@ async function createFrequencyTableForStoragePaths(blobStoragePaths: string[]) {
1356
1358
  if (!statfs) return; // statfs is not available on all older node versions
1357
1359
  const availableSpaces = await Promise.all(
1358
1360
  blobStoragePaths.map(async (path) => {
1361
+ // This compares MULTIPLE distinct paths against each other, so it always needs the
1362
+ // real per-path number: quota-status.json (see getStorageSpaceStats, #1976) is a
1363
+ // single instance-wide figure that can't tell two disks apart, and would collapse
1364
+ // every path in a multi-volume STORAGE_BLOBPATHS config to the same "available" value.
1359
1365
  let stats: StatsFs;
1360
1366
  try {
1361
1367
  stats = await statfs(path);
@@ -1733,10 +1739,14 @@ addExtension({
1733
1739
  throw new Error('No store specified, cannot load blob from storage');
1734
1740
  }
1735
1741
  } else {
1742
+ // contentBuffer is a *copy* (copyingUnpacker uses copyBuffers), so it stays valid for the
1743
+ // lifetime of the blob. Do not retain the raw ext-body `buffer` here: it is a view into the
1744
+ // store's read buffer, which is recycled by later reads. Keeping it and re-emitting it on
1745
+ // re-encode (e.g. a read-modify-write / REST PATCH) serialized whatever foreign bytes had
1746
+ // since overwritten that buffer, corrupting the record (harper#2103).
1736
1747
  storageInfoForBlob.set(blob, {
1737
1748
  storageIndex: 0,
1738
1749
  fileId: null,
1739
- storageBuffer: buffer as any,
1740
1750
  contentBuffer: blobInfo[1] as any,
1741
1751
  });
1742
1752
  blob.size = blobInfo[1]?.length;
@@ -1757,9 +1767,6 @@ addExtension({
1757
1767
  if (blob.type) options.type = blob.type;
1758
1768
  if (blob.size !== undefined) options.size = blob.size;
1759
1769
  if (storageInfo) {
1760
- if (storageInfo.storageBuffer) {
1761
- return storageInfo.storageBuffer;
1762
- }
1763
1770
  if (
1764
1771
  storageInfo.contentBuffer &&
1765
1772
  (storageInfo.contentBuffer?.length < FILE_STORAGE_THRESHOLD || blob.saveInRecord)
@@ -33,7 +33,7 @@ import { handleLocalTimeForGets } from './RecordEncoder.ts';
33
33
  import { deleteRootBlobPathsForDB } from './blob.ts';
34
34
  import { CUSTOM_INDEXES } from './indexes/customIndexes.ts';
35
35
  import { OpenDBIObject } from '../utility/lmdb/OpenDBIObject.ts';
36
- import { RocksDatabase, type RocksDatabaseOptions } from '@harperfast/rocksdb-js';
36
+ import { RocksDatabase, supportedCompression, type RocksDatabaseOptions } from '@harperfast/rocksdb-js';
37
37
  import { PrimaryRocksDatabase } from './PrimaryRocksDatabase.ts';
38
38
  import { replayLogs } from './replayLogs.ts';
39
39
  import { totalmem } from 'node:os';
@@ -98,6 +98,80 @@ const DEFAULT_DATABASE_NAME = 'data';
98
98
  const DEFINED_TABLES = Symbol('defined-tables');
99
99
  const DEFAULT_COMPRESSION_THRESHOLD = (envGet(CONFIG_PARAMS.STORAGE_PAGESIZE) || 4096) - 60; // larger than this requires multiple pages
100
100
  initSync();
101
+ /**
102
+ * The RocksDB block/blob codec for every column family this process opens (`storage.rocks.compression`),
103
+ * or `undefined` to leave rocksdb-js on its own default (lz4 wherever the native build has it).
104
+ *
105
+ * Resolved on the first open and then frozen, deliberately. RocksDB fixes a column family's codec
106
+ * for as long as it is open and rejects a reopen that disagrees, and Harper's worker threads share
107
+ * one process-wide column-family registry — so every open, in every thread, has to resolve the same
108
+ * value. Re-reading config per open does not guarantee that: on a fresh install the system families
109
+ * are created by `mountHdb()` before the config file exists, so the main thread would resolve
110
+ * nothing and the workers would resolve the configured codec, after which `__dbis__` cannot be
111
+ * reopened and Harper fails with "The system database failed to load". The installer stages this
112
+ * value before `mountHdb()` (see utility/install/installer.ts) so that first open already sees it.
113
+ *
114
+ * Unset is NOT "use the build default" for a family that already exists — see toRocksCompression.
115
+ */
116
+ let resolvedRocksCompression: string | undefined;
117
+ let rocksCompressionResolved = false;
118
+
119
+ export function getRocksCompression(): string | undefined {
120
+ if (!rocksCompressionResolved) {
121
+ resolvedRocksCompression = readDatabaseCodec();
122
+ rocksCompressionResolved = true;
123
+ }
124
+ return resolvedRocksCompression;
125
+ }
126
+
127
+ /**
128
+ * Test-only: un-freezes the resolved codec. Production code never calls this — the freeze is the
129
+ * invariant (see getRocksCompression above) — but a test process runs many unrelated test files in
130
+ * one process, so whichever file happens to open a RocksDatabase first freezes this for everyone
131
+ * after it. Tests that need to exercise config changes call this to get back to the unresolved state.
132
+ */
133
+ export function resetRocksCompression(): void {
134
+ resolvedRocksCompression = undefined;
135
+ rocksCompressionResolved = false;
136
+ }
137
+
138
+ /**
139
+ * The codec every column family in this process opens under.
140
+ *
141
+ * Compression is a deployment setting, not a per-table one. RocksDB opens all of a database's
142
+ * column families in one call, so the codec has to be decided before the first open — which is
143
+ * before Harper has read any table's metadata (that catalog is itself one of the families being
144
+ * opened). Resolving one codec from configuration and applying it to every family is what makes
145
+ * that possible; it is passed with `compressionForAllColumnFamilies` so families this process
146
+ * never names individually adopt it too, which is what lets a database created before the codec
147
+ * existed start compressing.
148
+ *
149
+ * `storage.rocks.compression` names a codec outright. Otherwise `storage.compression` (default
150
+ * true) decides enabled-or-not and the build default fills in the algorithm. Per-table metadata
151
+ * still records the LMDB-era boolean, but no longer selects: a table persisted as disabled inside
152
+ * a deployment that enables compression would need its own codec, and it cannot have one.
153
+ */
154
+ function readDatabaseCodec(): string | undefined {
155
+ const explicit = readRocksCompressionConfig();
156
+ if (explicit) return explicit;
157
+ return toRocksCompression(getDefaultCompression()) as string | undefined;
158
+ }
159
+
160
+ function readRocksCompressionConfig(): string | undefined {
161
+ const configured = envGet(CONFIG_PARAMS.STORAGE_ROCKS_COMPRESSION);
162
+ if (configured === undefined || configured === null || configured === '') return undefined;
163
+ const requested = String(configured).trim().toLowerCase();
164
+ if (!requested) return undefined;
165
+ // Rejected here rather than at the open: an unsupported name throws inside RocksDatabase.open,
166
+ // which surfaces as the system database failing to load partway through startup.
167
+ if (!supportedCompression.includes(requested)) {
168
+ throw new Error(
169
+ `storage.rocks.compression="${requested}" is not available in this build of @harperfast/rocksdb-js. Supported: ${supportedCompression.join(', ')}`
170
+ );
171
+ }
172
+ return requested;
173
+ }
174
+
101
175
  // I don't know if this is the best place for this, but somewhere we need to specify which tables
102
176
  // replicate by default:
103
177
  export const NON_REPLICATING_SYSTEM_TABLES = [
@@ -177,22 +251,51 @@ export const tables: Tables = Object.create(null);
177
251
  export const databases: Databases = Object.create(null);
178
252
 
179
253
  /**
180
- * Map a persisted (LMDB-era) compression value to what rocksdb-js accepts. Table metadata
181
- * carries values where a defined falsy value (false, '') means compression was explicitly
182
- * disabled and true/{ threshold, ... } mean enabled with defaults. rocksdb-js >= 2.6
183
- * validates `compression` strictly and treats UNSET as "use the build default (lz4)", so
184
- * disabled must be mapped to 'none' explicitly, and true to unset.
254
+ * Codec used to honor an "enabled, unspecified" compression setting, or `undefined` where the
255
+ * native build cannot provide it (in which case the request degrades to the build default rather
256
+ * than throwing).
257
+ */
258
+ const DEFAULT_ENABLED_CODEC = supportedCompression.includes('lz4') ? 'lz4' : undefined;
259
+
260
+ /**
261
+ * Map a persisted (LMDB-era) compression value to what rocksdb-js accepts. Table metadata carries
262
+ * values where a defined falsy value (false, '') means compression was explicitly disabled, and
263
+ * `true` / `{ threshold, ... }` mean enabled with defaults — `storage.compression` defaults to
264
+ * `true` (defaultConfig.yaml), so essentially every pre-existing table asked for compression.
265
+ *
266
+ * "Enabled" resolves to an explicit codec rather than to unset. Unset is not equivalent: RocksDB
267
+ * persists the codec per column family and a reopen that requests nothing inherits what the family
268
+ * already has, applying the build default only when the family does not yet exist. Leaving these
269
+ * unset therefore silently ignores the operator's request on every database created before the
270
+ * native build carried codecs — it keeps writing uncompressed forever, while a brand-new database
271
+ * gets lz4. Naming the codec makes the setting mean the same thing in both cases.
272
+ *
273
+ * This governs newly written files; existing SSTs keep their codec until write traffic rewrites
274
+ * them (`db.compact()` will not — see getRocksCompression above).
185
275
  */
186
276
  export function toRocksCompression(compression: unknown): unknown {
187
- if (compression === true) return undefined;
188
- if (compression !== undefined && !compression) return 'none';
277
+ if (compression === undefined) return undefined;
278
+ if (!compression) return 'none';
279
+ // An object carrying an explicit `algorithm` is already a rocksdb-js request; anything else
280
+ // (`true`, or an LMDB descriptor like { startingOffset, threshold }) is "enabled, unspecified".
281
+ if (compression === true || (typeof compression === 'object' && !(compression as { algorithm?: unknown }).algorithm))
282
+ return DEFAULT_ENABLED_CODEC;
189
283
  return compression;
190
284
  }
191
285
 
192
286
  function openRocksDatabase(path: string, options: RocksDatabaseOptions & { dupSort?: boolean }) {
193
287
  options.disableWAL ??= true;
194
288
  const legacyOptions = options as { compression?: unknown };
195
- legacyOptions.compression = toRocksCompression(legacyOptions.compression);
289
+ // A configured codec applies to every column family, overriding whatever per-table metadata
290
+ // carries — that metadata records the LMDB-era boolean, so without this there is no way to
291
+ // select a RocksDB codec for a deployment.
292
+ // One codec for every column family, and applied to every family this open touches — not just
293
+ // the one being named. RocksDB opens them all at once and a family's codec cannot change while
294
+ // it is open, so a family this process never names individually would otherwise stay on
295
+ // whatever it was created with, forever.
296
+ const databaseCodec = getRocksCompression();
297
+ legacyOptions.compression = databaseCodec;
298
+ if (databaseCodec) (options as { compressionForAllColumnFamilies?: boolean }).compressionForAllColumnFamilies = true;
196
299
  // Apply read-only mode if enabled
197
300
  if (isReadOnlyMode()) {
198
301
  options.readOnly = true;
@@ -588,7 +691,7 @@ function initStores(
588
691
  ...internalDbiInit,
589
692
  disableWAL: false,
590
693
  name: INTERNAL_DBIS_NAME,
591
- } as any) as RocksDatabaseEx;
694
+ } as any);
592
695
  } else {
593
696
  attributesDbi = rootStore.openDB(INTERNAL_DBIS_NAME, internalDbiInit as any);
594
697
  }
@@ -1350,7 +1453,11 @@ function openIndex(dbiKey: string, rootStore: RootDatabaseKind, attribute: any)
1350
1453
  // Enable cache (WeakLRUCache + VT) for all custom-object index stores so the VT is
1351
1454
  // available before resolveIndexFormat decides the format. Versioned stores need the VT
1352
1455
  // for cached traversal; legacy stores pay a small per-write cache.delete() overhead only.
1353
- dbi = openRocksDatabase(rootStore.path, { ...dbiInit, name: dbiKey, cache: isCustomObjectIndex } as any) as any;
1456
+ dbi = openRocksDatabase(rootStore.path, {
1457
+ ...dbiInit,
1458
+ name: dbiKey,
1459
+ cache: isCustomObjectIndex,
1460
+ } as any) as any;
1354
1461
  (dbi as any).rootStore = rootStore;
1355
1462
  // Custom-index object stores (e.g. HNSW) write graph nodes via plain put() with no staged
1356
1463
  // transaction timestamp, so their values carry no version and the PrimaryRocksDatabase
@@ -1573,6 +1680,10 @@ export function table<TableResourceType>(tableDefinition: TableDefinition): Tabl
1573
1680
  clearInterruptedDropEntries(rootStore.path, tableName);
1574
1681
  }
1575
1682
  if (rootStore instanceof RocksDatabase) {
1683
+ // Usually a genuinely new column family (existingTableMeta above found no catalog
1684
+ // entry), but an interrupted drop just completed above can leave the physical CF
1685
+ // behind under its old codec even though the catalog entry is gone — same fallback
1686
+ // as the reconcile paths covers that remnant case too.
1576
1687
  primaryStore = openRocksDatabase(rootStore.path, { ...dbiInit, name: dbiName, cache: true } as any);
1577
1688
  } else {
1578
1689
  primaryStore = (rootStore as any).openDB(dbiName, dbiInit as any);
@@ -2183,7 +2294,7 @@ function completeInterruptedDrop(rootStore, attributesDbi, databaseName: string,
2183
2294
  if (rootStore instanceof RocksDatabase) {
2184
2295
  for (const columnName of (rootStore as any).columns) {
2185
2296
  if (columnName.startsWith(tableName + '/')) {
2186
- const columnStore = openRocksDatabase(rootStore.path, { name: columnName } as any);
2297
+ const columnStore = openRocksDatabase(rootStore.path, { name: columnName });
2187
2298
  try {
2188
2299
  columnStore.dropSync();
2189
2300
  } catch (error) {
@@ -3,7 +3,6 @@ import { FLOAT32_OPTIONS } from 'msgpackr';
3
3
  import { loggerWithTag } from '../../utility/logging/logger.ts';
4
4
  import { ClientError } from '../../utility/errors/hdbError.ts';
5
5
  import type { Id } from '../../resources/ResourceInterface.ts';
6
- import { RocksDatabase } from '@harperfast/rocksdb-js';
7
6
  import { SKIP } from '@harperfast/extended-iterable';
8
7
 
9
8
  const logger = loggerWithTag('HNSW');
@@ -50,13 +49,35 @@ function dequantizeInt8(q: Int8Array, scale: number): number[] {
50
49
  // cap deliberately favors latency — apps wanting higher recall set efConstructionSearch or a per-query
51
50
  // ef. Tune as graph build quality / larger-N data improves.
52
51
  const AUTO_EF_BASE = 100;
53
- const AUTO_EF_REF = 1000;
52
+ // The index store holds a graph node plus a primary-key mapping per record, so a key count is twice
53
+ // the node count. Sizes here are in nodes; this converts back for the one consumer still calibrated
54
+ // against keys.
55
+ const INDEX_KEYS_PER_NODE = 2;
56
+ // Graph nodes at which ef equals AUTO_EF_BASE. Originally expressed per index-store key against a
57
+ // reference of 1000, so 500 nodes is the same point. The node count is a high-water mark rather than
58
+ // a live count, so the resolved ef can differ from that formula by one at a rounding boundary.
59
+ const AUTO_EF_REF = 500;
54
60
  const AUTO_EF_MAX = 512;
55
61
  function autoScaleEf(nodeCount: number): number {
56
62
  const scaled = Math.round(AUTO_EF_BASE * Math.sqrt(Math.max(1, nodeCount / AUTO_EF_REF)));
57
63
  return Math.min(AUTO_EF_MAX, Math.max(AUTO_EF_BASE, scaled));
58
64
  }
59
65
 
66
+ // Candidate-list size used when searching a layer above 0. Those layers only supply the entry point
67
+ // for the next layer down, so a greedy walk is enough; a larger value costs work proportional to the
68
+ // layer's population without improving the entry point it hands off.
69
+ const ROUTING_EF = 1;
70
+ // Ceiling on the ef a query's own `offset + limit` can ask for. `limit` is unprivileged and set on
71
+ // every request, so this bounds what a caller can make one thread do synchronously: layer 0 holds
72
+ // `ef` candidates in a sorted array with an O(len) insert. Kept within a small multiple of
73
+ // AUTO_EF_MAX so the worst case stays the same order as the index's own auto-scaled ceiling; a
74
+ // caller who genuinely wants more sets an explicit `ef` and owns the cost.
75
+ const LIMIT_EF_MAX = 4 * AUTO_EF_MAX;
76
+ // How long a resolved graph size is reused before it is looked up again (see approximateNodeCount).
77
+ // ef moves with the square root of the count and is capped, so a slightly stale size is immaterial;
78
+ // this only has to be short enough that a table growing from empty picks up a larger ef promptly.
79
+ const NODE_COUNT_TTL = 10_000;
80
+
60
81
  class MinHeap {
61
82
  private data: Candidate[] = [];
62
83
  get size() {
@@ -181,6 +202,8 @@ export class HierarchicalNavigableSmallWorld {
181
202
  // frozen node the object store hands back. WeakMap so entries are collected when the store evicts
182
203
  // the frozen node — without it, every cache hit on a frozen node would re-slice and re-clone.
183
204
  private convertedNodes = new WeakMap<object, any>();
205
+ private nodeCount = 0;
206
+ private nodeCountAt = 0;
184
207
  constructor(indexStore: any, options: any) {
185
208
  this.indexStore = indexStore;
186
209
  if (indexStore) {
@@ -334,17 +357,12 @@ export class HierarchicalNavigableSmallWorld {
334
357
  this.indexStore.put(ENTRY_POINT, nodeId, options);
335
358
  }
336
359
 
337
- // For each level from top to bottom
360
+ // Pure descent only neighbors[0] is used — so it runs greedily for the same reason
361
+ // search() does. The connection-building pass below keeps efConstruction: it selects the
362
+ // edges that get stored.
338
363
  while (currentLevel > level) {
339
364
  // Search for closest neighbors at current level
340
- const neighbors = this.searchLayer(
341
- vector,
342
- entryPointId,
343
- entryPoint,
344
- this.efConstruction,
345
- currentLevel,
346
- options
347
- );
365
+ const neighbors = this.searchLayer(vector, entryPointId, entryPoint, ROUTING_EF, currentLevel, options);
348
366
 
349
367
  if (neighbors.length > 0) {
350
368
  entryPointId = neighbors[0].id; // closest neighbor becomes new entry point
@@ -692,6 +710,34 @@ export class HierarchicalNavigableSmallWorld {
692
710
  }
693
711
  }
694
712
 
713
+ /**
714
+ * Number of nodes in the graph, for the ef auto-scale. Must stay O(1): it runs per query, and an
715
+ * exact count is a full key scan. RocksDB's `estimate-num-keys` is not a usable substitute — it
716
+ * counts unreconciled overwrites, which graph construction produces in bulk. See DESIGN.md for the
717
+ * measurements behind both. The memo is the only gate on how often the size is resolved; nothing on
718
+ * the query path may bypass it, or the O(1) lookup becomes per-query work again.
719
+ */
720
+ private approximateNodeCount(): number {
721
+ const now = Date.now();
722
+ if (this.nodeCountAt > 0 && now - this.nodeCountAt < NODE_COUNT_TTL) return this.nodeCount;
723
+ this.nodeCount = this.resolveNodeCount();
724
+ this.nodeCountAt = now;
725
+ return this.nodeCount;
726
+ }
727
+
728
+ /** O(1) node count — the shared id counter, else a single reverse seek to the largest node id. */
729
+ private resolveNodeCount(): number {
730
+ if (this.idIncrementer) return Number(Atomics.load(this.idIncrementer, 0));
731
+ try {
732
+ for (const key of this.indexStore.getKeys({ reverse: true, limit: 1, start: Infinity, end: 0 })) {
733
+ if (typeof key === 'number') return key + 1;
734
+ }
735
+ } catch (error) {
736
+ logger.debug?.('could not resolve node count from the largest node id', error);
737
+ }
738
+ return 0; // empty (or unreadable) graph — autoScaleEf falls back to its floor
739
+ }
740
+
695
741
  private safeGetSync(key: any, options?: any): any {
696
742
  try {
697
743
  let node = this.indexStore.getSync(key, options);
@@ -945,7 +991,12 @@ export class HierarchicalNavigableSmallWorld {
945
991
  // returns true are admitted to the result list at layer 0; routing is unaffected. Composed by
946
992
  // search.ts from companion AND conditions and caller-supplied vector/row filters. Must be
947
993
  // synchronous and side-effect free. JS-API only (never from a REST query string).
948
- filter?: (primaryKey: Id) => boolean
994
+ filter?: (primaryKey: Id) => boolean,
995
+ // offset + limit for a bounded query. A layer-0 search returns at most `ef` candidates, so a
996
+ // query asking for more rows than that used to come back short with no error — capped at 512
997
+ // (AUTO_EF_MAX) however large the limit was. Raising ef to cover the request keeps `limit`
998
+ // meaningful; the caller pays for what it asked for.
999
+ minResults?: number
949
1000
  ) {
950
1001
  let limit: number | undefined; // only set for threshold comparators; 0 is a valid threshold (e.g. dotProduct)
951
1002
  let limitInclusive = false; // true for `le`, false for `lt`
@@ -978,18 +1029,33 @@ export class HierarchicalNavigableSmallWorld {
978
1029
  // Resolve search ef: per-query ef wins; else an explicitly-configured efConstructionSearch;
979
1030
  // else auto-scale with the graph size so recall holds as the table grows.
980
1031
  let effectiveEf = this.efConstructionSearch;
981
- if (ef !== undefined && ef > 0) effectiveEf = ef;
982
- else if (!this.efSearchConfigured) {
983
- const nodeCount = this.indexStore.getKeysCount
984
- ? this.indexStore.getKeysCount()
985
- : (this.indexStore.getStats?.()?.entryCount ?? 0);
986
- effectiveEf = autoScaleEf(nodeCount);
1032
+ const explicitEf = ef !== undefined && ef > 0;
1033
+ if (explicitEf) effectiveEf = ef;
1034
+ else if (!this.efSearchConfigured) effectiveEf = autoScaleEf(this.approximateNodeCount());
1035
+ // The ef the index chose for itself, before any limit-derived widening. The filter budget below
1036
+ // stays calibrated against this rather than against what a caller's `limit` asked for.
1037
+ const resolvedEf = effectiveEf;
1038
+ // A bounded query must be able to come back full: layer 0 keeps at most `ef` candidates, so a
1039
+ // limit above the resolved ef truncated the result set with no error. Widen the candidate list
1040
+ // to cover the request, up to LIMIT_EF_MAX — `ef` sizes a synchronous traversal that holds every
1041
+ // admitted candidate in a sorted array with an O(len) insert, so an unbounded one lets a plain
1042
+ // `limit` stall the thread. Past the ceiling the result set is still short, as it was before.
1043
+ // A per-query `ef` is left authoritative: it is an explicit cost ceiling, and a caller who sets
1044
+ // one has said what they are willing to spend.
1045
+ // The ceiling is the only bound: clamping to the graph size as well would need a count exact as
1046
+ // of this query — the memo reads low while a table grows, truncating the very limit this
1047
+ // honours — and an ef above the node count is free, the traversal ending at the graph, not ef.
1048
+ if (minResults !== undefined && !explicitEf && minResults > effectiveEf) {
1049
+ effectiveEf = Math.max(effectiveEf, Math.min(minResults, LIMIT_EF_MAX));
987
1050
  }
988
1051
  // Predicate-aware traversal budget (#1241): matches accrue slower than visits under a selective
989
1052
  // filter, so bound layer-0 work at ef * filterExpansion nodes. Only built when a filter is active.
1053
+ // Deliberately `resolvedEf`, not the limit-widened ef: this budget is what stops a selective
1054
+ // filter crawling the whole graph, and multiplying it by a caller's limit would turn a filtered
1055
+ // vector query into a record-loading scan.
990
1056
  const filterState: FilterState | undefined = filter
991
1057
  ? {
992
- maxVisits: effectiveEf * (filterExpansion && filterExpansion > 0 ? filterExpansion : this.filterExpansion),
1058
+ maxVisits: resolvedEf * (filterExpansion && filterExpansion > 0 ? filterExpansion : this.filterExpansion),
993
1059
  nodesVisited: 0,
994
1060
  filterEvaluations: 0,
995
1061
  }
@@ -1000,13 +1066,17 @@ export class HierarchicalNavigableSmallWorld {
1000
1066
  let results: Candidate[] = [];
1001
1067
  // For each level from top to bottom. The filter applies only at layer 0 (result admission);
1002
1068
  // upper layers route unfiltered so non-matching hubs still guide the descent.
1069
+ //
1070
+ // Only layer 0 gets the full candidate list; the layers above it just hand down an entry point,
1071
+ // so searching them at the full ef costs work proportional to the layer's population (~N/M
1072
+ // nodes) rather than to ef. See DESIGN.md.
1003
1073
  for (let l = entryPoint.level; l >= 0; l--) {
1004
1074
  // Search for closest neighbors at current level
1005
1075
  results = this.searchLayer(
1006
1076
  target,
1007
1077
  entryPointId,
1008
1078
  entryPoint,
1009
- effectiveEf,
1079
+ l === 0 ? effectiveEf : ROUTING_EF,
1010
1080
  l,
1011
1081
  options,
1012
1082
  distanceFunction,
@@ -1040,10 +1110,8 @@ export class HierarchicalNavigableSmallWorld {
1040
1110
  * and $distance.
1041
1111
  */
1042
1112
  exactDistance(searchCondition: { target: number[]; distance?: string }, recordVector: number[] | Int8Array): number {
1043
- if (recordVector == null) return Infinity; // missing vector sorts last
1044
- // distance fns require a plain Array (they guard on Array.isArray); records normally store a
1045
- // float[] vector, but convert defensively in case a typed array slips through.
1046
- const vec = Array.isArray(recordVector) ? recordVector : Array.from(recordVector);
1113
+ if (!searchCondition.target) throw new ClientError('A target vector must be provided for an HNSW query');
1114
+ if (!Array.isArray(searchCondition.target)) throw new ClientError('The target vector must be an array');
1047
1115
  const fn =
1048
1116
  searchCondition.distance === 'euclidean'
1049
1117
  ? euclideanDistance
@@ -1051,7 +1119,14 @@ export class HierarchicalNavigableSmallWorld {
1051
1119
  ? dotProductDistance
1052
1120
  : searchCondition.distance === 'cosine'
1053
1121
  ? cosineDistance
1054
- : this.distance;
1122
+ : searchCondition.distance
1123
+ ? null
1124
+ : this.distance;
1125
+ if (!fn) throw new ClientError('Unknown distance function');
1126
+ if (recordVector == null) return Infinity; // missing vector sorts last
1127
+ // distance fns require a plain Array (they guard on Array.isArray); records normally store a
1128
+ // float[] vector, but convert defensively in case a typed array slips through.
1129
+ const vec = Array.isArray(recordVector) ? recordVector : Array.from(recordVector);
1055
1130
  return fn(searchCondition.target, vec);
1056
1131
  }
1057
1132
  /**
@@ -1223,9 +1298,11 @@ export class HierarchicalNavigableSmallWorld {
1223
1298
  * @returns
1224
1299
  */
1225
1300
  estimateCountAsSort() {
1226
- const count =
1227
- this.indexStore instanceof RocksDatabase ? this.indexStore.getKeysCount() : this.indexStore.getStats().entryCount;
1228
- return Math.sqrt(count * this.efConstructionSearch);
1301
+ // Same O(1) source search() uses — this runs per query whenever a vector sort is planned
1302
+ // alongside another condition, where an exact getKeysCount() is a second full key scan on the
1303
+ // query path. Scaled back to the index-store key count it used to be given, so the planner's
1304
+ // condition ordering is unchanged by the switch to a node count.
1305
+ return Math.sqrt(this.approximateNodeCount() * INDEX_KEYS_PER_NODE * this.efConstructionSearch);
1229
1306
  }
1230
1307
 
1231
1308
  /**
@@ -1234,27 +1311,24 @@ export class HierarchicalNavigableSmallWorld {
1234
1311
  * @param vector
1235
1312
  * @param context
1236
1313
  * @param entry
1314
+ * @param sortDefinition
1237
1315
  */
1238
- propertyResolver(vector: number[], context: any, entry: any) {
1239
- const sortDefinition = context?.sort;
1316
+ propertyResolver(vector: number[], context: any, entry: any, sortDefinition?: any) {
1240
1317
  if (sortDefinition) {
1318
+ if (!context) return this.exactDistance(sortDefinition, vector);
1241
1319
  // set up a cache for these so they can be accessed by $distance and not be recalculated during a sort
1242
- let vectorDistances = sortDefinition.vectorDistances;
1243
- if (vectorDistances) {
1244
- const difference = vectorDistances.get(entry);
1245
- if (difference) return difference;
1246
- } else vectorDistances = context.vectorDistances = sortDefinition.vectorDistances = new Map();
1320
+ let vectorDistanceCaches = context.vectorDistanceCaches;
1321
+ if (!vectorDistanceCaches) vectorDistanceCaches = context.vectorDistanceCaches = new WeakMap();
1322
+ let vectorDistances = vectorDistanceCaches.get(sortDefinition);
1323
+ const cacheKey =
1324
+ entry && typeof entry === 'object' ? entry : vector && typeof vector === 'object' ? vector : null;
1325
+ if (vectorDistances && cacheKey) {
1326
+ const difference = vectorDistances.get(cacheKey);
1327
+ if (difference !== undefined) return difference;
1328
+ } else if (!vectorDistances) vectorDistanceCaches.set(sortDefinition, (vectorDistances = new WeakMap()));
1247
1329
 
1248
- let distanceFunction = this.distance;
1249
- if (sortDefinition.type)
1250
- distanceFunction =
1251
- sortDefinition.distance === 'euclidean'
1252
- ? euclideanDistance
1253
- : sortDefinition.distance === 'dotProduct'
1254
- ? dotProductDistance
1255
- : cosineDistance;
1256
- const distance = distanceFunction(sortDefinition.target, vector);
1257
- vectorDistances.set(entry, distance);
1330
+ const distance = this.exactDistance(sortDefinition, vector);
1331
+ if (cacheKey) vectorDistances.set(cacheKey, distance);
1258
1332
  return distance;
1259
1333
  }
1260
1334
  return vector;