@shaferllc/keel 0.66.0 → 0.74.0

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 (239) hide show
  1. package/AGENTS.md +169 -0
  2. package/README.md +44 -6
  3. package/bin/keel-mcp.mjs +9 -0
  4. package/dist/api/api.config.stub +9 -0
  5. package/dist/api/config.d.ts +13 -0
  6. package/dist/api/config.js +14 -0
  7. package/dist/api/index.d.ts +16 -0
  8. package/dist/api/index.js +13 -0
  9. package/dist/api/provider.d.ts +10 -0
  10. package/dist/api/provider.js +17 -0
  11. package/dist/api/query.d.ts +35 -0
  12. package/dist/api/query.js +42 -0
  13. package/dist/api/resource.d.ts +91 -0
  14. package/dist/api/resource.js +188 -0
  15. package/dist/core/application.d.ts +5 -5
  16. package/dist/core/application.js +8 -2
  17. package/dist/core/cache.d.ts +81 -5
  18. package/dist/core/cache.js +188 -23
  19. package/dist/core/cli/stubs.d.ts +26 -0
  20. package/dist/core/cli/stubs.js +225 -0
  21. package/dist/core/console-prompt.d.ts +79 -0
  22. package/dist/core/console-prompt.js +239 -0
  23. package/dist/core/console-ui.d.ts +96 -0
  24. package/dist/core/console-ui.js +187 -0
  25. package/dist/core/console.d.ts +188 -0
  26. package/dist/core/console.js +395 -0
  27. package/dist/core/database.d.ts +70 -1
  28. package/dist/core/database.js +174 -15
  29. package/dist/core/env.d.ts +96 -0
  30. package/dist/core/env.js +140 -0
  31. package/dist/core/events.d.ts +129 -5
  32. package/dist/core/events.js +165 -7
  33. package/dist/core/health.d.ts +141 -0
  34. package/dist/core/health.js +226 -0
  35. package/dist/core/helpers.d.ts +9 -3
  36. package/dist/core/helpers.js +11 -3
  37. package/dist/core/http/kernel.d.ts +2 -0
  38. package/dist/core/http/kernel.js +48 -0
  39. package/dist/core/http/router.d.ts +5 -5
  40. package/dist/core/http/router.js +5 -5
  41. package/dist/core/i18n.d.ts +162 -0
  42. package/dist/core/i18n.js +472 -0
  43. package/dist/core/index.d.ts +39 -13
  44. package/dist/core/index.js +19 -6
  45. package/dist/core/instrumentation.d.ts +113 -0
  46. package/dist/core/instrumentation.js +52 -0
  47. package/dist/core/lock.d.ts +139 -0
  48. package/dist/core/lock.js +215 -0
  49. package/dist/core/logger.d.ts +89 -4
  50. package/dist/core/logger.js +167 -22
  51. package/dist/core/mail.d.ts +128 -7
  52. package/dist/core/mail.js +264 -16
  53. package/dist/core/notification.js +10 -1
  54. package/dist/core/package.d.ts +120 -0
  55. package/dist/core/package.js +169 -0
  56. package/dist/core/pages.d.ts +108 -0
  57. package/dist/core/pages.js +199 -0
  58. package/dist/core/queue.d.ts +134 -9
  59. package/dist/core/queue.js +325 -14
  60. package/dist/core/repl.d.ts +33 -0
  61. package/dist/core/repl.js +88 -0
  62. package/dist/core/scheduler.js +6 -0
  63. package/dist/core/social.d.ts +4 -4
  64. package/dist/core/social.js +4 -4
  65. package/dist/core/storage.d.ts +159 -6
  66. package/dist/core/storage.js +299 -7
  67. package/dist/core/telemetry.d.ts +208 -0
  68. package/dist/core/telemetry.js +383 -0
  69. package/dist/core/template.d.ts +2 -3
  70. package/dist/core/template.js +2 -3
  71. package/dist/core/testing.d.ts +170 -1
  72. package/dist/core/testing.js +504 -2
  73. package/dist/db/d1.js +13 -0
  74. package/dist/db/pg.d.ts +13 -0
  75. package/dist/db/pg.js +46 -4
  76. package/dist/mcp/server.d.ts +19 -0
  77. package/dist/mcp/server.js +355 -0
  78. package/dist/openapi/config.d.ts +28 -0
  79. package/dist/openapi/config.js +25 -0
  80. package/dist/openapi/doc.d.ts +40 -0
  81. package/dist/openapi/doc.js +20 -0
  82. package/dist/openapi/export.d.ts +8 -0
  83. package/dist/openapi/export.js +19 -0
  84. package/dist/openapi/gate.d.ts +15 -0
  85. package/dist/openapi/gate.js +27 -0
  86. package/dist/openapi/index.d.ts +19 -0
  87. package/dist/openapi/index.js +15 -0
  88. package/dist/openapi/openapi.config.stub +29 -0
  89. package/dist/openapi/provider.d.ts +18 -0
  90. package/dist/openapi/provider.js +35 -0
  91. package/dist/openapi/routes.d.ts +9 -0
  92. package/dist/openapi/routes.js +23 -0
  93. package/dist/openapi/spec.d.ts +23 -0
  94. package/dist/openapi/spec.js +132 -0
  95. package/dist/openapi/ui.d.ts +8 -0
  96. package/dist/openapi/ui.js +31 -0
  97. package/dist/openapi/zod.d.ts +12 -0
  98. package/dist/openapi/zod.js +46 -0
  99. package/dist/watch/config.d.ts +33 -0
  100. package/dist/watch/config.js +38 -0
  101. package/dist/watch/entry.d.ts +53 -0
  102. package/dist/watch/entry.js +105 -0
  103. package/dist/watch/gate.d.ts +20 -0
  104. package/dist/watch/gate.js +32 -0
  105. package/dist/watch/index.d.ts +21 -0
  106. package/dist/watch/index.js +17 -0
  107. package/dist/watch/migration.d.ts +7 -0
  108. package/dist/watch/migration.js +20 -0
  109. package/dist/watch/provider.d.ts +22 -0
  110. package/dist/watch/provider.js +58 -0
  111. package/dist/watch/prune.d.ts +11 -0
  112. package/dist/watch/prune.js +20 -0
  113. package/dist/watch/recorder.d.ts +24 -0
  114. package/dist/watch/recorder.js +39 -0
  115. package/dist/watch/routes.d.ts +13 -0
  116. package/dist/watch/routes.js +55 -0
  117. package/dist/watch/store.d.ts +54 -0
  118. package/dist/watch/store.js +158 -0
  119. package/dist/watch/ui/dist/watch.css +1 -0
  120. package/dist/watch/ui/dist/watch.js +555 -0
  121. package/dist/watch/ui-shell.d.ts +12 -0
  122. package/dist/watch/ui-shell.js +24 -0
  123. package/dist/watch/watch.config.stub +47 -0
  124. package/dist/watch/watchers.d.ts +12 -0
  125. package/dist/watch/watchers.js +156 -0
  126. package/docs/ai-manifest.json +3206 -0
  127. package/docs/ai.md +128 -0
  128. package/docs/api-resources.md +118 -0
  129. package/docs/architecture.md +331 -0
  130. package/docs/authentication.md +453 -0
  131. package/docs/authorization.md +167 -0
  132. package/docs/broadcasting.md +137 -0
  133. package/docs/broker.md +500 -0
  134. package/docs/cache.md +558 -0
  135. package/docs/configuration.md +385 -0
  136. package/docs/console.md +536 -0
  137. package/docs/container.md +467 -0
  138. package/docs/controllers.md +265 -0
  139. package/docs/cors.md +51 -0
  140. package/docs/database.md +631 -0
  141. package/docs/debugging.md +129 -0
  142. package/docs/decorators.md +127 -0
  143. package/docs/errors.md +395 -0
  144. package/docs/events.md +496 -0
  145. package/docs/examples/architecture-app.ts +27 -0
  146. package/docs/examples/authentication.ts +61 -0
  147. package/docs/examples/authorization.ts +79 -0
  148. package/docs/examples/broadcasting.ts +60 -0
  149. package/docs/examples/broker-cache-validate.ts +34 -0
  150. package/docs/examples/broker-fault-tolerance.ts +29 -0
  151. package/docs/examples/broker-middleware.ts +27 -0
  152. package/docs/examples/broker.ts +203 -0
  153. package/docs/examples/cache.ts +222 -0
  154. package/docs/examples/configuration.ts +121 -0
  155. package/docs/examples/console.ts +134 -0
  156. package/docs/examples/container.ts +134 -0
  157. package/docs/examples/controllers.ts +86 -0
  158. package/docs/examples/database.ts +208 -0
  159. package/docs/examples/debugging.ts +41 -0
  160. package/docs/examples/decorators.ts +40 -0
  161. package/docs/examples/errors.ts +121 -0
  162. package/docs/examples/events.ts +204 -0
  163. package/docs/examples/factories.ts +84 -0
  164. package/docs/examples/hashing.ts +71 -0
  165. package/docs/examples/health.ts +94 -0
  166. package/docs/examples/helpers.ts +171 -0
  167. package/docs/examples/hooks.ts +54 -0
  168. package/docs/examples/i18n.ts +117 -0
  169. package/docs/examples/inertia.ts +81 -0
  170. package/docs/examples/locks.ts +120 -0
  171. package/docs/examples/logger.ts +166 -0
  172. package/docs/examples/mail.ts +263 -0
  173. package/docs/examples/middleware.ts +119 -0
  174. package/docs/examples/migrations.ts +126 -0
  175. package/docs/examples/models.ts +239 -0
  176. package/docs/examples/notification.ts +124 -0
  177. package/docs/examples/pages.ts +82 -0
  178. package/docs/examples/providers.ts +123 -0
  179. package/docs/examples/queues.ts +254 -0
  180. package/docs/examples/rate-limiting.ts +42 -0
  181. package/docs/examples/redis.ts +99 -0
  182. package/docs/examples/request-response.ts +197 -0
  183. package/docs/examples/routing.ts +186 -0
  184. package/docs/examples/scheduling.ts +62 -0
  185. package/docs/examples/sessions.ts +102 -0
  186. package/docs/examples/static-files.ts +63 -0
  187. package/docs/examples/storage.ts +132 -0
  188. package/docs/examples/telemetry.ts +127 -0
  189. package/docs/examples/templates.ts +58 -0
  190. package/docs/examples/testing.ts +215 -0
  191. package/docs/examples/transformer.ts +141 -0
  192. package/docs/examples/transformers.ts +49 -0
  193. package/docs/examples/url-builder.ts +86 -0
  194. package/docs/examples/validation.ts +102 -0
  195. package/docs/examples/views.tsx +62 -0
  196. package/docs/examples/vite.ts +106 -0
  197. package/docs/factories.md +166 -0
  198. package/docs/getting-started.md +290 -0
  199. package/docs/hashing.md +259 -0
  200. package/docs/health.md +225 -0
  201. package/docs/helpers.md +347 -0
  202. package/docs/hono.md +186 -0
  203. package/docs/hooks.md +118 -0
  204. package/docs/i18n.md +302 -0
  205. package/docs/inertia.md +241 -0
  206. package/docs/locks.md +323 -0
  207. package/docs/logger.md +436 -0
  208. package/docs/mail.md +751 -0
  209. package/docs/middleware.md +425 -0
  210. package/docs/migrations.md +476 -0
  211. package/docs/models.md +810 -0
  212. package/docs/notifications.md +474 -0
  213. package/docs/openapi.md +111 -0
  214. package/docs/packages.md +118 -0
  215. package/docs/pages.md +217 -0
  216. package/docs/providers.md +363 -0
  217. package/docs/queues.md +679 -0
  218. package/docs/rate-limiting.md +155 -0
  219. package/docs/redis.md +178 -0
  220. package/docs/request-response.md +953 -0
  221. package/docs/routing.md +804 -0
  222. package/docs/scheduling.md +110 -0
  223. package/docs/security.md +85 -0
  224. package/docs/sessions.md +354 -0
  225. package/docs/social-auth.md +174 -0
  226. package/docs/static-files.md +211 -0
  227. package/docs/storage.md +452 -0
  228. package/docs/telemetry.md +263 -0
  229. package/docs/templates.md +314 -0
  230. package/docs/testing.md +376 -0
  231. package/docs/transformers.md +381 -0
  232. package/docs/url-builder.md +295 -0
  233. package/docs/validation.md +288 -0
  234. package/docs/views.md +267 -0
  235. package/docs/vite.md +434 -0
  236. package/docs/watch.md +118 -0
  237. package/llms-full.txt +19816 -0
  238. package/llms.txt +127 -0
  239. package/package.json +42 -7
@@ -7,8 +7,10 @@
7
7
  * so it stays edge-safe.
8
8
  *
9
9
  * class SendWelcome extends Job {
10
+ * static maxRetries = 3;
10
11
  * constructor(private userId: number) { super(); }
11
12
  * async handle() { await mail().to(...).send(); }
13
+ * async failed(error: unknown) { logger().error("welcome failed", { error }); }
12
14
  * }
13
15
  *
14
16
  * await dispatch(new SendWelcome(user.id)); // runs now with the sync driver
@@ -17,38 +19,248 @@
17
19
  * setQueue(new MemoryDriver());
18
20
  * await dispatch(new SendWelcome(user.id)); // queued
19
21
  * await work(); // runs everything pending
22
+ *
23
+ * A job that throws is **retried** with backoff up to `maxRetries`, and only then
24
+ * declared failed — at which point `failed()` runs and it lands in the driver's
25
+ * dead-letter list rather than vanishing.
26
+ */
27
+ import { logger, hasApplication } from "./helpers.js";
28
+ import { instrument, currentRequestId } from "./instrumentation.js";
29
+ /**
30
+ * A unit of background work. Subclass and implement `handle`.
31
+ *
32
+ * Retry policy is per-class, via statics:
33
+ *
34
+ * class ChargeCard extends Job {
35
+ * static maxRetries = 5;
36
+ * static backoff = exponentialBackoff(1_000);
37
+ * async handle() { … }
38
+ * }
20
39
  */
21
- /** A unit of background work. Subclass and implement `handle`. */
22
40
  export class Job {
41
+ /** How many times to retry after the first failure. Default: 0 (no retries). */
42
+ static maxRetries = 0;
43
+ /** How long to wait before each retry. Default: exponential from 1s. */
44
+ static backoff = exponentialBackoff(1_000);
45
+ /** Default lane for this job class. */
46
+ static queue;
47
+ /** Default priority for this job class (lower runs first). */
48
+ static priority;
49
+ /** Set by the driver before `handle()` runs. */
50
+ context;
51
+ /**
52
+ * Called once the job has exhausted its retries — the last chance to record
53
+ * the failure, alert, or compensate. A throw in here is logged and swallowed:
54
+ * failing to handle a failure must not itself crash the worker.
55
+ */
56
+ failed(_error) { }
57
+ }
58
+ /** Doubles each time: 1s, 2s, 4s, 8s… Capped at `maxMs`. */
59
+ export function exponentialBackoff(baseMs = 1_000, maxMs = 60_000) {
60
+ return (attempt) => Math.min(baseMs * 2 ** (attempt - 1), maxMs);
61
+ }
62
+ /** Adds a fixed step each time: 5s, 10s, 15s… Capped at `maxMs`. */
63
+ export function linearBackoff(stepMs = 1_000, maxMs = 60_000) {
64
+ return (attempt) => Math.min(stepMs * attempt, maxMs);
65
+ }
66
+ /** The same delay every time. */
67
+ export function fixedBackoff(delayMs = 1_000) {
68
+ return () => delayMs;
69
+ }
70
+ /** No delay at all — retry straight away. */
71
+ export const noBackoff = () => 0;
72
+ /* ------------------------------- execution -------------------------------- */
73
+ /** The retry policy for a dispatchable, from its class statics and any overrides. */
74
+ function policy(job, options) {
75
+ const cls = job instanceof Job ? job.constructor : undefined;
76
+ return {
77
+ maxRetries: options.maxRetries ?? cls?.maxRetries ?? 0,
78
+ backoff: options.backoff ?? cls?.backoff ?? exponentialBackoff(1_000),
79
+ };
80
+ }
81
+ /** The display name of a dispatchable — its class name, or "fn" for a closure. */
82
+ function jobName(job) {
83
+ return job instanceof Job ? job.constructor.name : "fn";
84
+ }
85
+ async function invoke(job, context) {
86
+ const name = jobName(job);
87
+ const requestId = currentRequestId();
88
+ const start = Date.now();
89
+ instrument("job.processing", {
90
+ job: name,
91
+ ...(job instanceof Job ? { payload: { ...job } } : {}),
92
+ ...(requestId ? { requestId } : {}),
93
+ });
94
+ if (typeof job === "function")
95
+ await job();
96
+ else {
97
+ job.context = context;
98
+ await job.handle();
99
+ }
100
+ instrument("job.processed", {
101
+ job: name,
102
+ durationMs: Date.now() - start,
103
+ ...(requestId ? { requestId } : {}),
104
+ });
105
+ }
106
+ /**
107
+ * Log through the application logger, falling back to the console. The queue is
108
+ * a primitive — it has to work in a worker that never bootstrapped an
109
+ * Application — and reporting a failure must never itself be the thing that
110
+ * throws.
111
+ */
112
+ function log(message, context) {
113
+ if (hasApplication())
114
+ logger().error(message, context);
115
+ else
116
+ console.error(message, context);
23
117
  }
24
- function run(job) {
25
- return Promise.resolve(job instanceof Job ? job.handle() : job());
118
+ /**
119
+ * A job has exhausted its retries. Log it — loudly, because a worker that keeps
120
+ * running past a failure must not do so silently — then run the `failed()` hook.
121
+ * A throw in the hook is logged too, never rethrown: failing to handle a failure
122
+ * must not itself crash the worker.
123
+ */
124
+ async function reportFailure(job, error, context) {
125
+ log("queue: job failed", {
126
+ job: jobName(job),
127
+ jobId: context.jobId,
128
+ attempts: context.attempt,
129
+ queue: context.queue,
130
+ error,
131
+ });
132
+ instrument("job.failed", { job: jobName(job), error });
133
+ if (!(job instanceof Job))
134
+ return;
135
+ try {
136
+ await job.failed(error);
137
+ }
138
+ catch (hookError) {
139
+ log("queue: a job's failed() hook threw", { error: hookError });
140
+ }
26
141
  }
27
142
  /* -------------------------------- drivers --------------------------------- */
28
- /** Runs jobs the moment they're dispatched — the default; ideal for dev/tests. */
143
+ let counter = 0;
144
+ function nextId() {
145
+ return `job_${++counter}`;
146
+ }
147
+ /**
148
+ * Runs jobs the moment they're dispatched — the default; ideal for dev/tests.
149
+ *
150
+ * Retries still apply, but the backoff delay is **not** slept through: an
151
+ * inline driver blocking the request for a 30-second backoff would be worse than
152
+ * useless. Use `MemoryDriver` or a real broker to exercise real delays.
153
+ */
29
154
  export class SyncDriver {
30
- async push(job, _options) {
31
- await run(job);
155
+ /** Jobs that exhausted their retries. */
156
+ failed = [];
157
+ async push(job, options) {
158
+ const { maxRetries } = policy(job, options);
159
+ const id = nextId();
160
+ const queue = options.queue ?? "default";
161
+ for (let attempt = 1;; attempt++) {
162
+ const context = { jobId: id, attempt, queue };
163
+ try {
164
+ await invoke(job, context);
165
+ return;
166
+ }
167
+ catch (error) {
168
+ if (attempt > maxRetries) {
169
+ await reportFailure(job, error, context);
170
+ this.failed.push({ id, job, options, attempts: attempt, error });
171
+ // The sync driver *is* the work — it ran inline, so the caller gets the
172
+ // error. A background driver can't do that; it records instead.
173
+ throw error;
174
+ }
175
+ }
176
+ }
32
177
  }
33
178
  }
34
179
  /** Holds jobs in memory; `work()` drains them. Assert on `.jobs` in tests. */
35
180
  export class MemoryDriver {
36
181
  jobs = [];
182
+ /** Jobs that exhausted their retries. */
183
+ failed = [];
37
184
  async push(job, options) {
38
- this.jobs.push({ job, options });
185
+ this.jobs.push({
186
+ id: nextId(),
187
+ job,
188
+ options,
189
+ attempts: 0,
190
+ availableAt: Date.now() + (options.delay ?? 0) * 1000,
191
+ });
39
192
  }
40
193
  get size() {
41
194
  return this.jobs.length;
42
195
  }
43
- /** Run every pending job in FIFO order; returns how many ran. */
196
+ /** The next job that's due, by priority then insertion order. */
197
+ takeNext(now) {
198
+ let best = -1;
199
+ for (let i = 0; i < this.jobs.length; i++) {
200
+ const candidate = this.jobs[i];
201
+ if (candidate.availableAt > now)
202
+ continue; // not due yet
203
+ if (best === -1) {
204
+ best = i;
205
+ continue;
206
+ }
207
+ const incumbent = this.jobs[best];
208
+ if ((candidate.options.priority ?? 0) < (incumbent.options.priority ?? 0))
209
+ best = i;
210
+ }
211
+ return best === -1 ? undefined : this.jobs.splice(best, 1)[0];
212
+ }
213
+ /**
214
+ * Run every job that's currently due, highest priority first; returns how many
215
+ * ran. A job that throws is re-queued with its backoff delay until it runs out
216
+ * of retries, then moves to `failed`.
217
+ *
218
+ * Jobs whose delay or backoff hasn't elapsed are left in place — `work()`
219
+ * drains what's *due*, it doesn't sleep. Call it again later, or advance your
220
+ * test's clock.
221
+ */
44
222
  async work() {
45
223
  let count = 0;
46
- while (this.jobs.length) {
47
- const next = this.jobs.shift();
48
- await run(next.job);
49
- count++;
224
+ for (;;) {
225
+ const now = Date.now();
226
+ const next = this.takeNext(now);
227
+ if (!next)
228
+ return count;
229
+ const { maxRetries, backoff } = policy(next.job, next.options);
230
+ const attempt = next.attempts + 1;
231
+ const context = {
232
+ jobId: next.id,
233
+ attempt,
234
+ queue: next.options.queue ?? "default",
235
+ };
236
+ try {
237
+ await invoke(next.job, context);
238
+ count++;
239
+ }
240
+ catch (error) {
241
+ if (attempt > maxRetries) {
242
+ // A failed job must not take down the worker — record it, log it, and
243
+ // carry on with the rest of the queue.
244
+ await reportFailure(next.job, error, context);
245
+ this.failed.push({
246
+ id: next.id,
247
+ job: next.job,
248
+ options: next.options,
249
+ attempts: attempt,
250
+ error,
251
+ });
252
+ }
253
+ else {
254
+ // Back on the queue, not runnable until the backoff has elapsed.
255
+ this.jobs.push({
256
+ ...next,
257
+ attempts: attempt,
258
+ availableAt: Date.now() + backoff(attempt),
259
+ });
260
+ }
261
+ count++;
262
+ }
50
263
  }
51
- return count;
52
264
  }
53
265
  }
54
266
  /* --------------------------------- queue ---------------------------------- */
@@ -59,16 +271,93 @@ export class Queue {
59
271
  }
60
272
  /** Place a job on the queue (the driver decides when it runs). */
61
273
  async dispatch(job, options = {}) {
62
- await this.driver.push(job, options);
274
+ const cls = job instanceof Job ? job.constructor : undefined;
275
+ await this.driver.push(job, {
276
+ ...options,
277
+ queue: options.queue ?? cls?.queue ?? "default",
278
+ priority: options.priority ?? cls?.priority ?? 0,
279
+ });
63
280
  }
64
281
  /** Drain the driver if it holds jobs locally; returns how many ran. */
65
282
  async work() {
66
283
  const drainable = this.driver;
67
284
  return typeof drainable.work === "function" ? drainable.work() : 0;
68
285
  }
286
+ /** Jobs that exhausted their retries, if the driver tracks them. */
287
+ get failed() {
288
+ return this.driver.failed ?? [];
289
+ }
290
+ }
291
+ /* --------------------------------- faking --------------------------------- */
292
+ /**
293
+ * A driver that records dispatches without running anything, plus assertions —
294
+ * what `fakeQueue()` installs so a test can check a job was *queued* without
295
+ * paying for it to run.
296
+ */
297
+ export class FakeQueue extends Queue {
298
+ pushed = [];
299
+ constructor() {
300
+ const pushed = [];
301
+ super({
302
+ async push(job, options) {
303
+ pushed.push({
304
+ id: nextId(),
305
+ job,
306
+ options,
307
+ attempts: 0,
308
+ availableAt: Date.now() + (options.delay ?? 0) * 1000,
309
+ });
310
+ },
311
+ });
312
+ this.pushed = pushed;
313
+ }
314
+ /** Dispatches of a job class (or all of them, with no argument). */
315
+ matching(type, where) {
316
+ return this.pushed.filter((entry) => {
317
+ if (type && !(entry.job instanceof type))
318
+ return false;
319
+ return where ? where(entry.job) : true;
320
+ });
321
+ }
322
+ assertPushed(type, where) {
323
+ if (this.matching(type, where).length)
324
+ return;
325
+ const total = this.pushed.filter((e) => e.job instanceof type).length;
326
+ throw new Error(where && total
327
+ ? `Expected a ${type.name} matching the predicate. ${total} were pushed, but none matched.`
328
+ : `Expected ${type.name} to be pushed, but it was not. ${this.summary()}`);
329
+ }
330
+ assertNotPushed(type, where) {
331
+ const found = this.matching(type, where).length;
332
+ if (found)
333
+ throw new Error(`Expected no ${type.name}, but ${found} were pushed.`);
334
+ }
335
+ assertPushedCount(expected, type) {
336
+ const found = this.matching(type).length;
337
+ if (found !== expected) {
338
+ const what = type ? `${type.name} job(s)` : "job(s)";
339
+ throw new Error(`Expected ${expected} ${what} to be pushed, but ${found} were.`);
340
+ }
341
+ }
342
+ assertNothingPushed() {
343
+ if (this.pushed.length) {
344
+ throw new Error(`Expected nothing to be pushed, but ${this.pushed.length} were. ${this.summary()}`);
345
+ }
346
+ }
347
+ /** The queued entry for a job class — to assert on its delay, lane, or priority. */
348
+ pushedJobs(type) {
349
+ return this.matching(type);
350
+ }
351
+ summary() {
352
+ if (!this.pushed.length)
353
+ return "Nothing was pushed.";
354
+ const names = [...new Set(this.pushed.map((e) => (e.job instanceof Job ? e.job.constructor.name : "fn")))];
355
+ return `Pushed: ${names.join(", ")}.`;
356
+ }
69
357
  }
70
358
  /* --------------------------------- global --------------------------------- */
71
359
  let queue = new Queue(new SyncDriver());
360
+ let real;
72
361
  /** Register the default queue driver used by `dispatch()`. */
73
362
  export function setQueue(driver) {
74
363
  queue = new Queue(driver);
@@ -86,3 +375,25 @@ export function dispatch(job, options) {
86
375
  export function work() {
87
376
  return queue.work();
88
377
  }
378
+ /**
379
+ * Swap the queue for one that records dispatches without running them, so a test
380
+ * can assert a job was queued without paying for it to run. Undo with
381
+ * `restoreQueue()`.
382
+ *
383
+ * const q = fakeQueue();
384
+ * await register(user);
385
+ * q.assertPushed(SendWelcome, (job) => job.userId === user.id);
386
+ */
387
+ export function fakeQueue() {
388
+ if (!real)
389
+ real = queue; // only remember the *real* queue — faking twice must not stash a fake
390
+ const fake = new FakeQueue();
391
+ queue = fake;
392
+ return fake;
393
+ }
394
+ /** Restore the real queue after `fakeQueue()`. */
395
+ export function restoreQueue() {
396
+ if (real)
397
+ queue = real;
398
+ real = undefined;
399
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * An interactive shell with the application booted — `keel repl`.
3
+ *
4
+ * keel repl
5
+ * > await db("users").get()
6
+ * > make(Router).all()
7
+ * > .ls
8
+ *
9
+ * The point is that everything is *already wired*: the container is booted, the
10
+ * providers have run, and the global helpers are in scope. Poking at a model in a
11
+ * REPL is the fastest debugging loop there is, and it shouldn't cost you a
12
+ * throwaway script to get one.
13
+ *
14
+ * `node:repl` is imported dynamically, so the core still loads on the edge (where
15
+ * there is, of course, no REPL to start).
16
+ */
17
+ import type { Application } from "./application.js";
18
+ /** A helper exposed in the REPL, listed by `.ls`. */
19
+ export interface ReplHelper {
20
+ name: string;
21
+ description: string;
22
+ value: unknown;
23
+ }
24
+ /** Extra helpers to put in scope, beyond the defaults. */
25
+ export interface ReplOptions {
26
+ helpers?: ReplHelper[];
27
+ /** The prompt string. Default: `"keel > "`. */
28
+ prompt?: string;
29
+ }
30
+ /**
31
+ * Start the REPL. Resolves when the user exits it.
32
+ */
33
+ export declare function startRepl(app: Application, options?: ReplOptions): Promise<void>;
@@ -0,0 +1,88 @@
1
+ /**
2
+ * An interactive shell with the application booted — `keel repl`.
3
+ *
4
+ * keel repl
5
+ * > await db("users").get()
6
+ * > make(Router).all()
7
+ * > .ls
8
+ *
9
+ * The point is that everything is *already wired*: the container is booted, the
10
+ * providers have run, and the global helpers are in scope. Poking at a model in a
11
+ * REPL is the fastest debugging loop there is, and it shouldn't cost you a
12
+ * throwaway script to get one.
13
+ *
14
+ * `node:repl` is imported dynamically, so the core still loads on the edge (where
15
+ * there is, of course, no REPL to start).
16
+ */
17
+ /** The helpers every Keel REPL gets. */
18
+ async function defaultHelpers(app) {
19
+ const core = await import("./index.js");
20
+ const helpers = [
21
+ { name: "app", description: "The booted Application", value: app },
22
+ { name: "config", description: "config(key, fallback?)", value: core.config },
23
+ { name: "make", description: "Resolve a binding from the container", value: core.make },
24
+ { name: "db", description: "Start a query: db('users').where(…)", value: core.db },
25
+ { name: "cache", description: "The cache", value: core.cache },
26
+ { name: "logger", description: "The logger", value: core.logger },
27
+ { name: "events", description: "The event emitter", value: core.events },
28
+ { name: "mail", description: "Start a message", value: core.mail },
29
+ { name: "dispatch", description: "Dispatch a job", value: core.dispatch },
30
+ { name: "storage", description: "The default disk", value: core.storage },
31
+ { name: "router", description: "The router", value: app.make(core.Router) },
32
+ { name: "hash", description: "Password hashing", value: core.hash },
33
+ { name: "jwt", description: "JWT sign/verify", value: core.jwt },
34
+ {
35
+ name: "p",
36
+ description: "Await a promise and print the result — p(db('users').get())",
37
+ value: async (value) => await value,
38
+ },
39
+ ];
40
+ return helpers;
41
+ }
42
+ /**
43
+ * Start the REPL. Resolves when the user exits it.
44
+ */
45
+ export async function startRepl(app, options = {}) {
46
+ const [{ start }, { join }] = await Promise.all([import("node:repl"), import("node:path")]);
47
+ const helpers = [...(await defaultHelpers(app)), ...(options.helpers ?? [])];
48
+ const name = app.config().get("app.name", "Keel");
49
+ console.log(`⚓ ${name} — type .ls to see what's in scope, .exit to leave.\n`);
50
+ const server = start({
51
+ prompt: options.prompt ?? "keel > ",
52
+ useColors: true,
53
+ // Await at the top level, so `> await db("users").get()` just works.
54
+ useGlobal: false,
55
+ breakEvalOnSigint: true,
56
+ });
57
+ for (const helper of helpers) {
58
+ Object.defineProperty(server.context, helper.name, {
59
+ configurable: false,
60
+ enumerable: true,
61
+ value: helper.value,
62
+ });
63
+ }
64
+ // History, so the last thing you typed survives a restart.
65
+ try {
66
+ await new Promise((resolve) => {
67
+ server.setupHistory(join(app.basePath, ".keel_repl_history"), () => resolve());
68
+ });
69
+ }
70
+ catch {
71
+ // No history file — not worth failing the REPL over.
72
+ }
73
+ server.defineCommand("ls", {
74
+ help: "List everything in scope",
75
+ action() {
76
+ const width = Math.max(...helpers.map((h) => h.name.length));
77
+ for (const helper of helpers) {
78
+ console.log(` ${helper.name.padEnd(width + 2)}${helper.description}`);
79
+ }
80
+ this.displayPrompt();
81
+ },
82
+ });
83
+ return new Promise((resolve) => {
84
+ server.on("exit", () => {
85
+ void app.terminate().finally(() => resolve());
86
+ });
87
+ });
88
+ }
@@ -15,6 +15,7 @@
15
15
  * cron expression matches `now` (to the minute).
16
16
  */
17
17
  import { Job } from "./queue.js";
18
+ import { instrument } from "./instrumentation.js";
18
19
  function runTask(job) {
19
20
  return Promise.resolve(job instanceof Job ? job.handle() : job());
20
21
  }
@@ -142,7 +143,12 @@ export class Scheduler {
142
143
  let count = 0;
143
144
  for (const task of this.tasks) {
144
145
  if (task.isDue(now)) {
146
+ const start = Date.now();
145
147
  await runTask(task.job);
148
+ instrument("schedule.task.run", {
149
+ task: task.job instanceof Job ? task.job.constructor.name : "fn",
150
+ durationMs: Date.now() - start,
151
+ });
146
152
  count++;
147
153
  }
148
154
  }
@@ -1,8 +1,8 @@
1
1
  /**
2
- * Social authentication — OAuth 2.0 "sign in with GitHub/Google/…". Like Adonis
3
- * Ally, this owns the OAuth dance only: it hands you a normalized `SocialUser`,
4
- * and *you* find-or-create your own user and log them in (with a session,
5
- * `jwt`, or an access `token`). It stores nothing.
2
+ * Social authentication — OAuth 2.0 "sign in with GitHub/Google/…". This owns the
3
+ * OAuth dance only: it hands you a normalized `SocialUser`, and *you*
4
+ * find-or-create your own user and log them in (with a session, `jwt`, or an
5
+ * access `token`). It stores nothing.
6
6
  *
7
7
  * const github = social.github({ clientId, clientSecret, redirectUri });
8
8
  *
@@ -1,8 +1,8 @@
1
1
  /**
2
- * Social authentication — OAuth 2.0 "sign in with GitHub/Google/…". Like Adonis
3
- * Ally, this owns the OAuth dance only: it hands you a normalized `SocialUser`,
4
- * and *you* find-or-create your own user and log them in (with a session,
5
- * `jwt`, or an access `token`). It stores nothing.
2
+ * Social authentication — OAuth 2.0 "sign in with GitHub/Google/…". This owns the
3
+ * OAuth dance only: it hands you a normalized `SocialUser`, and *you*
4
+ * find-or-create your own user and log them in (with a session, `jwt`, or an
5
+ * access `token`). It stores nothing.
6
6
  *
7
7
  * const github = social.github({ clientId, clientSecret, redirectUri });
8
8
  *