@shaferllc/keel 0.66.0 → 0.74.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +169 -0
- package/README.md +44 -6
- package/bin/keel-mcp.mjs +9 -0
- package/dist/api/api.config.stub +9 -0
- package/dist/api/config.d.ts +13 -0
- package/dist/api/config.js +14 -0
- package/dist/api/index.d.ts +16 -0
- package/dist/api/index.js +13 -0
- package/dist/api/provider.d.ts +10 -0
- package/dist/api/provider.js +17 -0
- package/dist/api/query.d.ts +35 -0
- package/dist/api/query.js +42 -0
- package/dist/api/resource.d.ts +91 -0
- package/dist/api/resource.js +188 -0
- package/dist/core/application.d.ts +5 -5
- package/dist/core/application.js +8 -2
- package/dist/core/cache.d.ts +81 -5
- package/dist/core/cache.js +188 -23
- package/dist/core/cli/stubs.d.ts +26 -0
- package/dist/core/cli/stubs.js +225 -0
- package/dist/core/console-prompt.d.ts +79 -0
- package/dist/core/console-prompt.js +239 -0
- package/dist/core/console-ui.d.ts +96 -0
- package/dist/core/console-ui.js +187 -0
- package/dist/core/console.d.ts +188 -0
- package/dist/core/console.js +395 -0
- package/dist/core/database.d.ts +70 -1
- package/dist/core/database.js +174 -15
- package/dist/core/env.d.ts +96 -0
- package/dist/core/env.js +140 -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/http/kernel.d.ts +2 -0
- package/dist/core/http/kernel.js +48 -0
- package/dist/core/http/router.d.ts +5 -5
- package/dist/core/http/router.js +5 -5
- package/dist/core/i18n.d.ts +162 -0
- package/dist/core/i18n.js +472 -0
- package/dist/core/index.d.ts +39 -13
- package/dist/core/index.js +19 -6
- package/dist/core/instrumentation.d.ts +113 -0
- package/dist/core/instrumentation.js +52 -0
- package/dist/core/lock.d.ts +139 -0
- package/dist/core/lock.js +215 -0
- package/dist/core/logger.d.ts +89 -4
- package/dist/core/logger.js +167 -22
- package/dist/core/mail.d.ts +128 -7
- package/dist/core/mail.js +264 -16
- package/dist/core/notification.js +10 -1
- package/dist/core/package.d.ts +120 -0
- package/dist/core/package.js +169 -0
- package/dist/core/pages.d.ts +108 -0
- package/dist/core/pages.js +199 -0
- package/dist/core/queue.d.ts +134 -9
- package/dist/core/queue.js +325 -14
- package/dist/core/repl.d.ts +33 -0
- package/dist/core/repl.js +88 -0
- package/dist/core/scheduler.js +6 -0
- package/dist/core/social.d.ts +4 -4
- package/dist/core/social.js +4 -4
- package/dist/core/storage.d.ts +159 -6
- package/dist/core/storage.js +299 -7
- package/dist/core/telemetry.d.ts +208 -0
- package/dist/core/telemetry.js +383 -0
- package/dist/core/template.d.ts +2 -3
- package/dist/core/template.js +2 -3
- package/dist/core/testing.d.ts +170 -1
- package/dist/core/testing.js +504 -2
- package/dist/db/d1.js +13 -0
- package/dist/db/pg.d.ts +13 -0
- package/dist/db/pg.js +46 -4
- package/dist/mcp/server.d.ts +19 -0
- package/dist/mcp/server.js +355 -0
- package/dist/openapi/config.d.ts +28 -0
- package/dist/openapi/config.js +25 -0
- package/dist/openapi/doc.d.ts +40 -0
- package/dist/openapi/doc.js +20 -0
- package/dist/openapi/export.d.ts +8 -0
- package/dist/openapi/export.js +19 -0
- package/dist/openapi/gate.d.ts +15 -0
- package/dist/openapi/gate.js +27 -0
- package/dist/openapi/index.d.ts +19 -0
- package/dist/openapi/index.js +15 -0
- package/dist/openapi/openapi.config.stub +29 -0
- package/dist/openapi/provider.d.ts +18 -0
- package/dist/openapi/provider.js +35 -0
- package/dist/openapi/routes.d.ts +9 -0
- package/dist/openapi/routes.js +23 -0
- package/dist/openapi/spec.d.ts +23 -0
- package/dist/openapi/spec.js +132 -0
- package/dist/openapi/ui.d.ts +8 -0
- package/dist/openapi/ui.js +31 -0
- package/dist/openapi/zod.d.ts +12 -0
- package/dist/openapi/zod.js +46 -0
- package/dist/watch/config.d.ts +33 -0
- package/dist/watch/config.js +38 -0
- package/dist/watch/entry.d.ts +53 -0
- package/dist/watch/entry.js +105 -0
- package/dist/watch/gate.d.ts +20 -0
- package/dist/watch/gate.js +32 -0
- package/dist/watch/index.d.ts +21 -0
- package/dist/watch/index.js +17 -0
- package/dist/watch/migration.d.ts +7 -0
- package/dist/watch/migration.js +20 -0
- package/dist/watch/provider.d.ts +22 -0
- package/dist/watch/provider.js +58 -0
- package/dist/watch/prune.d.ts +11 -0
- package/dist/watch/prune.js +20 -0
- package/dist/watch/recorder.d.ts +24 -0
- package/dist/watch/recorder.js +39 -0
- package/dist/watch/routes.d.ts +13 -0
- package/dist/watch/routes.js +55 -0
- package/dist/watch/store.d.ts +54 -0
- package/dist/watch/store.js +158 -0
- package/dist/watch/ui/dist/watch.css +1 -0
- package/dist/watch/ui/dist/watch.js +555 -0
- package/dist/watch/ui-shell.d.ts +12 -0
- package/dist/watch/ui-shell.js +24 -0
- package/dist/watch/watch.config.stub +47 -0
- package/dist/watch/watchers.d.ts +12 -0
- package/dist/watch/watchers.js +156 -0
- package/docs/ai-manifest.json +3206 -0
- package/docs/ai.md +128 -0
- package/docs/api-resources.md +118 -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 +385 -0
- package/docs/console.md +536 -0
- package/docs/container.md +467 -0
- package/docs/controllers.md +265 -0
- package/docs/cors.md +51 -0
- package/docs/database.md +631 -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 +121 -0
- package/docs/examples/console.ts +134 -0
- package/docs/examples/container.ts +134 -0
- package/docs/examples/controllers.ts +86 -0
- package/docs/examples/database.ts +208 -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/i18n.ts +117 -0
- package/docs/examples/inertia.ts +81 -0
- package/docs/examples/locks.ts +120 -0
- package/docs/examples/logger.ts +166 -0
- package/docs/examples/mail.ts +263 -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/pages.ts +82 -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/telemetry.ts +127 -0
- package/docs/examples/templates.ts +58 -0
- package/docs/examples/testing.ts +215 -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/i18n.md +302 -0
- package/docs/inertia.md +241 -0
- package/docs/locks.md +323 -0
- package/docs/logger.md +436 -0
- package/docs/mail.md +751 -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/openapi.md +111 -0
- package/docs/packages.md +118 -0
- package/docs/pages.md +217 -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 +452 -0
- package/docs/telemetry.md +263 -0
- package/docs/templates.md +314 -0
- package/docs/testing.md +376 -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/docs/watch.md +118 -0
- package/llms-full.txt +19816 -0
- package/llms.txt +127 -0
- package/package.json +42 -7
package/dist/core/testing.js
CHANGED
|
@@ -12,6 +12,13 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { Application } from "./application.js";
|
|
14
14
|
import { HttpKernel } from "./http/kernel.js";
|
|
15
|
+
import { db, connection } from "./database.js";
|
|
16
|
+
import { hash } from "./crypto.js";
|
|
17
|
+
import { events, cache, hasApplication } from "./helpers.js";
|
|
18
|
+
import { restoreDisk } from "./storage.js";
|
|
19
|
+
import { restoreQueue, setQueue, SyncDriver } from "./queue.js";
|
|
20
|
+
import { restoreMail } from "./mail.js";
|
|
21
|
+
import { setLockStore, MemoryLockStore } from "./lock.js";
|
|
15
22
|
function deepEqual(a, b) {
|
|
16
23
|
if (a === b)
|
|
17
24
|
return true;
|
|
@@ -89,15 +96,180 @@ export class TestResponse {
|
|
|
89
96
|
this.assertHeader("location", location);
|
|
90
97
|
return this;
|
|
91
98
|
}
|
|
99
|
+
/* --------------------------- status shorthands -------------------------- */
|
|
100
|
+
assertCreated() {
|
|
101
|
+
return this.assertStatus(201);
|
|
102
|
+
}
|
|
103
|
+
assertNoContent() {
|
|
104
|
+
return this.assertStatus(204);
|
|
105
|
+
}
|
|
106
|
+
assertBadRequest() {
|
|
107
|
+
return this.assertStatus(400);
|
|
108
|
+
}
|
|
109
|
+
assertUnauthorized() {
|
|
110
|
+
return this.assertStatus(401);
|
|
111
|
+
}
|
|
112
|
+
assertForbidden() {
|
|
113
|
+
return this.assertStatus(403);
|
|
114
|
+
}
|
|
115
|
+
assertNotFound() {
|
|
116
|
+
return this.assertStatus(404);
|
|
117
|
+
}
|
|
118
|
+
/** 422 — the status a failed `validate()` returns. */
|
|
119
|
+
assertUnprocessable() {
|
|
120
|
+
return this.assertStatus(422);
|
|
121
|
+
}
|
|
122
|
+
assertServerError() {
|
|
123
|
+
if (this.status < 500)
|
|
124
|
+
throw new Error(`Expected a 5xx status, got ${this.status}.`);
|
|
125
|
+
return this;
|
|
126
|
+
}
|
|
127
|
+
/* --------------------------------- body --------------------------------- */
|
|
128
|
+
/**
|
|
129
|
+
* Assert the JSON body *contains* `expected` — a subset match, so a test can
|
|
130
|
+
* pin the fields it cares about without breaking every time an unrelated one
|
|
131
|
+
* is added.
|
|
132
|
+
*
|
|
133
|
+
* res.assertJsonContains({ user: { email: "a@b.com" } });
|
|
134
|
+
*/
|
|
135
|
+
assertJsonContains(expected) {
|
|
136
|
+
const actual = this.json();
|
|
137
|
+
if (!contains(actual, expected)) {
|
|
138
|
+
throw new Error(`JSON body does not contain the expected subset.\n` +
|
|
139
|
+
` expected: ${JSON.stringify(expected)}\n actual: ${JSON.stringify(actual)}`);
|
|
140
|
+
}
|
|
141
|
+
return this;
|
|
142
|
+
}
|
|
143
|
+
/** Assert the body contains `text` anywhere. */
|
|
144
|
+
assertSee(text) {
|
|
145
|
+
if (!this.bodyText.includes(text)) {
|
|
146
|
+
throw new Error(`Expected the body to contain "${text}". Body: ${this.bodyText}`);
|
|
147
|
+
}
|
|
148
|
+
return this;
|
|
149
|
+
}
|
|
150
|
+
/** Assert the body does *not* contain `text`. */
|
|
151
|
+
assertDontSee(text) {
|
|
152
|
+
if (this.bodyText.includes(text)) {
|
|
153
|
+
throw new Error(`Expected the body not to contain "${text}". Body: ${this.bodyText}`);
|
|
154
|
+
}
|
|
155
|
+
return this;
|
|
156
|
+
}
|
|
157
|
+
/* ------------------------------ validation ------------------------------ */
|
|
158
|
+
/**
|
|
159
|
+
* Assert a 422 carrying an error on each named field. With no fields, it just
|
|
160
|
+
* asserts that validation failed.
|
|
161
|
+
*
|
|
162
|
+
* res.assertValidationErrors("email", "password");
|
|
163
|
+
*/
|
|
164
|
+
assertValidationErrors(...fields) {
|
|
165
|
+
this.assertStatus(422);
|
|
166
|
+
const errors = this.json().errors ?? {};
|
|
167
|
+
for (const field of fields) {
|
|
168
|
+
if (!errors[field]?.length) {
|
|
169
|
+
throw new Error(`Expected a validation error on "${field}". Fields with errors: ${JSON.stringify(Object.keys(errors))}`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return this;
|
|
173
|
+
}
|
|
174
|
+
/** Assert there is *no* validation error on `field`. */
|
|
175
|
+
assertNoValidationError(field) {
|
|
176
|
+
const errors = this.json().errors;
|
|
177
|
+
if (errors?.[field]?.length) {
|
|
178
|
+
throw new Error(`Expected no validation error on "${field}", got: ${JSON.stringify(errors[field])}`);
|
|
179
|
+
}
|
|
180
|
+
return this;
|
|
181
|
+
}
|
|
182
|
+
/* -------------------------- headers and cookies ------------------------- */
|
|
183
|
+
assertHeaderMissing(name) {
|
|
184
|
+
const actual = this.header(name);
|
|
185
|
+
if (actual !== null)
|
|
186
|
+
throw new Error(`Expected no ${name} header, got "${actual}".`);
|
|
187
|
+
return this;
|
|
188
|
+
}
|
|
189
|
+
/** The cookies this response set, by name. */
|
|
190
|
+
cookies() {
|
|
191
|
+
return cookiesOf(this.raw);
|
|
192
|
+
}
|
|
193
|
+
/** Assert the response set a cookie — with `value`, if you give one. */
|
|
194
|
+
assertCookie(name, value) {
|
|
195
|
+
const cookies = this.cookies();
|
|
196
|
+
if (!(name in cookies)) {
|
|
197
|
+
throw new Error(`Expected a "${name}" cookie. Got: ${JSON.stringify(Object.keys(cookies))}`);
|
|
198
|
+
}
|
|
199
|
+
if (value !== undefined && cookies[name] !== value) {
|
|
200
|
+
throw new Error(`Expected cookie "${name}" to be "${value}", got "${cookies[name]}".`);
|
|
201
|
+
}
|
|
202
|
+
return this;
|
|
203
|
+
}
|
|
204
|
+
assertCookieMissing(name) {
|
|
205
|
+
if (name in this.cookies())
|
|
206
|
+
throw new Error(`Expected no "${name}" cookie, but one was set.`);
|
|
207
|
+
return this;
|
|
208
|
+
}
|
|
209
|
+
/* --------------------------------- debug -------------------------------- */
|
|
210
|
+
/** Print status, headers, and body — for when a test fails and you're stuck. */
|
|
211
|
+
dump() {
|
|
212
|
+
console.log(`\n--- ${this.status} ---`);
|
|
213
|
+
console.log(Object.fromEntries(this.raw.headers.entries()));
|
|
214
|
+
console.log(this.bodyText);
|
|
215
|
+
return this;
|
|
216
|
+
}
|
|
92
217
|
}
|
|
93
218
|
/** Injects requests into an app and returns `TestResponse`s. */
|
|
94
219
|
export class TestClient {
|
|
95
220
|
target;
|
|
96
|
-
|
|
221
|
+
defaults;
|
|
222
|
+
constructor(target, defaults = { headers: {}, cookies: {} }) {
|
|
97
223
|
this.target = target;
|
|
224
|
+
this.defaults = defaults;
|
|
225
|
+
}
|
|
226
|
+
/* ---------------------------- request building -------------------------- */
|
|
227
|
+
/** A copy of this client with extra default headers. */
|
|
228
|
+
withHeaders(headers) {
|
|
229
|
+
return new TestClient(this.target, {
|
|
230
|
+
...this.defaults,
|
|
231
|
+
headers: { ...this.defaults.headers, ...headers },
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
withHeader(name, value) {
|
|
235
|
+
return this.withHeaders({ [name]: value });
|
|
236
|
+
}
|
|
237
|
+
/** A copy of this client that sends these cookies. */
|
|
238
|
+
withCookies(cookies) {
|
|
239
|
+
return new TestClient(this.target, {
|
|
240
|
+
...this.defaults,
|
|
241
|
+
cookies: { ...this.defaults.cookies, ...cookies },
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
withCookie(name, value) {
|
|
245
|
+
return this.withCookies({ [name]: value });
|
|
246
|
+
}
|
|
247
|
+
/** Send `Authorization: Bearer <token>` — the shorthand for an API test. */
|
|
248
|
+
withToken(token) {
|
|
249
|
+
return this.withHeader("authorization", `Bearer ${token}`);
|
|
250
|
+
}
|
|
251
|
+
/** Send HTTP basic credentials. */
|
|
252
|
+
withBasicAuth(username, password) {
|
|
253
|
+
return this.withHeader("authorization", `Basic ${btoa(`${username}:${password}`)}`);
|
|
254
|
+
}
|
|
255
|
+
/** Ask for JSON back — sets `Accept: application/json`. */
|
|
256
|
+
acceptJson() {
|
|
257
|
+
return this.withHeader("accept", "application/json");
|
|
258
|
+
}
|
|
259
|
+
/** Merge the client's defaults into one request's init. */
|
|
260
|
+
init(init) {
|
|
261
|
+
const headers = {
|
|
262
|
+
...this.defaults.headers,
|
|
263
|
+
...(init.headers ?? {}),
|
|
264
|
+
};
|
|
265
|
+
const cookies = Object.entries(this.defaults.cookies);
|
|
266
|
+
if (cookies.length && !headers.cookie) {
|
|
267
|
+
headers.cookie = cookies.map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join("; ");
|
|
268
|
+
}
|
|
269
|
+
return { ...init, headers };
|
|
98
270
|
}
|
|
99
271
|
async request(path, init = {}) {
|
|
100
|
-
const res = await this.target.request(path, init);
|
|
272
|
+
const res = await this.target.request(path, this.init(init));
|
|
101
273
|
const text = await res.text();
|
|
102
274
|
return new TestResponse(res, text);
|
|
103
275
|
}
|
|
@@ -116,6 +288,24 @@ export class TestClient {
|
|
|
116
288
|
patch(path, body, init) {
|
|
117
289
|
return this.request(path, withJson("PATCH", body, init));
|
|
118
290
|
}
|
|
291
|
+
/** POST a URL-encoded form, the way a browser would. */
|
|
292
|
+
form(path, fields, init = {}) {
|
|
293
|
+
const body = new URLSearchParams(Object.entries(fields).map(([k, v]) => [k, String(v)])).toString();
|
|
294
|
+
return this.request(path, {
|
|
295
|
+
...init,
|
|
296
|
+
method: "POST",
|
|
297
|
+
headers: { "content-type": "application/x-www-form-urlencoded", ...init.headers },
|
|
298
|
+
body,
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
/** POST a multipart form — for file uploads. */
|
|
302
|
+
multipart(path, fields, init = {}) {
|
|
303
|
+
const form = new FormData();
|
|
304
|
+
for (const [key, value] of Object.entries(fields))
|
|
305
|
+
form.append(key, value);
|
|
306
|
+
// No content-type header: the runtime sets it, with the multipart boundary.
|
|
307
|
+
return this.request(path, { ...init, method: "POST", body: form });
|
|
308
|
+
}
|
|
119
309
|
}
|
|
120
310
|
function withJson(method, body, init = {}) {
|
|
121
311
|
if (body === undefined)
|
|
@@ -139,3 +329,315 @@ export function testClient(target) {
|
|
|
139
329
|
return new TestClient(target.build());
|
|
140
330
|
return new TestClient(target);
|
|
141
331
|
}
|
|
332
|
+
/* ==========================================================================
|
|
333
|
+
* Below: the rest of the testing toolkit — a richer request builder, more
|
|
334
|
+
* response assertions, database assertions, state reset between tests, time
|
|
335
|
+
* control, spies, and console-command testing.
|
|
336
|
+
* ========================================================================== */
|
|
337
|
+
/* ---------------------------- deep subset match --------------------------- */
|
|
338
|
+
/** Whether `actual` contains everything in `expected` (recursively). */
|
|
339
|
+
function contains(actual, expected) {
|
|
340
|
+
if (deepEqual(actual, expected))
|
|
341
|
+
return true;
|
|
342
|
+
if (typeof expected !== "object" || expected === null)
|
|
343
|
+
return false;
|
|
344
|
+
if (typeof actual !== "object" || actual === null)
|
|
345
|
+
return false;
|
|
346
|
+
if (Array.isArray(expected)) {
|
|
347
|
+
if (!Array.isArray(actual))
|
|
348
|
+
return false;
|
|
349
|
+
// Every expected element must appear somewhere in actual.
|
|
350
|
+
return expected.every((want) => actual.some((got) => contains(got, want)));
|
|
351
|
+
}
|
|
352
|
+
return Object.entries(expected).every(([key, want]) => contains(actual[key], want));
|
|
353
|
+
}
|
|
354
|
+
/** Parse `Set-Cookie` headers into a name -> value map. */
|
|
355
|
+
function cookiesOf(res) {
|
|
356
|
+
const out = {};
|
|
357
|
+
// getSetCookie() is the standard way to read repeated Set-Cookie headers.
|
|
358
|
+
const raw = typeof res.headers.getSetCookie === "function"
|
|
359
|
+
? res.headers.getSetCookie()
|
|
360
|
+
: [res.headers.get("set-cookie")].filter(Boolean);
|
|
361
|
+
for (const line of raw) {
|
|
362
|
+
const [pair] = line.split(";");
|
|
363
|
+
const index = (pair ?? "").indexOf("=");
|
|
364
|
+
if (index > 0)
|
|
365
|
+
out[pair.slice(0, index).trim()] = decodeURIComponent(pair.slice(index + 1).trim());
|
|
366
|
+
}
|
|
367
|
+
return out;
|
|
368
|
+
}
|
|
369
|
+
/* ------------------------------ state reset ------------------------------- */
|
|
370
|
+
/**
|
|
371
|
+
* Put the framework's global state back the way it was, so one test can't leak
|
|
372
|
+
* into the next. Call it in an `afterEach`.
|
|
373
|
+
*
|
|
374
|
+
* afterEach(() => resetState());
|
|
375
|
+
*
|
|
376
|
+
* It restores every fake (disk, queue, mail, hash, time), drops event listeners,
|
|
377
|
+
* empties the cache, and gives you a fresh lock store. It does **not** touch the
|
|
378
|
+
* database — see `refreshDatabase()`.
|
|
379
|
+
*/
|
|
380
|
+
export function resetState() {
|
|
381
|
+
restoreDisk();
|
|
382
|
+
restoreQueue();
|
|
383
|
+
restoreMail();
|
|
384
|
+
restoreTime();
|
|
385
|
+
hash.restore();
|
|
386
|
+
setQueue(new SyncDriver());
|
|
387
|
+
setLockStore(new MemoryLockStore());
|
|
388
|
+
// These live on the application, so they only exist if one was booted.
|
|
389
|
+
if (hasApplication()) {
|
|
390
|
+
events().clearAll();
|
|
391
|
+
void cache().flush();
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
/* ------------------------------ time control ------------------------------ */
|
|
395
|
+
const RealDate = Date;
|
|
396
|
+
let frozenAt;
|
|
397
|
+
/** Whether time is currently frozen. */
|
|
398
|
+
export function timeIsFrozen() {
|
|
399
|
+
return frozenAt !== undefined;
|
|
400
|
+
}
|
|
401
|
+
function installFakeDate() {
|
|
402
|
+
if (globalThis.Date !== RealDate)
|
|
403
|
+
return;
|
|
404
|
+
class FakeDate extends RealDate {
|
|
405
|
+
constructor(...args) {
|
|
406
|
+
if (args.length === 0)
|
|
407
|
+
super(frozenAt ?? RealDate.now());
|
|
408
|
+
else
|
|
409
|
+
super(...args);
|
|
410
|
+
}
|
|
411
|
+
static now() {
|
|
412
|
+
return frozenAt ?? RealDate.now();
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
globalThis.Date = FakeDate;
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* Freeze the clock, so anything that reads the time sees the same instant —
|
|
419
|
+
* which is how you test a token that expires in an hour without waiting an hour.
|
|
420
|
+
*
|
|
421
|
+
* freezeTime("2026-07-11T12:00:00Z");
|
|
422
|
+
* timeTravel(3600_000); // an hour later
|
|
423
|
+
* restoreTime();
|
|
424
|
+
*
|
|
425
|
+
* Mocks `Date` and `Date.now()`. It does **not** mock timers: a `setTimeout` still
|
|
426
|
+
* fires on the real clock.
|
|
427
|
+
*/
|
|
428
|
+
export function freezeTime(at = RealDate.now()) {
|
|
429
|
+
frozenAt = at instanceof RealDate ? at.getTime() : new RealDate(at).getTime();
|
|
430
|
+
installFakeDate();
|
|
431
|
+
return frozenAt;
|
|
432
|
+
}
|
|
433
|
+
/** Move the frozen clock forward (or back, with a negative value) by milliseconds. */
|
|
434
|
+
export function timeTravel(ms) {
|
|
435
|
+
if (frozenAt === undefined)
|
|
436
|
+
freezeTime();
|
|
437
|
+
frozenAt = frozenAt + ms;
|
|
438
|
+
return frozenAt;
|
|
439
|
+
}
|
|
440
|
+
/** Hand the real clock back. */
|
|
441
|
+
export function restoreTime() {
|
|
442
|
+
frozenAt = undefined;
|
|
443
|
+
globalThis.Date = RealDate;
|
|
444
|
+
}
|
|
445
|
+
/* --------------------------- database assertions -------------------------- */
|
|
446
|
+
/** Rows in `table` matching every column in `where`. */
|
|
447
|
+
async function matching(table, where) {
|
|
448
|
+
let query = db(table);
|
|
449
|
+
for (const [column, value] of Object.entries(where))
|
|
450
|
+
query = query.where(column, value);
|
|
451
|
+
return query.get();
|
|
452
|
+
}
|
|
453
|
+
/** Assert at least one row in `table` matches `where` (an exact `count` if given). */
|
|
454
|
+
export async function assertDatabaseHas(table, where, count) {
|
|
455
|
+
const rows = await matching(table, where);
|
|
456
|
+
if (count !== undefined) {
|
|
457
|
+
if (rows.length !== count) {
|
|
458
|
+
throw new Error(`Expected ${count} row(s) in "${table}" matching ${JSON.stringify(where)}, found ${rows.length}.`);
|
|
459
|
+
}
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
if (!rows.length) {
|
|
463
|
+
const total = (await db(table).get()).length;
|
|
464
|
+
throw new Error(`Expected a row in "${table}" matching ${JSON.stringify(where)}, but found none. ` +
|
|
465
|
+
`The table has ${total} row(s).`);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
/** Assert no row in `table` matches `where`. */
|
|
469
|
+
export async function assertDatabaseMissing(table, where) {
|
|
470
|
+
const rows = await matching(table, where);
|
|
471
|
+
if (rows.length) {
|
|
472
|
+
throw new Error(`Expected no row in "${table}" matching ${JSON.stringify(where)}, but found ${rows.length}.`);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
/** Assert `table` holds exactly `count` rows. */
|
|
476
|
+
export async function assertDatabaseCount(table, count) {
|
|
477
|
+
const rows = await db(table).get();
|
|
478
|
+
if (rows.length !== count) {
|
|
479
|
+
throw new Error(`Expected "${table}" to hold ${count} row(s), found ${rows.length}.`);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
/** Assert `table` is empty. */
|
|
483
|
+
export function assertDatabaseEmpty(table) {
|
|
484
|
+
return assertDatabaseCount(table, 0);
|
|
485
|
+
}
|
|
486
|
+
/**
|
|
487
|
+
* Empty tables between tests. Order matters when foreign keys do — pass children
|
|
488
|
+
* before parents.
|
|
489
|
+
*
|
|
490
|
+
* afterEach(() => truncate("comments", "posts", "users"));
|
|
491
|
+
*
|
|
492
|
+
* This deletes rows; it doesn't roll back a transaction, so it works on every
|
|
493
|
+
* driver (D1, Postgres, libSQL) rather than only the ones with savepoints.
|
|
494
|
+
*/
|
|
495
|
+
export async function truncate(...tables) {
|
|
496
|
+
for (const table of tables)
|
|
497
|
+
await connection().write(`DELETE FROM ${table}`);
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* A function that records how it was called — the smallest useful test double.
|
|
501
|
+
*
|
|
502
|
+
* const send = spy<[string], void>();
|
|
503
|
+
* notify(send);
|
|
504
|
+
* assert.equal(send.callCount, 1);
|
|
505
|
+
* assert.ok(send.calledWith("hello"));
|
|
506
|
+
*/
|
|
507
|
+
export function spy(implementation) {
|
|
508
|
+
const calls = [];
|
|
509
|
+
let impl = implementation;
|
|
510
|
+
const fn = ((...args) => {
|
|
511
|
+
calls.push(args);
|
|
512
|
+
return impl ? impl(...args) : undefined;
|
|
513
|
+
});
|
|
514
|
+
Object.defineProperties(fn, {
|
|
515
|
+
calls: { get: () => calls },
|
|
516
|
+
callCount: { get: () => calls.length },
|
|
517
|
+
called: { get: () => calls.length > 0 },
|
|
518
|
+
});
|
|
519
|
+
fn.calledWith = (...args) => calls.some((call) => deepEqual(call, args));
|
|
520
|
+
fn.returns = (value) => {
|
|
521
|
+
impl = () => value;
|
|
522
|
+
return fn;
|
|
523
|
+
};
|
|
524
|
+
fn.reset = () => {
|
|
525
|
+
calls.length = 0;
|
|
526
|
+
};
|
|
527
|
+
return fn;
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* Replace a method on an object with a spy, and hand back a `restore()`.
|
|
531
|
+
*
|
|
532
|
+
* const charge = spyOn(stripe, "charge").returns(Promise.resolve(receipt));
|
|
533
|
+
* …
|
|
534
|
+
* restoreSpies();
|
|
535
|
+
*/
|
|
536
|
+
const spied = [];
|
|
537
|
+
export function spyOn(target, method) {
|
|
538
|
+
const original = target[method];
|
|
539
|
+
const replacement = spy((...args) => typeof original === "function" ? original.apply(target, args) : undefined);
|
|
540
|
+
target[method] = replacement;
|
|
541
|
+
spied.push(() => {
|
|
542
|
+
target[method] = original;
|
|
543
|
+
});
|
|
544
|
+
return replacement;
|
|
545
|
+
}
|
|
546
|
+
/** Undo every `spyOn()`. */
|
|
547
|
+
export function restoreSpies() {
|
|
548
|
+
for (const restore of spied.splice(0))
|
|
549
|
+
restore();
|
|
550
|
+
}
|
|
551
|
+
/* ------------------------------ console tests ----------------------------- */
|
|
552
|
+
/** What a command did: its output, its error output, and its exit code. */
|
|
553
|
+
export class CommandResult {
|
|
554
|
+
stdout;
|
|
555
|
+
stderr;
|
|
556
|
+
exitCode;
|
|
557
|
+
constructor(stdout, stderr, exitCode) {
|
|
558
|
+
this.stdout = stdout;
|
|
559
|
+
this.stderr = stderr;
|
|
560
|
+
this.exitCode = exitCode;
|
|
561
|
+
}
|
|
562
|
+
/** Everything written to stdout, joined. */
|
|
563
|
+
output() {
|
|
564
|
+
return this.stdout.join("\n");
|
|
565
|
+
}
|
|
566
|
+
assertExitCode(expected) {
|
|
567
|
+
if (this.exitCode !== expected) {
|
|
568
|
+
throw new Error(`Expected exit code ${expected}, got ${this.exitCode}.\n${this.output()}\n${this.stderr.join("\n")}`);
|
|
569
|
+
}
|
|
570
|
+
return this;
|
|
571
|
+
}
|
|
572
|
+
/** Exit code 0. */
|
|
573
|
+
assertSucceeded() {
|
|
574
|
+
return this.assertExitCode(0);
|
|
575
|
+
}
|
|
576
|
+
/** A non-zero exit code. */
|
|
577
|
+
assertFailed() {
|
|
578
|
+
if (this.exitCode === 0) {
|
|
579
|
+
throw new Error(`Expected the command to fail, but it exited 0.\n${this.output()}`);
|
|
580
|
+
}
|
|
581
|
+
return this;
|
|
582
|
+
}
|
|
583
|
+
/** Assert a line of output contains `text`. */
|
|
584
|
+
assertOutputContains(text) {
|
|
585
|
+
if (!this.output().includes(text)) {
|
|
586
|
+
throw new Error(`Expected the output to contain "${text}".\nOutput:\n${this.output()}`);
|
|
587
|
+
}
|
|
588
|
+
return this;
|
|
589
|
+
}
|
|
590
|
+
assertOutputMatches(pattern) {
|
|
591
|
+
if (!pattern.test(this.output())) {
|
|
592
|
+
throw new Error(`Expected the output to match ${pattern}.\nOutput:\n${this.output()}`);
|
|
593
|
+
}
|
|
594
|
+
return this;
|
|
595
|
+
}
|
|
596
|
+
assertErrorContains(text) {
|
|
597
|
+
const stderr = this.stderr.join("\n");
|
|
598
|
+
if (!stderr.includes(text)) {
|
|
599
|
+
throw new Error(`Expected stderr to contain "${text}".\nstderr:\n${stderr}`);
|
|
600
|
+
}
|
|
601
|
+
return this;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* Run a console command in-process and capture what it printed and the exit code
|
|
606
|
+
* it set — no subprocess, so it's fast and you can assert on it.
|
|
607
|
+
*
|
|
608
|
+
* You pass the command in, because the console entry point belongs to your app
|
|
609
|
+
* (it's the thing that knows how to build your application), not to the core:
|
|
610
|
+
*
|
|
611
|
+
* import { run } from "@shaferllc/keel/core/cli"; // or your own bin
|
|
612
|
+
*
|
|
613
|
+
* const result = await runCommand(() => run(["node", "keel", "routes"]));
|
|
614
|
+
* result.assertSucceeded().assertOutputContains("GET /users");
|
|
615
|
+
*
|
|
616
|
+
* `console.log`/`warn` are captured as stdout, `console.error` as stderr, and
|
|
617
|
+
* `process.exitCode` is read and then restored.
|
|
618
|
+
*/
|
|
619
|
+
export async function runCommand(command) {
|
|
620
|
+
const stdout = [];
|
|
621
|
+
const stderr = [];
|
|
622
|
+
const real = { log: console.log, error: console.error, warn: console.warn };
|
|
623
|
+
const previousExit = process.exitCode;
|
|
624
|
+
process.exitCode = 0;
|
|
625
|
+
console.log = (...args) => stdout.push(args.map(String).join(" "));
|
|
626
|
+
console.warn = (...args) => stdout.push(args.map(String).join(" "));
|
|
627
|
+
console.error = (...args) => stderr.push(args.map(String).join(" "));
|
|
628
|
+
try {
|
|
629
|
+
await command();
|
|
630
|
+
}
|
|
631
|
+
catch (error) {
|
|
632
|
+
// A command that throws is a command that failed — record it, don't blow up
|
|
633
|
+
// the test with an unrelated stack.
|
|
634
|
+
stderr.push(error instanceof Error ? error.message : String(error));
|
|
635
|
+
process.exitCode = 1;
|
|
636
|
+
}
|
|
637
|
+
finally {
|
|
638
|
+
Object.assign(console, real);
|
|
639
|
+
}
|
|
640
|
+
const exitCode = Number(process.exitCode ?? 0);
|
|
641
|
+
process.exitCode = previousExit;
|
|
642
|
+
return new CommandResult(stdout, stderr, exitCode);
|
|
643
|
+
}
|
package/dist/db/d1.js
CHANGED
|
@@ -22,5 +22,18 @@ export function d1Connection(database) {
|
|
|
22
22
|
const { meta } = await database.prepare(sql).bind(...bindings).run();
|
|
23
23
|
return { rowsAffected: meta.changes ?? 0, insertId: meta.last_row_id };
|
|
24
24
|
},
|
|
25
|
+
/**
|
|
26
|
+
* D1 has no interactive transactions — it can't hold one open across awaits
|
|
27
|
+
* over its HTTP protocol, and a `BEGIN` sent through `prepare()` is rejected.
|
|
28
|
+
*
|
|
29
|
+
* So say that, rather than letting the generic BEGIN fallback fire and fail
|
|
30
|
+
* with something cryptic from the driver. A transaction that quietly isn't one
|
|
31
|
+
* is far worse than a transaction that refuses to start.
|
|
32
|
+
*/
|
|
33
|
+
async begin() {
|
|
34
|
+
throw new Error("D1 does not support interactive transactions. Use `database.batch([...])` for an " +
|
|
35
|
+
"atomic group of statements, or run transactional work against a database that " +
|
|
36
|
+
"supports them (Postgres, libSQL, SQLite).");
|
|
37
|
+
},
|
|
25
38
|
};
|
|
26
39
|
}
|
package/dist/db/pg.d.ts
CHANGED
|
@@ -24,6 +24,19 @@ export interface PgLike {
|
|
|
24
24
|
rows: Row[];
|
|
25
25
|
rowCount: number | null;
|
|
26
26
|
}>;
|
|
27
|
+
/**
|
|
28
|
+
* Check a single connection out of the pool. Present on a `Pool`, absent on a
|
|
29
|
+
* bare `Client` — which is exactly how this adapter tells the two apart.
|
|
30
|
+
*/
|
|
31
|
+
connect?(): Promise<PgClientLike>;
|
|
32
|
+
}
|
|
33
|
+
/** A checked-out client. `release()` hands it back to the pool. */
|
|
34
|
+
export interface PgClientLike {
|
|
35
|
+
query(text: string, values?: unknown[]): Promise<{
|
|
36
|
+
rows: Row[];
|
|
37
|
+
rowCount: number | null;
|
|
38
|
+
}>;
|
|
39
|
+
release(): void;
|
|
27
40
|
}
|
|
28
41
|
/** Build a `Connection` backed by a node-postgres-compatible client. */
|
|
29
42
|
export declare function pgConnection(client: PgLike): Connection;
|
package/dist/db/pg.js
CHANGED
|
@@ -17,17 +17,59 @@
|
|
|
17
17
|
* has a `RETURNING` clause. This adapter surfaces `rows[0].id` when present;
|
|
18
18
|
* otherwise `insertId` is `undefined` (so `insertGetId()` needs a `RETURNING id`).
|
|
19
19
|
*/
|
|
20
|
+
function toWriteResult(result) {
|
|
21
|
+
const insertId = result.rows?.[0]?.id;
|
|
22
|
+
return { rowsAffected: result.rowCount ?? 0, insertId };
|
|
23
|
+
}
|
|
20
24
|
/** Build a `Connection` backed by a node-postgres-compatible client. */
|
|
21
25
|
export function pgConnection(client) {
|
|
22
|
-
|
|
26
|
+
const conn = {
|
|
23
27
|
async select(sql, bindings) {
|
|
24
28
|
const { rows } = await client.query(sql, bindings);
|
|
25
29
|
return rows;
|
|
26
30
|
},
|
|
27
31
|
async write(sql, bindings) {
|
|
28
|
-
|
|
29
|
-
const insertId = rows?.[0]?.id;
|
|
30
|
-
return { rowsAffected: rowCount ?? 0, insertId };
|
|
32
|
+
return toWriteResult(await client.query(sql, bindings));
|
|
31
33
|
},
|
|
32
34
|
};
|
|
35
|
+
/**
|
|
36
|
+
* A pool hands each statement to whichever connection is free, so running
|
|
37
|
+
* `BEGIN` through it would wrap *nothing* — the INSERT after it could land on a
|
|
38
|
+
* different connection entirely. So when the client can check one out, the
|
|
39
|
+
* whole transaction runs on that single connection, and it goes back to the
|
|
40
|
+
* pool afterwards whatever happens.
|
|
41
|
+
*
|
|
42
|
+
* A bare `Client` has no `connect()`, and doesn't need one: it *is* a single
|
|
43
|
+
* connection. Keel falls back to BEGIN/COMMIT on it.
|
|
44
|
+
*/
|
|
45
|
+
if (typeof client.connect === "function") {
|
|
46
|
+
conn.begin = async () => {
|
|
47
|
+
const checkout = await client.connect();
|
|
48
|
+
await checkout.query("BEGIN");
|
|
49
|
+
let done = false;
|
|
50
|
+
const finish = async (sql) => {
|
|
51
|
+
if (done)
|
|
52
|
+
return;
|
|
53
|
+
done = true;
|
|
54
|
+
try {
|
|
55
|
+
await checkout.query(sql);
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
checkout.release(); // never leak the connection, even if COMMIT throws
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
return {
|
|
62
|
+
async select(sql, bindings) {
|
|
63
|
+
const { rows } = await checkout.query(sql, bindings);
|
|
64
|
+
return rows;
|
|
65
|
+
},
|
|
66
|
+
async write(sql, bindings) {
|
|
67
|
+
return toWriteResult(await checkout.query(sql, bindings));
|
|
68
|
+
},
|
|
69
|
+
commit: () => finish("COMMIT"),
|
|
70
|
+
rollback: () => finish("ROLLBACK"),
|
|
71
|
+
};
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
return conn;
|
|
33
75
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Keel MCP server. Exposes Keel's documentation, public API surface, and
|
|
3
|
+
* code generators to AI agents (Claude Code, Cursor, …) over stdio, so an agent
|
|
4
|
+
* editing a Keel app can look things up and scaffold code without guessing.
|
|
5
|
+
*
|
|
6
|
+
* Run it with `keel mcp` (dev) or the shipped `keel-mcp` bin (consumers).
|
|
7
|
+
* Tools:
|
|
8
|
+
* keel_overview framework facts, conventions, folder layout
|
|
9
|
+
* keel_search_docs full-text search across the guides
|
|
10
|
+
* keel_read_doc a full guide (optionally with its runnable example)
|
|
11
|
+
* keel_search_api search the public export surface
|
|
12
|
+
* keel_list_generators the `keel make:*` generators
|
|
13
|
+
* keel_scaffold generate a controller/provider/job/… stub (no write)
|
|
14
|
+
* Resources: keel://overview, keel://llms-full, and keel://docs/<slug> per guide.
|
|
15
|
+
*/
|
|
16
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
17
|
+
export declare function createServer(): Promise<McpServer>;
|
|
18
|
+
/** Boot the server on stdio. Called by the `keel-mcp` bin and `keel mcp`. */
|
|
19
|
+
export declare function runMcpServer(): Promise<void>;
|