@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,153 @@
1
+ import { DependencyNotMet } from "../errors/framework-errors.ts";
2
+ import { processValue } from "../process/slot.ts";
3
+ import type { ArgusSettings } from "../project/config.ts";
4
+ import { type ArgusFetch, argusHeaders, joinUrl } from "./register.ts";
5
+
6
+ const state = processValue("argus", () => ({
7
+ active: null as ArgusSettings | null,
8
+ }));
9
+
10
+ export class ServiceCallError extends Error {
11
+ constructor(message: string) {
12
+ super(message);
13
+ this.name = "ServiceCallError";
14
+ }
15
+ }
16
+
17
+ export function setArgus(config: ArgusSettings | null) {
18
+ state.active = config;
19
+ }
20
+
21
+ export async function callService<Data = unknown>(
22
+ service: string,
23
+ path: string,
24
+ body?: unknown,
25
+ options: {
26
+ fetchImpl?: ArgusFetch;
27
+ random?: () => number;
28
+ } = {},
29
+ ): Promise<Data> {
30
+ if (!state.active) {
31
+ throw new DependencyNotMet("service call", "argus");
32
+ }
33
+ const serviceName = serviceNameOf(service);
34
+ const requestPath = servicePath(path);
35
+ const fetchImpl = options.fetchImpl ?? fetch;
36
+ const discovery = await fetchImpl(
37
+ `${joinUrl(state.active.url, `/v1/services/${encodeURIComponent(serviceName)}`)}?env=${encodeURIComponent(state.active.env)}`,
38
+ { headers: argusHeaders(state.active.username, state.active.password) },
39
+ );
40
+ if (!discovery.ok) {
41
+ throw await discoveryFailure(discovery, serviceName);
42
+ }
43
+ const listed = (await discovery.json()) as { instances?: unknown };
44
+ const baseUrl = pickInstance(listed.instances, options.random ?? Math.random);
45
+ if (!baseUrl) {
46
+ throw new ServiceCallError(
47
+ `Argus has no healthy instance for ${serviceName}`,
48
+ );
49
+ }
50
+ const response = await fetchImpl(joinUrl(baseUrl, requestPath), {
51
+ method: "POST",
52
+ headers: new Headers({ "content-type": "application/json" }),
53
+ body: JSON.stringify(body ?? {}),
54
+ });
55
+ const payload = await readPayload(response);
56
+ if (!response.ok) {
57
+ const detail = payload === undefined ? "" : `: ${payloadText(payload)}`;
58
+ throw new ServiceCallError(
59
+ `${serviceName} returned ${response.status}${detail}`,
60
+ );
61
+ }
62
+ return payload as Data;
63
+ }
64
+
65
+ function serviceNameOf(service: string) {
66
+ const name = service.trim();
67
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name)) {
68
+ throw new ServiceCallError(`invalid service name: ${service}`);
69
+ }
70
+ return name;
71
+ }
72
+
73
+ function servicePath(path: string) {
74
+ const trimmed = path.trim();
75
+ if (trimmed === "" || trimmed.includes("://") || trimmed.startsWith("//")) {
76
+ throw new ServiceCallError(`invalid service path: ${path}`);
77
+ }
78
+ return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
79
+ }
80
+
81
+ function pickInstance(value: unknown, random: () => number) {
82
+ if (!Array.isArray(value)) {
83
+ return null;
84
+ }
85
+ const healthy = value.flatMap((item) => {
86
+ if (!item || typeof item !== "object") {
87
+ return [];
88
+ }
89
+ const instance = item as {
90
+ base_url?: unknown;
91
+ weight?: unknown;
92
+ status?: unknown;
93
+ };
94
+ if (
95
+ instance.status !== "healthy" ||
96
+ typeof instance.base_url !== "string"
97
+ ) {
98
+ return [];
99
+ }
100
+ const weight =
101
+ typeof instance.weight === "number" && instance.weight > 0
102
+ ? instance.weight
103
+ : 0;
104
+ return [{ baseUrl: instance.base_url, weight }];
105
+ });
106
+ if (healthy.length === 0) {
107
+ return null;
108
+ }
109
+ const total = healthy.reduce((sum, instance) => sum + instance.weight, 0);
110
+ if (total === 0) {
111
+ const index = Math.min(
112
+ healthy.length - 1,
113
+ Math.floor(random() * healthy.length),
114
+ );
115
+ return healthy[index]?.baseUrl ?? null;
116
+ }
117
+ let cursor = random() * total;
118
+ for (const instance of healthy) {
119
+ cursor -= instance.weight;
120
+ if (cursor < 0) {
121
+ return instance.baseUrl;
122
+ }
123
+ }
124
+ return healthy[healthy.length - 1]?.baseUrl ?? null;
125
+ }
126
+
127
+ async function discoveryFailure(response: Response, service: string) {
128
+ const payload = await readPayload(response);
129
+ if (response.status === 404) {
130
+ return new ServiceCallError(`Argus has no service ${service}`);
131
+ }
132
+ const detail = payload === undefined ? "" : `: ${payloadText(payload)}`;
133
+ return new ServiceCallError(
134
+ `Argus rejected ${service} with status ${response.status}${detail}`,
135
+ );
136
+ }
137
+
138
+ async function readPayload(response: Response) {
139
+ const text = await response.text();
140
+ if (text.trim() === "") {
141
+ return undefined;
142
+ }
143
+ try {
144
+ return JSON.parse(text) as unknown;
145
+ } catch {
146
+ return text;
147
+ }
148
+ }
149
+
150
+ function payloadText(payload: unknown) {
151
+ const text = typeof payload === "string" ? payload : JSON.stringify(payload);
152
+ return text.length > 200 ? `${text.slice(0, 200)}...` : text;
153
+ }
@@ -0,0 +1,65 @@
1
+ import type { ArgusSettings } from "../project/config.ts";
2
+ import type { ServiceIdentity } from "../server/identity.ts";
3
+ import {
4
+ type ArgusFetch,
5
+ argusRegistrations,
6
+ publishArgus,
7
+ withdrawArgus,
8
+ } from "./register.ts";
9
+
10
+ export const argusHeartbeatMs = 10_000;
11
+
12
+ export function startArgusHeartbeat(options: {
13
+ argus: ArgusSettings;
14
+ lead?: boolean;
15
+ identity: ServiceIdentity;
16
+ version?: string;
17
+ fetchImpl?: ArgusFetch;
18
+ intervalMs?: number;
19
+ log?: (message: string) => void;
20
+ }) {
21
+ const log = options.log ?? ((message: string) => console.error(message));
22
+ const registrations = () =>
23
+ argusRegistrations({
24
+ identity: options.identity,
25
+ argus: options.argus,
26
+ version: options.version,
27
+ });
28
+ const beat = async () => {
29
+ const errors = await publishArgus({
30
+ url: options.argus.url,
31
+ username: options.argus.username,
32
+ password: options.argus.password,
33
+ registrations: registrations(),
34
+ fetchImpl: options.fetchImpl,
35
+ });
36
+ for (const error of errors) {
37
+ log(`Argus register failed: ${error}`);
38
+ }
39
+ };
40
+ if (options.lead !== false) {
41
+ void beat();
42
+ }
43
+ const timer = setInterval(() => {
44
+ void beat();
45
+ }, options.intervalMs ?? argusHeartbeatMs);
46
+ timer.unref?.();
47
+ let stopped = false;
48
+ return async () => {
49
+ if (stopped) {
50
+ return;
51
+ }
52
+ stopped = true;
53
+ clearInterval(timer);
54
+ const errors = await withdrawArgus({
55
+ url: options.argus.url,
56
+ username: options.argus.username,
57
+ password: options.argus.password,
58
+ registrations: registrations(),
59
+ fetchImpl: options.fetchImpl,
60
+ });
61
+ for (const error of errors) {
62
+ log(`Argus deregister failed: ${error}`);
63
+ }
64
+ };
65
+ }
@@ -0,0 +1,120 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { defineConfig } from "../project/config.ts";
3
+ import { argusRegistrations, publishArgus } from "./register.ts";
4
+
5
+ const identity = {
6
+ name: "billing",
7
+ public: ["http://192.168.1.10:3000", "http://100.96.0.5:3000"],
8
+ service: ["http://192.168.1.10:3001", "http://100.96.0.5:3001"],
9
+ };
10
+
11
+ describe("argusRegistrations", () => {
12
+ it("registers every reachable address for both surfaces", () => {
13
+ const argus = defineConfig({
14
+ argus: {
15
+ enabled: true,
16
+ url: "https://argus.veducio.com",
17
+ env: "staging",
18
+ username: "argus",
19
+ password: "secret",
20
+ },
21
+ }).argus;
22
+ if (!argus.enabled) {
23
+ throw new Error("missing argus config");
24
+ }
25
+ const registrations = argusRegistrations({
26
+ identity,
27
+ argus,
28
+ version: "0.1.0",
29
+ });
30
+ expect(
31
+ registrations.map((item) => [
32
+ item.service,
33
+ item.base_url,
34
+ item.instance_id,
35
+ ]),
36
+ ).toEqual([
37
+ ["billing", "http://192.168.1.10:3000", "billing-192-168-1-10-3000"],
38
+ ["billing", "http://100.96.0.5:3000", "billing-100-96-0-5-3000"],
39
+ [
40
+ "billing-service",
41
+ "http://192.168.1.10:3001",
42
+ "billing-service-192-168-1-10-3001",
43
+ ],
44
+ [
45
+ "billing-service",
46
+ "http://100.96.0.5:3001",
47
+ "billing-service-100-96-0-5-3001",
48
+ ],
49
+ ]);
50
+ expect(registrations[0]).toMatchObject({
51
+ env: "staging",
52
+ version: "0.1.0",
53
+ ttl_seconds: 30,
54
+ metadata: {
55
+ surface: "public",
56
+ public: identity.public,
57
+ service: identity.service,
58
+ },
59
+ });
60
+ });
61
+
62
+ it("sends a basic-auth heartbeat to the registry", async () => {
63
+ const calls: {
64
+ url: string;
65
+ authorization: string | null;
66
+ body: unknown;
67
+ }[] = [];
68
+ const errors = await publishArgus({
69
+ url: "https://argus.veducio.com/",
70
+ username: "argus",
71
+ password: "secret",
72
+ registrations: [
73
+ {
74
+ instance_id: "billing-192-168-1-10-3000",
75
+ service: "billing",
76
+ env: "staging",
77
+ base_url: "http://192.168.1.10:3000",
78
+ weight: 100,
79
+ status: "healthy",
80
+ ttl_seconds: 30,
81
+ metadata: {
82
+ surface: "public",
83
+ public: identity.public,
84
+ service: identity.service,
85
+ },
86
+ },
87
+ ],
88
+ fetchImpl: async (url, init) => {
89
+ const headers = new Headers(init?.headers);
90
+ calls.push({
91
+ url: String(url),
92
+ authorization: headers.get("authorization"),
93
+ body: JSON.parse(String(init?.body)),
94
+ });
95
+ return new Response(JSON.stringify({ ok: true }), { status: 200 });
96
+ },
97
+ });
98
+ expect(errors).toEqual([]);
99
+ expect(calls).toEqual([
100
+ {
101
+ url: "https://argus.veducio.com/v1/register",
102
+ authorization: `Basic ${Buffer.from("argus:secret").toString("base64")}`,
103
+ body: {
104
+ instance_id: "billing-192-168-1-10-3000",
105
+ service: "billing",
106
+ env: "staging",
107
+ base_url: "http://192.168.1.10:3000",
108
+ weight: 100,
109
+ status: "healthy",
110
+ ttl_seconds: 30,
111
+ metadata: {
112
+ surface: "public",
113
+ public: identity.public,
114
+ service: identity.service,
115
+ },
116
+ },
117
+ },
118
+ ]);
119
+ });
120
+ });
@@ -0,0 +1,145 @@
1
+ import type { ArgusSettings } from "../project/config.ts";
2
+ import type { ServiceIdentity } from "../server/identity.ts";
3
+
4
+ export const argusTtlSeconds = 30;
5
+
6
+ export type ArgusRegistration = {
7
+ instance_id: string;
8
+ service: string;
9
+ env: string;
10
+ base_url: string;
11
+ weight: number;
12
+ status: "healthy";
13
+ version?: string;
14
+ ttl_seconds: number;
15
+ metadata: {
16
+ surface: "public" | "service";
17
+ public: string[];
18
+ service: string[];
19
+ };
20
+ };
21
+
22
+ export function argusRegistrations(input: {
23
+ identity: ServiceIdentity;
24
+ argus: ArgusSettings;
25
+ version?: string;
26
+ }) {
27
+ const publicService = input.argus.public ?? input.identity.name;
28
+ const serviceService =
29
+ input.argus.service ?? `${input.identity.name}-service`;
30
+ return [
31
+ ...registrations(publicService, "public", input.identity.public, input),
32
+ ...registrations(serviceService, "service", input.identity.service, input),
33
+ ];
34
+ }
35
+
36
+ export type ArgusFetch = (
37
+ url: string,
38
+ init?: { method?: string; headers?: Headers; body?: string },
39
+ ) => Promise<Response>;
40
+
41
+ export async function publishArgus(options: {
42
+ url: string;
43
+ username: string | null;
44
+ password: string | null;
45
+ registrations: readonly ArgusRegistration[];
46
+ fetchImpl?: ArgusFetch;
47
+ }) {
48
+ return sendArgus(options, "/v1/register", (registration) => registration);
49
+ }
50
+
51
+ export async function withdrawArgus(options: {
52
+ url: string;
53
+ username: string | null;
54
+ password: string | null;
55
+ registrations: readonly ArgusRegistration[];
56
+ fetchImpl?: ArgusFetch;
57
+ }) {
58
+ return sendArgus(options, "/v1/deregister", (registration) => ({
59
+ instance_id: registration.instance_id,
60
+ service: registration.service,
61
+ env: registration.env,
62
+ }));
63
+ }
64
+
65
+ function registrations(
66
+ service: string,
67
+ surface: "public" | "service",
68
+ urls: readonly string[],
69
+ input: {
70
+ identity: ServiceIdentity;
71
+ argus: ArgusSettings;
72
+ version?: string;
73
+ },
74
+ ): ArgusRegistration[] {
75
+ return urls.map((baseUrl) => ({
76
+ instance_id: instanceId(service, baseUrl),
77
+ service,
78
+ env: input.argus.env,
79
+ base_url: baseUrl,
80
+ weight: 100,
81
+ status: "healthy",
82
+ ...(input.version ? { version: input.version } : {}),
83
+ ttl_seconds: argusTtlSeconds,
84
+ metadata: {
85
+ surface,
86
+ public: input.identity.public,
87
+ service: input.identity.service,
88
+ },
89
+ }));
90
+ }
91
+
92
+ function instanceId(service: string, baseUrl: string) {
93
+ const host = new URL(baseUrl).host
94
+ .replaceAll(/[^A-Za-z0-9]+/g, "-")
95
+ .replace(/^-|-$/g, "");
96
+ return `${service}-${host}`;
97
+ }
98
+
99
+ async function sendArgus(
100
+ options: {
101
+ url: string;
102
+ username: string | null;
103
+ password: string | null;
104
+ registrations: readonly ArgusRegistration[];
105
+ fetchImpl?: ArgusFetch;
106
+ },
107
+ pathname: string,
108
+ body: (registration: ArgusRegistration) => unknown,
109
+ ) {
110
+ const fetchImpl = options.fetchImpl ?? fetch;
111
+ const errors: string[] = [];
112
+ for (const registration of options.registrations) {
113
+ try {
114
+ const response = await fetchImpl(joinUrl(options.url, pathname), {
115
+ method: "POST",
116
+ headers: argusHeaders(options.username, options.password),
117
+ body: JSON.stringify(body(registration)),
118
+ });
119
+ if (!response.ok) {
120
+ errors.push(
121
+ `${registration.service} ${registration.base_url} ${response.status}`,
122
+ );
123
+ }
124
+ } catch (error) {
125
+ const detail = error instanceof Error ? error.message : "request failed";
126
+ errors.push(`${registration.service} ${registration.base_url} ${detail}`);
127
+ }
128
+ }
129
+ return errors;
130
+ }
131
+
132
+ export function argusHeaders(username: string | null, password: string | null) {
133
+ const headers = new Headers({ "content-type": "application/json" });
134
+ if (username !== null && password !== null) {
135
+ headers.set(
136
+ "authorization",
137
+ `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`,
138
+ );
139
+ }
140
+ return headers;
141
+ }
142
+
143
+ export function joinUrl(url: string, pathname: string) {
144
+ return `${url.replace(/\/+$/, "")}${pathname}`;
145
+ }
@@ -0,0 +1,98 @@
1
+ import { mkdtempSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import path from "node:path";
4
+ import { Chalk } from "chalk";
5
+ import { describe, expect, it } from "vitest";
6
+ import {
7
+ formatAddresses,
8
+ formatBanner,
9
+ formatDatabase,
10
+ formatIdentity,
11
+ formatProjectPaths,
12
+ formatProjectReady,
13
+ formatReloading,
14
+ projectLabel,
15
+ readProjectLabel,
16
+ veducioAnnotation,
17
+ } from "./banner.ts";
18
+
19
+ describe("projectLabel", () => {
20
+ it("prints the package name and version", () => {
21
+ expect(projectLabel({ name: "example-app", version: "0.1.0" }, "app")).toBe(
22
+ "example-app 0.1.0",
23
+ );
24
+ expect(projectLabel({}, "app")).toBe("app");
25
+ });
26
+
27
+ it("reads the project package and keeps the Veducio annotation", () => {
28
+ const root = mkdtempSync(path.join(tmpdir(), "keja-"));
29
+ writeFileSync(
30
+ path.join(root, "package.json"),
31
+ JSON.stringify({ name: "@veducio/keja", version: "0.1.0" }),
32
+ );
33
+ expect(readProjectLabel(root)).toBe("@veducio/keja 0.1.0");
34
+ expect(veducioAnnotation).toBe(
35
+ "Veducio Keja is part of Veducio and its Labs program, with a mission to contribute quality software to open source.",
36
+ );
37
+ });
38
+
39
+ it("lays the banner and addresses out like a dev server", () => {
40
+ expect(formatBanner({ name: "example-app", version: "0.1.0" })).toBe(
41
+ `\n example-app 0.1.0\n\n Veducio Keja is part of Veducio and its Labs program, with a mission to\n contribute quality software to open source.\n`,
42
+ );
43
+ expect(
44
+ formatAddresses("http://127.0.0.1:3500", "http://127.0.0.1:3501"),
45
+ ).toBe(
46
+ "\n - Public http://127.0.0.1:3500\n - Service http://127.0.0.1:3501\n",
47
+ );
48
+ expect(
49
+ formatIdentity(
50
+ {
51
+ name: "billing",
52
+ public: ["http://192.168.1.10:3000"],
53
+ service: ["http://192.168.1.10:3001"],
54
+ },
55
+ { url: "https://argus.veducio.com", env: "staging" },
56
+ ),
57
+ ).toBe(
58
+ "\n - Name billing\n - Public http://192.168.1.10:3000\n - Service http://192.168.1.10:3001\n - Argus https://argus.veducio.com (staging)\n",
59
+ );
60
+ expect(formatDatabase(null)).toBe("\n - Database disabled\n");
61
+ expect(
62
+ formatDatabase({
63
+ migrations: "C:/app/migrations",
64
+ applied: ["0001_init.sql"],
65
+ }),
66
+ ).toBe(
67
+ "\n - Database postgres\n - Migrations C:/app/migrations\n - Applied 0001_init.sql\n",
68
+ );
69
+ expect(
70
+ formatProjectPaths({
71
+ config: "C:/app/keja.config.ts",
72
+ versioning: "C:/app/keja.versioning.ts",
73
+ public: "C:/app/src/public",
74
+ service: "C:/app/src/service",
75
+ }),
76
+ ).toBe(
77
+ "\n - Config C:/app/keja.config.ts\n - Versioning C:/app/keja.versioning.ts\n - Public C:/app/src/public\n - Service C:/app/src/service\n",
78
+ );
79
+ expect(
80
+ formatProjectPaths({
81
+ config: null,
82
+ versioning: "C:/app/keja.versioning.ts",
83
+ public: "C:/app/src/public",
84
+ service: "C:/app/src/service",
85
+ }),
86
+ ).toContain("- Config defaults");
87
+ });
88
+
89
+ it("prints reloading and a clean project", () => {
90
+ const style = new Chalk({ level: 1 });
91
+ expect(formatReloading()).toBe("\n reloading...\n");
92
+ expect(formatProjectReady()).toBe("\n no issues found\n");
93
+ expect(formatReloading(true)).toBe(`\n ${style.dim("reloading...")}\n`);
94
+ expect(formatProjectReady(true)).toBe(
95
+ `\n ${style.green("no issues found")}\n`,
96
+ );
97
+ });
98
+ });