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 +8 -8
  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
@@ -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) {
@@ -102,6 +102,10 @@ export function generateJsonApi(resources: Resources, serverHttpURL: string) {
102
102
  // @hidden type-level: drop the Resource from the OpenAPI document entirely.
103
103
  // Data remains queryable through Harper's other interfaces under RBAC.
104
104
  if (resource.Resource.hidden === true) continue;
105
+ // request-contract resources (`defineResource`/`Resource.withSchema`) at a static (non-parameterised)
106
+ // path are emitted below, alongside their parameterised counterparts, off the declared contract —
107
+ // not the table-CRUD assumptions this loop makes (primary key, generated schema component, etc).
108
+ if (resource.Resource.requestContract) continue;
105
109
 
106
110
  const { path } = resource;
107
111
  const strippedPath = path.split('/').pop(); // strip any namespace from path
@@ -311,26 +315,15 @@ export function generateJsonApi(resources: Resources, serverHttpURL: string) {
311
315
  }
312
316
  }
313
317
 
314
- // Parameterised routes (e.g. `/widget/:id/action/:action`) live outside the resource Map; emit them as templated
315
- // paths with `{param}` path parameters so they appear in the OpenAPI document like any other endpoint.
316
- for (const route of resources.paramRoutes ?? []) {
317
- const entry = route.entry;
318
- if (!entry?.path || entry.Resource?.isError) continue;
319
- // @hidden: drop the Resource from the OpenAPI document entirely.
320
- if (entry.Resource?.hidden === true) continue;
318
+ // Emit OpenAPI paths for a request-contract resource (`defineResource`/`Resource.withSchema`), whether
319
+ // declared at a static path (`/OrderIntake`) or a parameterised one (`/widget/:id`) — each verb's
320
+ // query/body/response is driven off the shared `inputSchemas`/`outputSchemas` the contract compiled,
321
+ // the same `JsonSchemaFragment` IR that drives runtime validation/coercion, so the spec matches what
322
+ // the server actually enforces (rather than the table-CRUD generation above).
323
+ const emitContractRoutes = (url: string, entry: { Resource: any }, pathParams: any[]) => {
324
+ if (!entry?.Resource) return;
321
325
  const { prototype } = entry.Resource;
322
- if (!prototype) continue;
323
-
324
- const { template, params } = routePatternToTemplate(route.segments);
325
- const url = `/${template}`;
326
- const pathParams = params.map((param) => {
327
- const parameter = new Parameter(param.name, 'path', { type: 'string' });
328
- parameter.required = true;
329
- parameter.description = param.wildcard
330
- ? 'captures the remaining path segments'
331
- : `value bound from the :${param.name} path segment`;
332
- return parameter;
333
- });
326
+ if (!prototype) return;
334
327
 
335
328
  const tableDoc: string | undefined = entry.Resource.description;
336
329
  const withDoc = (sentence: string) => (tableDoc ? `${tableDoc} ${sentence}` : sentence);
@@ -408,6 +401,38 @@ export function generateJsonApi(resources: Resources, serverHttpURL: string) {
408
401
  }
409
402
  );
410
403
  }
404
+ };
405
+
406
+ // Request-contract resources declared at a static (non-parameterised) path (e.g. `defineResource({path:
407
+ // '/OrderIntake', ...})`) — skipped by the table-CRUD loop above, emitted here off the declared contract.
408
+ for (const [, entry] of resources) {
409
+ if (!entry) continue;
410
+ if (!entry.path || !entry.Resource?.requestContract || entry.Resource.isError || entry.Resource.hidden === true)
411
+ continue;
412
+ const path = entry.path.startsWith('/') ? entry.path : '/' + entry.path;
413
+ emitContractRoutes(path, entry, []);
414
+ }
415
+
416
+ // Parameterised routes (e.g. `/widget/:id/action/:action`) live outside the resource Map; emit them as templated
417
+ // paths with `{param}` path parameters so they appear in the OpenAPI document like any other endpoint.
418
+ for (const route of resources.paramRoutes ?? []) {
419
+ const entry = route.entry;
420
+ if (!entry?.path || entry.Resource?.isError) continue;
421
+ // @hidden: drop the Resource from the OpenAPI document entirely.
422
+ if (entry.Resource?.hidden === true) continue;
423
+
424
+ const { template, params } = routePatternToTemplate(route.segments);
425
+ const url = `/${template}`;
426
+ const pathParams = params.map((param) => {
427
+ const parameter = new Parameter(param.name, 'path', { type: 'string' });
428
+ parameter.required = true;
429
+ parameter.description = param.wildcard
430
+ ? 'captures the remaining path segments'
431
+ : `value bound from the :${param.name} path segment`;
432
+ return parameter;
433
+ });
434
+
435
+ emitContractRoutes(url, entry, pathParams);
411
436
  }
412
437
 
413
438
  for (const [, value] of resources.allTypes) {
@@ -0,0 +1,394 @@
1
+ import { ClientError } from '../../utility/errors/hdbError.ts';
2
+
3
+ /**
4
+ * Thrown when a cron expression cannot be parsed, or a schedule can never fire
5
+ * (e.g. `0 0 30 2 *` — February 30th).
6
+ */
7
+ export class InvalidCronExpressionError extends ClientError {
8
+ constructor(message: string) {
9
+ super(message, 400);
10
+ this.name = 'InvalidCronExpressionError';
11
+ }
12
+ }
13
+
14
+ const MONTH_NAMES = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC'];
15
+ const DAY_OF_WEEK_NAMES = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT'];
16
+
17
+ const MACROS: Record<string, string> = {
18
+ '@yearly': '0 0 1 1 *',
19
+ '@annually': '0 0 1 1 *',
20
+ '@monthly': '0 0 1 * *',
21
+ '@weekly': '0 0 * * 0',
22
+ '@daily': '0 0 * * *',
23
+ '@midnight': '0 0 * * *',
24
+ '@hourly': '0 * * * *',
25
+ };
26
+
27
+ // How far ahead/behind nextDate/prevDate will search before concluding the
28
+ // schedule can never fire. Eight years covers leap-day schedules even across
29
+ // a century non-leap year (2096 -> 2104 is the longest Feb 29 gap).
30
+ const MAX_SEARCH_YEARS = 8;
31
+ // DST transitions shift offsets by at most two hours in the IANA database
32
+ const MAX_DST_DELTA_MS = 2 * 60 * 60 * 1000;
33
+
34
+ interface FieldSpec {
35
+ min: number;
36
+ max: number;
37
+ names?: string[];
38
+ // Offset applied to named values (month names are 1-based: JAN = 1)
39
+ nameOffset?: number;
40
+ }
41
+
42
+ const MINUTE_FIELD: FieldSpec = { min: 0, max: 59 };
43
+ const HOUR_FIELD: FieldSpec = { min: 0, max: 23 };
44
+ const DAY_OF_MONTH_FIELD: FieldSpec = { min: 1, max: 31 };
45
+ const MONTH_FIELD: FieldSpec = { min: 1, max: 12, names: MONTH_NAMES, nameOffset: 1 };
46
+ const DAY_OF_WEEK_FIELD: FieldSpec = { min: 0, max: 7, names: DAY_OF_WEEK_NAMES, nameOffset: 0 };
47
+
48
+ /**
49
+ * Parse one cron field (e.g. `1,15,30-45`, `MON-FRI`, or a step like `0-59/15`)
50
+ * into a boolean lookup keyed by value. Supports `*`, single values, ranges,
51
+ * steps, lists, and the standard month/day names.
52
+ */
53
+ function parseField(fieldValue: string, spec: FieldSpec, fieldName: string, expression: string): boolean[] {
54
+ const allowed: boolean[] = new Array(spec.max + 1).fill(false);
55
+ const fail = (reason: string): never => {
56
+ throw new InvalidCronExpressionError(
57
+ `Invalid ${fieldName} field "${fieldValue}" in cron expression "${expression}": ${reason}`
58
+ );
59
+ };
60
+ const parseValue = (raw: string): number => {
61
+ if (spec.names) {
62
+ const nameIndex = spec.names.indexOf(raw.toUpperCase());
63
+ if (nameIndex >= 0) return nameIndex + (spec.nameOffset ?? 0);
64
+ }
65
+ if (!/^\d+$/.test(raw)) fail(`"${raw}" is not a number${spec.names ? ' or recognized name' : ''}`);
66
+ const value = Number(raw);
67
+ if (value < spec.min || value > spec.max) fail(`${value} is outside the allowed range ${spec.min}-${spec.max}`);
68
+ return value;
69
+ };
70
+
71
+ for (const part of fieldValue.split(',')) {
72
+ if (part === '') fail('empty list entry');
73
+ const [base, stepText, extra] = part.split('/');
74
+ if (extra !== undefined) fail('multiple "/" step separators');
75
+ let step = 1;
76
+ if (stepText !== undefined) {
77
+ if (!/^\d+$/.test(stepText) || Number(stepText) < 1) fail(`step "${stepText}" must be a positive integer`);
78
+ step = Number(stepText);
79
+ }
80
+ let rangeStart: number;
81
+ let rangeEnd: number;
82
+ if (base === '*') {
83
+ rangeStart = spec.min;
84
+ rangeEnd = spec.max;
85
+ } else if (base.includes('-')) {
86
+ const [startText, endText, extraRange] = base.split('-');
87
+ if (extraRange !== undefined || startText === '' || endText === '') fail('malformed range');
88
+ rangeStart = parseValue(startText);
89
+ rangeEnd = parseValue(endText);
90
+ if (rangeStart > rangeEnd) fail(`range start ${rangeStart} is greater than range end ${rangeEnd}`);
91
+ } else {
92
+ rangeStart = parseValue(base);
93
+ // `5/15` means "every 15 starting at 5"; a bare `5` means just 5
94
+ rangeEnd = stepText !== undefined ? spec.max : rangeStart;
95
+ }
96
+ for (let value = rangeStart; value <= rangeEnd; value += step) {
97
+ allowed[value] = true;
98
+ }
99
+ }
100
+ return allowed;
101
+ }
102
+
103
+ // Cache one formatter per timezone: Intl.DateTimeFormat construction is
104
+ // expensive relative to formatting, and the same few zones recur.
105
+ const zoneFormatters = new Map<string, Intl.DateTimeFormat>();
106
+
107
+ function getZoneFormatter(timezone: string): Intl.DateTimeFormat {
108
+ let formatter = zoneFormatters.get(timezone);
109
+ if (!formatter) {
110
+ formatter = new Intl.DateTimeFormat('en-US', {
111
+ timeZone: timezone,
112
+ year: 'numeric',
113
+ month: '2-digit',
114
+ day: '2-digit',
115
+ hour: '2-digit',
116
+ minute: '2-digit',
117
+ second: '2-digit',
118
+ hourCycle: 'h23',
119
+ });
120
+ zoneFormatters.set(timezone, formatter);
121
+ }
122
+ return formatter;
123
+ }
124
+
125
+ /**
126
+ * Validate an IANA timezone name. Returns the normalized name, or throws.
127
+ */
128
+ export function validateTimezone(timezone: string): string {
129
+ try {
130
+ getZoneFormatter(timezone);
131
+ return timezone;
132
+ } catch (error) {
133
+ zoneFormatters.delete(timezone);
134
+ throw new ClientError(`Unknown timezone "${timezone}": ${error.message}`, 400);
135
+ }
136
+ }
137
+
138
+ let systemTimezone: string | undefined;
139
+
140
+ export function getSystemTimezone(): string {
141
+ systemTimezone ??= Intl.DateTimeFormat().resolvedOptions().timeZone;
142
+ return systemTimezone;
143
+ }
144
+
145
+ /**
146
+ * Shift a real instant so its UTC fields carry the wall-clock time in the given
147
+ * timezone. The evaluator works entirely on these "zoned wall time" Dates
148
+ * (reading getUTC* fields), keeping the schedule math independent of the
149
+ * system timezone.
150
+ */
151
+ export function toZonedWallTime(date: Date, timezone: string): Date {
152
+ const parts: Record<string, string> = {};
153
+ for (const part of getZoneFormatter(timezone).formatToParts(date)) {
154
+ parts[part.type] = part.value;
155
+ }
156
+ return new Date(
157
+ Date.UTC(
158
+ Number(parts.year),
159
+ Number(parts.month) - 1,
160
+ Number(parts.day),
161
+ // hourCycle h23 should prevent "24", but guard anyway — some ICU
162
+ // versions have produced it for midnight
163
+ Number(parts.hour === '24' ? '0' : parts.hour),
164
+ Number(parts.minute),
165
+ Number(parts.second)
166
+ )
167
+ );
168
+ }
169
+
170
+ /**
171
+ * Inverse of toZonedWallTime: given a Date whose UTC fields represent
172
+ * wall-clock time in `timezone`, return the real instant.
173
+ *
174
+ * Offsets are discovered by probing the zone at the wall time misread as UTC,
175
+ * at the candidate instant that offset implies, and two hours either side of
176
+ * it (DST deltas never exceed 2h), so both sides of a nearby transition are
177
+ * seen regardless of hemisphere or offset sign — a single re-probe missed the
178
+ * pre-transition offset in eastern zones (a Sydney gap job fired an hour
179
+ * EARLY; Lord Howe ambiguous times resolved to their second occurrence).
180
+ * Resolution is then deterministic in every zone: a wall time that exists
181
+ * maps to its instant; an ambiguous (fall-back overlap) wall time maps to its
182
+ * FIRST occurrence; a nonexistent (spring-forward gap) wall time maps to the
183
+ * first instant after the gap.
184
+ */
185
+ export function fromZonedWallTime(wallTime: Date, timezone: string): Date {
186
+ const asUtc = wallTime.getTime();
187
+ const offsetAt = (instant: number) => toZonedWallTime(new Date(instant), timezone).getTime() - instant;
188
+ const firstOffset = offsetAt(asUtc);
189
+ const nearCandidate = asUtc - firstOffset;
190
+ const candidateOffsets = new Set([
191
+ firstOffset,
192
+ offsetAt(nearCandidate),
193
+ offsetAt(nearCandidate - MAX_DST_DELTA_MS),
194
+ offsetAt(nearCandidate + MAX_DST_DELTA_MS),
195
+ ]);
196
+ let earliestRoundTrip = Infinity;
197
+ let latestCandidate = -Infinity;
198
+ for (const offset of candidateOffsets) {
199
+ const instant = asUtc - offset;
200
+ latestCandidate = Math.max(latestCandidate, instant);
201
+ if (offsetAt(instant) === offset) {
202
+ earliestRoundTrip = Math.min(earliestRoundTrip, instant);
203
+ }
204
+ }
205
+ // No candidate reproduces this wall time: it falls in a spring-forward gap;
206
+ // the latest candidate is the first instant after the transition
207
+ return new Date(Number.isFinite(earliestRoundTrip) ? earliestRoundTrip : latestCandidate);
208
+ }
209
+
210
+ const MINUTE_MS = 60_000;
211
+ // DST overlaps are at most ~2h (Lord Howe aside, offsets change by <=1h in
212
+ // practice; 3h covers every IANA zone with margin), so occurrence convergence
213
+ // through a fall-back never needs to look further than this past the reference
214
+ const DST_CONVERGENCE_HORIZON_MS = 3 * 60 * 60 * 1000;
215
+ // A minutely cron converging through a 2h overlap needs ~120 occurrences;
216
+ // each iteration is cheap, so the cap is safety against pathologies only
217
+ const MAX_CONVERGENCE_ATTEMPTS = 400;
218
+
219
+ /**
220
+ * A parsed five-field POSIX cron expression (minute, hour, day-of-month,
221
+ * month, day-of-week) supporting `*`, lists, ranges, steps, month/day names,
222
+ * and the common `@daily`-style macros. Follows the standard rule that when
223
+ * both day-of-month and day-of-week are restricted, a date matches if EITHER
224
+ * matches.
225
+ */
226
+ export class CronExpression {
227
+ expression: string;
228
+ #minutes: boolean[];
229
+ #hours: boolean[];
230
+ #daysOfMonth: boolean[];
231
+ #months: boolean[];
232
+ #daysOfWeek: boolean[];
233
+ #dayOfMonthRestricted: boolean;
234
+ #dayOfWeekRestricted: boolean;
235
+
236
+ constructor(expression: string) {
237
+ this.expression = expression;
238
+ const normalized = (MACROS[expression.trim().toLowerCase()] ?? expression).trim();
239
+ const fields = normalized.split(/\s+/);
240
+ if (fields.length !== 5) {
241
+ throw new InvalidCronExpressionError(
242
+ `Cron expression "${expression}" has ${fields.length} fields; expected 5 (minute hour day-of-month month day-of-week)`
243
+ );
244
+ }
245
+ this.#minutes = parseField(fields[0], MINUTE_FIELD, 'minute', expression);
246
+ this.#hours = parseField(fields[1], HOUR_FIELD, 'hour', expression);
247
+ this.#daysOfMonth = parseField(fields[2], DAY_OF_MONTH_FIELD, 'day-of-month', expression);
248
+ this.#months = parseField(fields[3], MONTH_FIELD, 'month', expression);
249
+ this.#daysOfWeek = parseField(fields[4], DAY_OF_WEEK_FIELD, 'day-of-week', expression);
250
+ // 7 is an alias for Sunday
251
+ if (this.#daysOfWeek[7]) this.#daysOfWeek[0] = true;
252
+ this.#dayOfMonthRestricted = fields[2] !== '*';
253
+ this.#dayOfWeekRestricted = fields[4] !== '*';
254
+ // Surface never-firing schedules (e.g. February 30th) at parse time
255
+ // rather than as a silently idle job
256
+ if (this.nextWallTime(new Date()) === null) {
257
+ throw new InvalidCronExpressionError(`Cron expression "${expression}" can never fire`);
258
+ }
259
+ }
260
+
261
+ #dayMatches(wallTime: Date): boolean {
262
+ const dayOfMonthMatch = this.#daysOfMonth[wallTime.getUTCDate()];
263
+ const dayOfWeekMatch = this.#daysOfWeek[wallTime.getUTCDay()];
264
+ if (this.#dayOfMonthRestricted && this.#dayOfWeekRestricted) return dayOfMonthMatch || dayOfWeekMatch;
265
+ if (this.#dayOfMonthRestricted) return dayOfMonthMatch;
266
+ if (this.#dayOfWeekRestricted) return dayOfWeekMatch;
267
+ return true;
268
+ }
269
+
270
+ /**
271
+ * The next matching wall-clock time strictly after `afterWallTime` (both as
272
+ * "zoned wall time" Dates — see toZonedWallTime). Returns null if no match
273
+ * exists within the search horizon.
274
+ */
275
+ nextWallTime(afterWallTime: Date): Date | null {
276
+ // Truncate to the minute, then step forward
277
+ let candidate = new Date(Math.floor(afterWallTime.getTime() / MINUTE_MS) * MINUTE_MS + MINUTE_MS);
278
+ const horizon = afterWallTime.getTime() + MAX_SEARCH_YEARS * 366 * 24 * 60 * MINUTE_MS;
279
+ while (candidate.getTime() <= horizon) {
280
+ if (!this.#months[candidate.getUTCMonth() + 1]) {
281
+ // First minute of the next month
282
+ candidate = new Date(Date.UTC(candidate.getUTCFullYear(), candidate.getUTCMonth() + 1, 1));
283
+ continue;
284
+ }
285
+ if (!this.#dayMatches(candidate)) {
286
+ candidate = new Date(Date.UTC(candidate.getUTCFullYear(), candidate.getUTCMonth(), candidate.getUTCDate() + 1));
287
+ continue;
288
+ }
289
+ if (!this.#hours[candidate.getUTCHours()]) {
290
+ candidate = new Date(
291
+ Date.UTC(
292
+ candidate.getUTCFullYear(),
293
+ candidate.getUTCMonth(),
294
+ candidate.getUTCDate(),
295
+ candidate.getUTCHours() + 1
296
+ )
297
+ );
298
+ continue;
299
+ }
300
+ if (!this.#minutes[candidate.getUTCMinutes()]) {
301
+ candidate = new Date(candidate.getTime() + MINUTE_MS);
302
+ continue;
303
+ }
304
+ return candidate;
305
+ }
306
+ return null;
307
+ }
308
+
309
+ /**
310
+ * The latest matching wall-clock time strictly before `beforeWallTime`
311
+ * (zoned wall time in, zoned wall time out). Returns null if no match
312
+ * exists within the search horizon.
313
+ */
314
+ previousWallTime(beforeWallTime: Date): Date | null {
315
+ let candidate = new Date(Math.ceil(beforeWallTime.getTime() / MINUTE_MS) * MINUTE_MS - MINUTE_MS);
316
+ const horizon = beforeWallTime.getTime() - MAX_SEARCH_YEARS * 366 * 24 * 60 * MINUTE_MS;
317
+ while (candidate.getTime() >= horizon) {
318
+ if (!this.#months[candidate.getUTCMonth() + 1]) {
319
+ // Last minute of the previous month
320
+ candidate = new Date(Date.UTC(candidate.getUTCFullYear(), candidate.getUTCMonth(), 1) - MINUTE_MS);
321
+ continue;
322
+ }
323
+ if (!this.#dayMatches(candidate)) {
324
+ // Last minute of the previous day
325
+ candidate = new Date(
326
+ Date.UTC(candidate.getUTCFullYear(), candidate.getUTCMonth(), candidate.getUTCDate()) - MINUTE_MS
327
+ );
328
+ continue;
329
+ }
330
+ if (!this.#hours[candidate.getUTCHours()]) {
331
+ // Last minute of the previous hour
332
+ candidate = new Date(
333
+ Date.UTC(
334
+ candidate.getUTCFullYear(),
335
+ candidate.getUTCMonth(),
336
+ candidate.getUTCDate(),
337
+ candidate.getUTCHours()
338
+ ) - MINUTE_MS
339
+ );
340
+ continue;
341
+ }
342
+ if (!this.#minutes[candidate.getUTCMinutes()]) {
343
+ candidate = new Date(candidate.getTime() - MINUTE_MS);
344
+ continue;
345
+ }
346
+ return candidate;
347
+ }
348
+ return null;
349
+ }
350
+
351
+ /**
352
+ * The next real instant strictly after `after` at which this expression
353
+ * fires in the given timezone (defaults to the system timezone). Returns
354
+ * null for schedules with no future occurrence in the search horizon.
355
+ */
356
+ nextDate(after: Date, timezone: string = getSystemTimezone()): Date | null {
357
+ let wall = toZonedWallTime(after, timezone);
358
+ // A DST fall-back overlap maps repeated-hour wall times to their FIRST
359
+ // occurrence, which can sit at or before `after` when `after` is inside
360
+ // the second pass — advance occurrences until we cross it. The bound is
361
+ // wall-clock distance, not attempt count: a minutely cron needs ~an
362
+ // hour's worth of occurrences to converge through the overlap (surfaced
363
+ // empirically in review; a fixed small attempt cap wedged sub-hourly
364
+ // jobs for the whole window).
365
+ const wallHorizon = wall.getTime() + DST_CONVERGENCE_HORIZON_MS;
366
+ for (let attempts = 0; attempts < MAX_CONVERGENCE_ATTEMPTS; attempts++) {
367
+ const nextWall = this.nextWallTime(wall);
368
+ if (nextWall === null) return null;
369
+ const instant = fromZonedWallTime(nextWall, timezone);
370
+ if (instant.getTime() > after.getTime()) return instant;
371
+ if (nextWall.getTime() > wallHorizon) return null;
372
+ wall = nextWall;
373
+ }
374
+ return null;
375
+ }
376
+
377
+ /**
378
+ * The most recent real instant strictly before `before` at which this
379
+ * expression fired in the given timezone (defaults to the system timezone).
380
+ */
381
+ previousDate(before: Date, timezone: string = getSystemTimezone()): Date | null {
382
+ let wall = toZonedWallTime(before, timezone);
383
+ const wallHorizon = wall.getTime() - DST_CONVERGENCE_HORIZON_MS;
384
+ for (let attempts = 0; attempts < MAX_CONVERGENCE_ATTEMPTS; attempts++) {
385
+ const previousWall = this.previousWallTime(wall);
386
+ if (previousWall === null) return null;
387
+ const instant = fromZonedWallTime(previousWall, timezone);
388
+ if (instant.getTime() < before.getTime()) return instant;
389
+ if (previousWall.getTime() < wallHorizon) return null;
390
+ wall = previousWall;
391
+ }
392
+ return null;
393
+ }
394
+ }