@shaferllc/keel 0.59.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.
Files changed (166) hide show
  1. package/AGENTS.md +167 -0
  2. package/README.md +30 -1
  3. package/bin/keel-mcp.mjs +9 -0
  4. package/dist/core/application.d.ts +5 -5
  5. package/dist/core/application.js +14 -2
  6. package/dist/core/auth.d.ts +47 -0
  7. package/dist/core/auth.js +77 -0
  8. package/dist/core/authorization.d.ts +9 -1
  9. package/dist/core/authorization.js +22 -2
  10. package/dist/core/cache.d.ts +82 -5
  11. package/dist/core/cache.js +181 -23
  12. package/dist/core/cli/stubs.d.ts +12 -0
  13. package/dist/core/cli/stubs.js +120 -0
  14. package/dist/core/container.d.ts +20 -0
  15. package/dist/core/container.js +52 -0
  16. package/dist/core/cors.d.ts +29 -0
  17. package/dist/core/cors.js +72 -0
  18. package/dist/core/crypto.d.ts +40 -4
  19. package/dist/core/crypto.js +66 -6
  20. package/dist/core/csrf.d.ts +25 -0
  21. package/dist/core/csrf.js +78 -0
  22. package/dist/core/database.d.ts +49 -4
  23. package/dist/core/database.js +89 -21
  24. package/dist/core/events.d.ts +129 -5
  25. package/dist/core/events.js +165 -7
  26. package/dist/core/health.d.ts +141 -0
  27. package/dist/core/health.js +226 -0
  28. package/dist/core/helpers.d.ts +15 -3
  29. package/dist/core/helpers.js +23 -3
  30. package/dist/core/index.d.ts +33 -18
  31. package/dist/core/index.js +16 -8
  32. package/dist/core/lock.d.ts +139 -0
  33. package/dist/core/lock.js +215 -0
  34. package/dist/core/logger.d.ts +82 -4
  35. package/dist/core/logger.js +141 -23
  36. package/dist/core/mail.d.ts +128 -7
  37. package/dist/core/mail.js +264 -16
  38. package/dist/core/model.d.ts +2 -0
  39. package/dist/core/model.js +16 -14
  40. package/dist/core/provider.d.ts +7 -0
  41. package/dist/core/provider.js +7 -0
  42. package/dist/core/queue.d.ts +134 -9
  43. package/dist/core/queue.js +304 -14
  44. package/dist/core/rate-limit.js +3 -0
  45. package/dist/core/relations.js +13 -13
  46. package/dist/core/request.d.ts +26 -0
  47. package/dist/core/request.js +77 -0
  48. package/dist/core/shield.d.ts +39 -0
  49. package/dist/core/shield.js +60 -0
  50. package/dist/core/social.d.ts +173 -0
  51. package/dist/core/social.js +337 -0
  52. package/dist/core/storage.d.ts +159 -6
  53. package/dist/core/storage.js +287 -7
  54. package/dist/core/tokens.d.ts +74 -0
  55. package/dist/core/tokens.js +155 -0
  56. package/dist/db/d1.d.ts +32 -0
  57. package/dist/db/d1.js +26 -0
  58. package/dist/db/libsql.d.ts +29 -0
  59. package/dist/db/libsql.js +32 -0
  60. package/dist/db/pg.d.ts +29 -0
  61. package/dist/db/pg.js +33 -0
  62. package/dist/mcp/server.d.ts +19 -0
  63. package/dist/mcp/server.js +355 -0
  64. package/docs/ai-manifest.json +2472 -0
  65. package/docs/ai.md +128 -0
  66. package/docs/architecture.md +331 -0
  67. package/docs/authentication.md +453 -0
  68. package/docs/authorization.md +167 -0
  69. package/docs/broadcasting.md +137 -0
  70. package/docs/broker.md +500 -0
  71. package/docs/cache.md +558 -0
  72. package/docs/configuration.md +311 -0
  73. package/docs/console.md +356 -0
  74. package/docs/container.md +467 -0
  75. package/docs/controllers.md +265 -0
  76. package/docs/cors.md +51 -0
  77. package/docs/database.md +530 -0
  78. package/docs/debugging.md +129 -0
  79. package/docs/decorators.md +127 -0
  80. package/docs/errors.md +395 -0
  81. package/docs/events.md +496 -0
  82. package/docs/examples/architecture-app.ts +27 -0
  83. package/docs/examples/authentication.ts +61 -0
  84. package/docs/examples/authorization.ts +79 -0
  85. package/docs/examples/broadcasting.ts +60 -0
  86. package/docs/examples/broker-cache-validate.ts +34 -0
  87. package/docs/examples/broker-fault-tolerance.ts +29 -0
  88. package/docs/examples/broker-middleware.ts +27 -0
  89. package/docs/examples/broker.ts +203 -0
  90. package/docs/examples/cache.ts +222 -0
  91. package/docs/examples/configuration.ts +81 -0
  92. package/docs/examples/container.ts +134 -0
  93. package/docs/examples/controllers.ts +86 -0
  94. package/docs/examples/database.ts +118 -0
  95. package/docs/examples/debugging.ts +41 -0
  96. package/docs/examples/decorators.ts +40 -0
  97. package/docs/examples/errors.ts +121 -0
  98. package/docs/examples/events.ts +204 -0
  99. package/docs/examples/factories.ts +84 -0
  100. package/docs/examples/hashing.ts +71 -0
  101. package/docs/examples/health.ts +94 -0
  102. package/docs/examples/helpers.ts +171 -0
  103. package/docs/examples/hooks.ts +54 -0
  104. package/docs/examples/inertia.ts +81 -0
  105. package/docs/examples/locks.ts +120 -0
  106. package/docs/examples/logger.ts +92 -0
  107. package/docs/examples/mail.ts +160 -0
  108. package/docs/examples/middleware.ts +119 -0
  109. package/docs/examples/migrations.ts +126 -0
  110. package/docs/examples/models.ts +239 -0
  111. package/docs/examples/notification.ts +124 -0
  112. package/docs/examples/providers.ts +123 -0
  113. package/docs/examples/queues.ts +254 -0
  114. package/docs/examples/rate-limiting.ts +42 -0
  115. package/docs/examples/redis.ts +99 -0
  116. package/docs/examples/request-response.ts +197 -0
  117. package/docs/examples/routing.ts +186 -0
  118. package/docs/examples/scheduling.ts +62 -0
  119. package/docs/examples/sessions.ts +102 -0
  120. package/docs/examples/static-files.ts +63 -0
  121. package/docs/examples/storage.ts +132 -0
  122. package/docs/examples/templates.ts +58 -0
  123. package/docs/examples/testing.ts +66 -0
  124. package/docs/examples/transformer.ts +141 -0
  125. package/docs/examples/transformers.ts +49 -0
  126. package/docs/examples/url-builder.ts +86 -0
  127. package/docs/examples/validation.ts +102 -0
  128. package/docs/examples/views.tsx +62 -0
  129. package/docs/examples/vite.ts +106 -0
  130. package/docs/factories.md +166 -0
  131. package/docs/getting-started.md +290 -0
  132. package/docs/hashing.md +259 -0
  133. package/docs/health.md +225 -0
  134. package/docs/helpers.md +347 -0
  135. package/docs/hono.md +186 -0
  136. package/docs/hooks.md +118 -0
  137. package/docs/inertia.md +241 -0
  138. package/docs/locks.md +323 -0
  139. package/docs/logger.md +290 -0
  140. package/docs/mail.md +678 -0
  141. package/docs/middleware.md +425 -0
  142. package/docs/migrations.md +476 -0
  143. package/docs/models.md +810 -0
  144. package/docs/notifications.md +474 -0
  145. package/docs/providers.md +363 -0
  146. package/docs/queues.md +679 -0
  147. package/docs/rate-limiting.md +155 -0
  148. package/docs/redis.md +178 -0
  149. package/docs/request-response.md +953 -0
  150. package/docs/routing.md +804 -0
  151. package/docs/scheduling.md +110 -0
  152. package/docs/security.md +85 -0
  153. package/docs/sessions.md +354 -0
  154. package/docs/social-auth.md +174 -0
  155. package/docs/static-files.md +211 -0
  156. package/docs/storage.md +450 -0
  157. package/docs/templates.md +315 -0
  158. package/docs/testing.md +125 -0
  159. package/docs/transformers.md +381 -0
  160. package/docs/url-builder.md +295 -0
  161. package/docs/validation.md +288 -0
  162. package/docs/views.md +267 -0
  163. package/docs/vite.md +434 -0
  164. package/llms-full.txt +17694 -0
  165. package/llms.txt +116 -0
  166. package/package.json +38 -7
@@ -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.