@shaferllc/keel 0.66.0 → 0.68.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/AGENTS.md +167 -0
- package/README.md +30 -1
- package/bin/keel-mcp.mjs +9 -0
- package/dist/core/application.d.ts +5 -5
- package/dist/core/application.js +2 -2
- package/dist/core/cache.d.ts +82 -5
- package/dist/core/cache.js +181 -23
- package/dist/core/cli/stubs.d.ts +12 -0
- package/dist/core/cli/stubs.js +120 -0
- package/dist/core/events.d.ts +129 -5
- package/dist/core/events.js +165 -7
- package/dist/core/health.d.ts +141 -0
- package/dist/core/health.js +226 -0
- package/dist/core/helpers.d.ts +9 -3
- package/dist/core/helpers.js +11 -3
- package/dist/core/index.d.ts +15 -10
- package/dist/core/index.js +7 -4
- package/dist/core/lock.d.ts +139 -0
- package/dist/core/lock.js +215 -0
- package/dist/core/logger.d.ts +82 -4
- package/dist/core/logger.js +141 -23
- package/dist/core/mail.d.ts +128 -7
- package/dist/core/mail.js +264 -16
- package/dist/core/queue.d.ts +134 -9
- package/dist/core/queue.js +304 -14
- package/dist/core/storage.d.ts +159 -6
- package/dist/core/storage.js +287 -7
- package/dist/mcp/server.d.ts +19 -0
- package/dist/mcp/server.js +355 -0
- package/docs/ai-manifest.json +2472 -0
- package/docs/ai.md +128 -0
- package/docs/architecture.md +331 -0
- package/docs/authentication.md +453 -0
- package/docs/authorization.md +167 -0
- package/docs/broadcasting.md +137 -0
- package/docs/broker.md +500 -0
- package/docs/cache.md +558 -0
- package/docs/configuration.md +311 -0
- package/docs/console.md +356 -0
- package/docs/container.md +467 -0
- package/docs/controllers.md +265 -0
- package/docs/cors.md +51 -0
- package/docs/database.md +530 -0
- package/docs/debugging.md +129 -0
- package/docs/decorators.md +127 -0
- package/docs/errors.md +395 -0
- package/docs/events.md +496 -0
- package/docs/examples/architecture-app.ts +27 -0
- package/docs/examples/authentication.ts +61 -0
- package/docs/examples/authorization.ts +79 -0
- package/docs/examples/broadcasting.ts +60 -0
- package/docs/examples/broker-cache-validate.ts +34 -0
- package/docs/examples/broker-fault-tolerance.ts +29 -0
- package/docs/examples/broker-middleware.ts +27 -0
- package/docs/examples/broker.ts +203 -0
- package/docs/examples/cache.ts +222 -0
- package/docs/examples/configuration.ts +81 -0
- package/docs/examples/container.ts +134 -0
- package/docs/examples/controllers.ts +86 -0
- package/docs/examples/database.ts +118 -0
- package/docs/examples/debugging.ts +41 -0
- package/docs/examples/decorators.ts +40 -0
- package/docs/examples/errors.ts +121 -0
- package/docs/examples/events.ts +204 -0
- package/docs/examples/factories.ts +84 -0
- package/docs/examples/hashing.ts +71 -0
- package/docs/examples/health.ts +94 -0
- package/docs/examples/helpers.ts +171 -0
- package/docs/examples/hooks.ts +54 -0
- package/docs/examples/inertia.ts +81 -0
- package/docs/examples/locks.ts +120 -0
- package/docs/examples/logger.ts +92 -0
- package/docs/examples/mail.ts +160 -0
- package/docs/examples/middleware.ts +119 -0
- package/docs/examples/migrations.ts +126 -0
- package/docs/examples/models.ts +239 -0
- package/docs/examples/notification.ts +124 -0
- package/docs/examples/providers.ts +123 -0
- package/docs/examples/queues.ts +254 -0
- package/docs/examples/rate-limiting.ts +42 -0
- package/docs/examples/redis.ts +99 -0
- package/docs/examples/request-response.ts +197 -0
- package/docs/examples/routing.ts +186 -0
- package/docs/examples/scheduling.ts +62 -0
- package/docs/examples/sessions.ts +102 -0
- package/docs/examples/static-files.ts +63 -0
- package/docs/examples/storage.ts +132 -0
- package/docs/examples/templates.ts +58 -0
- package/docs/examples/testing.ts +66 -0
- package/docs/examples/transformer.ts +141 -0
- package/docs/examples/transformers.ts +49 -0
- package/docs/examples/url-builder.ts +86 -0
- package/docs/examples/validation.ts +102 -0
- package/docs/examples/views.tsx +62 -0
- package/docs/examples/vite.ts +106 -0
- package/docs/factories.md +166 -0
- package/docs/getting-started.md +290 -0
- package/docs/hashing.md +259 -0
- package/docs/health.md +225 -0
- package/docs/helpers.md +347 -0
- package/docs/hono.md +186 -0
- package/docs/hooks.md +118 -0
- package/docs/inertia.md +241 -0
- package/docs/locks.md +323 -0
- package/docs/logger.md +290 -0
- package/docs/mail.md +678 -0
- package/docs/middleware.md +425 -0
- package/docs/migrations.md +476 -0
- package/docs/models.md +810 -0
- package/docs/notifications.md +474 -0
- package/docs/providers.md +363 -0
- package/docs/queues.md +679 -0
- package/docs/rate-limiting.md +155 -0
- package/docs/redis.md +178 -0
- package/docs/request-response.md +953 -0
- package/docs/routing.md +804 -0
- package/docs/scheduling.md +110 -0
- package/docs/security.md +85 -0
- package/docs/sessions.md +354 -0
- package/docs/social-auth.md +174 -0
- package/docs/static-files.md +211 -0
- package/docs/storage.md +450 -0
- package/docs/templates.md +315 -0
- package/docs/testing.md +125 -0
- package/docs/transformers.md +381 -0
- package/docs/url-builder.md +295 -0
- package/docs/validation.md +288 -0
- package/docs/views.md +267 -0
- package/docs/vite.md +434 -0
- package/llms-full.txt +17694 -0
- package/llms.txt +116 -0
- package/package.json +26 -7
package/docs/inertia.md
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
# Inertia
|
|
2
|
+
|
|
3
|
+
Keel ships a server-side [Inertia.js](https://inertiajs.com) adapter. Pair Keel's
|
|
4
|
+
routing with an Inertia client (React, Vue, or Svelte) and render page components
|
|
5
|
+
from the server without building an API — `inertia("Page", props)` returns the
|
|
6
|
+
right response automatically.
|
|
7
|
+
|
|
8
|
+
## Configure it
|
|
9
|
+
|
|
10
|
+
Bind an `Inertia` instance in a service provider. You supply the **root view**
|
|
11
|
+
(the HTML shell that embeds the page data and loads your client bundle) and an
|
|
12
|
+
optional asset **version**:
|
|
13
|
+
|
|
14
|
+
```ts
|
|
15
|
+
import { ServiceProvider, singleton, Inertia, inertiaPageAttr } from "@shaferllc/keel/core";
|
|
16
|
+
|
|
17
|
+
export class InertiaServiceProvider extends ServiceProvider {
|
|
18
|
+
register(): void {
|
|
19
|
+
singleton(
|
|
20
|
+
Inertia,
|
|
21
|
+
() =>
|
|
22
|
+
new Inertia({
|
|
23
|
+
version: "1",
|
|
24
|
+
rootView: (page) =>
|
|
25
|
+
`<!DOCTYPE html><html><head><meta charset="utf-8"></head>` +
|
|
26
|
+
`<body><div id="app" data-page="${inertiaPageAttr(page)}"></div>` +
|
|
27
|
+
`<script src="/assets/app.js"></script></body></html>`,
|
|
28
|
+
}),
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`inertiaPageAttr(page)` serializes and HTML-escapes the page object for the
|
|
35
|
+
`data-page` attribute.
|
|
36
|
+
|
|
37
|
+
> **The version defaults to `"1"`.** Omit it and every deploy reports the same
|
|
38
|
+
> asset version — fine until you ship new assets, at which point stale clients
|
|
39
|
+
> won't be told to hard-reload. Bump it (a build hash, a timestamp) whenever your
|
|
40
|
+
> bundle changes so the adapter can force a full reload on mismatch.
|
|
41
|
+
|
|
42
|
+
## Render a page
|
|
43
|
+
|
|
44
|
+
From a controller, or straight from a route:
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
import { inertia } from "@shaferllc/keel/core";
|
|
48
|
+
|
|
49
|
+
// controller
|
|
50
|
+
show() {
|
|
51
|
+
return inertia("Users/Show", { user: getUser(param("id")) });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// brisk route
|
|
55
|
+
router.on("/dashboard").renderInertia("Dashboard", { title: "Welcome" });
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`inertia()` looks up the bound `Inertia` instance and delegates to its `render`.
|
|
59
|
+
The component name is the client-side path Inertia resolves (e.g. `Users/Show`
|
|
60
|
+
maps to your `Pages/Users/Show` component); `props` is any JSON-serializable
|
|
61
|
+
object.
|
|
62
|
+
|
|
63
|
+
> **Configure the adapter before you render.** `inertia()` throws
|
|
64
|
+
> `Inertia is not configured…` if no `Inertia` instance is bound in the
|
|
65
|
+
> container. Register the provider (above) during boot, before any route runs.
|
|
66
|
+
|
|
67
|
+
## What the adapter does
|
|
68
|
+
|
|
69
|
+
It implements the Inertia protocol for you. Every branch below is decided from
|
|
70
|
+
the incoming request headers — you call `inertia("Page", props)` once and the
|
|
71
|
+
adapter picks the response:
|
|
72
|
+
|
|
73
|
+
| Situation | Response |
|
|
74
|
+
|-----------|----------|
|
|
75
|
+
| First visit (no `X-Inertia` header) | The full HTML document from your `rootView` (a `string`) |
|
|
76
|
+
| Inertia navigation (`X-Inertia: true`) | `{ component, props, url, version }` JSON + `X-Inertia: true` and `Vary: X-Inertia` headers |
|
|
77
|
+
| Asset version changed (GET) | `409` + `X-Inertia-Location` so the client hard-reloads |
|
|
78
|
+
| Partial reload (`X-Inertia-Partial-Data`) | Only the requested props, for the matching component |
|
|
79
|
+
|
|
80
|
+
The `url` embedded in the page object is the request's `pathname + search` —
|
|
81
|
+
Inertia uses it to keep the browser history in sync.
|
|
82
|
+
|
|
83
|
+
### Version mismatches
|
|
84
|
+
|
|
85
|
+
The version check only fires on a **GET** Inertia request whose
|
|
86
|
+
`X-Inertia-Version` header differs from the adapter's configured version. On a
|
|
87
|
+
mismatch the adapter returns an empty `409` with `X-Inertia-Location` set to the
|
|
88
|
+
current URL; the Inertia client sees the `409` and does a full page reload to
|
|
89
|
+
pull fresh assets. Non-GET requests (a form POST, say) skip the check and render
|
|
90
|
+
normally.
|
|
91
|
+
|
|
92
|
+
### Partial reloads
|
|
93
|
+
|
|
94
|
+
When the client asks for a partial reload it sends two headers:
|
|
95
|
+
`X-Inertia-Partial-Component` (the component it already has mounted) and
|
|
96
|
+
`X-Inertia-Partial-Data` (a comma-separated list of prop keys it wants). The
|
|
97
|
+
adapter only trims props when the partial component **matches** the component
|
|
98
|
+
you're rendering — so a partial reload of `Users/Index` won't accidentally
|
|
99
|
+
strip props when you render `Users/Show`. Matching props are filtered down to
|
|
100
|
+
the requested keys; everything else is dropped from the payload.
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
// Client requests only `notifications` for the already-mounted Dashboard.
|
|
104
|
+
// The adapter sends { component: "Dashboard", props: { notifications }, ... }.
|
|
105
|
+
inertia("Dashboard", { stats, notifications, activity });
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
> Partial reloads are an **allow-list** (the `only` mechanism). The adapter does
|
|
109
|
+
> not implement Inertia's `except` variant — every listed key is kept, all others
|
|
110
|
+
> are dropped.
|
|
111
|
+
|
|
112
|
+
## The client
|
|
113
|
+
|
|
114
|
+
The adapter is the server half. On the client, set up Inertia as usual
|
|
115
|
+
(`@inertiajs/react` / `-vue` / `-svelte`) pointing at `#app`, and build your
|
|
116
|
+
`app.js` bundle referenced by the root view. See
|
|
117
|
+
[inertiajs.com](https://inertiajs.com) for the client setup.
|
|
118
|
+
|
|
119
|
+
## Related
|
|
120
|
+
|
|
121
|
+
`inertia()` resolves the `Inertia` instance from the [container](./container.md),
|
|
122
|
+
so it's bound like any other [service provider](./providers.md) singleton. The
|
|
123
|
+
`renderInertia` brisk-route helper lives on the [router](./routing.md).
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
## API reference
|
|
128
|
+
|
|
129
|
+
### `inertia(component, props?)`
|
|
130
|
+
|
|
131
|
+
`inertia(component: string, props?: Record<string, unknown>): Response | string`
|
|
132
|
+
|
|
133
|
+
Renders an Inertia response for the current request using the `Inertia` instance
|
|
134
|
+
bound in the container.
|
|
135
|
+
|
|
136
|
+
```ts
|
|
137
|
+
import { inertia } from "@shaferllc/keel/core";
|
|
138
|
+
|
|
139
|
+
return inertia("Users/Show", { user });
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
**Notes:** `props` defaults to `{}`. Throws `Inertia is not configured…` if no
|
|
143
|
+
`Inertia` instance is bound — bind one in a provider first. Returns a `string`
|
|
144
|
+
(the `rootView` HTML) on a first load and a `Response` (JSON, or a `409`) on an
|
|
145
|
+
Inertia navigation, so it fits anywhere a route handler can return either.
|
|
146
|
+
|
|
147
|
+
### `inertiaPageAttr(page)`
|
|
148
|
+
|
|
149
|
+
`inertiaPageAttr(page: InertiaPage): string`
|
|
150
|
+
|
|
151
|
+
HTML-escapes a JSON-serialized page object for embedding in the `data-page`
|
|
152
|
+
attribute of your root element.
|
|
153
|
+
|
|
154
|
+
```ts
|
|
155
|
+
`<div id="app" data-page="${inertiaPageAttr(page)}"></div>`;
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
**Notes:** escapes `&`, `"`, `'`, `<`, and `>` (in that order, so `&` isn't
|
|
159
|
+
double-escaped). Use it only inside a double-quoted attribute in your `rootView`
|
|
160
|
+
— it is the escaping counterpart the Inertia client reads back off `#app`.
|
|
161
|
+
|
|
162
|
+
### `Inertia`
|
|
163
|
+
|
|
164
|
+
The adapter itself. Construct one and bind it as a container singleton; the
|
|
165
|
+
`inertia()` helper resolves it per request. You rarely call its methods directly
|
|
166
|
+
— `inertia()` and `renderInertia()` do.
|
|
167
|
+
|
|
168
|
+
#### `new Inertia(options)`
|
|
169
|
+
|
|
170
|
+
`new Inertia(options: InertiaOptions)`
|
|
171
|
+
|
|
172
|
+
Creates an adapter with a root view and an optional asset version.
|
|
173
|
+
|
|
174
|
+
```ts
|
|
175
|
+
new Inertia({
|
|
176
|
+
version: "1",
|
|
177
|
+
rootView: (page) => `<div id="app" data-page="${inertiaPageAttr(page)}"></div>`,
|
|
178
|
+
});
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
**Notes:** `options.version` defaults to `"1"` when omitted; `options.rootView`
|
|
182
|
+
is required.
|
|
183
|
+
|
|
184
|
+
#### `render(component, props?)`
|
|
185
|
+
|
|
186
|
+
`render(component: string, props?: Record<string, unknown>): Response | string`
|
|
187
|
+
|
|
188
|
+
Produces the correct response for the current request: the full HTML document on
|
|
189
|
+
a first visit, the page JSON on an Inertia navigation, a `409` on a version
|
|
190
|
+
mismatch, or a trimmed payload for a partial reload.
|
|
191
|
+
|
|
192
|
+
```ts
|
|
193
|
+
const html = new Inertia({ rootView }).render("Dashboard", { title: "Welcome" });
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
**Notes:** reads the active request from `ctx()`, so call it inside a request
|
|
197
|
+
(the `inertia()` helper does this for you). `props` defaults to `{}`. The JSON
|
|
198
|
+
branch sets `X-Inertia: true` and `Vary: X-Inertia`; the `409` branch sets
|
|
199
|
+
`X-Inertia-Location`.
|
|
200
|
+
|
|
201
|
+
### Interfaces & types
|
|
202
|
+
|
|
203
|
+
#### `InertiaOptions`
|
|
204
|
+
|
|
205
|
+
```ts
|
|
206
|
+
interface InertiaOptions {
|
|
207
|
+
version?: string;
|
|
208
|
+
rootView: (page: InertiaPage) => string;
|
|
209
|
+
}
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
The constructor argument. `version` is the asset version (default `"1"`); a
|
|
213
|
+
mismatch against the client's `X-Inertia-Version` forces a full reload.
|
|
214
|
+
`rootView` renders the HTML shell for a first, non-XHR load — it receives the
|
|
215
|
+
`InertiaPage` and must embed it (typically via `inertiaPageAttr`) so the client
|
|
216
|
+
can boot.
|
|
217
|
+
|
|
218
|
+
```ts
|
|
219
|
+
const options: InertiaOptions = {
|
|
220
|
+
version: "abc123",
|
|
221
|
+
rootView: (page) =>
|
|
222
|
+
`<div id="app" data-page="${inertiaPageAttr(page)}"></div>`,
|
|
223
|
+
};
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
#### `InertiaPage`
|
|
227
|
+
|
|
228
|
+
```ts
|
|
229
|
+
interface InertiaPage {
|
|
230
|
+
component: string;
|
|
231
|
+
props: Record<string, unknown>;
|
|
232
|
+
url: string;
|
|
233
|
+
version: string;
|
|
234
|
+
}
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
The Inertia page object — the payload both the JSON response and the `rootView`
|
|
238
|
+
receive. `component` is the page name, `props` its (possibly partial-reload
|
|
239
|
+
filtered) data, `url` the request's `pathname + search`, and `version` the
|
|
240
|
+
adapter's asset version. You consume it inside `rootView`; you don't build it
|
|
241
|
+
yourself.
|
package/docs/locks.md
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
# Locks
|
|
2
|
+
|
|
3
|
+
"Only one of you may do this at a time" — across processes, across nodes.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { lock } from "@shaferllc/keel/core";
|
|
7
|
+
|
|
8
|
+
const [ran] = await lock("invoice:42").run(async () => {
|
|
9
|
+
await charge(invoice);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
if (!ran) return; // another worker is already charging it
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
This is the counterpart to the [cache](./cache.md)'s stampede protection. That
|
|
16
|
+
collapses concurrent work **inside one isolate**; a lock coordinates work
|
|
17
|
+
**between** them. Reach for a lock when doing the thing twice would be *wrong* —
|
|
18
|
+
charging a card, sending an invoice, running a migration — not merely wasteful.
|
|
19
|
+
|
|
20
|
+
Like every other backend in Keel, the store is a small pluggable seam and the core
|
|
21
|
+
imports no driver. `MemoryLockStore` is the default; it's per-isolate, so it
|
|
22
|
+
coordinates within one process and nothing more — fine for tests and
|
|
23
|
+
single-process apps, useless across a cluster. Point it at Redis for the real
|
|
24
|
+
thing.
|
|
25
|
+
|
|
26
|
+
## `run()` — the form you want
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
const [ran, result] = await lock("invoice:42").run(() => charge(invoice));
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
It acquires, runs, and **always** releases — the `finally` is what stops a
|
|
33
|
+
throwing callback from leaving the lock held until its TTL runs out. It returns
|
|
34
|
+
`[ran, result]`; `ran` is `false` if someone else holds the lock, in which case
|
|
35
|
+
your callback never ran and `result` is `undefined`.
|
|
36
|
+
|
|
37
|
+
By default it doesn't wait: if the lock is taken, it gives up immediately. To wait
|
|
38
|
+
for it, pass a timeout:
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
// Wait up to 5 seconds for the lock, checking every 100ms.
|
|
42
|
+
const [ran] = await lock("report").run(() => rebuild(), {
|
|
43
|
+
timeout: 5_000,
|
|
44
|
+
retryDelay: 100,
|
|
45
|
+
});
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`runImmediately()` is the explicit "never wait" spelling.
|
|
49
|
+
|
|
50
|
+
## TTL and expiry
|
|
51
|
+
|
|
52
|
+
**A lock always expires.** There's no hold-forever mode, because a holder that
|
|
53
|
+
crashes would keep the lock forever and nothing would ever run again. The default
|
|
54
|
+
TTL is 30 seconds:
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
lock("invoice:42"); // held for 30s once acquired
|
|
58
|
+
lock("nightly-report", 300_000); // 5 minutes
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Pick a TTL comfortably longer than the work. If the work might outrun it, `extend()`
|
|
62
|
+
from inside:
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
await lock("import", 60_000).run(async () => {
|
|
66
|
+
for (const batch of batches) {
|
|
67
|
+
await process(batch);
|
|
68
|
+
await l.extend(); // push the expiry out another 60s
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`extend()` **throws** `LockNotHeldError` if you've already lost the lock. That's
|
|
74
|
+
deliberate: the alternative — silently doing nothing — would let you carry on
|
|
75
|
+
believing you hold a lock you don't.
|
|
76
|
+
|
|
77
|
+
## Ownership, and why it matters
|
|
78
|
+
|
|
79
|
+
Every acquisition mints a random owner token, and `release()`/`extend()` only
|
|
80
|
+
succeed for the owner. This is not bookkeeping — it's the property that makes the
|
|
81
|
+
lock correct:
|
|
82
|
+
|
|
83
|
+
1. Process **A** takes the lock with a 30s TTL.
|
|
84
|
+
2. A's work takes longer than 30s. The lock **expires**.
|
|
85
|
+
3. Process **B** takes the now-free lock and starts working.
|
|
86
|
+
4. A finishes and calls `release()`.
|
|
87
|
+
|
|
88
|
+
Without ownership, A's release deletes **B's** lock, and a third process walks
|
|
89
|
+
straight in while B is still working. With it, A's release is a no-op that returns
|
|
90
|
+
`false`. A store must *compare-and-delete*, not just delete.
|
|
91
|
+
|
|
92
|
+
## Manual acquisition
|
|
93
|
+
|
|
94
|
+
`acquire()` / `release()` are there when the lock's lifetime doesn't fit a single
|
|
95
|
+
callback. You own the `try/finally`:
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
const l = lock("invoice:42");
|
|
99
|
+
if (!(await l.acquire({ timeout: 2_000 }))) return;
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
await charge(invoice);
|
|
103
|
+
} finally {
|
|
104
|
+
await l.release(); // without this, the lock leaks for the rest of its TTL
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Prefer `run()`. It exists so you can't forget the `finally`.
|
|
109
|
+
|
|
110
|
+
## Handing a lock to another process
|
|
111
|
+
|
|
112
|
+
`serialize()` freezes the key, TTL, and **owner token** to a string.
|
|
113
|
+
`restoreLock()` rebuilds it elsewhere — so one process can take the lock and
|
|
114
|
+
another can release or extend the *same* lock:
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
// worker A
|
|
118
|
+
const l = lock("import:99", 60_000);
|
|
119
|
+
await l.acquire();
|
|
120
|
+
await queue.dispatch(new FinishImport(l.serialize()));
|
|
121
|
+
|
|
122
|
+
// worker B, later
|
|
123
|
+
const l = restoreLock(serialized);
|
|
124
|
+
await l.extend(60_000);
|
|
125
|
+
// ...finish the work...
|
|
126
|
+
await l.release();
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Inspecting a lock
|
|
130
|
+
|
|
131
|
+
```ts
|
|
132
|
+
await l.isLocked(); // does anyone hold this key? (not necessarily you)
|
|
133
|
+
await l.isExpired(); // did we hold it and lose it?
|
|
134
|
+
await l.getRemainingTime(); // ms until expiry, or null if unheld
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## Testing
|
|
138
|
+
|
|
139
|
+
The default `MemoryLockStore` needs no setup. Give each test a clean one so keys
|
|
140
|
+
can't leak between them:
|
|
141
|
+
|
|
142
|
+
```ts
|
|
143
|
+
import { setLockStore, MemoryLockStore } from "@shaferllc/keel/core";
|
|
144
|
+
|
|
145
|
+
beforeEach(() => setLockStore(new MemoryLockStore()));
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## Writing a store
|
|
149
|
+
|
|
150
|
+
A store is the `LockStore` interface. Implementations **must** make `acquire`
|
|
151
|
+
atomic (set-if-absent) and make `release`/`extend` conditional on the owner
|
|
152
|
+
matching — a store that can't do both isn't a lock, it's a suggestion.
|
|
153
|
+
|
|
154
|
+
### Redis
|
|
155
|
+
|
|
156
|
+
Redis gives you both: `SET key owner PX ttl NX` is an atomic set-if-absent, and a
|
|
157
|
+
small Lua script makes release and extend compare-and-act. This example uses
|
|
158
|
+
[ioredis](https://github.com/redis/ioredis); any client with `set` and `eval` works
|
|
159
|
+
the same way.
|
|
160
|
+
|
|
161
|
+
```ts
|
|
162
|
+
import type { LockStore } from "@shaferllc/keel/core";
|
|
163
|
+
import type { Redis } from "ioredis";
|
|
164
|
+
|
|
165
|
+
// Compare-and-delete: only delete if the value still matches our owner token.
|
|
166
|
+
const RELEASE = `
|
|
167
|
+
if redis.call("get", KEYS[1]) == ARGV[1] then
|
|
168
|
+
return redis.call("del", KEYS[1])
|
|
169
|
+
end
|
|
170
|
+
return 0
|
|
171
|
+
`;
|
|
172
|
+
|
|
173
|
+
// Compare-and-extend, same idea.
|
|
174
|
+
const EXTEND = `
|
|
175
|
+
if redis.call("get", KEYS[1]) == ARGV[1] then
|
|
176
|
+
return redis.call("pexpire", KEYS[1], ARGV[2])
|
|
177
|
+
end
|
|
178
|
+
return 0
|
|
179
|
+
`;
|
|
180
|
+
|
|
181
|
+
export const redisLockStore = (client: Redis): LockStore => ({
|
|
182
|
+
async acquire(key, owner, ttlMs) {
|
|
183
|
+
// NX = only if absent. This is the atomic bit.
|
|
184
|
+
const res = await client.set(key, owner, "PX", ttlMs, "NX");
|
|
185
|
+
return res === "OK";
|
|
186
|
+
},
|
|
187
|
+
async release(key, owner) {
|
|
188
|
+
return (await client.eval(RELEASE, 1, key, owner)) === 1;
|
|
189
|
+
},
|
|
190
|
+
async extend(key, owner, ttlMs) {
|
|
191
|
+
return (await client.eval(EXTEND, 1, key, owner, String(ttlMs))) === 1;
|
|
192
|
+
},
|
|
193
|
+
async isLocked(key) {
|
|
194
|
+
return (await client.exists(key)) === 1;
|
|
195
|
+
},
|
|
196
|
+
async remainingTime(key) {
|
|
197
|
+
const ms = await client.pttl(key);
|
|
198
|
+
return ms < 0 ? null : ms; // -1 = no expiry, -2 = no key
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
```ts
|
|
204
|
+
setLockStore(redisLockStore(client));
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
### Database
|
|
208
|
+
|
|
209
|
+
A unique primary key on `key` gives you the atomicity — the insert fails if
|
|
210
|
+
someone already holds it. Expired rows are treated as free.
|
|
211
|
+
|
|
212
|
+
```sql
|
|
213
|
+
CREATE TABLE locks (
|
|
214
|
+
key TEXT PRIMARY KEY,
|
|
215
|
+
owner TEXT NOT NULL,
|
|
216
|
+
expires_at BIGINT NOT NULL
|
|
217
|
+
);
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
```ts
|
|
221
|
+
import { connection } from "@shaferllc/keel/core";
|
|
222
|
+
|
|
223
|
+
export const databaseLockStore = (table = "locks"): LockStore => ({
|
|
224
|
+
async acquire(key, owner, ttlMs) {
|
|
225
|
+
const now = Date.now();
|
|
226
|
+
// Clear the row first if it has expired, then insert. The PK makes the
|
|
227
|
+
// insert the atomic step: two racing writers, one unique-violation.
|
|
228
|
+
await connection().write(`DELETE FROM ${table} WHERE key = ? AND expires_at <= ?`, [key, now]);
|
|
229
|
+
try {
|
|
230
|
+
await connection().write(
|
|
231
|
+
`INSERT INTO ${table} (key, owner, expires_at) VALUES (?, ?, ?)`,
|
|
232
|
+
[key, owner, now + ttlMs],
|
|
233
|
+
);
|
|
234
|
+
return true;
|
|
235
|
+
} catch {
|
|
236
|
+
return false; // someone else holds it
|
|
237
|
+
}
|
|
238
|
+
},
|
|
239
|
+
async release(key, owner) {
|
|
240
|
+
const res = await connection().write(`DELETE FROM ${table} WHERE key = ? AND owner = ?`, [key, owner]);
|
|
241
|
+
return res.changes > 0;
|
|
242
|
+
},
|
|
243
|
+
async extend(key, owner, ttlMs) {
|
|
244
|
+
const res = await connection().write(
|
|
245
|
+
`UPDATE ${table} SET expires_at = ? WHERE key = ? AND owner = ? AND expires_at > ?`,
|
|
246
|
+
[Date.now() + ttlMs, key, owner, Date.now()],
|
|
247
|
+
);
|
|
248
|
+
return res.changes > 0;
|
|
249
|
+
},
|
|
250
|
+
async isLocked(key) {
|
|
251
|
+
const rows = await connection().select(
|
|
252
|
+
`SELECT 1 FROM ${table} WHERE key = ? AND expires_at > ?`,
|
|
253
|
+
[key, Date.now()],
|
|
254
|
+
);
|
|
255
|
+
return rows.length > 0;
|
|
256
|
+
},
|
|
257
|
+
async remainingTime(key) {
|
|
258
|
+
const rows = await connection().select(`SELECT expires_at FROM ${table} WHERE key = ?`, [key]);
|
|
259
|
+
const row = rows[0] as { expires_at: number } | undefined;
|
|
260
|
+
if (!row) return null;
|
|
261
|
+
const left = Number(row.expires_at) - Date.now();
|
|
262
|
+
return left > 0 ? left : null;
|
|
263
|
+
},
|
|
264
|
+
});
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
Redis is the better fit if you have it — the database store pays a round trip per
|
|
268
|
+
operation and needs the expired rows cleaned up.
|
|
269
|
+
|
|
270
|
+
---
|
|
271
|
+
|
|
272
|
+
## API reference
|
|
273
|
+
|
|
274
|
+
### `lock(key, ttlMs?)`
|
|
275
|
+
|
|
276
|
+
`lock(key: string, ttlMs?: number): Lock`
|
|
277
|
+
|
|
278
|
+
A lock on `key`, held for `ttlMs` once acquired (default `30_000`).
|
|
279
|
+
|
|
280
|
+
### `Lock`
|
|
281
|
+
|
|
282
|
+
| Method | Signature |
|
|
283
|
+
|--------|-----------|
|
|
284
|
+
| `run` | `<T>(fn, options?: AcquireOptions) => Promise<[boolean, T \| undefined]>` — acquire, run, always release |
|
|
285
|
+
| `runImmediately` | `<T>(fn) => Promise<[boolean, T \| undefined]>` — never waits |
|
|
286
|
+
| `acquire` | `(options?: AcquireOptions) => Promise<boolean>` |
|
|
287
|
+
| `acquireImmediately` | `() => Promise<boolean>` |
|
|
288
|
+
| `release` | `() => Promise<boolean>` — false if we no longer hold it |
|
|
289
|
+
| `extend` | `(ttlMs?) => Promise<void>` — throws `LockNotHeldError` if lost |
|
|
290
|
+
| `isLocked` | `() => Promise<boolean>` — does *anyone* hold it |
|
|
291
|
+
| `isExpired` | `() => Promise<boolean>` — did *we* hold it and lose it |
|
|
292
|
+
| `getRemainingTime` | `() => Promise<number \| null>` — ms until expiry |
|
|
293
|
+
| `serialize` | `() => string` — key + TTL + owner token |
|
|
294
|
+
|
|
295
|
+
### `restoreLock(serialized)`
|
|
296
|
+
|
|
297
|
+
`restoreLock(serialized: string): Lock` — rebuild a lock from `serialize()`, owner
|
|
298
|
+
token and all, so another process can release or extend it.
|
|
299
|
+
|
|
300
|
+
### `setLockStore(store)` / `getLockStore()`
|
|
301
|
+
|
|
302
|
+
Register the store `lock()` uses, and read it back.
|
|
303
|
+
|
|
304
|
+
### Interfaces & types
|
|
305
|
+
|
|
306
|
+
#### `LockStore`
|
|
307
|
+
|
|
308
|
+
`acquire(key, owner, ttlMs)` / `release(key, owner)` / `extend(key, owner, ttlMs)` /
|
|
309
|
+
`isLocked(key)` / `remainingTime(key)`. `acquire` must be atomic; `release` and
|
|
310
|
+
`extend` must be conditional on the owner.
|
|
311
|
+
|
|
312
|
+
#### `AcquireOptions`
|
|
313
|
+
|
|
314
|
+
`{ timeout?: number, retryDelay?: number }` — how long to wait for a held lock
|
|
315
|
+
(default `0`, don't wait) and how often to retry (default `50`ms).
|
|
316
|
+
|
|
317
|
+
#### `MemoryLockStore`
|
|
318
|
+
|
|
319
|
+
The default. Per-isolate — for tests and single-process apps.
|
|
320
|
+
|
|
321
|
+
#### `LockNotHeldError`
|
|
322
|
+
|
|
323
|
+
Thrown by `extend()` when the lock has expired or was never acquired.
|