@harperfast/harper 5.2.0-alpha.6 → 5.2.0-beta.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 (276) hide show
  1. package/bin/cliOperations.ts +76 -12
  2. package/bin/run.ts +10 -0
  3. package/bin/status.ts +1 -1
  4. package/components/Application.ts +146 -83
  5. package/components/Scope.ts +4 -0
  6. package/components/componentLoader.ts +7 -0
  7. package/components/operations.js +21 -1
  8. package/config/configUtils.ts +139 -5
  9. package/config-app.schema.json +70 -0
  10. package/dataLayer/harperBridge/ResourceBridge.ts +7 -0
  11. package/dist/bin/cliOperations.js +76 -12
  12. package/dist/bin/cliOperations.js.map +1 -1
  13. package/dist/bin/run.js +9 -0
  14. package/dist/bin/run.js.map +1 -1
  15. package/dist/bin/status.js +1 -1
  16. package/dist/bin/status.js.map +1 -1
  17. package/dist/components/Application.d.ts +12 -5
  18. package/dist/components/Application.js +121 -60
  19. package/dist/components/Application.js.map +1 -1
  20. package/dist/components/Scope.d.ts +1 -0
  21. package/dist/components/Scope.js +4 -0
  22. package/dist/components/Scope.js.map +1 -1
  23. package/dist/components/componentLoader.js +7 -0
  24. package/dist/components/componentLoader.js.map +1 -1
  25. package/dist/components/operations.js +20 -1
  26. package/dist/components/operations.js.map +1 -1
  27. package/dist/config/configUtils.d.ts +31 -0
  28. package/dist/config/configUtils.js +127 -5
  29. package/dist/config/configUtils.js.map +1 -1
  30. package/dist/dataLayer/harperBridge/ResourceBridge.js +8 -0
  31. package/dist/dataLayer/harperBridge/ResourceBridge.js.map +1 -1
  32. package/dist/resources/DatabaseTransaction.d.ts +12 -0
  33. package/dist/resources/DatabaseTransaction.js +97 -0
  34. package/dist/resources/DatabaseTransaction.js.map +1 -1
  35. package/dist/resources/RequestTarget.js +13 -3
  36. package/dist/resources/RequestTarget.js.map +1 -1
  37. package/dist/resources/Resource.js +21 -2
  38. package/dist/resources/Resource.js.map +1 -1
  39. package/dist/resources/Table.d.ts +3 -1
  40. package/dist/resources/Table.js +48 -5
  41. package/dist/resources/Table.js.map +1 -1
  42. package/dist/resources/analytics/metadata.d.ts +3 -0
  43. package/dist/resources/analytics/metadata.js +3 -0
  44. package/dist/resources/analytics/metadata.js.map +1 -1
  45. package/dist/resources/analytics/write.js +22 -0
  46. package/dist/resources/analytics/write.js.map +1 -1
  47. package/dist/resources/databases.js +21 -0
  48. package/dist/resources/databases.js.map +1 -1
  49. package/dist/resources/defineResource.js +20 -7
  50. package/dist/resources/defineResource.js.map +1 -1
  51. package/dist/resources/jsResource.d.ts +24 -0
  52. package/dist/resources/jsResource.js +58 -2
  53. package/dist/resources/jsResource.js.map +1 -1
  54. package/dist/resources/openApi.js +45 -20
  55. package/dist/resources/openApi.js.map +1 -1
  56. package/dist/resources/scheduler/CronExpression.d.ts +71 -0
  57. package/dist/resources/scheduler/CronExpression.js +367 -0
  58. package/dist/resources/scheduler/CronExpression.js.map +1 -0
  59. package/dist/resources/scheduler/engine.d.ts +91 -0
  60. package/dist/resources/scheduler/engine.js +767 -0
  61. package/dist/resources/scheduler/engine.js.map +1 -0
  62. package/dist/resources/scheduler/scheduler.d.ts +33 -0
  63. package/dist/resources/scheduler/scheduler.js +200 -0
  64. package/dist/resources/scheduler/scheduler.js.map +1 -0
  65. package/dist/security/auth.js +1 -0
  66. package/dist/security/auth.js.map +1 -1
  67. package/dist/security/jsLoader.js +8 -0
  68. package/dist/security/jsLoader.js.map +1 -1
  69. package/dist/security/keys.d.ts +32 -0
  70. package/dist/security/keys.js +147 -0
  71. package/dist/security/keys.js.map +1 -1
  72. package/dist/server/REST.js +67 -1
  73. package/dist/server/REST.js.map +1 -1
  74. package/dist/server/Server.d.ts +6 -0
  75. package/dist/server/Server.js.map +1 -1
  76. package/dist/server/http.d.ts +2 -0
  77. package/dist/server/http.js +139 -14
  78. package/dist/server/http.js.map +1 -1
  79. package/dist/server/operationsServer.js +3 -3
  80. package/dist/server/operationsServer.js.map +1 -1
  81. package/dist/server/serverHelpers/progressEmitter.js +5 -1
  82. package/dist/server/serverHelpers/progressEmitter.js.map +1 -1
  83. package/dist/server/threads/threadServer.js +9 -5
  84. package/dist/server/threads/threadServer.js.map +1 -1
  85. package/dist/sqlEngine/binder/bind.d.ts +72 -0
  86. package/dist/sqlEngine/binder/bind.js +289 -0
  87. package/dist/sqlEngine/binder/bind.js.map +1 -0
  88. package/dist/sqlEngine/config.d.ts +35 -0
  89. package/dist/sqlEngine/config.js +62 -0
  90. package/dist/sqlEngine/config.js.map +1 -0
  91. package/dist/sqlEngine/diff/differential.d.ts +23 -0
  92. package/dist/sqlEngine/diff/differential.js +90 -0
  93. package/dist/sqlEngine/diff/differential.js.map +1 -0
  94. package/dist/sqlEngine/errors.d.ts +22 -0
  95. package/dist/sqlEngine/errors.js +36 -0
  96. package/dist/sqlEngine/errors.js.map +1 -0
  97. package/dist/sqlEngine/executor/runMutation.d.ts +35 -0
  98. package/dist/sqlEngine/executor/runMutation.js +352 -0
  99. package/dist/sqlEngine/executor/runMutation.js.map +1 -0
  100. package/dist/sqlEngine/executor/runSelect.d.ts +7 -0
  101. package/dist/sqlEngine/executor/runSelect.js +14 -0
  102. package/dist/sqlEngine/executor/runSelect.js.map +1 -0
  103. package/dist/sqlEngine/expressions/compile.d.ts +24 -0
  104. package/dist/sqlEngine/expressions/compile.js +326 -0
  105. package/dist/sqlEngine/expressions/compile.js.map +1 -0
  106. package/dist/sqlEngine/functions/aggregates.d.ts +12 -0
  107. package/dist/sqlEngine/functions/aggregates.js +299 -0
  108. package/dist/sqlEngine/functions/aggregates.js.map +1 -0
  109. package/dist/sqlEngine/functions/registry.d.ts +36 -0
  110. package/dist/sqlEngine/functions/registry.js +33 -0
  111. package/dist/sqlEngine/functions/registry.js.map +1 -0
  112. package/dist/sqlEngine/functions/standard.d.ts +9 -0
  113. package/dist/sqlEngine/functions/standard.js +86 -0
  114. package/dist/sqlEngine/functions/standard.js.map +1 -0
  115. package/dist/sqlEngine/index.d.ts +29 -0
  116. package/dist/sqlEngine/index.js +62 -0
  117. package/dist/sqlEngine/index.js.map +1 -0
  118. package/dist/sqlEngine/logical/build.d.ts +20 -0
  119. package/dist/sqlEngine/logical/build.js +326 -0
  120. package/dist/sqlEngine/logical/build.js.map +1 -0
  121. package/dist/sqlEngine/logical/op.d.ts +105 -0
  122. package/dist/sqlEngine/logical/op.js +10 -0
  123. package/dist/sqlEngine/logical/op.js.map +1 -0
  124. package/dist/sqlEngine/optimizer/joinAnalysis.d.ts +37 -0
  125. package/dist/sqlEngine/optimizer/joinAnalysis.js +144 -0
  126. package/dist/sqlEngine/optimizer/joinAnalysis.js.map +1 -0
  127. package/dist/sqlEngine/optimizer/optimize.d.ts +13 -0
  128. package/dist/sqlEngine/optimizer/optimize.js +53 -0
  129. package/dist/sqlEngine/optimizer/optimize.js.map +1 -0
  130. package/dist/sqlEngine/optimizer/ruleEngine.d.ts +7 -0
  131. package/dist/sqlEngine/optimizer/ruleEngine.js +26 -0
  132. package/dist/sqlEngine/optimizer/ruleEngine.js.map +1 -0
  133. package/dist/sqlEngine/optimizer/rules/limitPushdown.d.ts +19 -0
  134. package/dist/sqlEngine/optimizer/rules/limitPushdown.js +55 -0
  135. package/dist/sqlEngine/optimizer/rules/limitPushdown.js.map +1 -0
  136. package/dist/sqlEngine/optimizer/rules/planJoins.d.ts +19 -0
  137. package/dist/sqlEngine/optimizer/rules/planJoins.js +62 -0
  138. package/dist/sqlEngine/optimizer/rules/planJoins.js.map +1 -0
  139. package/dist/sqlEngine/optimizer/rules/predicateNormalize.d.ts +18 -0
  140. package/dist/sqlEngine/optimizer/rules/predicateNormalize.js +80 -0
  141. package/dist/sqlEngine/optimizer/rules/predicateNormalize.js.map +1 -0
  142. package/dist/sqlEngine/optimizer/rules/predicatePushdown.d.ts +11 -0
  143. package/dist/sqlEngine/optimizer/rules/predicatePushdown.js +48 -0
  144. package/dist/sqlEngine/optimizer/rules/predicatePushdown.js.map +1 -0
  145. package/dist/sqlEngine/optimizer/rules/projectionPushdown.d.ts +23 -0
  146. package/dist/sqlEngine/optimizer/rules/projectionPushdown.js +160 -0
  147. package/dist/sqlEngine/optimizer/rules/projectionPushdown.js.map +1 -0
  148. package/dist/sqlEngine/optimizer/rules/sortFromIndex.d.ts +14 -0
  149. package/dist/sqlEngine/optimizer/rules/sortFromIndex.js +49 -0
  150. package/dist/sqlEngine/optimizer/rules/sortFromIndex.js.map +1 -0
  151. package/dist/sqlEngine/optimizer/rules/validateScannable.d.ts +15 -0
  152. package/dist/sqlEngine/optimizer/rules/validateScannable.js +60 -0
  153. package/dist/sqlEngine/optimizer/rules/validateScannable.js.map +1 -0
  154. package/dist/sqlEngine/optimizer/whereToConditions.d.ts +85 -0
  155. package/dist/sqlEngine/optimizer/whereToConditions.js +484 -0
  156. package/dist/sqlEngine/optimizer/whereToConditions.js.map +1 -0
  157. package/dist/sqlEngine/parser/ast.d.ts +139 -0
  158. package/dist/sqlEngine/parser/ast.js +10 -0
  159. package/dist/sqlEngine/parser/ast.js.map +1 -0
  160. package/dist/sqlEngine/parser/normalizer.d.ts +28 -0
  161. package/dist/sqlEngine/parser/normalizer.js +413 -0
  162. package/dist/sqlEngine/parser/normalizer.js.map +1 -0
  163. package/dist/sqlEngine/parser/parse.d.ts +14 -0
  164. package/dist/sqlEngine/parser/parse.js +20 -0
  165. package/dist/sqlEngine/parser/parse.js.map +1 -0
  166. package/dist/sqlEngine/physical/PhysicalDistinct.d.ts +9 -0
  167. package/dist/sqlEngine/physical/PhysicalDistinct.js +29 -0
  168. package/dist/sqlEngine/physical/PhysicalDistinct.js.map +1 -0
  169. package/dist/sqlEngine/physical/PhysicalFilter.d.ts +10 -0
  170. package/dist/sqlEngine/physical/PhysicalFilter.js +25 -0
  171. package/dist/sqlEngine/physical/PhysicalFilter.js.map +1 -0
  172. package/dist/sqlEngine/physical/PhysicalHashAggregate.d.ts +27 -0
  173. package/dist/sqlEngine/physical/PhysicalHashAggregate.js +100 -0
  174. package/dist/sqlEngine/physical/PhysicalHashAggregate.js.map +1 -0
  175. package/dist/sqlEngine/physical/PhysicalHashJoin.d.ts +29 -0
  176. package/dist/sqlEngine/physical/PhysicalHashJoin.js +93 -0
  177. package/dist/sqlEngine/physical/PhysicalHashJoin.js.map +1 -0
  178. package/dist/sqlEngine/physical/PhysicalIndexNestedLoopJoin.d.ts +36 -0
  179. package/dist/sqlEngine/physical/PhysicalIndexNestedLoopJoin.js +78 -0
  180. package/dist/sqlEngine/physical/PhysicalIndexNestedLoopJoin.js.map +1 -0
  181. package/dist/sqlEngine/physical/PhysicalIndexScan.d.ts +22 -0
  182. package/dist/sqlEngine/physical/PhysicalIndexScan.js +77 -0
  183. package/dist/sqlEngine/physical/PhysicalIndexScan.js.map +1 -0
  184. package/dist/sqlEngine/physical/PhysicalLimit.d.ts +5 -0
  185. package/dist/sqlEngine/physical/PhysicalLimit.js +28 -0
  186. package/dist/sqlEngine/physical/PhysicalLimit.js.map +1 -0
  187. package/dist/sqlEngine/physical/PhysicalNestedLoopJoin.d.ts +20 -0
  188. package/dist/sqlEngine/physical/PhysicalNestedLoopJoin.js +51 -0
  189. package/dist/sqlEngine/physical/PhysicalNestedLoopJoin.js.map +1 -0
  190. package/dist/sqlEngine/physical/PhysicalProject.d.ts +18 -0
  191. package/dist/sqlEngine/physical/PhysicalProject.js +117 -0
  192. package/dist/sqlEngine/physical/PhysicalProject.js.map +1 -0
  193. package/dist/sqlEngine/physical/PhysicalQualify.d.ts +13 -0
  194. package/dist/sqlEngine/physical/PhysicalQualify.js +29 -0
  195. package/dist/sqlEngine/physical/PhysicalQualify.js.map +1 -0
  196. package/dist/sqlEngine/physical/PhysicalSort.d.ts +12 -0
  197. package/dist/sqlEngine/physical/PhysicalSort.js +59 -0
  198. package/dist/sqlEngine/physical/PhysicalSort.js.map +1 -0
  199. package/dist/sqlEngine/physical/op.d.ts +11 -0
  200. package/dist/sqlEngine/physical/op.js +9 -0
  201. package/dist/sqlEngine/physical/op.js.map +1 -0
  202. package/dist/sqlEngine/physical/plan.d.ts +23 -0
  203. package/dist/sqlEngine/physical/plan.js +162 -0
  204. package/dist/sqlEngine/physical/plan.js.map +1 -0
  205. package/dist/sqlEngine/router.d.ts +26 -0
  206. package/dist/sqlEngine/router.js +48 -0
  207. package/dist/sqlEngine/router.js.map +1 -0
  208. package/dist/sqlEngine/types.d.ts +37 -0
  209. package/dist/sqlEngine/types.js +13 -0
  210. package/dist/sqlEngine/types.js.map +1 -0
  211. package/dist/sqlTranslator/index.js +7 -1
  212. package/dist/sqlTranslator/index.js.map +1 -1
  213. package/dist/utility/common_utils.js +25 -0
  214. package/dist/utility/common_utils.js.map +1 -1
  215. package/dist/utility/install/installer.d.ts +9 -1
  216. package/dist/utility/install/installer.js +21 -0
  217. package/dist/utility/install/installer.js.map +1 -1
  218. package/dist/validation/configValidator.js +3 -0
  219. package/dist/validation/configValidator.js.map +1 -1
  220. package/dist/validation/deleteValidator.js +10 -2
  221. package/dist/validation/deleteValidator.js.map +1 -1
  222. package/npm-shrinkwrap.json +272 -230
  223. package/package.json +3 -3
  224. package/resources/DESIGN.md +17 -16
  225. package/resources/DatabaseTransaction.ts +95 -0
  226. package/resources/RequestTarget.ts +12 -3
  227. package/resources/Resource.ts +23 -2
  228. package/resources/Table.ts +57 -6
  229. package/resources/analytics/metadata.ts +3 -0
  230. package/resources/analytics/write.ts +23 -0
  231. package/resources/databases.ts +24 -0
  232. package/resources/defineResource.ts +17 -4
  233. package/resources/jsResource.ts +61 -2
  234. package/resources/openApi.ts +44 -19
  235. package/resources/scheduler/CronExpression.ts +394 -0
  236. package/resources/scheduler/engine.ts +812 -0
  237. package/resources/scheduler/scheduler.ts +236 -0
  238. package/security/auth.ts +1 -0
  239. package/security/jsLoader.ts +8 -0
  240. package/security/keys.ts +152 -0
  241. package/server/REST.ts +70 -1
  242. package/server/Server.ts +6 -0
  243. package/server/http.ts +122 -15
  244. package/server/operationsServer.ts +5 -3
  245. package/server/serverHelpers/progressEmitter.ts +5 -1
  246. package/server/threads/threadServer.js +9 -5
  247. package/sqlTranslator/index.ts +16 -6
  248. package/studio/web/assets/{Chat-CTjtL8Z4.js → Chat-DHP4XpID.js} +2 -2
  249. package/studio/web/assets/{Chat-CTjtL8Z4.js.map → Chat-DHP4XpID.js.map} +1 -1
  250. package/studio/web/assets/{FloatingChat-CafHR4Ur.js → FloatingChat-CJ7PssCv.js} +4 -4
  251. package/studio/web/assets/{FloatingChat-CafHR4Ur.js.map → FloatingChat-CJ7PssCv.js.map} +1 -1
  252. package/studio/web/assets/{applications-Buh_q0Vj.js → applications-DxXiGpsR.js} +2 -2
  253. package/studio/web/assets/{applications-Buh_q0Vj.js.map → applications-DxXiGpsR.js.map} +1 -1
  254. package/studio/web/assets/{index-0hXeECkS.js → index-BdbBanDP.js} +6 -6
  255. package/studio/web/assets/{index-0hXeECkS.js.map → index-BdbBanDP.js.map} +1 -1
  256. package/studio/web/assets/{index.lazy-B00B7VBT.js → index.lazy-B2eH28zD.js} +4 -4
  257. package/studio/web/assets/{index.lazy-B00B7VBT.js.map → index.lazy-B2eH28zD.js.map} +1 -1
  258. package/studio/web/assets/{profile-Cg2wwYPn.js → profile-DK5hgucv.js} +2 -2
  259. package/studio/web/assets/{profile-Cg2wwYPn.js.map → profile-DK5hgucv.js.map} +1 -1
  260. package/studio/web/assets/{setComponentFile-DCaDIvyB.js → setComponentFile-BVDWRYxx.js} +2 -2
  261. package/studio/web/assets/{setComponentFile-DCaDIvyB.js.map → setComponentFile-BVDWRYxx.js.map} +1 -1
  262. package/studio/web/assets/{setup-CAVcAQjK.js → setup-DJ9BInoK.js} +2 -2
  263. package/studio/web/assets/{setup-CAVcAQjK.js.map → setup-DJ9BInoK.js.map} +1 -1
  264. package/studio/web/assets/{status-BRXorNdD.js → status-B_qzmgfD.js} +2 -2
  265. package/studio/web/assets/{status-BRXorNdD.js.map → status-B_qzmgfD.js.map} +1 -1
  266. package/studio/web/assets/{swagger-ui-react-Dy1D62vO.js → swagger-ui-react-DOL5jCqg.js} +2 -2
  267. package/studio/web/assets/{swagger-ui-react-Dy1D62vO.js.map → swagger-ui-react-DOL5jCqg.js.map} +1 -1
  268. package/studio/web/assets/{tsMode-A8gbL74v.js → tsMode-DpxUxfTW.js} +2 -2
  269. package/studio/web/assets/{tsMode-A8gbL74v.js.map → tsMode-DpxUxfTW.js.map} +1 -1
  270. package/studio/web/assets/{useEntityRestURL-cDodrVcQ.js → useEntityRestURL-CU_lY6XW.js} +2 -2
  271. package/studio/web/assets/{useEntityRestURL-cDodrVcQ.js.map → useEntityRestURL-CU_lY6XW.js.map} +1 -1
  272. package/studio/web/index.html +1 -1
  273. package/utility/common_utils.ts +26 -0
  274. package/utility/install/installer.ts +26 -1
  275. package/validation/configValidator.ts +3 -0
  276. package/validation/deleteValidator.ts +11 -2
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@harperfast/harper",
3
3
  "description": "Harper is an open-source Node.js performance platform that unifies database, cache, application, and messaging layers into one in-memory process.",
4
- "version": "5.2.0-alpha.6",
4
+ "version": "5.2.0-beta.2",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://harper.fast",
7
7
  "bugs": {
@@ -167,14 +167,14 @@
167
167
  },
168
168
  "dependencies": {
169
169
  "@aws-sdk/client-s3": "^3.1012.0",
170
- "@aws-sdk/lib-storage": "3.1080.0",
170
+ "@aws-sdk/lib-storage": "3.1086.0",
171
171
  "@endo/static-module-record": "^1.1.2",
172
172
  "@fastify/autoload": "^6.3.1",
173
173
  "@fastify/compress": "^8.3.1",
174
174
  "@fastify/cors": "^11.2.0",
175
175
  "@fastify/static": "^9.1.3",
176
176
  "@harperfast/extended-iterable": "^1.0.1",
177
- "@harperfast/rocksdb-js": "^2.4.0",
177
+ "@harperfast/rocksdb-js": "^2.5.0",
178
178
  "@harperfast/skills": "^1.10.8",
179
179
  "@turf/area": "6.5.0",
180
180
  "@turf/boolean-contains": "6.5.0",
@@ -78,22 +78,23 @@ One giant `makeTable()` factory that returns a `TableResource extends Resource`
78
78
 
79
79
  ## "Where is X" cheat sheet
80
80
 
81
- | Question | Where |
82
- | --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
83
- | How is a CRUD request authorized? | `Table.ts → #section: authz-hooks`; defaults in `Resource.ts` (`allowRead` etc.) |
84
- | Where does versioning / conflict resolution happen? | `Table.ts → _writeUpdate` (`#section: write-path-internals`) |
85
- | How does `search()` choose an index? | `Table.ts → search` (`#section: search-query`) |
86
- | How are subscriptions replayed? | `Table.ts → subscribe` (`#section: pub-sub`) |
87
- | How is the response body shaped (select clause)? | `Table.ts → transformEntryForSelect` (`#section: search-query`) |
88
- | Where is record-level TTL evaluated? | `Table.ts → setTTLExpiration` (`#section: lifecycle-admin`); `Updatable.getExpiresAt` (`#section: setup-and-factory`) |
89
- | How are residencies enforced (replication)? | `Table.ts → #section: lifecycle-admin` (residency block: `getResidencyRecord`, `setResidency`, `setResidencyById`, `getResidency`) |
90
- | How is the RecordObject prototype applied? | `RecordEncoder.ts` (see `../DESIGN.md`) |
91
- | Where is the per-request transaction stored? | `transaction.ts` + `contextStorage` (AsyncLocalStorage) |
92
- | How does a query opt out of a read snapshot? | Pass `snapshot: false` on the search request (e.g. `get_analytics`). `Table.ts → search` calls `txn.useReadTxn(snapshot === false)`; on RocksDB `DatabaseTransaction.getReadTxn` then builds the read txn with `{ disableSnapshot: true }` so a long scan reads latest without pinning a snapshot. No-op on LMDB (`LMDBTransaction.useReadTxn`). |
93
- | How does a URL path map to a Resource? | `Resources.ts → getMatch` (exact/prefix fast path) then `matchParamRoute` (parameterised routes); see "Path routing" below |
94
- | How does HNSW keep the graph connected on delete? | `indexes/HierarchicalNavigableSmallWorld.ts → index()` delete path: zero-degree orphans reindexed via `needsReindexing`; severed multi-node islands detected and reconnected by `repairSeveredNeighbors` (#1712) |
95
- | How is a filter applied _during_ a vector search? | Predicate-aware traversal (#1241): `search.ts → executeConditions` composes companion AND conditions + a request `vectorFilter` + a record-scoped `allowRead` override into one `(primaryKey) => boolean` (`composeRecordFilter`) and passes it to `HierarchicalNavigableSmallWorld.search(cond, ctx, filter)`. The filter gates result admission at layer 0 only (routing ignores it, ACORN-style); a visit budget (`filterExpansion`) bounds the under-filled/selective case. Very selective _condition_ filters are instead diverted to the exact brute-force path by the query planner's `estimateCountAsSort` ordering. |
96
- | How is row-level read access control enforced? | Unified `allowRead` (#1422 gap 2 / #1241): an application-OVERRIDDEN `allowRead` (detected via the `isDefaultAllowRead` marker on the framework defaults) is record-scoped evaluated once per record with `this` = the (frozen) record during query execution, fail-closed on throw, dispatched via the resolved method (never `record.allowRead` lookup data shadowing). The authorize wrapper (`Resource.ts → authorizeActionOnResource`) defers collection reads on tables (`supportsRowLevelAllowRead`) to this per-record path; single-record `get` keeps the entry check (record loaded, proxied reads work). Records also expose a non-enumerable `allowRead` delegate on the per-table `structPrototype`. |
81
+ | Question | Where |
82
+ | --------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
83
+ | How is a CRUD request authorized? | `Table.ts → #section: authz-hooks`; defaults in `Resource.ts` (`allowRead` etc.) |
84
+ | Where does versioning / conflict resolution happen? | `Table.ts → _writeUpdate` (`#section: write-path-internals`) |
85
+ | How does `search()` choose an index? | `Table.ts → search` (`#section: search-query`) |
86
+ | How are subscriptions replayed? | `Table.ts → subscribe` (`#section: pub-sub`) |
87
+ | How is the response body shaped (select clause)? | `Table.ts → transformEntryForSelect` (`#section: search-query`) |
88
+ | Where is record-level TTL evaluated? | `Table.ts → setTTLExpiration` (`#section: lifecycle-admin`); `Updatable.getExpiresAt` (`#section: setup-and-factory`). Stored expiry metadata is resolved in the `_writeUpdate` commit closure: `options.expiresAt ?? context.expiresAt ?? (record @expiresAt field, if finite & ≥ 0) ?? table default`. This metadata drives read-hiding + the cleanup sweep. The `@expiresAt` attribute is authoritative for **direct** put/patch only; cache/source fills persist via `recordUpdater` and derive expiry from `sourceContext.expiresAt` (source freshness / table default), not the field. |
89
+ | Why does `search()` hide a row that's past its TTL but not yet swept? | `Table.ts → transformEntryForSelect` unconditionally treats `entry.expiresAt < Date.now()` as gone (lazy eviction on read) — correct for a SELECT, but a mutation locating rows to overwrite needs the opposite: pass `target.includeExpired = true` (read by the SQL engine's `runUpdate`/`runDelete` via `SqlEngineContext.includeExpiredRows`) to treat such a row as a live match, matching the leniency a direct by-id `put`/`patch` already has (they skip this check entirely, since `Resource.patch`'s static options don't request `ensureLoaded`). |
90
+ | How are residencies enforced (replication)? | `Table.ts → #section: lifecycle-admin` (residency block: `getResidencyRecord`, `setResidency`, `setResidencyById`, `getResidency`) |
91
+ | How is the RecordObject prototype applied? | `RecordEncoder.ts` (see `../DESIGN.md`) |
92
+ | Where is the per-request transaction stored? | `transaction.ts` + `contextStorage` (AsyncLocalStorage) |
93
+ | How does a query opt out of a read snapshot? | Pass `snapshot: false` on the search request (e.g. `get_analytics`). `Table.ts → search` calls `txn.useReadTxn(snapshot === false)`; on RocksDB `DatabaseTransaction.getReadTxn` then builds the read txn with `{ disableSnapshot: true }` so a long scan reads latest without pinning a snapshot. No-op on LMDB (`LMDBTransaction.useReadTxn`). |
94
+ | How does a URL path map to a Resource? | `Resources.ts → getMatch` (exact/prefix fast path) then `matchParamRoute` (parameterised routes); see "Path routing" below |
95
+ | How does HNSW keep the graph connected on delete? | `indexes/HierarchicalNavigableSmallWorld.ts → index()` delete path: zero-degree orphans reindexed via `needsReindexing`; severed multi-node islands detected and reconnected by `repairSeveredNeighbors` (#1712) |
96
+ | How is a filter applied _during_ a vector search? | Predicate-aware traversal (#1241): `search.ts executeConditions` composes companion AND conditions + a request `vectorFilter` + a record-scoped `allowRead` override into one `(primaryKey) => boolean` (`composeRecordFilter`) and passes it to `HierarchicalNavigableSmallWorld.search(cond, ctx, filter)`. The filter gates result admission at layer 0 only (routing ignores it, ACORN-style); a visit budget (`filterExpansion`) bounds the under-filled/selective case. Very selective _condition_ filters are instead diverted to the exact brute-force path by the query planner's `estimateCountAsSort` ordering. |
97
+ | How is row-level read access control enforced? | Unified `allowRead` (#1422 gap 2 / #1241): an application-OVERRIDDEN `allowRead` (detected via the `isDefaultAllowRead` marker on the framework defaults) is record-scoped — evaluated once per record with `this` = the (frozen) record during query execution, fail-closed on throw, dispatched via the resolved method (never `record.allowRead` lookup — data shadowing). The authorize wrapper (`Resource.ts → authorizeActionOnResource`) defers collection reads on tables (`supportsRowLevelAllowRead`) to this per-record path; single-record `get` keeps the entry check (record loaded, proxied reads work). Records also expose a non-enumerable `allowRead` delegate on the per-table `structPrototype`. |
97
98
 
98
99
  ---
99
100
 
@@ -27,6 +27,80 @@ const MAX_RETRIES = 40;
27
27
  // cap (see the commit rejection handler), don't grow the delay unbounded.
28
28
  const MAX_RETRY_DELAY_MS = 1000;
29
29
  let outstandingCommit, outstandingCommitStart;
30
+
31
+ // The analytics module registers a recorder here at load (dependency inversion, mirroring
32
+ // `replicationConfirmation` below) so the storage layer doesn't statically import the analytics/server
33
+ // modules. Unset until analytics loads, and when analytics is disabled the recorder call is cheap.
34
+ let recordCommitLatencyMs: ((durationMs: number) => void) | undefined;
35
+ export function setCommitLatencyRecorder(recorder: ((durationMs: number) => void) | undefined) {
36
+ recordCommitLatencyMs = recorder;
37
+ }
38
+
39
+ // Emit the submit→settle duration of a write commit as the `transaction-commit-time` distribution
40
+ // metric. Recorded on both fulfilment and rejection since a slow-then-failed commit still consumed
41
+ // queue time. The recorder is wrapped so it can never throw — a metrics failure must neither break the
42
+ // commit nor surface as an unhandled rejection on this floating `.then`. The thenable guard protects
43
+ // against a future caller passing a non-Promise `commitResolution` (today it is always the rocksdb-js
44
+ // async `Transaction.commit()` result, which is guaranteed to be a Promise).
45
+ function recordCommitLatency(commitResolution: Promise<void>, submittedAt: number) {
46
+ if (!recordCommitLatencyMs) return;
47
+ const record = () => {
48
+ try {
49
+ recordCommitLatencyMs(performance.now() - submittedAt);
50
+ } catch {
51
+ // analytics recording is best-effort and must never disturb the commit path
52
+ }
53
+ };
54
+ if (commitResolution && typeof (commitResolution as any).then === 'function') {
55
+ commitResolution.then(record, record);
56
+ }
57
+ }
58
+
59
+ // Queue-depth gauges surfaced through the analytics pipeline (write-transaction-queue-depth /
60
+ // read-transaction-queue-depth). Per-thread state; the analytics aggregator sums across threads.
61
+ // `writeTxnQueueDepth` counts write commits handed to the storage engine but not yet resolved —
62
+ // this is the backlog that, when it drains too slowly, produces the "Outstanding write transactions
63
+ // have too long of queue" overload error. Read depth is derived from the live `trackedTxns` set
64
+ // (every tracked transaction holds an open read snapshot). We also retain a high-water mark per
65
+ // sampling window because the queue can fill and drain within a single (~1s) analytics period, so an
66
+ // instantaneous sample taken at emit time would routinely miss the spike operators need to see.
67
+ // RocksDB-write-path only: LMDB routes through the separate LMDBTransaction.commit()/getReadTxn()
68
+ // overrides (resources/LMDBTransaction.ts), which maintain their own unrelated `trackedTxns` set and
69
+ // do not call into this accounting.
70
+ let writeTxnQueueDepth = 0;
71
+ let writeTxnQueueDepthHighWater = 0;
72
+ let readTxnQueueDepthHighWater = 0;
73
+
74
+ function enterWriteQueue() {
75
+ if (++writeTxnQueueDepth > writeTxnQueueDepthHighWater) writeTxnQueueDepthHighWater = writeTxnQueueDepth;
76
+ }
77
+ function leaveWriteQueue() {
78
+ // Floor at zero: accounting is balanced by construction (every enterWriteQueue has exactly one
79
+ // matching settlement), but the guard is cheap insurance against a future call-site imbalance
80
+ // producing a negative depth that would corrupt every subsequent sample.
81
+ if (writeTxnQueueDepth > 0) writeTxnQueueDepth--;
82
+ }
83
+
84
+ /**
85
+ * Returns the current write/read transaction queue depths for this thread along with the high-water
86
+ * mark observed since the previous call, then resets the high-water marks to the current depth so the
87
+ * next sampling window starts fresh. Consumed by the analytics writer (see analytics/write.ts).
88
+ */
89
+ export function getTransactionQueueDepths() {
90
+ // `readTxnQueueDepthHighWater` is maintained at the single trackedTxns growth site, so it already
91
+ // dominates the current size here — no need to reconcile against `readDepth` before reporting.
92
+ const readDepth = trackedTxns.size;
93
+ const depths = {
94
+ writeDepth: writeTxnQueueDepth,
95
+ writeMaxDepth: writeTxnQueueDepthHighWater,
96
+ readDepth,
97
+ readMaxDepth: readTxnQueueDepthHighWater,
98
+ };
99
+ writeTxnQueueDepthHighWater = writeTxnQueueDepth;
100
+ readTxnQueueDepthHighWater = readDepth;
101
+ return depths;
102
+ }
103
+
30
104
  let confirmReplication;
31
105
  export function replicationConfirmation(callback) {
32
106
  confirmReplication = callback;
@@ -161,6 +235,7 @@ export class DatabaseTransaction implements Transaction {
161
235
  }
162
236
  if ((this.transaction as any).openTimer) (this.transaction as any).openTimer = 0;
163
237
  trackedTxns.add(this);
238
+ if (trackedTxns.size > readTxnQueueDepthHighWater) readTxnQueueDepthHighWater = trackedTxns.size;
164
239
  return this.transaction;
165
240
  }
166
241
 
@@ -329,6 +404,26 @@ export class DatabaseTransaction implements Transaction {
329
404
  // cast to Promise<void> is safe — the sentinel never propagates
330
405
  // past that branch.
331
406
  commitResolution = transaction.commit() as Promise<void>;
407
+ // Record how long this commit stays outstanding (submit → settle) as a distribution
408
+ // metric. This is the same clock the overload check uses (outstandingCommitStart is
409
+ // stamped at submit), so a rising p99/p999 is the leading indicator for the
410
+ // "Outstanding write transactions have too long of queue" (503) rejection. A transient-
411
+ // conflict retry rejects this promise and issues a fresh commit(), and outstandingCommit
412
+ // re-arms per attempt, so recording per attempt matches the overload semantics.
413
+ recordCommitLatency(commitResolution, performance.now());
414
+ // Count this commit against the write queue depth until the storage engine
415
+ // resolves it. A transient-conflict retry rejects this promise and issues a
416
+ // fresh commit() (re-entering here), so the enter/leave stays balanced. leaveWriteQueue
417
+ // never throws, so the settled promise resolves and needs no rejection handling of its own.
418
+ // The thenable guard protects against a future caller passing a non-Promise
419
+ // `commitResolution` (today it is always rocksdb-js's async Transaction.commit()
420
+ // result, guaranteed to be a Promise).
421
+ enterWriteQueue();
422
+ if (commitResolution && typeof (commitResolution as any).then === 'function') {
423
+ commitResolution.then(leaveWriteQueue, leaveWriteQueue);
424
+ } else {
425
+ leaveWriteQueue();
426
+ }
332
427
  } else {
333
428
  try {
334
429
  commitResolution = transaction.abort();
@@ -111,17 +111,26 @@ export class RequestTarget extends URLSearchParams {
111
111
  if (path) {
112
112
  // parse for properties and set the id
113
113
  if (path.startsWith('/')) path = path.substring(1);
114
- } else {
115
- return; // leave this.id undefined
114
+ } else if (target === undefined) {
115
+ return; // constructed with no target at all (internal use) — leave id/isCollection unset
116
116
  }
117
117
  if (path) {
118
118
  if (path.endsWith('/')) {
119
119
  this.isCollection = true;
120
120
  }
121
121
  this.id = decodeURIComponent(path);
122
- } else {
122
+ } else if (this.pathname === '/') {
123
+ // a bare trailing slash is the documented way to address a resource's collection
123
124
  this.isCollection = true;
124
125
  this.id = null;
126
+ } else {
127
+ // an exact resource-path match with nothing left to parse and no trailing slash
128
+ // (e.g. `/redirects` instead of the required `/redirects/`, harper#678): this is
129
+ // neither a valid collection request nor a specific record, so id/isCollection must
130
+ // still be well-defined (never left `undefined`) for dispatch to reject it cleanly
131
+ // instead of letting downstream code assume one of them is always set.
132
+ this.isCollection = false;
133
+ this.id = null;
125
134
  }
126
135
  }
127
136
  toString() {
@@ -689,8 +689,11 @@ function transactional(
689
689
  query = new RequestTarget();
690
690
  query.id = id;
691
691
  if (id == null) {
692
- if (options.method === 'get') {
693
- throw new Error(`Using an argument with a value of ${id} for ${options.method}, is not allowed`);
692
+ if (options.method === 'get' || options.method === 'delete') {
693
+ // For delete, a bare null/undefined id would be coerced into a whole-collection
694
+ // target and silently delete every record (studio#1199); a deliberate delete-all
695
+ // must use an explicit query object instead.
696
+ throw new ClientError(`Using an argument with a value of ${id} for ${options.method}, is not allowed`);
694
697
  }
695
698
  query.isCollection = true;
696
699
  }
@@ -701,6 +704,24 @@ function transactional(
701
704
  query.id = id;
702
705
  }
703
706
  isCollection = query.isCollection;
707
+ if (
708
+ options.method === 'post' &&
709
+ query.id === null &&
710
+ !isCollection &&
711
+ this.prototype.post === Resource.prototype.post
712
+ ) {
713
+ // the matched path had nothing left to resolve into a collection or a specific record —
714
+ // i.e. it exactly matched a resource's base path without the required trailing slash
715
+ // (harper#678). This only matters for the base/default post() dispatch: it reads
716
+ // this.#isCollection (set from this same query) and falls back to missingMethod() for
717
+ // this state anyway, so rejecting early here just gives a clearer, purpose-built message.
718
+ // A resource with its own post() override (e.g. a component doing a bulk import via
719
+ // POST to its collection root, like the redirector template's Redirect.post()) is
720
+ // trusted to handle a null-id/non-collection target itself — it may not use id/isCollection
721
+ // at all, and forcing the trailing slash on it would break a currently-supported no-slash
722
+ // bulk-POST convention. See harper#678's regression on PR #1807.
723
+ throw new ClientError(`A trailing slash is required to POST to the ${this.name} collection`, 404);
724
+ }
704
725
  let resourceOptions;
705
726
  if (!context) {
706
727
  // try to get the context from the async context if possible
@@ -1244,7 +1244,7 @@ export function makeTable(options) {
1244
1244
  // go back to the static search method so it gets a chance to override
1245
1245
  return constructor.search(target, this.getContext());
1246
1246
  }
1247
- if (target && target.id === undefined && !target.toString()) {
1247
+ if (target && target.id == null && !target.toString()) {
1248
1248
  const description = {
1249
1249
  // basically a describe call
1250
1250
  records: './', // an href to the records themselves
@@ -2081,8 +2081,10 @@ export function makeTable(options) {
2081
2081
  const type = fullUpdate ? 'put' : 'patch';
2082
2082
  let residencyId: number | undefined;
2083
2083
  if (options?.residencyId != undefined) residencyId = options.residencyId;
2084
- const expiresAt: number =
2085
- options?.expiresAt ?? context?.expiresAt ?? (expirationMs ? expirationMs + Date.now() : -1);
2084
+ // options/context expiresAt are the most specific overrides; a record @expiresAt field
2085
+ // (resolved below, once recordToStore is merged) overrides the table default in both
2086
+ // directions; the table default is the final fallback. -1 means no expiration.
2087
+ let expiresAt: number | undefined = options?.expiresAt ?? context?.expiresAt;
2086
2088
  const additionalAuditRefs: Array<{ version: number; nodeId: number }> = []; // track additional audit refs to store
2087
2089
  // Bulk base-copy snapshot apply: store current-state directly with no audit/transaction-log entry
2088
2090
  // and no out-of-order resequencing/dedup (the source of the O(n) keyed-lookup spin in
@@ -2499,6 +2501,35 @@ export function makeTable(options) {
2499
2501
  }
2500
2502
  residencyId = getResidencyId(residency);
2501
2503
  }
2504
+ if (expiresAt == undefined) {
2505
+ // A schema @expiresAt attribute makes the record field authoritative over the table
2506
+ // default, in both directions: stamp it into the stored expiry metadata that governs
2507
+ // read-hiding and the cleanup sweep, not just the separate index-pruning sweep (which
2508
+ // only removes already-past records and so can never extend past the table default).
2509
+ // Read from recordToStore so the metadata matches exactly what the pruning sweep later
2510
+ // reads back. Falls back to the table default when the field is unset or not a timestamp.
2511
+ const fieldExpiresAt = expiresAtProperty ? recordToStore?.[expiresAtProperty.name] : undefined;
2512
+ // Coerce only genuine timestamp shapes: a number/bigint epoch, a Date, or a numeric/ISO
2513
+ // string. Booleans, empty/whitespace strings, and null/undefined fall through to NaN so a
2514
+ // nonsensical field value uses the table default rather than expiring the record at epoch 0.
2515
+ let fieldExpiresAtMs = NaN;
2516
+ if (typeof fieldExpiresAt === 'number' || typeof fieldExpiresAt === 'bigint')
2517
+ fieldExpiresAtMs = Number(fieldExpiresAt);
2518
+ else if (fieldExpiresAt instanceof Date) fieldExpiresAtMs = fieldExpiresAt.getTime();
2519
+ else if (typeof fieldExpiresAt === 'string' && fieldExpiresAt.trim() !== '') {
2520
+ const numeric = Number(fieldExpiresAt);
2521
+ fieldExpiresAtMs = Number.isFinite(numeric) ? numeric : Date.parse(fieldExpiresAt);
2522
+ }
2523
+ // Only a finite, non-negative epoch counts: negatives collide with the -1 "no expiration"
2524
+ // sentinel (the encoder omits HAS_EXPIRATION for <0, but the field sweep would still evict a
2525
+ // negative field value), so treat a negative/NaN field as unset and use the table default.
2526
+ expiresAt =
2527
+ Number.isFinite(fieldExpiresAtMs) && fieldExpiresAtMs >= 0
2528
+ ? fieldExpiresAtMs
2529
+ : expirationMs
2530
+ ? expirationMs + Date.now()
2531
+ : -1;
2532
+ }
2502
2533
  if (!fullUpdate) {
2503
2534
  // we use our own data as the basis for the audit record, which will include information about the incremental updates, even if it was overwritten by CRDT resolution
2504
2535
  auditRecordToStore = recordUpdate;
@@ -2989,6 +3020,14 @@ export function makeTable(options) {
2989
3020
  // there, after materialization (the earlier evaluation stays as a prune that also bounds HNSW
2990
3021
  // traversal). vectorFilter and condition filters intentionally keep the local-record
2991
3022
  // semantics all query filters have on caching tables.
3023
+ //
3024
+ // A row that is past its TTL but not yet swept by the background eviction
3025
+ // scan is still physically present. A write that is about to overwrite it
3026
+ // anyway (e.g. the SQL engine locating UPDATE/DELETE targets) needs to see
3027
+ // it as a match — the same leniency a direct by-id put/patch already gets,
3028
+ // since those never run the ensureLoaded-gated freshness check this transform
3029
+ // otherwise applies unconditionally to every read.
3030
+ const includeExpired = (target as any).includeExpired === true;
2992
3031
  const transformToRecord = TableResource.transformEntryForSelect(
2993
3032
  select,
2994
3033
  context,
@@ -2996,7 +3035,8 @@ export function makeTable(options) {
2996
3035
  filtered,
2997
3036
  ensure_loaded,
2998
3037
  true,
2999
- recordGuard
3038
+ recordGuard,
3039
+ includeExpired
3000
3040
  );
3001
3041
  let results = TableResource.transformToOrderedSelect(
3002
3042
  entries,
@@ -3210,9 +3250,20 @@ export function makeTable(options) {
3210
3250
  * @param recordGuard record-level read check (#1241) applied to the record actually being
3211
3251
  * returned — i.e. AFTER any caching-source revalidation replaces a stale local copy — so an
3212
3252
  * authorization verdict can't be made on bytes that differ from what the caller receives.
3253
+ * @param includeExpired when true, a row past its TTL but not yet swept is treated as a live
3254
+ * match rather than gone (used by the SQL engine's UPDATE/DELETE row-finder).
3213
3255
  * @returns
3214
3256
  */
3215
- static transformEntryForSelect(select, context, readTxn, filtered, ensure_loaded?, canSkip?, recordGuard?) {
3257
+ static transformEntryForSelect(
3258
+ select,
3259
+ context,
3260
+ readTxn,
3261
+ filtered,
3262
+ ensure_loaded?,
3263
+ canSkip?,
3264
+ recordGuard?,
3265
+ includeExpired?
3266
+ ) {
3216
3267
  let checkLoaded;
3217
3268
  if (
3218
3269
  ensure_loaded &&
@@ -3260,7 +3311,7 @@ export function makeTable(options) {
3260
3311
  }
3261
3312
  if (
3262
3313
  (checkLoaded && entry?.metadataFlags & (INVALIDATED | EVICTED)) || // invalidated or evicted should go to load from source
3263
- (entry?.expiresAt != undefined && entry?.expiresAt < Date.now())
3314
+ (!includeExpired && entry?.expiresAt != undefined && entry?.expiresAt < Date.now())
3264
3315
  ) {
3265
3316
  // should expiration really apply?
3266
3317
  if (context.onlyIfCached) {
@@ -8,6 +8,9 @@ export const METRIC = {
8
8
  NODE_STORAGE: 'node-storage',
9
9
  ROCKSDB_STATS: 'rocksdb-stats',
10
10
  ROCKSDB_TXNLOG_STATS: 'rocksdb-txnlog-stats',
11
+ TRANSACTION_COMMIT_TIME: 'transaction-commit-time',
12
+ WRITE_TRANSACTION_QUEUE_DEPTH: 'write-transaction-queue-depth',
13
+ READ_TRANSACTION_QUEUE_DEPTH: 'read-transaction-queue-depth',
11
14
  } as const;
12
15
 
13
16
  export type BuiltInMetricName = (typeof METRIC)[keyof typeof METRIC];
@@ -14,6 +14,7 @@ import { server } from '../../server/Server.ts';
14
14
  import * as fs from 'node:fs';
15
15
  import { getAnalyticsHostnameTable, nodeIds, stableNodeId } from './hostnames.ts';
16
16
  import { METRIC } from './metadata.ts';
17
+ import { getTransactionQueueDepths, setCommitLatencyRecorder } from '../DatabaseTransaction.ts';
17
18
  import { RocksDatabase, type TransactionLogStats } from '@harperfast/rocksdb-js';
18
19
 
19
20
  const log = forComponent('analytics').conditional;
@@ -130,6 +131,9 @@ export function recordAction(value: Value, metric: string, path?: string, method
130
131
 
131
132
  server.recordAnalytics = recordAction;
132
133
 
134
+ // Let the storage layer emit write-commit latency without statically depending on this module.
135
+ setCommitLatencyRecorder((durationMs) => recordAction(durationMs, METRIC.TRANSACTION_COMMIT_TIME));
136
+
133
137
  export function recordActionBinary(value, metric, path?, method?, type?) {
134
138
  recordAction(Boolean(value), metric, path, method, type);
135
139
  }
@@ -213,6 +217,25 @@ function sendAnalytics() {
213
217
  byThread: true,
214
218
  ...memoryUsage,
215
219
  });
220
+ // Transaction queue depth gauges. `depth` is the instantaneous depth at emit time; `maxDepth` is
221
+ // the high-water mark over this sampling window (the queue can fill and drain within a single
222
+ // period, so the instantaneous sample alone would miss short spikes). Reported per-thread and
223
+ // summed across threads by the aggregator, mirroring the `memory` gauge above.
224
+ const queueDepths = getTransactionQueueDepths();
225
+ metrics.push({
226
+ metric: METRIC.WRITE_TRANSACTION_QUEUE_DEPTH,
227
+ threadId,
228
+ byThread: true,
229
+ depth: queueDepths.writeDepth,
230
+ maxDepth: queueDepths.writeMaxDepth,
231
+ });
232
+ metrics.push({
233
+ metric: METRIC.READ_TRANSACTION_QUEUE_DEPTH,
234
+ threadId,
235
+ byThread: true,
236
+ depth: queueDepths.readDepth,
237
+ maxDepth: queueDepths.readMaxDepth,
238
+ });
216
239
  for (const listener of analyticsListeners) {
217
240
  listener(metrics);
218
241
  }
@@ -13,6 +13,7 @@ import { makeTable } from './Table.ts';
13
13
  import OpenEnvironmentObject from '../utility/lmdb/OpenEnvironmentObject.ts';
14
14
  import { CONFIG_PARAMS, LEGACY_DATABASES_DIR_NAME, DATABASES_DIR_NAME } from '../utility/hdbTerms.ts';
15
15
  import { getConfigPath } from '../config/configUtils.ts';
16
+ import { ClientError } from '../utility/errors/hdbError.ts';
16
17
  import { _assignPackageExport } from '../globals.js';
17
18
  import { getIndexedValues } from '../utility/lmdb/commonUtility.ts';
18
19
  import * as signalling from '../utility/signalling.ts';
@@ -1120,6 +1121,29 @@ export function table<TableResourceType>(tableDefinition: TableDefinition): Tabl
1120
1121
  if (Table.primaryStore.rootStore.status === 'closed') {
1121
1122
  throw new Error(`Can not use a closed data store from ${tableName} class`);
1122
1123
  }
1124
+ // Reject moving the primary key to a different attribute on a table that already has records.
1125
+ // The storage key (Table.primaryKey) is never re-pointed here, so honoring the change would
1126
+ // leave describe reporting the new attribute while every record — old and newly inserted — stays
1127
+ // keyed by the original one; search_by_id/update/delete by the declared key then all miss. Only
1128
+ // schema-authored callers (@table / defineTable / create_table) reassert the declaration, so
1129
+ // gate on schemaDefinedExplicit to leave cluster schema-replication / data-loader callers alone.
1130
+ // See HarperFast/studio#1199.
1131
+ const declaredPrimaryKey = attributes.find((attribute) => attribute.isPrimaryKey)?.name;
1132
+ if (schemaDefinedExplicit && declaredPrimaryKey && declaredPrimaryKey !== Table.primaryKey) {
1133
+ let hasRecords = false;
1134
+ for (const _entry of Table.primaryStore.getRange({ start: true })) {
1135
+ hasRecords = true;
1136
+ break;
1137
+ }
1138
+ if (hasRecords) {
1139
+ throw new ClientError(
1140
+ `Cannot change the primary key of table '${databaseName}.${tableName}' from '${Table.primaryKey}' to ` +
1141
+ `'${declaredPrimaryKey}' because it already contains records. Recreate the table with the new primary ` +
1142
+ `key, or migrate the existing records.`,
1143
+ 400
1144
+ );
1145
+ }
1146
+ }
1123
1147
  // it table already exists, get the split segments setting
1124
1148
  if (splitSegments == undefined) splitSegments = Table.splitSegments;
1125
1149
  Table.attributes.splice(0, Table.attributes.length, ...attributes);
@@ -550,12 +550,25 @@ function applyContractMetadata(carrier: any, contract: Contract): void {
550
550
  */
551
551
  function wrapStaticVerb(original: Function, compiled: CompiledVerb): Function {
552
552
  const { queryFragment, bodyFragment, hasBody } = compiled;
553
+ function validateAndDispatch(this: any, target: any, rest: any[], body: any) {
554
+ const issues: ValidationIssue[] = [];
555
+ if (queryFragment) coerceAndValidateQuery(queryFragment, target, issues);
556
+ if (hasBody && bodyFragment) rest[0] = validateBody(bodyFragment, body, issues);
557
+ if (issues.length) throw new ValidationError(issues);
558
+ return original.call(this, target, ...rest);
559
+ }
553
560
  return function (this: any, target: any, ...rest: any[]) {
554
561
  if (target instanceof URLSearchParams) {
555
- const issues: ValidationIssue[] = [];
556
- if (queryFragment) coerceAndValidateQuery(queryFragment, target, issues);
557
- if (hasBody && bodyFragment) rest[0] = validateBody(bodyFragment, rest[0], issues);
558
- if (issues.length) throw new ValidationError(issues);
562
+ const body = rest[0];
563
+ // REST.ts's streaming deserializer hands the body over as a still-pending promise
564
+ // (`request.data`); await it before validating, or it reads as an empty object and every
565
+ // declared-required field is (incorrectly) reported missing. A plain value (as passed by a
566
+ // programmatic call or a unit test) validates synchronously, preserving the synchronous-throw
567
+ // contract those callers rely on.
568
+ if (hasBody && bodyFragment && body && typeof body.then === 'function') {
569
+ return body.then((resolvedBody: any) => validateAndDispatch.call(this, target, rest, resolvedBody));
570
+ }
571
+ return validateAndDispatch.call(this, target, rest, body);
559
572
  }
560
573
  return original.call(this, target, ...rest);
561
574
  };
@@ -71,9 +71,39 @@ export class ResourceLoadError extends Error {
71
71
  *
72
72
  * Thus, this plugin only handle files as they are added (`add` event). All other events result in a restart request.
73
73
  *
74
+ * A redeploy tears down and reinstalls the component's files while this scope's watcher is paused
75
+ * (see `Scope`/`EntryHandler` deploy lifecycle); on resume the fresh chokidar scan re-emits every
76
+ * existing file as `'add'` — including ones whose contents just changed. Treating those as plain
77
+ * adds would silently re-run against the stale module cache and never flag a restart (harper#1817).
78
+ * So we track which files this scope has already loaded: a re-`add` of a known file is a redeploy of
79
+ * loaded code we cannot hot-swap, and is handled like a `change` — request a restart. A first-time
80
+ * `add` (initial load, or a genuinely new file added at runtime) still loads without a restart.
81
+ *
82
+ * A redeploy that *deletes* a loaded file is a different shape of the same problem: the fresh
83
+ * chokidar scan only reports what's currently on disk, so a file that's gone produces no event at
84
+ * all — no re-`add`, no `unlink` — and the modified-file handling above never sees it. Left
85
+ * unhandled, the deleted resource stays registered and active in memory (harper#1817 follow-up). So
86
+ * we also track which files the post-redeploy scan pass reports, and once that scan's `ready` fires,
87
+ * diff it against everything this scope has ever loaded: anything missing was deleted, and is
88
+ * handled the same way as a modified file — request a restart.
89
+ *
90
+ * That diff must only run for an actual redeploy rescan, not every time `EntryHandler` emits
91
+ * `ready` — it also refires after each ordinary runtime add/change once that file's read settles
92
+ * (its initial-scan-complete latch never resets outside a full rescan), and diffing against that
93
+ * would falsely treat every other already-loaded file as deleted. So the diff window is gated by
94
+ * the scope's own `deploy:start`/`deploy:end` bracket (see `Scope`): `deploy:start` pauses the
95
+ * watcher and opens the window (and is where we reset the scan-file tracking, since no file events
96
+ * can land while paused), and the first `ready` afterward — the resumed watcher's fresh scan
97
+ * completing — closes it and runs the diff.
74
98
  */
75
99
  export async function handleApplication(scope: Scope) {
76
- scope.handleEntry(async function handleResourceEntry(entryEvent) {
100
+ const loadedResourceFiles = new Set<string>();
101
+ // Files reported as `add` since the most recent `deploy:start`, populated only while
102
+ // `awaitingPostRedeployScan` is true — see the gating note above.
103
+ let currentScanFiles = new Set<string>();
104
+ let awaitingPostRedeployScan = false;
105
+
106
+ const entryHandler = scope.handleEntry(async function handleResourceEntry(entryEvent) {
77
107
  if (entryEvent.entryType !== 'file') {
78
108
  scope.logger.warn(
79
109
  `jsResource plugin cannot handle entry type ${entryEvent.entryType}. Modify the 'files' option in ${scope.configFilePath} to only include files.`
@@ -81,7 +111,13 @@ export async function handleApplication(scope: Scope) {
81
111
  return;
82
112
  }
83
113
 
84
- if (entryEvent.eventType !== 'add') {
114
+ if (awaitingPostRedeployScan && entryEvent.eventType === 'add') {
115
+ // Recorded unconditionally — before the loaded/re-add branch below — so the post-scan
116
+ // deletion diff sees every file this scan reported, whether newly loaded or already known.
117
+ currentScanFiles.add(entryEvent.absolutePath);
118
+ }
119
+
120
+ if (entryEvent.eventType !== 'add' || loadedResourceFiles.has(entryEvent.absolutePath)) {
85
121
  scope.requestRestart();
86
122
  return;
87
123
  }
@@ -97,6 +133,9 @@ export async function handleApplication(scope: Scope) {
97
133
  scope.logger.debug?.(`Registered root resource: ${path}`);
98
134
  }
99
135
  recurseForResources(scope, resourceModule, root);
136
+ // Record the load so a later re-`add` of this same file (a redeploy re-scan) is treated
137
+ // as a change and requests a restart rather than silently re-serving stale cached code.
138
+ loadedResourceFiles.add(entryEvent.absolutePath);
100
139
  // A JS resource that extends an exported @table is the one carrying author opt-ins
101
140
  // (`static mcpTools`/`mcpPrompts`), and it registers here — after the schema-derived
102
141
  // table class and after the MCP component's boot scan. Signal so listing surfaces
@@ -107,6 +146,26 @@ export async function handleApplication(scope: Scope) {
107
146
  throw new ResourceLoadError(entryEvent.absolutePath, error);
108
147
  }
109
148
  });
149
+
150
+ // Optional chaining: a mock/test scope may not implement EventEmitter, and Scope#handleEntry
151
+ // itself can return undefined (e.g. MissingDefaultFilesOptionError). In real use `scope` is
152
+ // always an EventEmitter and `entryHandler` is always the EntryHandler backing this watcher.
153
+ scope.on?.('deploy:start', () => {
154
+ awaitingPostRedeployScan = true;
155
+ currentScanFiles = new Set();
156
+ });
157
+
158
+ entryHandler?.on?.('ready', () => {
159
+ if (!awaitingPostRedeployScan) return;
160
+ awaitingPostRedeployScan = false;
161
+ for (const loadedFile of loadedResourceFiles) {
162
+ if (!currentScanFiles.has(loadedFile)) {
163
+ // Known file that the just-completed scan never reported — deleted during the redeploy.
164
+ loadedResourceFiles.delete(loadedFile);
165
+ scope.requestRestart();
166
+ }
167
+ }
168
+ });
110
169
  }
111
170
 
112
171
  function recurseForResources(scope: Scope, resourceModule: any, prefix: string) {