@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,144 @@
1
+ const middlewareBrand = "keja.middleware";
2
+ const middlewareFields: unique symbol = Symbol("keja.middleware.fields");
3
+ declare const passed: unique symbol;
4
+
5
+ const reservedFields = ["params", "query", "body", "respond"] as const;
6
+
7
+ type ReservedField = (typeof reservedFields)[number];
8
+
9
+ type ForbidReserved<Fields> = Fields & {
10
+ [Key in ReservedField]?: never;
11
+ };
12
+
13
+ export type MiddlewareContext = {
14
+ request: Request;
15
+ version: string;
16
+ params: Record<string, string>;
17
+ next: (fields?: Record<string, unknown>) => Promise<Response>;
18
+ };
19
+
20
+ export type MiddlewareDefinition<Fields extends object = object> = {
21
+ readonly [middlewareBrand]: true;
22
+ readonly [middlewareFields]: Fields;
23
+ handle: (input: MiddlewareContext) => Response | Promise<Response>;
24
+ };
25
+
26
+ type MiddlewareStep = {
27
+ definition: MiddlewareDefinition<object>;
28
+ allowFields: boolean;
29
+ };
30
+
31
+ type Passed<Result> = Result extends {
32
+ readonly [passed]: infer Fields extends Record<string, unknown>;
33
+ }
34
+ ? Fields
35
+ : never;
36
+
37
+ type WithoutReserved<Fields> = {
38
+ [Key in keyof Fields as Key extends ReservedField ? never : Key]: Fields[Key];
39
+ };
40
+
41
+ type MiddlewareFields<Result> = [Passed<Result>] extends [never]
42
+ ? Record<never, never>
43
+ : WithoutReserved<Passed<Result>>;
44
+
45
+ type MiddlewareNext = {
46
+ (): Promise<Response & { readonly [passed]: Record<never, never> }>;
47
+ <const Fields extends Record<string, unknown>>(
48
+ fields: ForbidReserved<Fields>,
49
+ ): Promise<Response & { readonly [passed]: Fields }>;
50
+ };
51
+
52
+ export function defineMiddleware<Result>(
53
+ handle: (input: {
54
+ request: Request;
55
+ version: string;
56
+ params: Record<string, string>;
57
+ next: MiddlewareNext;
58
+ }) => Promise<Result>,
59
+ ): MiddlewareDefinition<MiddlewareFields<Result>> {
60
+ return {
61
+ [middlewareBrand]: true,
62
+ [middlewareFields]: undefined as unknown as MiddlewareFields<Result>,
63
+ handle,
64
+ } as unknown as MiddlewareDefinition<MiddlewareFields<Result>>;
65
+ }
66
+
67
+ export function middlewareList(
68
+ value: unknown,
69
+ ): MiddlewareDefinition<object>[] | null {
70
+ const exported = Array.isArray(value) ? value : [value];
71
+ if (!exported.every(isMiddleware)) {
72
+ return null;
73
+ }
74
+ return exported;
75
+ }
76
+
77
+ export function isMiddleware(
78
+ value: unknown,
79
+ ): value is MiddlewareDefinition<object> {
80
+ return (
81
+ typeof value === "object" &&
82
+ value !== null &&
83
+ middlewareBrand in value &&
84
+ (value as Record<string, unknown>)[middlewareBrand] === true &&
85
+ "handle" in value &&
86
+ typeof value.handle === "function"
87
+ );
88
+ }
89
+
90
+ export async function runMiddleware(
91
+ steps: readonly MiddlewareStep[],
92
+ context: {
93
+ request: Request;
94
+ version: string;
95
+ params: Record<string, string>;
96
+ },
97
+ done: (fields: Record<string, unknown>) => Response | Promise<Response>,
98
+ ) {
99
+ const fields: Record<string, unknown> = {};
100
+ let index = 0;
101
+ const dispatch = async (): Promise<Response> => {
102
+ const step = steps[index];
103
+ index += 1;
104
+ if (!step) {
105
+ return done(fields);
106
+ }
107
+ let called = false;
108
+ const response = await step.definition.handle({
109
+ request: context.request,
110
+ version: context.version,
111
+ params: context.params,
112
+ next: async (passed) => {
113
+ if (called) {
114
+ throw new Error("next() was already called");
115
+ }
116
+ called = true;
117
+ if (passed !== undefined) {
118
+ if (!isFieldBag(passed)) {
119
+ throw new Error("next() values must be an object");
120
+ }
121
+ if (!step.allowFields && Object.keys(passed).length > 0) {
122
+ throw new Error("Server middleware cannot pass values to next()");
123
+ }
124
+ for (const key of Object.keys(passed)) {
125
+ if (reservedFields.includes(key as ReservedField)) {
126
+ throw new Error(`Middleware cannot pass reserved field ${key}`);
127
+ }
128
+ }
129
+ Object.assign(fields, passed);
130
+ }
131
+ return dispatch();
132
+ },
133
+ });
134
+ if (!(response instanceof Response)) {
135
+ throw new Error("Middleware must return a Response");
136
+ }
137
+ return response;
138
+ };
139
+ return dispatch();
140
+ }
141
+
142
+ function isFieldBag(value: unknown): value is Record<string, unknown> {
143
+ return typeof value === "object" && value !== null && !Array.isArray(value);
144
+ }
@@ -0,0 +1,65 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { setDatabase } from "../db/client.ts";
3
+ import { createTypeId } from "../typeid/id.ts";
4
+ import { applySeeder } from "./apply.ts";
5
+ import { defineSeeder } from "./define.ts";
6
+
7
+ describe("applySeeder", () => {
8
+ it("inserts owned rows and skips an existing _id", async () => {
9
+ const calls: unknown[][] = [];
10
+ setDatabase(((...args: unknown[]) => {
11
+ calls.push(args);
12
+ const first = args[0];
13
+ if (typeof first === "string") {
14
+ return new Identifier(first);
15
+ }
16
+ if (isTemplate(first)) {
17
+ return [{ _id: "kept" }];
18
+ }
19
+ return new InsertHelper(Array.isArray(first) ? first : [first]);
20
+ }) as never);
21
+ try {
22
+ const at = new Date("2026-01-15T00:00:00.000Z");
23
+ const inserted = await applySeeder(
24
+ defineSeeder({
25
+ table: "users",
26
+ objects: [
27
+ {
28
+ _id: createTypeId("users"),
29
+ _object: "v1.users",
30
+ _tenant: "tenant_1",
31
+ _archived: false,
32
+ _createdAt: at,
33
+ _updatedAt: at,
34
+ name: "Ada",
35
+ },
36
+ ],
37
+ }),
38
+ );
39
+ expect(inserted).toBe(1);
40
+ const template = calls.find((args) => isTemplate(args[0]));
41
+ const statement = Array.from(template?.[0] as ArrayLike<string>).join(
42
+ " ",
43
+ );
44
+ expect(statement).toContain("on conflict (_id) do nothing");
45
+ expect(statement).toContain("returning _id");
46
+ } finally {
47
+ setDatabase(null);
48
+ }
49
+ });
50
+ });
51
+
52
+ class Identifier {
53
+ constructor(readonly name: string) {}
54
+ }
55
+
56
+ class InsertHelper {
57
+ constructor(readonly value: unknown[]) {}
58
+ columns = ["_id"];
59
+ }
60
+
61
+ function isTemplate(value: unknown) {
62
+ return (
63
+ Array.isArray(value) && value.every((part) => typeof part === "string")
64
+ );
65
+ }
@@ -0,0 +1,11 @@
1
+ import { DBCall } from "../db/client.ts";
2
+ import type { Seeder } from "./define.ts";
3
+
4
+ export async function applySeeder(seeder: Seeder) {
5
+ const inserted = await DBCall`
6
+ insert into ${DBCall(seeder.table)} ${DBCall(seeder.objects)}
7
+ on conflict (_id) do nothing
8
+ returning _id
9
+ `;
10
+ return Array.isArray(inserted) ? inserted.length : 0;
11
+ }
@@ -0,0 +1,51 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { createTypeId } from "../typeid/id.ts";
3
+ import { defineSeeder, isSeeder } from "./define.ts";
4
+
5
+ const at = new Date("2026-01-15T00:00:00.000Z");
6
+
7
+ function row(name: string) {
8
+ return {
9
+ _id: createTypeId("users"),
10
+ _object: "v1.users",
11
+ _tenant: "tenant_1",
12
+ _archived: false,
13
+ _createdAt: at,
14
+ _updatedAt: at,
15
+ name,
16
+ };
17
+ }
18
+
19
+ describe("defineSeeder", () => {
20
+ it("keeps a table and its owned objects", () => {
21
+ const seeder = defineSeeder({
22
+ table: "users",
23
+ objects: [row("Ada Lovelace")],
24
+ });
25
+ expect(isSeeder(seeder)).toBe(true);
26
+ expect(seeder.table).toBe("users");
27
+ expect(seeder.objects[0]?.name).toBe("Ada Lovelace");
28
+ });
29
+
30
+ it("rejects a table name, an empty list, and a row that is not owned", () => {
31
+ expect(() =>
32
+ defineSeeder({ table: "Users", objects: [row("Ada")] }),
33
+ ).toThrow("Users is not a table name");
34
+ expect(() => defineSeeder({ table: "users", objects: [] })).toThrow(
35
+ "users has no objects",
36
+ );
37
+ expect(() =>
38
+ defineSeeder({
39
+ table: "users",
40
+ objects: [{ name: "Ada" }],
41
+ }),
42
+ ).toThrow("OwnedObjects _id must be a typeid");
43
+ const ada = row("Ada");
44
+ expect(() =>
45
+ defineSeeder({
46
+ table: "users",
47
+ objects: [ada, { ...ada, email: "ada@example.com" }],
48
+ }),
49
+ ).toThrow("users objects must use the same columns");
50
+ });
51
+ });
@@ -0,0 +1,55 @@
1
+ import { projectError } from "../errors/framework-errors.ts";
2
+ import { processBrand } from "../process/slot.ts";
3
+ import { assertOwnedObject, type OwnedObject } from "../typeid/owned.ts";
4
+
5
+ const seederBrand = processBrand("seeder");
6
+ const tableName = /^[a-z_][a-z0-9_]*$/;
7
+
8
+ export type Seeder = {
9
+ table: string;
10
+ objects: OwnedObject[];
11
+ };
12
+
13
+ export function defineSeeder(input: {
14
+ table: string;
15
+ objects: readonly Record<string, unknown>[];
16
+ }): Seeder {
17
+ if (!tableName.test(input.table)) {
18
+ throw projectError("invalidSeeder", `${input.table} is not a table name`);
19
+ }
20
+ if (input.objects.length === 0) {
21
+ throw projectError("invalidSeeder", `${input.table} has no objects`);
22
+ }
23
+ const objects = input.objects.map((object) => readObject(object));
24
+ const columns = Object.keys(objects[0] ?? {}).join("\0");
25
+ for (const object of objects) {
26
+ if (Object.keys(object).join("\0") !== columns) {
27
+ throw projectError(
28
+ "invalidSeeder",
29
+ `${input.table} objects must use the same columns`,
30
+ );
31
+ }
32
+ }
33
+ return Object.assign(
34
+ { table: input.table, objects },
35
+ { [seederBrand]: true },
36
+ );
37
+ }
38
+
39
+ export function isSeeder(value: unknown): value is Seeder {
40
+ return (
41
+ typeof value === "object" &&
42
+ value !== null &&
43
+ seederBrand in value &&
44
+ (value as Record<symbol, unknown>)[seederBrand] === true
45
+ );
46
+ }
47
+
48
+ function readObject(object: Record<string, unknown>) {
49
+ try {
50
+ return assertOwnedObject(object);
51
+ } catch (error) {
52
+ const detail = error instanceof Error ? error.message : "Invalid object";
53
+ throw projectError("invalidSeeder", detail);
54
+ }
55
+ }
@@ -0,0 +1,54 @@
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 { createTypeId } from "../typeid/id.ts";
6
+ import { defineSeeder } from "./define.ts";
7
+ import {
8
+ collectSeederFiles,
9
+ readSeederFile,
10
+ seedersFromModule,
11
+ } from "./load.ts";
12
+
13
+ describe("seed files", () => {
14
+ it("walks seed modules and skips tests", async () => {
15
+ const root = mkdtempSync(path.join(tmpdir(), "keja-seed-"));
16
+ mkdirSync(path.join(root, "billing"), { recursive: true });
17
+ writeFileSync(path.join(root, "users.ts"), "");
18
+ writeFileSync(path.join(root, "users.test.ts"), "");
19
+ writeFileSync(path.join(root, "billing", "notes.ts"), "");
20
+ const files = await collectSeederFiles(root);
21
+ expect(files.sort()).toEqual(["billing/notes.ts", "users.ts"]);
22
+ });
23
+
24
+ it("reads every seeder export and rejects a file with none", () => {
25
+ const users = defineSeeder({
26
+ table: "users",
27
+ objects: [owned("users")],
28
+ });
29
+ const notes = defineSeeder({
30
+ table: "notes",
31
+ objects: [owned("notes")],
32
+ });
33
+ expect(seedersFromModule({ default: users, notes })).toEqual([
34
+ users,
35
+ notes,
36
+ ]);
37
+ expect(() => readSeederFile("helpers.ts", { value: 1 })).toThrow(
38
+ "helpers.ts must export defineSeeder(...)",
39
+ );
40
+ });
41
+ });
42
+
43
+ function owned(prefix: string) {
44
+ const at = new Date("2026-01-15T00:00:00.000Z");
45
+ return {
46
+ _id: createTypeId(prefix),
47
+ _object: "v1.notes",
48
+ _tenant: "tenant_1",
49
+ _archived: false as const,
50
+ _createdAt: at,
51
+ _updatedAt: at,
52
+ name: prefix,
53
+ };
54
+ }
@@ -0,0 +1,67 @@
1
+ import { existsSync } from "node:fs";
2
+ import { readdir } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+ import { projectError } from "../errors/framework-errors.ts";
6
+ import { isSeeder, type Seeder } from "./define.ts";
7
+
8
+ export async function loadSeeders(directory: string) {
9
+ if (!existsSync(directory)) {
10
+ return [];
11
+ }
12
+ const files = (await collectSeederFiles(directory)).sort();
13
+ const seeders: Seeder[] = [];
14
+ for (const file of files) {
15
+ const loaded = await import(pathToFileURL(path.join(directory, file)).href);
16
+ seeders.push(...readSeederFile(file, loaded));
17
+ }
18
+ return seeders;
19
+ }
20
+
21
+ export function readSeederFile(file: string, module: object) {
22
+ const exported = seedersFromModule(module);
23
+ if (exported.length === 0) {
24
+ throw projectError(
25
+ "invalidSeederFile",
26
+ `${file} must export defineSeeder(...)`,
27
+ );
28
+ }
29
+ return exported;
30
+ }
31
+
32
+ export function seedersFromModule(module: object) {
33
+ const record = module as Record<string, unknown>;
34
+ const names = Object.keys(record).filter((name) => name !== "default");
35
+ const ordered = "default" in record ? ["default", ...names] : names;
36
+ const seeders: Seeder[] = [];
37
+ for (const name of ordered) {
38
+ const value = record[name];
39
+ if (isSeeder(value)) {
40
+ seeders.push(value);
41
+ }
42
+ }
43
+ return seeders;
44
+ }
45
+
46
+ export async function collectSeederFiles(directory: string) {
47
+ const files = await walk(directory, "");
48
+ return files.filter(
49
+ (file) => file.endsWith(".ts") && !file.endsWith(".test.ts"),
50
+ );
51
+ }
52
+
53
+ async function walk(directory: string, prefix: string): Promise<string[]> {
54
+ const entries = await readdir(directory, { withFileTypes: true });
55
+ const files: string[] = [];
56
+ for (const entry of entries) {
57
+ const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
58
+ if (entry.isDirectory()) {
59
+ files.push(...(await walk(path.join(directory, entry.name), relative)));
60
+ continue;
61
+ }
62
+ if (entry.isFile()) {
63
+ files.push(relative);
64
+ }
65
+ }
66
+ return files;
67
+ }
@@ -0,0 +1,16 @@
1
+ import { Chalk } from "chalk";
2
+ import { describe, expect, it } from "vitest";
3
+ import { formatSeed } from "./report.ts";
4
+
5
+ describe("formatSeed", () => {
6
+ it("lists inserted rows and an empty seed folder", () => {
7
+ expect(formatSeed([{ table: "users", inserted: 2 }])).toBe(
8
+ "\n SEED\n\n - users 2\n",
9
+ );
10
+ expect(formatSeed([])).toBe("\n SEED\n\n - Seeders none\n");
11
+ const style = new Chalk({ level: 1 });
12
+ expect(formatSeed([{ table: "users", inserted: 2 }], true)).toContain(
13
+ style.dim("SEED"),
14
+ );
15
+ });
16
+ });
@@ -0,0 +1,15 @@
1
+ import { Chalk } from "chalk";
2
+
3
+ export function formatSeed(
4
+ results: readonly { table: string; inserted: number }[],
5
+ color = false,
6
+ ) {
7
+ const style = new Chalk({ level: color ? 1 : 0 });
8
+ const row = (label: string, value: string) =>
9
+ ` ${style.dim(`- ${label.padEnd(10)}`)} ${value}`;
10
+ const lines =
11
+ results.length > 0
12
+ ? results.map((result) => row(result.table, String(result.inserted)))
13
+ : [row("Seeders", "none")];
14
+ return `\n ${style.dim("SEED")}\n\n${lines.join("\n")}\n`;
15
+ }
@@ -0,0 +1,88 @@
1
+ import os from "node:os";
2
+ import {
3
+ type EnvSource,
4
+ openApiServerEnv,
5
+ requiredEnv,
6
+ validateOpenApiServer,
7
+ } from "../env/required-env.ts";
8
+
9
+ const supportedPlatforms = new Set(["darwin", "linux", "win32"]);
10
+ const supportedArchitectures = new Set(["arm64", "x64"]);
11
+
12
+ export type BunRuntime = {
13
+ version?: string;
14
+ env?: EnvSource;
15
+ };
16
+
17
+ export type MachineInfo = {
18
+ platform: string;
19
+ arch: string;
20
+ hostname: string;
21
+ };
22
+
23
+ type Failure = {
24
+ ok: false;
25
+ error: string;
26
+ };
27
+
28
+ export type BunCheck =
29
+ | {
30
+ ok: true;
31
+ version: string;
32
+ }
33
+ | Failure;
34
+
35
+ export type MachineCheck = ({ ok: true } & MachineInfo) | Failure;
36
+
37
+ export type EnvCheck = { ok: true } | Failure;
38
+
39
+ export function checkBun(runtime: BunRuntime | undefined): BunCheck {
40
+ if (
41
+ runtime == null ||
42
+ typeof runtime.version !== "string" ||
43
+ runtime.version === ""
44
+ ) {
45
+ return { ok: false, error: "Process is not running in Bun" };
46
+ }
47
+ return { ok: true, version: runtime.version };
48
+ }
49
+
50
+ export function checkMachine(
51
+ machine: MachineInfo = readMachine(),
52
+ ): MachineCheck {
53
+ if (!supportedPlatforms.has(machine.platform)) {
54
+ return { ok: false, error: `Unsupported platform ${machine.platform}` };
55
+ }
56
+ if (!supportedArchitectures.has(machine.arch)) {
57
+ return { ok: false, error: `Unsupported architecture ${machine.arch}` };
58
+ }
59
+ return { ok: true, ...machine };
60
+ }
61
+
62
+ export function checkEnv(env: EnvSource): EnvCheck {
63
+ const errors: string[] = [];
64
+ for (const requirement of requiredEnv) {
65
+ const error = requirement.validate(env[requirement.name]);
66
+ if (error) {
67
+ errors.push(error);
68
+ }
69
+ }
70
+ for (const server of openApiServerEnv) {
71
+ const error = validateOpenApiServer(server.name, env[server.name]);
72
+ if (error) {
73
+ errors.push(error);
74
+ }
75
+ }
76
+ if (errors.length > 0) {
77
+ return { ok: false, error: errors.join("\n") };
78
+ }
79
+ return { ok: true };
80
+ }
81
+
82
+ function readMachine(): MachineInfo {
83
+ return {
84
+ platform: os.platform(),
85
+ arch: os.arch(),
86
+ hostname: os.hostname(),
87
+ };
88
+ }
@@ -0,0 +1,128 @@
1
+ import { Chalk } from "chalk";
2
+ import { describe, expect, it } from "vitest";
3
+ import { checkEnv, checkMachine } from "./checks.ts";
4
+ import { formatSelfTest, runSelfTest } from "./run.ts";
5
+
6
+ const validEnv = {
7
+ HOST: "127.0.0.1",
8
+ HTTP_PORT: "3000",
9
+ HTTP_SERVICE_PORT: "3001",
10
+ };
11
+
12
+ describe("runSelfTest", () => {
13
+ it("stops when the process is not Bun", () => {
14
+ const result = runSelfTest({
15
+ runtime: undefined,
16
+ env: {},
17
+ });
18
+ expect(result).toEqual({
19
+ ok: false,
20
+ step: "bun",
21
+ error: "Process is not running in Bun",
22
+ });
23
+ });
24
+
25
+ it("checks the machine after Bun and before env", () => {
26
+ const result = runSelfTest({
27
+ runtime: { version: "1.4.4", env: validEnv },
28
+ machine: { platform: "freebsd", arch: "x64", hostname: "test" },
29
+ env: {},
30
+ });
31
+ expect(result).toEqual({
32
+ ok: false,
33
+ step: "machine",
34
+ error: "Unsupported platform freebsd",
35
+ });
36
+ });
37
+
38
+ it("checks required env after the machine", () => {
39
+ const result = runSelfTest({
40
+ runtime: { version: "1.4.4" },
41
+ machine: { platform: "linux", arch: "x64", hostname: "test" },
42
+ env: { HOST: "127.0.0.1" },
43
+ });
44
+ if (result.ok) {
45
+ throw new Error("expected env check to fail");
46
+ }
47
+ expect(result.step).toBe("env");
48
+ const checked = checkEnv({
49
+ HOST: " ",
50
+ HTTP_PORT: "70000",
51
+ HTTP_SERVICE_PORT: "3001",
52
+ });
53
+ if (checked.ok) {
54
+ throw new Error("expected env check to fail");
55
+ }
56
+ expect(checked.error).toContain("HOST is required");
57
+ expect(checked.error).toContain(
58
+ "HTTP_PORT must be an integer from 1 to 65535",
59
+ );
60
+ const openApi = checkEnv({
61
+ ...validEnv,
62
+ OPENAPI_SERVER: "notaurl",
63
+ });
64
+ if (openApi.ok) {
65
+ throw new Error("expected openapi server check to fail");
66
+ }
67
+ expect(openApi.error).toContain(
68
+ "OPENAPI_SERVER must be an absolute http or https URL",
69
+ );
70
+ });
71
+
72
+ it("prints the checks in the same rows as the server addresses", () => {
73
+ expect(
74
+ formatSelfTest({
75
+ ok: true,
76
+ bun: { ok: true, version: "1.4.4" },
77
+ machine: {
78
+ ok: true,
79
+ platform: "win32",
80
+ arch: "x64",
81
+ hostname: "Plomyk",
82
+ },
83
+ }),
84
+ ).toBe(
85
+ "\n SELF TEST\n\n - Bun 1.4.4\n - Machine win32 x64 Plomyk\n - Env ready\n\n successful\n",
86
+ );
87
+ expect(
88
+ formatSelfTest({
89
+ ok: false,
90
+ step: "env",
91
+ error: "HOST is required\nHTTP_PORT must be an integer from 1 to 65535",
92
+ }),
93
+ ).toBe(
94
+ "\n SELF TEST\n\n x Env\n HOST is required\n HTTP_PORT must be an integer from 1 to 65535\n\n failed, exiting\n",
95
+ );
96
+ const style = new Chalk({ level: 1 });
97
+ const passed = formatSelfTest(
98
+ {
99
+ ok: true,
100
+ bun: { ok: true, version: "1.4.4" },
101
+ machine: {
102
+ ok: true,
103
+ platform: "win32",
104
+ arch: "x64",
105
+ hostname: "Plomyk",
106
+ },
107
+ },
108
+ true,
109
+ );
110
+ expect(passed).toContain(style.green("successful"));
111
+ const failed = formatSelfTest(
112
+ { ok: false, step: "env", error: "HOST is required" },
113
+ true,
114
+ );
115
+ expect(failed).toContain(style.red("failed, exiting"));
116
+ expect(failed).toContain(style.red("x Env"));
117
+ });
118
+
119
+ it("accepts this machine when the platform and architecture are supported", () => {
120
+ const machine = checkMachine();
121
+ if (!machine.ok) {
122
+ throw new Error(machine.error);
123
+ }
124
+ expect(["darwin", "linux", "win32"]).toContain(machine.platform);
125
+ expect(["arm64", "x64"]).toContain(machine.arch);
126
+ expect(machine.hostname.length).toBeGreaterThan(0);
127
+ });
128
+ });