@shaferllc/keel 0.68.0 → 0.77.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 (143) hide show
  1. package/AGENTS.md +2 -0
  2. package/README.md +15 -5
  3. package/dist/api/api.config.stub +9 -0
  4. package/dist/api/config.d.ts +13 -0
  5. package/dist/api/config.js +14 -0
  6. package/dist/api/index.d.ts +16 -0
  7. package/dist/api/index.js +13 -0
  8. package/dist/api/provider.d.ts +10 -0
  9. package/dist/api/provider.js +17 -0
  10. package/dist/api/query.d.ts +35 -0
  11. package/dist/api/query.js +42 -0
  12. package/dist/api/resource.d.ts +93 -0
  13. package/dist/api/resource.js +185 -0
  14. package/dist/core/application.js +6 -0
  15. package/dist/core/cache.d.ts +1 -2
  16. package/dist/core/cache.js +9 -2
  17. package/dist/core/cli/index.d.ts +28 -0
  18. package/dist/core/cli/index.js +427 -0
  19. package/dist/core/cli/stubs.d.ts +14 -0
  20. package/dist/core/cli/stubs.js +105 -0
  21. package/dist/core/console-prompt.d.ts +79 -0
  22. package/dist/core/console-prompt.js +239 -0
  23. package/dist/core/console-ui.d.ts +96 -0
  24. package/dist/core/console-ui.js +187 -0
  25. package/dist/core/console.d.ts +188 -0
  26. package/dist/core/console.js +395 -0
  27. package/dist/core/database.d.ts +70 -1
  28. package/dist/core/database.js +174 -15
  29. package/dist/core/env.d.ts +96 -0
  30. package/dist/core/env.js +140 -0
  31. package/dist/core/health.d.ts +2 -2
  32. package/dist/core/health.js +2 -2
  33. package/dist/core/http/kernel.d.ts +2 -0
  34. package/dist/core/http/kernel.js +48 -0
  35. package/dist/core/http/router.d.ts +5 -5
  36. package/dist/core/http/router.js +5 -5
  37. package/dist/core/i18n.d.ts +162 -0
  38. package/dist/core/i18n.js +472 -0
  39. package/dist/core/index.d.ts +25 -4
  40. package/dist/core/index.js +13 -3
  41. package/dist/core/instrumentation.d.ts +113 -0
  42. package/dist/core/instrumentation.js +52 -0
  43. package/dist/core/logger.d.ts +7 -0
  44. package/dist/core/logger.js +28 -1
  45. package/dist/core/notification.js +10 -1
  46. package/dist/core/package.d.ts +117 -0
  47. package/dist/core/package.js +169 -0
  48. package/dist/core/pages.d.ts +108 -0
  49. package/dist/core/pages.js +199 -0
  50. package/dist/core/queue.js +26 -5
  51. package/dist/core/repl.d.ts +33 -0
  52. package/dist/core/repl.js +88 -0
  53. package/dist/core/scheduler.js +6 -0
  54. package/dist/core/social.d.ts +4 -4
  55. package/dist/core/social.js +4 -4
  56. package/dist/core/storage.js +15 -3
  57. package/dist/core/telemetry.d.ts +208 -0
  58. package/dist/core/telemetry.js +383 -0
  59. package/dist/core/template.d.ts +2 -3
  60. package/dist/core/template.js +2 -3
  61. package/dist/core/testing.d.ts +171 -1
  62. package/dist/core/testing.js +505 -2
  63. package/dist/db/d1.js +13 -0
  64. package/dist/db/pg.d.ts +13 -0
  65. package/dist/db/pg.js +46 -4
  66. package/dist/openapi/config.d.ts +28 -0
  67. package/dist/openapi/config.js +25 -0
  68. package/dist/openapi/doc.d.ts +40 -0
  69. package/dist/openapi/doc.js +20 -0
  70. package/dist/openapi/export.d.ts +8 -0
  71. package/dist/openapi/export.js +19 -0
  72. package/dist/openapi/gate.d.ts +15 -0
  73. package/dist/openapi/gate.js +27 -0
  74. package/dist/openapi/index.d.ts +19 -0
  75. package/dist/openapi/index.js +15 -0
  76. package/dist/openapi/openapi.config.stub +29 -0
  77. package/dist/openapi/provider.d.ts +18 -0
  78. package/dist/openapi/provider.js +35 -0
  79. package/dist/openapi/routes.d.ts +9 -0
  80. package/dist/openapi/routes.js +23 -0
  81. package/dist/openapi/spec.d.ts +23 -0
  82. package/dist/openapi/spec.js +132 -0
  83. package/dist/openapi/ui.d.ts +8 -0
  84. package/dist/openapi/ui.js +31 -0
  85. package/dist/openapi/zod.d.ts +12 -0
  86. package/dist/openapi/zod.js +46 -0
  87. package/dist/watch/config.d.ts +33 -0
  88. package/dist/watch/config.js +38 -0
  89. package/dist/watch/entry.d.ts +53 -0
  90. package/dist/watch/entry.js +105 -0
  91. package/dist/watch/gate.d.ts +20 -0
  92. package/dist/watch/gate.js +32 -0
  93. package/dist/watch/index.d.ts +21 -0
  94. package/dist/watch/index.js +17 -0
  95. package/dist/watch/migration.d.ts +7 -0
  96. package/dist/watch/migration.js +20 -0
  97. package/dist/watch/provider.d.ts +22 -0
  98. package/dist/watch/provider.js +58 -0
  99. package/dist/watch/prune.d.ts +11 -0
  100. package/dist/watch/prune.js +22 -0
  101. package/dist/watch/recorder.d.ts +24 -0
  102. package/dist/watch/recorder.js +39 -0
  103. package/dist/watch/routes.d.ts +13 -0
  104. package/dist/watch/routes.js +55 -0
  105. package/dist/watch/store.d.ts +54 -0
  106. package/dist/watch/store.js +158 -0
  107. package/dist/watch/ui/dist/watch.css +1 -0
  108. package/dist/watch/ui/dist/watch.js +555 -0
  109. package/dist/watch/ui-shell.d.ts +12 -0
  110. package/dist/watch/ui-shell.js +24 -0
  111. package/dist/watch/watch.config.stub +47 -0
  112. package/dist/watch/watchers.d.ts +12 -0
  113. package/dist/watch/watchers.js +156 -0
  114. package/docs/ai-manifest.json +737 -3
  115. package/docs/api-resources.md +119 -0
  116. package/docs/configuration.md +74 -0
  117. package/docs/console.md +217 -13
  118. package/docs/database.md +101 -0
  119. package/docs/examples/api-resources.ts +86 -0
  120. package/docs/examples/configuration.ts +40 -0
  121. package/docs/examples/console.ts +134 -0
  122. package/docs/examples/database.ts +90 -0
  123. package/docs/examples/i18n.ts +117 -0
  124. package/docs/examples/logger.ts +74 -0
  125. package/docs/examples/mail.ts +103 -0
  126. package/docs/examples/pages.ts +82 -0
  127. package/docs/examples/telemetry.ts +127 -0
  128. package/docs/examples/testing.ts +150 -0
  129. package/docs/health.md +4 -4
  130. package/docs/i18n.md +302 -0
  131. package/docs/logger.md +156 -10
  132. package/docs/mail.md +73 -0
  133. package/docs/openapi.md +111 -0
  134. package/docs/packages.md +118 -0
  135. package/docs/pages.md +217 -0
  136. package/docs/storage.md +5 -3
  137. package/docs/telemetry.md +263 -0
  138. package/docs/templates.md +3 -4
  139. package/docs/testing.md +252 -0
  140. package/docs/watch.md +118 -0
  141. package/llms-full.txt +2389 -241
  142. package/llms.txt +13 -1
  143. package/package.json +32 -6
@@ -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
- constructor(target) {
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,316 @@ 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/cli";
612
+ * import { createApplication } from "../bootstrap/app.js";
613
+ *
614
+ * const result = await runCommand(() => run(["node", "keel", "routes"], { createApplication }));
615
+ * result.assertSucceeded().assertOutputContains("GET /users");
616
+ *
617
+ * `console.log`/`warn` are captured as stdout, `console.error` as stderr, and
618
+ * `process.exitCode` is read and then restored.
619
+ */
620
+ export async function runCommand(command) {
621
+ const stdout = [];
622
+ const stderr = [];
623
+ const real = { log: console.log, error: console.error, warn: console.warn };
624
+ const previousExit = process.exitCode;
625
+ process.exitCode = 0;
626
+ console.log = (...args) => stdout.push(args.map(String).join(" "));
627
+ console.warn = (...args) => stdout.push(args.map(String).join(" "));
628
+ console.error = (...args) => stderr.push(args.map(String).join(" "));
629
+ try {
630
+ await command();
631
+ }
632
+ catch (error) {
633
+ // A command that throws is a command that failed — record it, don't blow up
634
+ // the test with an unrelated stack.
635
+ stderr.push(error instanceof Error ? error.message : String(error));
636
+ process.exitCode = 1;
637
+ }
638
+ finally {
639
+ Object.assign(console, real);
640
+ }
641
+ const exitCode = Number(process.exitCode ?? 0);
642
+ process.exitCode = previousExit;
643
+ return new CommandResult(stdout, stderr, exitCode);
644
+ }
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
- return {
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
- const { rows, rowCount } = await client.query(sql, bindings);
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,28 @@
1
+ /**
2
+ * OpenAPI configuration. Defaults live here and are merged under
3
+ * `config("openapi")` by the provider; an app overrides them in
4
+ * `config/openapi.ts` (publish it with `keel vendor:publish --tag openapi-config`).
5
+ */
6
+ export interface OpenApiConfig {
7
+ /** Master switch. Off → no docs routes and the spec 404s. */
8
+ enabled: boolean;
9
+ /** URL prefix the docs UI and spec mount under. Default: "docs". */
10
+ path: string;
11
+ /** The API title. Defaults to `config("app.name")`. */
12
+ title: string;
13
+ /** The API version string shown in the spec. */
14
+ version: string;
15
+ /** A longer description (Markdown) for the spec's `info`. */
16
+ description?: string;
17
+ /** Server URLs the API is served from (OpenAPI `servers`). */
18
+ servers: string[];
19
+ /** Serve the docs in production too. Off by default — docs expose your surface. */
20
+ public: boolean;
21
+ /** Swagger UI asset base (a CDN, or your own copy). Pin it to a version you trust. */
22
+ cdn: string;
23
+ /** Route path prefixes to leave out of the spec (the docs' own are always skipped). */
24
+ ignorePaths: string[];
25
+ }
26
+ export declare const defaultConfig: OpenApiConfig;
27
+ /** Read the effective OpenAPI config, filling gaps (title falls back to app name). */
28
+ export declare function resolveConfig(): OpenApiConfig;
@@ -0,0 +1,25 @@
1
+ /**
2
+ * OpenAPI configuration. Defaults live here and are merged under
3
+ * `config("openapi")` by the provider; an app overrides them in
4
+ * `config/openapi.ts` (publish it with `keel vendor:publish --tag openapi-config`).
5
+ */
6
+ import { config } from "../core/helpers.js";
7
+ export const defaultConfig = {
8
+ enabled: true,
9
+ path: "docs",
10
+ title: "",
11
+ version: "1.0.0",
12
+ servers: [],
13
+ public: false,
14
+ cdn: "https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.17.14",
15
+ ignorePaths: [],
16
+ };
17
+ /** Read the effective OpenAPI config, filling gaps (title falls back to app name). */
18
+ export function resolveConfig() {
19
+ const raw = config("openapi", {});
20
+ return {
21
+ ...defaultConfig,
22
+ ...raw,
23
+ title: raw.title || config("app.name", "API"),
24
+ };
25
+ }