@zerotal/core 1.3.0 → 1.5.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 (60) hide show
  1. package/CHANGELOG.md +351 -0
  2. package/package.json +1 -1
  3. package/src/application/Application.ts +107 -9
  4. package/src/application/DevErrorPage.ts +82 -0
  5. package/src/application/diagnostics.ts +111 -0
  6. package/src/command/CommandRunner.ts +82 -1
  7. package/src/command/builtin/AssetsBuildCommand.ts +102 -0
  8. package/src/command/builtin/DeployCommand.ts +315 -0
  9. package/src/command/builtin/DevCommand.ts +88 -0
  10. package/src/command/builtin/DoctorCommand.ts +97 -0
  11. package/src/command/builtin/MakeCommandCommand.ts +2 -0
  12. package/src/command/builtin/RouteTypesCommand.ts +56 -0
  13. package/src/command/builtin/ServeCommand.ts +232 -44
  14. package/src/command/builtin/index.ts +5 -0
  15. package/src/command/scaffold/zerotal.ts.txt +2 -10
  16. package/src/config/AppConfig.ts +109 -2
  17. package/src/config/DeployConfig.ts +71 -0
  18. package/src/config/index.ts +2 -0
  19. package/src/config/registry.ts +1 -0
  20. package/src/container/Container.ts +3 -3
  21. package/src/container/inject.ts +3 -2
  22. package/src/context/RequestContext.ts +60 -0
  23. package/src/contracts/session.ts +18 -3
  24. package/src/dev/BuildCache.ts +312 -0
  25. package/src/dev/CssPlugins.ts +93 -7
  26. package/src/dev/DevBuildHook.ts +14 -1
  27. package/src/dev/DevDeck.ts +549 -0
  28. package/src/dev/DevOrchestrator.ts +166 -31
  29. package/src/dev/DevProcess.ts +221 -0
  30. package/src/dev/DevReloadMiddleware.ts +1 -1
  31. package/src/dev/DevSupervisor.ts +363 -0
  32. package/src/dev/bootBuild.ts +94 -0
  33. package/src/dev/index.ts +24 -0
  34. package/src/dev/startDevMode.ts +145 -0
  35. package/src/doctor/AppDoctor.ts +399 -0
  36. package/src/doctor/TransportProbe.ts +169 -0
  37. package/src/events/Emitter.ts +4 -3
  38. package/src/facade/facades/App.ts +10 -2
  39. package/src/helpers/index.ts +23 -1
  40. package/src/helpers/response.ts +18 -8
  41. package/src/http/Uri.ts +7 -3
  42. package/src/http/originGuard.ts +1 -1
  43. package/src/http/url.ts +10 -4
  44. package/src/index.ts +43 -0
  45. package/src/lock/LockManager.ts +190 -14
  46. package/src/lock/drivers/LockDriver.ts +11 -0
  47. package/src/lock/drivers/MemoryLockDriver.ts +21 -1
  48. package/src/lock/drivers/RedisLockDriver.ts +64 -8
  49. package/src/lock/drivers/SqliteLockDriver.ts +13 -0
  50. package/src/lock/errors.ts +26 -0
  51. package/src/lock/facades/Lock.ts +30 -5
  52. package/src/lock/index.ts +2 -2
  53. package/src/macros/config.macro.ts +2 -0
  54. package/src/provider/ServiceProvider.ts +40 -0
  55. package/src/router/Router.ts +111 -13
  56. package/src/router/registry.ts +123 -0
  57. package/src/router/routeTypes.ts +132 -0
  58. package/src/support/classRef.ts +27 -0
  59. package/src/support/env.ts +69 -2
  60. package/src/support/unroutedRoutes.ts +37 -0
@@ -6,6 +6,7 @@
6
6
  import { RequestContext } from "../context/RequestContext.ts";
7
7
  import { HttpError, NotFoundError } from "../errors/HttpError.ts";
8
8
  import { route } from "../router/Router.ts";
9
+ import type { RouteParamValues, RouteParamsArg, RouteTarget } from "../router/registry.ts";
9
10
  import { safeRedirectPath } from "../pipeline/HttpContext.ts";
10
11
  import { DEFAULT_MD_OPTIONS, type BunMarkdownOptions } from "../helpers/markdown.ts";
11
12
  import type { ZerotalError } from "../errors/ZerotalError.ts";
@@ -107,13 +108,22 @@ export class RedirectBuilder {
107
108
  return new ResponseBuilder(this.#ctx);
108
109
  }
109
110
 
110
- /** Redirect to a named route, resolving its URL from the route name and params. */
111
- to(
112
- name: string,
113
- params: Record<string, string | number> = {},
111
+ /**
112
+ * Redirect to a named route, resolving its URL from the route name and params.
113
+ *
114
+ * Params are checked against the route's pattern once `types/routes.generated.ts`
115
+ * exists. For a query string, or a name only known at runtime, redirect to a
116
+ * built URL instead: `redirect().away(route.dynamic(name, params, query))`.
117
+ */
118
+ to<N extends RouteTarget>(
119
+ name: N,
120
+ params?: RouteParamsArg<N>,
114
121
  status: 301 | 302 | 303 | 307 | 308 = 302,
115
122
  ): ResponseBuilder {
116
- this.#ctx.redirect(route(name, params), status);
123
+ // The values in `RouteParams<N>` are `RouteParamValue`s by construction, but
124
+ // `N` is still a type variable here, so the compiler cannot see through the
125
+ // conditional to say so.
126
+ this.#ctx.redirect(route.dynamic(name, params as RouteParamValues), status);
117
127
  return new ResponseBuilder(this.#ctx);
118
128
  }
119
129
 
@@ -253,9 +263,9 @@ export function redirect(
253
263
  * @param status - Optional HTTP status code (default: 302).
254
264
  * @returns
255
265
  */
256
- export function redirectTo(
257
- name: string,
258
- params: Record<string, string | number> = {},
266
+ export function redirectTo<N extends RouteTarget>(
267
+ name: N,
268
+ params?: RouteParamsArg<N>,
259
269
  status: 301 | 302 | 303 | 307 | 308 = 302,
260
270
  ): ResponseBuilder {
261
271
  return redirect().to(name, params, status);
package/src/http/Uri.ts CHANGED
@@ -15,6 +15,7 @@
15
15
  */
16
16
  import { RequestContext } from "../context/RequestContext.ts";
17
17
  import { route } from "../router/Router.ts";
18
+ import type { RouteArgs, RouteParamValues, RouteQuery, RouteTarget } from "../router/registry.ts";
18
19
  import { safeRedirectPath } from "../pipeline/HttpContext.ts";
19
20
  import { ResponseBuilder } from "../helpers/response.ts";
20
21
  import { config } from "../helpers/config.ts";
@@ -185,11 +186,14 @@ export class Uri {
185
186
  }
186
187
 
187
188
  /**
188
- * Build a Uri from a named route + params.
189
+ * Build a Uri from a named route + params (+ optional query values), with the
190
+ * same typing as the global {@link route} helper. For a name only known at
191
+ * runtime: `Uri.of(route.dynamic(name, params))`.
189
192
  * @category Construction
190
193
  */
191
- static route(name: string, params: Record<string, string | number> = {}): Uri {
192
- return Uri.of(route(name, params));
194
+ static route<N extends RouteTarget>(name: N, ...args: RouteArgs<N>): Uri {
195
+ const [params = {}, query = {}] = args as [RouteParamValues?, RouteQuery?];
196
+ return Uri.of(route.dynamic(name, params, query));
193
197
  }
194
198
 
195
199
  #clone(patch: Partial<UriParts>): Uri {
@@ -32,7 +32,7 @@
32
32
  *
33
33
  * @example
34
34
  * ```ts
35
- * if (!isAllowedOrigin(req, config('app.wsAllowedOrigins'))) {
35
+ * if (!isAllowedOrigin(req, config('app.allowedOrigins'))) {
36
36
  * return new Response('Forbidden origin', { status: 403 });
37
37
  * }
38
38
  * ```
package/src/http/url.ts CHANGED
@@ -27,6 +27,7 @@
27
27
  */
28
28
  import { RequestContext } from "../context/RequestContext.ts";
29
29
  import { route } from "../router/Router.ts";
30
+ import type { RouteArgs, RouteParamValues, RouteQuery, RouteTarget } from "../router/registry.ts";
30
31
  import { ZerotalError } from "../errors/ZerotalError.ts";
31
32
  import { URLSigner } from "../crypt/URLSigner.ts";
32
33
  import { Uri, appBaseUrl, type QueryInput } from "./Uri.ts";
@@ -105,8 +106,12 @@ export interface UrlGenerator {
105
106
  secure(path: string, extra?: (string | number)[]): string;
106
107
  /** A fully-qualified URL with a query string appended. */
107
108
  query(path: string, query: QueryInput, extra?: (string | number)[]): string;
108
- /** A fully-qualified URL for a named route. */
109
- route(name: string, params?: Record<string, string | number>): string;
109
+ /**
110
+ * A fully-qualified URL for a named route. Same arguments as the global
111
+ * {@link route} helper — params are exact, query values go third. For a name
112
+ * only known at runtime: `url().to(route.dynamic(name, params))`.
113
+ */
114
+ route<N extends RouteTarget>(name: N, ...args: RouteArgs<N>): string;
110
115
  /**
111
116
  * The URL the user was heading to before authentication (the session's `intended_url`),
112
117
  * falling back to `fallback`. Cross-origin stored URLs are rejected (open-redirect guard).
@@ -167,8 +172,9 @@ const generator: UrlGenerator = {
167
172
  query(path, query, extra = []) {
168
173
  return Uri.of(toUrl(path, extra)).withQuery(query).value();
169
174
  },
170
- route(name, params = {}) {
171
- return toUrl(route(name, params));
175
+ route<N extends RouteTarget>(name: N, ...args: RouteArgs<N>) {
176
+ const [params = {}, query = {}] = args as [RouteParamValues?, RouteQuery?];
177
+ return toUrl(route.dynamic(name, params, query));
172
178
  },
173
179
  intended(fallback = "/") {
174
180
  // Uri.intended reads (and clears) the session's intended_url with an open-redirect guard.
package/src/index.ts CHANGED
@@ -94,6 +94,20 @@ export { ServiceProvider } from "./provider/ServiceProvider.ts";
94
94
  export type { AppEnvironment } from "./provider/ServiceProvider.ts";
95
95
  export type { ConcernDescriptor, ConcernContext } from "./conventions/ConventionLoader.ts";
96
96
 
97
+ // Doctor (`zt doctor`; providers contribute checks via doctorChecks() or app.registerDoctorCheck())
98
+ export { runDoctor, builtinDoctorChecks } from "./doctor/AppDoctor.ts";
99
+ export type { DoctorCheck, DoctorCheckResult, DoctorReportEntry } from "./doctor/AppDoctor.ts";
100
+
101
+ // Dev processes (`zt dev`; providers contribute them via devProcesses()).
102
+ // Types only: naming the return type of `devProcesses()` needs them, but
103
+ // `collectDevProcesses` is the runner's own wiring and lives on `/dev`.
104
+ export type {
105
+ DevProcessDefinition,
106
+ DevProcessColor,
107
+ ResolvedDevProcess,
108
+ DevConfigShape,
109
+ } from "./dev/DevProcess.ts";
110
+
97
111
  // Errors
98
112
  export {
99
113
  ZerotalError,
@@ -147,6 +161,8 @@ export {
147
161
  export { config } from "./helpers/config.ts";
148
162
  export { pluralize, singularize, snakeCase, camelCase, tableNameFor } from "./support/str.ts";
149
163
  export { deepMerge } from "./support/deepMerge.ts";
164
+ // The type every class-keyed registry uses — a class rather than an instance.
165
+ export type { ClassRef } from "./support/classRef.ts";
150
166
  export { safeEqual, sha256Hex, hmacHex } from "./support/crypto.ts";
151
167
  export {
152
168
  buildCookie,
@@ -156,6 +172,15 @@ export {
156
172
  type SameSite,
157
173
  } from "./support/cookie.ts";
158
174
  export { isDevSurfaceAllowed, devSurfacesEnabled } from "./support/env.ts";
175
+ // The deployment name (`production`/`staging`/…) as opposed to the runtime mode.
176
+ // Shared with first-party packages that gate behaviour on it — `APP_ENV` cannot be
177
+ // read directly for this after `setAppEnv()`.
178
+ export { isProdLike, deployEnv } from "./support/env.ts";
179
+ // Development-error-page diagnoses. A package that owns an error class registers
180
+ // a diagnoser so the overlay can say what to do about it — see `@zerotal/orm`,
181
+ // which turns "no such table" into the list of migrations that have not run.
182
+ export { registerErrorDiagnoser } from "./application/diagnostics.ts";
183
+ export type { ErrorDiagnoser, ErrorDiagnosis, DiagnosisAction } from "./application/diagnostics.ts";
159
184
  export { fluent, Fluent } from "./helpers/fluent.ts";
160
185
  export { collect, Collection } from "./helpers/Collection.ts";
161
186
  export {
@@ -187,7 +212,25 @@ export type {
187
212
  ViewRegistration,
188
213
  GroupOptions,
189
214
  RouterMacros,
215
+ RouteBuilder,
190
216
  } from "./router/Router.ts";
217
+ // Typed route names — `RouteRegistry` is the augmentation target that
218
+ // `types/routes.generated.ts` fills in (see `bun zt route:types`).
219
+ export type {
220
+ RouteRegistry,
221
+ RouteName,
222
+ RouteTarget,
223
+ RoutePattern,
224
+ RouteParams,
225
+ RouteParamsArg,
226
+ RouteParamValue,
227
+ RouteParamValues,
228
+ RouteQuery,
229
+ RouteArgs,
230
+ ParamsOf,
231
+ } from "./router/registry.ts";
232
+ // The generator itself stays off the kernel barrel — it is build-time tooling,
233
+ // reached through `bun zt route:types` (and by `serve --dev-worker` internally).
191
234
  export type {
192
235
  RouteDefinition,
193
236
  HttpMethod,
@@ -1,5 +1,37 @@
1
1
  import type { LockDriver } from "./drivers/LockDriver.ts";
2
- import { LockNotAcquiredError } from "./errors.ts";
2
+ import { LockNotAcquiredError, LockLostError } from "./errors.ts";
3
+
4
+ /**
5
+ * Keeping a lock alive across work that outlives its TTL.
6
+ *
7
+ * Shared by {@link TryOptions} and {@link BlockOptions} because the choice is
8
+ * about the critical section, not about how you got into it.
9
+ *
10
+ * @category Acquiring
11
+ */
12
+ export interface RefreshOptions {
13
+ /**
14
+ * Extend the lock in the background for as long as the callback runs.
15
+ *
16
+ * With this on, the TTL stops being "how long the job might take" — a
17
+ * question nobody can answer — and becomes "how long after a crash before
18
+ * someone else may take over", which is a decision rather than a guess.
19
+ */
20
+ refresh?: boolean;
21
+
22
+ /**
23
+ * Seconds between refreshes. Defaults to a third of the TTL, so two
24
+ * consecutive failures still leave a full attempt before the lock lapses.
25
+ */
26
+ refreshEvery?: number;
27
+ }
28
+
29
+ /**
30
+ * Options for {@link LockManager.try | try}.
31
+ *
32
+ * @category Acquiring
33
+ */
34
+ export type TryOptions = RefreshOptions;
3
35
 
4
36
  /**
5
37
  * Options controlling how {@link LockManager.block} and {@link Lock.block} wait
@@ -7,7 +39,7 @@ import { LockNotAcquiredError } from "./errors.ts";
7
39
  *
8
40
  * @category Acquiring
9
41
  */
10
- export interface BlockOptions {
42
+ export interface BlockOptions extends RefreshOptions {
11
43
  /**
12
44
  * Maximum seconds to wait for the lock before throwing.
13
45
  * Defaults to the lock TTL.
@@ -21,6 +53,20 @@ export interface BlockOptions {
21
53
  retryDelay?: number;
22
54
  }
23
55
 
56
+ /**
57
+ * The critical section run by {@link LockManager.try} and
58
+ * {@link LockManager.block}.
59
+ *
60
+ * Both arguments are additive — an existing zero-argument callback is still a
61
+ * valid one, and every call site written before refreshing existed keeps
62
+ * working untouched.
63
+ *
64
+ * @param lock - The held lock, for a manual {@link ManagedLock.refresh}.
65
+ * @param signal - Aborted if the lock is lost mid-run. Long work should watch it.
66
+ * @category Acquiring
67
+ */
68
+ export type LockedCallback<T> = (lock: ManagedLock, signal: AbortSignal) => Promise<T> | T;
69
+
24
70
  /**
25
71
  * A single named lock instance.
26
72
  *
@@ -42,6 +88,7 @@ export interface BlockOptions {
42
88
  export class ManagedLock {
43
89
  private readonly _owner: string;
44
90
  private _acquired = false;
91
+ private _expiresAt: number | undefined = undefined;
45
92
 
46
93
  constructor(
47
94
  private readonly _key: string,
@@ -59,9 +106,53 @@ export class ManagedLock {
59
106
  */
60
107
  async acquire(): Promise<boolean> {
61
108
  this._acquired = await this._driver.acquire(this._key, this._owner, this._ttl);
109
+ if (this._acquired) this._expiresAt = Date.now() + this._ttl * 1000;
62
110
  return this._acquired;
63
111
  }
64
112
 
113
+ /**
114
+ * Push this lock's deadline out, so work that outlives its TTL can keep it.
115
+ *
116
+ * Without this a TTL has to be sized for the worst case: too short and the
117
+ * lock evaporates mid-job, too long and a crashed holder blocks the key for
118
+ * however long you guessed. Refreshing lets the TTL describe *how quickly a
119
+ * crash is noticed* instead, which is a much easier number to pick.
120
+ *
121
+ * Returns `false` when the lock is gone — expired, or now held by someone
122
+ * else — and clears {@link isAcquired} so it stops claiming otherwise. A
123
+ * caller that ignores the return value at least will not go on to release
124
+ * another holder's lock, because release is owner-guarded too.
125
+ *
126
+ * @param ttlSeconds - Seconds from now. Defaults to the lock's own TTL.
127
+ * @category Acquiring
128
+ *
129
+ * @example
130
+ * ```ts
131
+ * if (!(await lock.refresh())) throw new LockLostError(lock.key);
132
+ * ```
133
+ */
134
+ async refresh(ttlSeconds?: number): Promise<boolean> {
135
+ if (!this._acquired) return false;
136
+ const ttl = ttlSeconds ?? this._ttl;
137
+
138
+ // `extend` is optional on the contract so a driver written against 1.x still
139
+ // satisfies it. `acquire` is the fallback because on every built-in driver it
140
+ // is an owner-guarded refresh — which is exactly what this needs, and is the
141
+ // behaviour the memory driver had to be fixed to honour.
142
+ const extended = this._driver.extend
143
+ ? await this._driver.extend(this._key, this._owner, ttl)
144
+ : await this._driver.acquire(this._key, this._owner, ttl);
145
+
146
+ if (!extended) {
147
+ this._acquired = false;
148
+ this._expiresAt = undefined;
149
+ return false;
150
+ }
151
+
152
+ this._expiresAt = Date.now() + ttl * 1000;
153
+ return true;
154
+ }
155
+
65
156
  /**
66
157
  * Block until the lock can be acquired or `timeoutSeconds` elapses, polling
67
158
  * every `retryDelayMs`.
@@ -93,6 +184,7 @@ export class ManagedLock {
93
184
  async release(): Promise<void> {
94
185
  if (!this._acquired) return;
95
186
  this._acquired = false;
187
+ this._expiresAt = undefined;
96
188
  await this._driver.release(this._key, this._owner);
97
189
  }
98
190
 
@@ -104,6 +196,7 @@ export class ManagedLock {
104
196
  */
105
197
  async forceRelease(): Promise<void> {
106
198
  this._acquired = false;
199
+ this._expiresAt = undefined;
107
200
  await this._driver.forceRelease(this._key);
108
201
  }
109
202
 
@@ -115,6 +208,22 @@ export class ManagedLock {
115
208
  get isAcquired(): boolean {
116
209
  return this._acquired;
117
210
  }
211
+ /** The lock's TTL in seconds, as configured. */
212
+ get ttl(): number {
213
+ return this._ttl;
214
+ }
215
+ /**
216
+ * When this lock is expected to expire, or `undefined` when not held.
217
+ *
218
+ * A **client-side estimate**, computed from the last successful acquire or
219
+ * refresh — not read back from the driver. It is for deciding when to refresh
220
+ * next, not for deciding whether you still hold the lock; clock skew between
221
+ * this process and the lock store makes it approximate, and only the driver
222
+ * knows the truth. Ask {@link refresh} if you need an answer you can act on.
223
+ */
224
+ get expiresAt(): Date | undefined {
225
+ return this._expiresAt === undefined ? undefined : new Date(this._expiresAt);
226
+ }
118
227
  }
119
228
 
120
229
  /**
@@ -176,15 +285,16 @@ export class LockManager {
176
285
  * @throws {LockNotAcquiredError} Immediately, if the lock is already held.
177
286
  * @category Acquiring
178
287
  */
179
- async try<T>(key: string, ttlSeconds: number, callback: () => Promise<T> | T): Promise<T> {
288
+ async try<T>(
289
+ key: string,
290
+ ttlSeconds: number,
291
+ callback: LockedCallback<T>,
292
+ options: TryOptions = {},
293
+ ): Promise<T> {
180
294
  const lock = this.lock(key, ttlSeconds);
181
295
  const acquired = await lock.acquire();
182
296
  if (!acquired) throw new LockNotAcquiredError(key);
183
- try {
184
- return await callback();
185
- } finally {
186
- await lock.release();
187
- }
297
+ return _runHeld(lock, callback, options);
188
298
  }
189
299
 
190
300
  /**
@@ -204,16 +314,12 @@ export class LockManager {
204
314
  async block<T>(
205
315
  key: string,
206
316
  ttlSeconds: number,
207
- callback: () => Promise<T> | T,
317
+ callback: LockedCallback<T>,
208
318
  options: BlockOptions = {},
209
319
  ): Promise<T> {
210
320
  const lock = this.lock(key, ttlSeconds);
211
321
  await lock.block(options.timeout ?? ttlSeconds, options.retryDelay);
212
- try {
213
- return await callback();
214
- } finally {
215
- await lock.release();
216
- }
322
+ return _runHeld(lock, callback, options);
217
323
  }
218
324
 
219
325
  /**
@@ -226,3 +332,73 @@ export class LockManager {
226
332
  this._driver.dispose?.();
227
333
  }
228
334
  }
335
+
336
+ /**
337
+ * Run the critical section with the lock held, optionally heartbeating it, and
338
+ * release on the way out whatever happened.
339
+ *
340
+ * Shared by `try` and `block`, which differ only in how they got the lock.
341
+ */
342
+ async function _runHeld<T>(
343
+ lock: ManagedLock,
344
+ callback: LockedCallback<T>,
345
+ options: RefreshOptions,
346
+ ): Promise<T> {
347
+ const controller = new AbortController();
348
+
349
+ if (!options.refresh) {
350
+ try {
351
+ return await callback(lock, controller.signal);
352
+ } finally {
353
+ await lock.release();
354
+ }
355
+ }
356
+
357
+ const everySeconds = options.refreshEvery ?? lock.ttl / 3;
358
+ const everyMs = Math.max(1, Math.round(everySeconds * 1000));
359
+
360
+ // Assigned once, further down, but read by the `beat()` and `stop()` closures
361
+ // declared above that assignment — so it cannot be a `const` initialiser.
362
+ // eslint-disable-next-line prefer-const -- see above
363
+ let timer: ReturnType<typeof setInterval> | undefined;
364
+ let rejectLost: ((error: Error) => void) | undefined;
365
+ // Never resolves — it exists only to lose the race below, and only when the
366
+ // lock is gone. Its rejection is always handled, by that race.
367
+ const lost = new Promise<never>((_, reject) => {
368
+ rejectLost = reject;
369
+ });
370
+
371
+ const beat = async (): Promise<void> => {
372
+ if (await lock.refresh().catch(() => false)) return;
373
+
374
+ // Stop beating first: a lost lock stays lost, and retrying would only add
375
+ // driver round trips to a job that now has to stop.
376
+ if (timer) clearInterval(timer);
377
+ const error = new LockLostError(lock.key);
378
+ // The signal comes first so cooperative work sees the abort before the
379
+ // caller sees the throw — the callback may still be mid-await, and telling
380
+ // it to stop is the only leverage we have. It cannot be forced: work that
381
+ // ignores its signal runs on, outside the lock it believes it holds.
382
+ controller.abort(error);
383
+ rejectLost?.(error);
384
+ };
385
+
386
+ timer = setInterval(() => void beat(), everyMs);
387
+ // Without this the interval alone keeps the event loop alive, and a CLI or a
388
+ // dev-mode process quietly refuses to exit — a symptom with nothing pointing
389
+ // back to a lock helper.
390
+ timer.unref?.();
391
+
392
+ try {
393
+ const work = Promise.resolve().then(() => callback(lock, controller.signal));
394
+ // Losing the race leaves `work` rejecting with nobody listening; this marks
395
+ // it handled so a lost lock cannot also produce an unhandled rejection.
396
+ work.catch(() => {});
397
+ return await Promise.race([work, lost]);
398
+ } finally {
399
+ // Both exits, always: the success path, the throw path, and the lost-lock
400
+ // path that is a throw arriving from somewhere other than the callback.
401
+ clearInterval(timer);
402
+ await lock.release();
403
+ }
404
+ }
@@ -27,6 +27,17 @@ export interface LockDriver {
27
27
  /** Returns `true` if the lock is currently held (not expired). */
28
28
  exists(key: string): Promise<boolean>;
29
29
 
30
+ /**
31
+ * Push a held lock's deadline out to `ttlSeconds` from now. Owner-guarded:
32
+ * returns `false` when the key is free or held by someone else, so a holder
33
+ * that lost the lock learns about it rather than extending a stranger's.
34
+ *
35
+ * **Optional** so a driver written against 1.x still satisfies this interface.
36
+ * {@link ManagedLock.refresh} falls back to `acquire(key, owner, ttl)`, which
37
+ * is an owner-guarded refresh on all three built-in drivers.
38
+ */
39
+ extend?(key: string, owner: string, ttlSeconds: number): Promise<boolean>;
40
+
30
41
  /** Release background resources (timers, DB connections). */
31
42
  dispose?(): void;
32
43
  }
@@ -22,13 +22,33 @@ export class MemoryLockDriver implements LockDriver {
22
22
  const existing = this._store.get(key);
23
23
 
24
24
  if (existing && now < existing.expiresAt) {
25
- return existing.owner === owner; // re-entrant for same owner
25
+ if (existing.owner !== owner) return false;
26
+ // Re-acquiring by the same owner pushes the deadline out. This used to
27
+ // return `true` without touching `expiresAt`, so the default driver — the
28
+ // one every app gets until it configures another — was the only one of the
29
+ // three that did not honour what `ManagedLock.acquire()` documents. Redis
30
+ // re-`expire`s and SQLite `UPDATE`s; a caller re-acquiring to stay alive
31
+ // was silently refused an extension here, and found out when the lock
32
+ // expired underneath them.
33
+ existing.expiresAt = now + ttlSeconds * 1000;
34
+ return true;
26
35
  }
27
36
 
28
37
  this._store.set(key, { owner, expiresAt: now + ttlSeconds * 1000 });
29
38
  return true;
30
39
  }
31
40
 
41
+ async extend(key: string, owner: string, ttlSeconds: number): Promise<boolean> {
42
+ const now = Date.now();
43
+ const existing = this._store.get(key);
44
+ // An expired record is not extendable even by its own owner: the lock is
45
+ // free, and anyone may have taken it in between. Re-acquiring is the honest
46
+ // way back, and it is what the caller does when this returns false.
47
+ if (!existing || existing.owner !== owner || now >= existing.expiresAt) return false;
48
+ existing.expiresAt = now + ttlSeconds * 1000;
49
+ return true;
50
+ }
51
+
32
52
  async release(key: string, owner: string): Promise<boolean> {
33
53
  const existing = this._store.get(key);
34
54
  if (!existing || existing.owner !== owner) return false;
@@ -1,6 +1,23 @@
1
- import { redis } from "bun";
1
+ import { redis } from "bun";
2
2
  import type { LockDriver } from "./LockDriver.ts";
3
3
 
4
+ /**
5
+ * The slice of Bun's Redis client this driver uses.
6
+ *
7
+ * Narrow on purpose: it is the seam a test substitutes, and every method on it
8
+ * is one this driver actually calls.
9
+ *
10
+ * @category Configuration
11
+ */
12
+ export interface RedisLockClient {
13
+ set(key: string, value: string, ...args: string[]): Promise<string | null>;
14
+ get(key: string): Promise<string | null>;
15
+ expire(key: string, seconds: number): Promise<unknown>;
16
+ del(key: string): Promise<unknown>;
17
+ exists(key: string): Promise<boolean>;
18
+ send(command: string, args: string[]): Promise<unknown>;
19
+ }
20
+
4
21
  // Lua script: delete the key only if the caller is still the owner.
5
22
  // Evaluated atomically by Redis — no race between GET and DEL.
6
23
  const RELEASE_SCRIPT = `
@@ -11,6 +28,19 @@ else
11
28
  end
12
29
  `;
13
30
 
31
+ // The same compare-and-set shape as RELEASE_SCRIPT, for the deadline instead of
32
+ // the key. It has to be one script for the same reason: between a GET and a
33
+ // separate PEXPIRE the lock can lapse and be taken, and the PEXPIRE would then
34
+ // extend a lock belonging to someone else. `pexpire` on a missing key returns 0,
35
+ // so an expired lock reports failure without a second round trip.
36
+ const EXTEND_SCRIPT = `
37
+ if redis.call('get', KEYS[1]) == ARGV[1] then
38
+ return redis.call('pexpire', KEYS[1], ARGV[2])
39
+ else
40
+ return 0
41
+ end
42
+ `;
43
+
14
44
  /**
15
45
  * Redis-backed distributed lock driver.
16
46
  *
@@ -23,36 +53,62 @@ end
23
53
  * @category Configuration
24
54
  */
25
55
  export class RedisLockDriver implements LockDriver {
26
- constructor(private readonly _prefix: string = "zerotal_lock:") {}
56
+ private readonly _redis: RedisLockClient;
57
+
58
+ /**
59
+ * @param _prefix - Key namespace.
60
+ * @param client - The Redis client. Defaults to Bun's built-in one; passing a
61
+ * fake is the only way to test this driver, because `mock.module` cannot
62
+ * intercept a Bun builtin. The queue's Redis driver learned that the
63
+ * expensive way — its suite reached a real Redis, found none, and timed out
64
+ * fifteen times while appearing to be thorough.
65
+ */
66
+ constructor(
67
+ private readonly _prefix: string = "zerotal_lock:",
68
+ client: RedisLockClient = redis,
69
+ ) {
70
+ this._redis = client;
71
+ }
27
72
 
28
73
  private _key(key: string): string {
29
74
  return this._prefix + key;
30
75
  }
31
76
 
32
77
  async acquire(key: string, owner: string, ttlSeconds: number): Promise<boolean> {
33
- const result = await redis.set(this._key(key), owner, "NX", "EX", String(ttlSeconds));
78
+ const result = await this._redis.set(this._key(key), owner, "NX", "EX", String(ttlSeconds));
34
79
  if (result !== null) return true;
35
80
 
36
81
  // Re-entrant: allow the same owner to refresh its own lock
37
- const current = await redis.get(this._key(key));
82
+ const current = await this._redis.get(this._key(key));
38
83
  if (current === owner) {
39
- await redis.expire(this._key(key), ttlSeconds);
84
+ await this._redis.expire(this._key(key), ttlSeconds);
40
85
  return true;
41
86
  }
42
87
 
43
88
  return false;
44
89
  }
45
90
 
91
+ async extend(key: string, owner: string, ttlSeconds: number): Promise<boolean> {
92
+ const extended = await this._redis.send("EVAL", [
93
+ EXTEND_SCRIPT,
94
+ "1",
95
+ this._key(key),
96
+ owner,
97
+ String(Math.max(1, Math.round(ttlSeconds * 1000))),
98
+ ]);
99
+ return extended === 1;
100
+ }
101
+
46
102
  async release(key: string, owner: string): Promise<boolean> {
47
- const deleted = await redis.send("EVAL", [RELEASE_SCRIPT, "1", this._key(key), owner]);
103
+ const deleted = await this._redis.send("EVAL", [RELEASE_SCRIPT, "1", this._key(key), owner]);
48
104
  return deleted === 1;
49
105
  }
50
106
 
51
107
  async forceRelease(key: string): Promise<void> {
52
- await redis.del(this._key(key));
108
+ await this._redis.del(this._key(key));
53
109
  }
54
110
 
55
111
  async exists(key: string): Promise<boolean> {
56
- return redis.exists(this._key(key));
112
+ return this._redis.exists(this._key(key));
57
113
  }
58
114
  }
@@ -59,6 +59,19 @@ export class SqliteLockDriver implements LockDriver {
59
59
  }
60
60
  }
61
61
 
62
+ async extend(key: string, owner: string, ttlSeconds: number): Promise<boolean> {
63
+ const now = Date.now();
64
+ // One statement, so the owner check and the write cannot be separated by
65
+ // another process's acquire. `expires_at > ?` refuses to revive a lapsed
66
+ // record, which someone else may already have taken over.
67
+ const result = this._db
68
+ .prepare(
69
+ "UPDATE zerotal_locks SET expires_at = ? WHERE key = ? AND owner = ? AND expires_at > ?",
70
+ )
71
+ .run(now + ttlSeconds * 1000, key, owner, now);
72
+ return result.changes > 0;
73
+ }
74
+
62
75
  async release(key: string, owner: string): Promise<boolean> {
63
76
  const result = this._db
64
77
  .prepare("DELETE FROM zerotal_locks WHERE key = ? AND owner = ?")