@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.
- package/CHANGELOG.md +351 -0
- package/package.json +1 -1
- package/src/application/Application.ts +107 -9
- package/src/application/DevErrorPage.ts +82 -0
- package/src/application/diagnostics.ts +111 -0
- package/src/command/CommandRunner.ts +82 -1
- package/src/command/builtin/AssetsBuildCommand.ts +102 -0
- package/src/command/builtin/DeployCommand.ts +315 -0
- package/src/command/builtin/DevCommand.ts +88 -0
- package/src/command/builtin/DoctorCommand.ts +97 -0
- package/src/command/builtin/MakeCommandCommand.ts +2 -0
- package/src/command/builtin/RouteTypesCommand.ts +56 -0
- package/src/command/builtin/ServeCommand.ts +232 -44
- package/src/command/builtin/index.ts +5 -0
- package/src/command/scaffold/zerotal.ts.txt +2 -10
- package/src/config/AppConfig.ts +109 -2
- package/src/config/DeployConfig.ts +71 -0
- package/src/config/index.ts +2 -0
- package/src/config/registry.ts +1 -0
- package/src/container/Container.ts +3 -3
- package/src/container/inject.ts +3 -2
- package/src/context/RequestContext.ts +60 -0
- package/src/contracts/session.ts +18 -3
- package/src/dev/BuildCache.ts +312 -0
- package/src/dev/CssPlugins.ts +93 -7
- package/src/dev/DevBuildHook.ts +14 -1
- package/src/dev/DevDeck.ts +549 -0
- package/src/dev/DevOrchestrator.ts +166 -31
- package/src/dev/DevProcess.ts +221 -0
- package/src/dev/DevReloadMiddleware.ts +1 -1
- package/src/dev/DevSupervisor.ts +363 -0
- package/src/dev/bootBuild.ts +94 -0
- package/src/dev/index.ts +24 -0
- package/src/dev/startDevMode.ts +145 -0
- package/src/doctor/AppDoctor.ts +399 -0
- package/src/doctor/TransportProbe.ts +169 -0
- package/src/events/Emitter.ts +4 -3
- package/src/facade/facades/App.ts +10 -2
- package/src/helpers/index.ts +23 -1
- package/src/helpers/response.ts +18 -8
- package/src/http/Uri.ts +7 -3
- package/src/http/originGuard.ts +1 -1
- package/src/http/url.ts +10 -4
- package/src/index.ts +43 -0
- package/src/lock/LockManager.ts +190 -14
- package/src/lock/drivers/LockDriver.ts +11 -0
- package/src/lock/drivers/MemoryLockDriver.ts +21 -1
- package/src/lock/drivers/RedisLockDriver.ts +64 -8
- package/src/lock/drivers/SqliteLockDriver.ts +13 -0
- package/src/lock/errors.ts +26 -0
- package/src/lock/facades/Lock.ts +30 -5
- package/src/lock/index.ts +2 -2
- package/src/macros/config.macro.ts +2 -0
- package/src/provider/ServiceProvider.ts +40 -0
- package/src/router/Router.ts +111 -13
- package/src/router/registry.ts +123 -0
- package/src/router/routeTypes.ts +132 -0
- package/src/support/classRef.ts +27 -0
- package/src/support/env.ts +69 -2
- package/src/support/unroutedRoutes.ts +37 -0
package/src/helpers/response.ts
CHANGED
|
@@ -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
|
-
/**
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
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:
|
|
258
|
-
params
|
|
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:
|
|
192
|
-
|
|
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 {
|
package/src/http/originGuard.ts
CHANGED
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
|
-
/**
|
|
109
|
-
|
|
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,
|
|
171
|
-
|
|
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,
|
package/src/lock/LockManager.ts
CHANGED
|
@@ -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>(
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
82
|
+
const current = await this._redis.get(this._key(key));
|
|
38
83
|
if (current === owner) {
|
|
39
|
-
await
|
|
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
|
|
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
|
|
108
|
+
await this._redis.del(this._key(key));
|
|
53
109
|
}
|
|
54
110
|
|
|
55
111
|
async exists(key: string): Promise<boolean> {
|
|
56
|
-
return
|
|
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 = ?")
|