@veducio/keja 0.0.0-stage → 0.2.3

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 (94) hide show
  1. package/LICENSE +7 -0
  2. package/README.md +39 -2
  3. package/examples/starter/.env.example +6 -0
  4. package/examples/starter/keja.config.ts +7 -0
  5. package/examples/starter/keja.versioning.ts +5 -0
  6. package/examples/starter/migrations/0001_init.sql +1 -0
  7. package/examples/starter/package.json +14 -0
  8. package/examples/starter/tsconfig.json +9 -0
  9. package/package.json +62 -4
  10. package/src/access/cloudflare.test.ts +131 -0
  11. package/src/access/cloudflare.ts +193 -0
  12. package/src/argus/call.test.ts +98 -0
  13. package/src/argus/call.ts +153 -0
  14. package/src/argus/heartbeat.ts +65 -0
  15. package/src/argus/register.test.ts +120 -0
  16. package/src/argus/register.ts +145 -0
  17. package/src/cli/banner.test.ts +98 -0
  18. package/src/cli/banner.ts +209 -0
  19. package/src/cli/commands/build.ts +12 -0
  20. package/src/cli/commands/dev.ts +12 -0
  21. package/src/cli/commands/generate.ts +12 -0
  22. package/src/cli/commands/init.ts +57 -0
  23. package/src/cli/commands/migrate.ts +12 -0
  24. package/src/cli/commands/seed.ts +12 -0
  25. package/src/cli/commands/start.ts +12 -0
  26. package/src/cli/dev-reload.test.ts +87 -0
  27. package/src/cli/dev-reload.ts +182 -0
  28. package/src/cli/generate-project.ts +39 -0
  29. package/src/cli/index.ts +29 -0
  30. package/src/cli/init-project.test.ts +312 -0
  31. package/src/cli/init-project.ts +416 -0
  32. package/src/cli/migrate-project.ts +31 -0
  33. package/src/cli/run-project.test.ts +50 -0
  34. package/src/cli/run-project.ts +93 -0
  35. package/src/cli/seed-project.ts +46 -0
  36. package/src/db/client.test.ts +49 -0
  37. package/src/db/client.ts +61 -0
  38. package/src/db/migrate.test.ts +141 -0
  39. package/src/db/migrate.ts +146 -0
  40. package/src/db/owned-sql.ts +202 -0
  41. package/src/db/postgres.ts +90 -0
  42. package/src/env/required-env.test.ts +64 -0
  43. package/src/env/required-env.ts +120 -0
  44. package/src/errors/framework-errors.ts +295 -0
  45. package/src/http/create-app.test.ts +340 -0
  46. package/src/http/create-app.ts +196 -0
  47. package/src/http/openapi.ts +111 -0
  48. package/src/process/slot.ts +18 -0
  49. package/src/project/config.test.ts +140 -0
  50. package/src/project/config.ts +413 -0
  51. package/src/project/load.test.ts +60 -0
  52. package/src/project/load.ts +158 -0
  53. package/src/project/reload-graph.test.ts +52 -0
  54. package/src/project/reload-graph.ts +175 -0
  55. package/src/repository/define.ts +123 -0
  56. package/src/repository/generate.test.ts +58 -0
  57. package/src/repository/generate.ts +117 -0
  58. package/src/repository/query.test.ts +99 -0
  59. package/src/repository/query.ts +287 -0
  60. package/src/repository/runtime.test.ts +154 -0
  61. package/src/repository/runtime.ts +434 -0
  62. package/src/repository/sql.test.ts +78 -0
  63. package/src/repository/sql.ts +282 -0
  64. package/src/route/define.test.ts +65 -0
  65. package/src/route/define.ts +307 -0
  66. package/src/route/middleware.test.ts +63 -0
  67. package/src/route/middleware.ts +144 -0
  68. package/src/seed/apply.test.ts +65 -0
  69. package/src/seed/apply.ts +11 -0
  70. package/src/seed/define.test.ts +51 -0
  71. package/src/seed/define.ts +55 -0
  72. package/src/seed/load.test.ts +54 -0
  73. package/src/seed/load.ts +67 -0
  74. package/src/seed/report.test.ts +16 -0
  75. package/src/seed/report.ts +15 -0
  76. package/src/self-test/checks.ts +88 -0
  77. package/src/self-test/run.test.ts +128 -0
  78. package/src/self-test/run.ts +105 -0
  79. package/src/server/identity.test.ts +57 -0
  80. package/src/server/identity.ts +116 -0
  81. package/src/server/index.test.ts +15 -0
  82. package/src/server/index.ts +169 -0
  83. package/src/server/swap.test.ts +89 -0
  84. package/src/server/swap.ts +29 -0
  85. package/src/typeid/id.test.ts +46 -0
  86. package/src/typeid/id.ts +136 -0
  87. package/src/typeid/owned.test.ts +73 -0
  88. package/src/typeid/owned.ts +193 -0
  89. package/src/vault/client.test.ts +122 -0
  90. package/src/vault/client.ts +191 -0
  91. package/src/versioning/define.test.ts +79 -0
  92. package/src/versioning/define.ts +173 -0
  93. package/src/versioning/select.test.ts +79 -0
  94. package/src/versioning/select.ts +62 -0
@@ -0,0 +1,140 @@
1
+ import { mkdtempSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import path from "node:path";
4
+ import { describe, expect, it } from "vitest";
5
+ import { defineConfig, loadConfig, resolveProjectPaths } from "./config.ts";
6
+
7
+ describe("defineConfig", () => {
8
+ it("uses the default project paths", () => {
9
+ expect(defineConfig()).toMatchObject({
10
+ versioning: "keja.versioning.ts",
11
+ public_path: "src/public",
12
+ service_path: "src/service",
13
+ });
14
+ });
15
+
16
+ it("keeps the paths that are set", () => {
17
+ expect(
18
+ defineConfig({
19
+ versioning: "config/versions.ts",
20
+ public_path: "routes/public",
21
+ }),
22
+ ).toMatchObject({
23
+ versioning: "config/versions.ts",
24
+ public_path: "routes/public",
25
+ service_path: "src/service",
26
+ });
27
+ });
28
+
29
+ it("keeps the database disabled until it is turned on", () => {
30
+ expect(defineConfig().database).toEqual({ enabled: false });
31
+ expect(defineConfig({ db: { enabled: false } }).database).toEqual({
32
+ enabled: false,
33
+ });
34
+ expect(defineConfig({ db: { enabled: true } }).database).toEqual({
35
+ enabled: true,
36
+ migrations: "migrations",
37
+ });
38
+ expect(
39
+ defineConfig({
40
+ db: { enabled: true, migrations: "db/migrations" },
41
+ }).database,
42
+ ).toEqual({
43
+ enabled: true,
44
+ migrations: "db/migrations",
45
+ });
46
+ });
47
+
48
+ it("rejects an empty path", () => {
49
+ expect(() => defineConfig({ service_path: " " })).toThrow(
50
+ "keja.config.ts service_path must be a non-empty path",
51
+ );
52
+ });
53
+
54
+ it("keeps an optional Argus registry", () => {
55
+ expect(
56
+ defineConfig({
57
+ name: "billing",
58
+ argus: {
59
+ enabled: true,
60
+ url: "https://argus.veducio.com/",
61
+ env: "staging",
62
+ username: "argus",
63
+ password: "secret",
64
+ public: "billing",
65
+ },
66
+ }).argus,
67
+ ).toEqual({
68
+ enabled: true,
69
+ url: "https://argus.veducio.com",
70
+ env: "staging",
71
+ username: "argus",
72
+ password: "secret",
73
+ public: "billing",
74
+ service: null,
75
+ });
76
+ expect(defineConfig({ argus: { enabled: false } }).argus).toEqual({
77
+ enabled: false,
78
+ });
79
+ expect(() =>
80
+ defineConfig({
81
+ argus: { enabled: true, url: "argus.veducio.com", env: "staging" },
82
+ }),
83
+ ).toThrow("keja.config.ts argus.url must be an absolute http or https URL");
84
+ });
85
+
86
+ it("reads Vault settings from config", () => {
87
+ expect(
88
+ defineConfig({
89
+ vault: {
90
+ enabled: true,
91
+ address: "https://vault.example/",
92
+ token: "s.token",
93
+ namespace: "apps",
94
+ mount: "kv",
95
+ },
96
+ }).vault,
97
+ ).toEqual({
98
+ enabled: true,
99
+ address: "https://vault.example",
100
+ token: "s.token",
101
+ namespace: "apps",
102
+ mount: "kv",
103
+ });
104
+ expect(defineConfig().vault).toEqual({ enabled: false });
105
+ });
106
+ });
107
+
108
+ describe("loadConfig", () => {
109
+ it("resolves the default paths from the project root", async () => {
110
+ const root = mkdtempSync(path.join(tmpdir(), "keja-config-"));
111
+ await expect(loadConfig(root)).resolves.toEqual({
112
+ config: null,
113
+ name: null,
114
+ argus: { enabled: false },
115
+ database: { enabled: false },
116
+ vault: { enabled: false },
117
+ versioning: path.resolve(root, "keja.versioning.ts"),
118
+ public: path.resolve(root, "src/public"),
119
+ service: path.resolve(root, "src/service"),
120
+ });
121
+ });
122
+
123
+ it("resolves custom paths from the project root", () => {
124
+ const root = path.resolve("app");
125
+ expect(
126
+ resolveProjectPaths(
127
+ root,
128
+ defineConfig({
129
+ versioning: "versioning/api.ts",
130
+ public_path: "http/public",
131
+ service_path: "http/service",
132
+ }),
133
+ ),
134
+ ).toEqual({
135
+ versioning: path.resolve(root, "versioning/api.ts"),
136
+ public: path.resolve(root, "http/public"),
137
+ service: path.resolve(root, "http/service"),
138
+ });
139
+ });
140
+ });
@@ -0,0 +1,413 @@
1
+ import { existsSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ import { projectError } from "../errors/framework-errors.ts";
5
+ import { freshImportUrl } from "./reload-graph.ts";
6
+
7
+ const configBrand = "keja.config";
8
+
9
+ const defaultConfig = {
10
+ versioning: "keja.versioning.ts",
11
+ public_path: "src/public",
12
+ service_path: "src/service",
13
+ };
14
+
15
+ export type ArgusSettings = {
16
+ url: string;
17
+ env: string;
18
+ username: string | null;
19
+ password: string | null;
20
+ public: string | null;
21
+ service: string | null;
22
+ };
23
+
24
+ export type ArgusConfigInput = {
25
+ enabled: boolean;
26
+ url?: string;
27
+ env?: string;
28
+ username?: string;
29
+ password?: string;
30
+ public?: string;
31
+ service?: string;
32
+ };
33
+
34
+ export type ArgusConfig =
35
+ | { enabled: false }
36
+ | ({ enabled: true } & ArgusSettings);
37
+
38
+ export type DatabaseConfigInput = {
39
+ enabled: boolean;
40
+ migrations?: string;
41
+ };
42
+
43
+ export type DatabaseConfig =
44
+ | { enabled: false }
45
+ | { enabled: true; migrations: string };
46
+
47
+ export type VaultSettings = {
48
+ address: string;
49
+ token: string;
50
+ namespace: string | null;
51
+ mount: string;
52
+ };
53
+
54
+ export type VaultConfigInput = {
55
+ enabled: boolean;
56
+ address?: string;
57
+ token?: string;
58
+ namespace?: string;
59
+ mount?: string;
60
+ };
61
+
62
+ export type VaultConfig =
63
+ | { enabled: false }
64
+ | ({ enabled: true } & VaultSettings);
65
+
66
+ export type KejaConfigInput = {
67
+ name?: string;
68
+ versioning?: string;
69
+ public_path?: string;
70
+ service_path?: string;
71
+ argus?: ArgusConfigInput;
72
+ db?: DatabaseConfigInput;
73
+ vault?: VaultConfigInput;
74
+ };
75
+
76
+ export type KejaConfig = {
77
+ name: string | null;
78
+ versioning: string;
79
+ public_path: string;
80
+ service_path: string;
81
+ argus: ArgusConfig;
82
+ database: DatabaseConfig;
83
+ vault: VaultConfig;
84
+ };
85
+
86
+ export function defineConfig(input: KejaConfigInput = {}) {
87
+ const config: KejaConfig = {
88
+ name: optionalText(input.name, "name", "service name"),
89
+ versioning: configPath(
90
+ input.versioning,
91
+ defaultConfig.versioning,
92
+ "versioning",
93
+ ),
94
+ public_path: configPath(
95
+ input.public_path,
96
+ defaultConfig.public_path,
97
+ "public_path",
98
+ ),
99
+ service_path: configPath(
100
+ input.service_path,
101
+ defaultConfig.service_path,
102
+ "service_path",
103
+ ),
104
+ argus: configArgus(input.argus),
105
+ database: configDatabase(input.db),
106
+ vault: configVault(input.vault),
107
+ };
108
+ return Object.assign(config, { [configBrand]: true as const });
109
+ }
110
+
111
+ export function isConfig(value: unknown): value is KejaConfig {
112
+ return (
113
+ typeof value === "object" &&
114
+ value !== null &&
115
+ configBrand in value &&
116
+ (value as Record<string, unknown>)[configBrand] === true &&
117
+ hasPath(value, "versioning") &&
118
+ hasPath(value, "public_path") &&
119
+ hasPath(value, "service_path") &&
120
+ hasName(value) &&
121
+ hasArgus(value) &&
122
+ hasDatabase(value) &&
123
+ hasVault(value)
124
+ );
125
+ }
126
+
127
+ export type ProjectPaths = {
128
+ config: string | null;
129
+ versioning: string;
130
+ public: string;
131
+ service: string;
132
+ };
133
+
134
+ export type LoadedConfig = ProjectPaths & {
135
+ name: string | null;
136
+ argus: ArgusConfig;
137
+ database: DatabaseConfig;
138
+ vault: VaultConfig;
139
+ };
140
+
141
+ export function resolveProjectPaths(root: string, config: KejaConfig) {
142
+ return {
143
+ versioning: path.resolve(root, config.versioning),
144
+ public: path.resolve(root, config.public_path),
145
+ service: path.resolve(root, config.service_path),
146
+ };
147
+ }
148
+
149
+ export async function loadConfig(
150
+ root: string,
151
+ generation?: string,
152
+ ): Promise<LoadedConfig> {
153
+ const file = path.join(root, "keja.config.ts");
154
+ if (!existsSync(file)) {
155
+ return loadedConfig(root, null, defineConfig());
156
+ }
157
+ const href = generation
158
+ ? freshImportUrl(file, generation)
159
+ : pathToFileURL(file).href;
160
+ const loaded = await import(href);
161
+ if (!isConfig(loaded.default)) {
162
+ throw projectError(
163
+ "invalidConfigFile",
164
+ `${file} must default-export defineConfig(...)`,
165
+ );
166
+ }
167
+ return loadedConfig(root, file, loaded.default);
168
+ }
169
+
170
+ function configPath(value: unknown, fallback: string, field: string) {
171
+ if (value === undefined) {
172
+ return fallback;
173
+ }
174
+ if (typeof value !== "string" || value.trim() === "") {
175
+ throw projectError(
176
+ "invalidConfigFile",
177
+ `keja.config.ts ${field} must be a non-empty path`,
178
+ );
179
+ }
180
+ return value.trim();
181
+ }
182
+
183
+ function loadedConfig(
184
+ root: string,
185
+ file: string | null,
186
+ config: KejaConfig,
187
+ ): LoadedConfig {
188
+ return {
189
+ config: file,
190
+ ...resolveProjectPaths(root, config),
191
+ name: config.name,
192
+ argus: config.argus,
193
+ database: resolvedDatabase(root, config.database),
194
+ vault: config.vault,
195
+ };
196
+ }
197
+
198
+ function resolvedDatabase(
199
+ root: string,
200
+ database: DatabaseConfig,
201
+ ): DatabaseConfig {
202
+ if (!database.enabled) {
203
+ return database;
204
+ }
205
+ return {
206
+ enabled: true,
207
+ migrations: path.resolve(root, database.migrations),
208
+ };
209
+ }
210
+
211
+ function configDatabase(
212
+ value: DatabaseConfigInput | undefined,
213
+ ): DatabaseConfig {
214
+ if (value === undefined) {
215
+ return { enabled: false };
216
+ }
217
+ if (!isEnabledObject(value)) {
218
+ throw projectError(
219
+ "invalidConfigFile",
220
+ "keja.config.ts db must be an object with enabled",
221
+ );
222
+ }
223
+ if (!value.enabled) {
224
+ return { enabled: false };
225
+ }
226
+ return {
227
+ enabled: true,
228
+ migrations: configPath(value.migrations, "migrations", "db.migrations"),
229
+ };
230
+ }
231
+
232
+ function configArgus(value: ArgusConfigInput | undefined): ArgusConfig {
233
+ if (value === undefined) {
234
+ return { enabled: false };
235
+ }
236
+ if (!isEnabledObject(value)) {
237
+ throw projectError(
238
+ "invalidConfigFile",
239
+ "keja.config.ts argus must be an object with enabled",
240
+ );
241
+ }
242
+ if (!value.enabled) {
243
+ return { enabled: false };
244
+ }
245
+ const username = optionalText(value.username, "argus.username", "username");
246
+ const password = optionalText(value.password, "argus.password", "password");
247
+ if ((username === null) !== (password === null)) {
248
+ throw projectError(
249
+ "invalidConfigFile",
250
+ "keja.config.ts argus.username and argus.password must both be set",
251
+ );
252
+ }
253
+ return {
254
+ enabled: true,
255
+ url: absoluteHttpUrl(value.url, "argus.url"),
256
+ env: requiredText(value.env, "argus.env", "environment name"),
257
+ username,
258
+ password,
259
+ public: optionalText(value.public, "argus.public", "service name"),
260
+ service: optionalText(value.service, "argus.service", "service name"),
261
+ };
262
+ }
263
+
264
+ function configVault(value: VaultConfigInput | undefined): VaultConfig {
265
+ if (value === undefined) {
266
+ return { enabled: false };
267
+ }
268
+ if (!isEnabledObject(value)) {
269
+ throw projectError(
270
+ "invalidConfigFile",
271
+ "keja.config.ts vault must be an object with enabled",
272
+ );
273
+ }
274
+ if (!value.enabled) {
275
+ return { enabled: false };
276
+ }
277
+ const namespace =
278
+ value.namespace ?? configEnv("VAULT_NAMESPACE") ?? undefined;
279
+ return {
280
+ enabled: true,
281
+ address: absoluteHttpUrl(
282
+ value.address ?? configEnv("VAULT_ADDR") ?? undefined,
283
+ "vault.address",
284
+ ),
285
+ token: requiredText(
286
+ value.token ?? configEnv("VAULT_TOKEN") ?? undefined,
287
+ "vault.token",
288
+ "token",
289
+ ),
290
+ namespace: optionalText(namespace, "vault.namespace", "namespace"),
291
+ mount: vaultMount(value.mount ?? configEnv("VAULT_KV_MOUNT") ?? "secret"),
292
+ };
293
+ }
294
+
295
+ function isEnabledObject(value: unknown): value is { enabled: boolean } {
296
+ return (
297
+ typeof value === "object" &&
298
+ value !== null &&
299
+ typeof (value as { enabled?: unknown }).enabled === "boolean"
300
+ );
301
+ }
302
+
303
+ function absoluteHttpUrl(value: unknown, field: string) {
304
+ if (typeof value !== "string" || value.trim() === "") {
305
+ throw projectError(
306
+ "invalidConfigFile",
307
+ `keja.config.ts ${field} must be an absolute http or https URL`,
308
+ );
309
+ }
310
+ try {
311
+ const url = new URL(value.trim());
312
+ if (url.protocol === "http:" || url.protocol === "https:") {
313
+ return url.href.replace(/\/$/, "");
314
+ }
315
+ } catch {
316
+ throw projectError(
317
+ "invalidConfigFile",
318
+ `keja.config.ts ${field} must be an absolute http or https URL`,
319
+ );
320
+ }
321
+ throw projectError(
322
+ "invalidConfigFile",
323
+ `keja.config.ts ${field} must be an absolute http or https URL`,
324
+ );
325
+ }
326
+
327
+ function vaultMount(value: string) {
328
+ const mount = value.trim().replace(/^\/+|\/+$/g, "");
329
+ if (!/^[A-Za-z0-9_-]+$/.test(mount)) {
330
+ throw projectError(
331
+ "invalidConfigFile",
332
+ `keja.config.ts vault.mount must be a mount name`,
333
+ );
334
+ }
335
+ return mount;
336
+ }
337
+
338
+ function configEnv(name: string) {
339
+ const runtime = globalThis as {
340
+ Bun?: { env?: Record<string, string | undefined> };
341
+ };
342
+ const value = runtime.Bun?.env?.[name] ?? process.env[name];
343
+ const trimmed = value?.trim();
344
+ return trimmed ? trimmed : null;
345
+ }
346
+
347
+ function requiredText(value: unknown, field: string, label: string) {
348
+ const text = optionalText(value, field, label);
349
+ if (text === null) {
350
+ throw projectError(
351
+ "invalidConfigFile",
352
+ `keja.config.ts ${field} must be a non-empty ${label}`,
353
+ );
354
+ }
355
+ return text;
356
+ }
357
+
358
+ function optionalText(value: unknown, field: string, label: string) {
359
+ if (value === undefined) {
360
+ return null;
361
+ }
362
+ if (typeof value !== "string" || value.trim() === "") {
363
+ throw projectError(
364
+ "invalidConfigFile",
365
+ `keja.config.ts ${field} must be a non-empty ${label}`,
366
+ );
367
+ }
368
+ return value.trim();
369
+ }
370
+
371
+ function hasName(value: object) {
372
+ const name = (value as KejaConfig).name;
373
+ return name === null || typeof name === "string";
374
+ }
375
+
376
+ function hasArgus(value: object) {
377
+ const argus = (value as KejaConfig).argus;
378
+ return (
379
+ typeof argus === "object" &&
380
+ argus !== null &&
381
+ (argus.enabled === false ||
382
+ (argus.enabled === true &&
383
+ typeof argus.url === "string" &&
384
+ typeof argus.env === "string"))
385
+ );
386
+ }
387
+
388
+ function hasVault(value: object) {
389
+ const vault = (value as KejaConfig).vault;
390
+ return (
391
+ typeof vault === "object" &&
392
+ vault !== null &&
393
+ (vault.enabled === false ||
394
+ (vault.enabled === true &&
395
+ typeof vault.address === "string" &&
396
+ typeof vault.token === "string" &&
397
+ typeof vault.mount === "string"))
398
+ );
399
+ }
400
+
401
+ function hasDatabase(value: object) {
402
+ const database = (value as KejaConfig).database;
403
+ return (
404
+ typeof database === "object" &&
405
+ database !== null &&
406
+ (database.enabled === false ||
407
+ (database.enabled === true && typeof database.migrations === "string"))
408
+ );
409
+ }
410
+
411
+ function hasPath(value: object, key: keyof KejaConfig) {
412
+ return typeof (value as KejaConfig)[key] === "string";
413
+ }
@@ -0,0 +1,60 @@
1
+ import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import path from "node:path";
4
+ import { describe, expect, it } from "vitest";
5
+ import { z } from "zod";
6
+ import { defineRoute, response } from "../route/define.ts";
7
+ import { collectRouteFiles, routesFromModule } from "./load.ts";
8
+
9
+ describe("collectRouteFiles", () => {
10
+ it("keeps route modules nested several directories deep", async () => {
11
+ const root = mkdtempSync(path.join(tmpdir(), "keja-routes-"));
12
+ const publicDirectory = path.join(root, "public");
13
+ mkdirSync(path.join(publicDirectory, "orgs", "teams"), {
14
+ recursive: true,
15
+ });
16
+ writeFileSync(path.join(publicDirectory, "_middleware.ts"), "");
17
+ writeFileSync(path.join(publicDirectory, "orgs", "_middleware.ts"), "");
18
+ writeFileSync(
19
+ path.join(publicDirectory, "orgs", "teams", "members.ts"),
20
+ "",
21
+ );
22
+ writeFileSync(
23
+ path.join(publicDirectory, "orgs", "teams", "members.test.ts"),
24
+ "",
25
+ );
26
+
27
+ await expect(collectRouteFiles(publicDirectory)).resolves.toEqual([
28
+ "orgs/teams/members.ts",
29
+ ]);
30
+ });
31
+ });
32
+
33
+ describe("routesFromModule", () => {
34
+ it("reads a default route and named routes from one module", () => {
35
+ const ready = route("get", "/ready");
36
+ const list = route("get", "/jobs");
37
+ const create = route("post", "/jobs");
38
+ expect(
39
+ routesFromModule({
40
+ default: ready,
41
+ list,
42
+ create,
43
+ schema: z.object({ name: z.string() }),
44
+ }).map((item) => `${item.method} ${item.path}`),
45
+ ).toEqual(["get /ready", "get /jobs", "post /jobs"]);
46
+ expect(routesFromModule({ schema: z.string() })).toEqual([]);
47
+ });
48
+ });
49
+
50
+ function route(method: "get" | "post", routePath: string) {
51
+ return defineRoute({
52
+ method,
53
+ path: routePath,
54
+ version: "v1",
55
+ responses: {
56
+ Ok: response({ code: 200, json: z.object({ ok: z.literal(true) }) }),
57
+ },
58
+ handler: ({ respond }) => respond.Ok({ ok: true }),
59
+ });
60
+ }