@veducio/keja 0.0.0-stage → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +7 -0
- package/README.md +39 -2
- package/examples/starter/.env.example +6 -0
- package/examples/starter/keja.config.ts +7 -0
- package/examples/starter/keja.versioning.ts +5 -0
- package/examples/starter/migrations/0001_init.sql +1 -0
- package/examples/starter/package.json +14 -0
- package/examples/starter/tsconfig.json +9 -0
- package/package.json +62 -4
- package/src/access/cloudflare.test.ts +131 -0
- package/src/access/cloudflare.ts +193 -0
- package/src/argus/call.test.ts +98 -0
- package/src/argus/call.ts +153 -0
- package/src/argus/heartbeat.ts +65 -0
- package/src/argus/register.test.ts +120 -0
- package/src/argus/register.ts +145 -0
- package/src/cli/banner.test.ts +98 -0
- package/src/cli/banner.ts +209 -0
- package/src/cli/commands/build.ts +12 -0
- package/src/cli/commands/dev.ts +12 -0
- package/src/cli/commands/generate.ts +12 -0
- package/src/cli/commands/init.ts +57 -0
- package/src/cli/commands/migrate.ts +12 -0
- package/src/cli/commands/seed.ts +12 -0
- package/src/cli/commands/start.ts +12 -0
- package/src/cli/dev-reload.test.ts +87 -0
- package/src/cli/dev-reload.ts +182 -0
- package/src/cli/generate-project.ts +39 -0
- package/src/cli/index.ts +29 -0
- package/src/cli/init-project.test.ts +312 -0
- package/src/cli/init-project.ts +416 -0
- package/src/cli/migrate-project.ts +31 -0
- package/src/cli/run-project.test.ts +50 -0
- package/src/cli/run-project.ts +93 -0
- package/src/cli/seed-project.ts +46 -0
- package/src/db/client.test.ts +49 -0
- package/src/db/client.ts +61 -0
- package/src/db/migrate.test.ts +141 -0
- package/src/db/migrate.ts +146 -0
- package/src/db/owned-sql.ts +202 -0
- package/src/db/postgres.ts +90 -0
- package/src/env/required-env.test.ts +64 -0
- package/src/env/required-env.ts +120 -0
- package/src/errors/framework-errors.ts +295 -0
- package/src/grant/grant.test.ts +130 -0
- package/src/grant/grant.ts +268 -0
- package/src/http/create-app.test.ts +340 -0
- package/src/http/create-app.ts +196 -0
- package/src/http/openapi.ts +111 -0
- package/src/process/slot.ts +18 -0
- package/src/project/config.test.ts +143 -0
- package/src/project/config.ts +450 -0
- package/src/project/load.test.ts +60 -0
- package/src/project/load.ts +158 -0
- package/src/project/reload-graph.test.ts +52 -0
- package/src/project/reload-graph.ts +175 -0
- package/src/repository/define.ts +123 -0
- package/src/repository/generate.test.ts +58 -0
- package/src/repository/generate.ts +117 -0
- package/src/repository/query.test.ts +99 -0
- package/src/repository/query.ts +287 -0
- package/src/repository/runtime.test.ts +154 -0
- package/src/repository/runtime.ts +434 -0
- package/src/repository/sql.test.ts +78 -0
- package/src/repository/sql.ts +282 -0
- package/src/route/define.test.ts +65 -0
- package/src/route/define.ts +307 -0
- package/src/route/middleware.test.ts +63 -0
- package/src/route/middleware.ts +144 -0
- package/src/seed/apply.test.ts +65 -0
- package/src/seed/apply.ts +11 -0
- package/src/seed/define.test.ts +51 -0
- package/src/seed/define.ts +55 -0
- package/src/seed/load.test.ts +54 -0
- package/src/seed/load.ts +67 -0
- package/src/seed/report.test.ts +16 -0
- package/src/seed/report.ts +15 -0
- package/src/self-test/checks.ts +88 -0
- package/src/self-test/run.test.ts +128 -0
- package/src/self-test/run.ts +105 -0
- package/src/server/identity.test.ts +57 -0
- package/src/server/identity.ts +116 -0
- package/src/server/index.test.ts +15 -0
- package/src/server/index.ts +171 -0
- package/src/server/swap.test.ts +89 -0
- package/src/server/swap.ts +29 -0
- package/src/typeid/id.test.ts +46 -0
- package/src/typeid/id.ts +136 -0
- package/src/typeid/owned.test.ts +73 -0
- package/src/typeid/owned.ts +193 -0
- package/src/vault/client.test.ts +128 -0
- package/src/vault/client.ts +282 -0
- package/src/versioning/define.test.ts +79 -0
- package/src/versioning/define.ts +173 -0
- package/src/versioning/select.test.ts +79 -0
- package/src/versioning/select.ts +62 -0
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import {
|
|
4
|
+
copyFile,
|
|
5
|
+
cp,
|
|
6
|
+
mkdir,
|
|
7
|
+
readdir,
|
|
8
|
+
readFile,
|
|
9
|
+
writeFile,
|
|
10
|
+
} from "node:fs/promises";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { createInterface } from "node:readline/promises";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
|
|
15
|
+
const projectName = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
16
|
+
|
|
17
|
+
export type InitInput = {
|
|
18
|
+
name?: string;
|
|
19
|
+
dir?: string;
|
|
20
|
+
public?: string;
|
|
21
|
+
service?: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type InitAnswers = {
|
|
25
|
+
name: string;
|
|
26
|
+
directory: string;
|
|
27
|
+
publicDir: string;
|
|
28
|
+
serviceDir: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
type Ask = (label: string, fallback: string) => Promise<string>;
|
|
32
|
+
|
|
33
|
+
type Run = (command: string, args: string[], cwd: string) => Promise<number>;
|
|
34
|
+
|
|
35
|
+
const publicHello = `import { defineRoute, response } from "@veducio/keja";
|
|
36
|
+
import { z } from "zod";
|
|
37
|
+
|
|
38
|
+
export default defineRoute({
|
|
39
|
+
method: "get",
|
|
40
|
+
path: "/hello",
|
|
41
|
+
version: "v1",
|
|
42
|
+
responses: {
|
|
43
|
+
Hello: response({
|
|
44
|
+
code: 200,
|
|
45
|
+
json: z.object({ message: z.string() }),
|
|
46
|
+
}),
|
|
47
|
+
},
|
|
48
|
+
handler: ({ respond }) => respond.Hello({ message: "hello" }),
|
|
49
|
+
});
|
|
50
|
+
`;
|
|
51
|
+
|
|
52
|
+
const publicMiddleware = `import { defineMiddleware } from "@veducio/keja";
|
|
53
|
+
|
|
54
|
+
export default defineMiddleware(async ({ next }) => {
|
|
55
|
+
const response = await next();
|
|
56
|
+
response.headers.set("x-keja", "public");
|
|
57
|
+
return response;
|
|
58
|
+
});
|
|
59
|
+
`;
|
|
60
|
+
|
|
61
|
+
const servicePing = `import { defineRoute, response } from "@veducio/keja";
|
|
62
|
+
import { z } from "zod";
|
|
63
|
+
|
|
64
|
+
export default defineRoute({
|
|
65
|
+
method: "post",
|
|
66
|
+
path: "/ping",
|
|
67
|
+
version: "v1",
|
|
68
|
+
responses: {
|
|
69
|
+
Pong: response({
|
|
70
|
+
code: 200,
|
|
71
|
+
json: z.object({ ok: z.literal(true) }),
|
|
72
|
+
}),
|
|
73
|
+
},
|
|
74
|
+
handler: ({ respond }) => respond.Pong({ ok: true }),
|
|
75
|
+
});
|
|
76
|
+
`;
|
|
77
|
+
|
|
78
|
+
export async function resolveInitAnswers(input: InitInput, ask: Ask) {
|
|
79
|
+
const name = await valueOrAsk(
|
|
80
|
+
input.name,
|
|
81
|
+
"Name of the project",
|
|
82
|
+
"my-app",
|
|
83
|
+
ask,
|
|
84
|
+
);
|
|
85
|
+
if (!projectName.test(name)) {
|
|
86
|
+
throw new Error(
|
|
87
|
+
"Project name must use letters, numbers, dots, hyphens, or underscores.",
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
const directory = await valueOrAsk(
|
|
91
|
+
input.dir,
|
|
92
|
+
"Where you want to create the project",
|
|
93
|
+
`./${name}`,
|
|
94
|
+
ask,
|
|
95
|
+
);
|
|
96
|
+
if (directory.trim() === "") {
|
|
97
|
+
throw new Error("Project directory is required.");
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
name,
|
|
101
|
+
directory: directory.trim(),
|
|
102
|
+
publicDir: routeFolder(
|
|
103
|
+
await valueOrAsk(
|
|
104
|
+
input.public,
|
|
105
|
+
"What folder should the public server be",
|
|
106
|
+
"./src/public",
|
|
107
|
+
ask,
|
|
108
|
+
),
|
|
109
|
+
"Public server folder",
|
|
110
|
+
),
|
|
111
|
+
serviceDir: routeFolder(
|
|
112
|
+
await valueOrAsk(
|
|
113
|
+
input.service,
|
|
114
|
+
"What folder should the service server be",
|
|
115
|
+
"./src/service",
|
|
116
|
+
ask,
|
|
117
|
+
),
|
|
118
|
+
"Service server folder",
|
|
119
|
+
),
|
|
120
|
+
} satisfies InitAnswers;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function createProject(options: {
|
|
124
|
+
answers: InitAnswers;
|
|
125
|
+
cwd: string;
|
|
126
|
+
templateDir: string;
|
|
127
|
+
packageRoot: string;
|
|
128
|
+
run: Run;
|
|
129
|
+
}) {
|
|
130
|
+
await requireBun(options.run, options.cwd);
|
|
131
|
+
await requireGit(options.run, options.cwd);
|
|
132
|
+
const destination = path.resolve(options.cwd, options.answers.directory);
|
|
133
|
+
if (existsSync(destination)) {
|
|
134
|
+
const entries = await readdir(destination);
|
|
135
|
+
if (entries.length > 0) {
|
|
136
|
+
throw new Error(`${destination} already exists and is not empty.`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
await cp(options.templateDir, destination, {
|
|
140
|
+
recursive: true,
|
|
141
|
+
filter(source) {
|
|
142
|
+
const base = path.basename(source);
|
|
143
|
+
return base !== "node_modules" && base !== ".git";
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
const envFile = path.join(destination, ".env");
|
|
147
|
+
const envExample = path.join(destination, ".env.example");
|
|
148
|
+
if (!existsSync(envFile) && existsSync(envExample)) {
|
|
149
|
+
await copyFile(envExample, envFile);
|
|
150
|
+
}
|
|
151
|
+
const keja = await readKejaPackage(options.packageRoot);
|
|
152
|
+
await writeFile(
|
|
153
|
+
path.join(destination, "package.json"),
|
|
154
|
+
`${JSON.stringify(
|
|
155
|
+
{
|
|
156
|
+
name: options.answers.name,
|
|
157
|
+
version: "0.1.0",
|
|
158
|
+
private: true,
|
|
159
|
+
type: "module",
|
|
160
|
+
scripts: {
|
|
161
|
+
dev: "keja dev",
|
|
162
|
+
start: "keja start",
|
|
163
|
+
},
|
|
164
|
+
dependencies: {
|
|
165
|
+
[keja.name]: `^${keja.version}`,
|
|
166
|
+
zod: "^4.6.7",
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
null,
|
|
170
|
+
2,
|
|
171
|
+
)}\n`,
|
|
172
|
+
);
|
|
173
|
+
await writeFile(
|
|
174
|
+
path.join(destination, "keja.config.ts"),
|
|
175
|
+
sourceFor(
|
|
176
|
+
keja.name,
|
|
177
|
+
configSource(options.answers.publicDir, options.answers.serviceDir),
|
|
178
|
+
),
|
|
179
|
+
);
|
|
180
|
+
await writeFile(
|
|
181
|
+
path.join(destination, "tsconfig.json"),
|
|
182
|
+
`${JSON.stringify(
|
|
183
|
+
{
|
|
184
|
+
compilerOptions: {
|
|
185
|
+
target: "ES2023",
|
|
186
|
+
lib: ["ES2023"],
|
|
187
|
+
module: "ESNext",
|
|
188
|
+
moduleResolution: "bundler",
|
|
189
|
+
strict: true,
|
|
190
|
+
noEmit: true,
|
|
191
|
+
skipLibCheck: true,
|
|
192
|
+
isolatedModules: true,
|
|
193
|
+
forceConsistentCasingInFileNames: true,
|
|
194
|
+
allowImportingTsExtensions: true,
|
|
195
|
+
},
|
|
196
|
+
include: ["**/*.ts"],
|
|
197
|
+
},
|
|
198
|
+
null,
|
|
199
|
+
2,
|
|
200
|
+
)}\n`,
|
|
201
|
+
);
|
|
202
|
+
if (usesGitHubPackages(keja)) {
|
|
203
|
+
await writeFile(
|
|
204
|
+
path.join(destination, ".npmrc"),
|
|
205
|
+
"@veducio:registry=https://npm.pkg.github.com\n",
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
await writeRoute(
|
|
209
|
+
destination,
|
|
210
|
+
options.answers.publicDir,
|
|
211
|
+
"_middleware.ts",
|
|
212
|
+
sourceFor(keja.name, publicMiddleware),
|
|
213
|
+
);
|
|
214
|
+
await writeRoute(
|
|
215
|
+
destination,
|
|
216
|
+
options.answers.publicDir,
|
|
217
|
+
"hello.ts",
|
|
218
|
+
sourceFor(keja.name, publicHello),
|
|
219
|
+
);
|
|
220
|
+
await writeRoute(
|
|
221
|
+
destination,
|
|
222
|
+
options.answers.serviceDir,
|
|
223
|
+
"ping.ts",
|
|
224
|
+
sourceFor(keja.name, servicePing),
|
|
225
|
+
);
|
|
226
|
+
const installCode = await options.run("bun", ["install"], destination);
|
|
227
|
+
if (installCode !== 0) {
|
|
228
|
+
throw new Error(`bun install failed in ${destination}`);
|
|
229
|
+
}
|
|
230
|
+
const initCode = await options.run("git", ["init"], destination);
|
|
231
|
+
if (initCode !== 0) {
|
|
232
|
+
throw new Error(`git init failed in ${destination}`);
|
|
233
|
+
}
|
|
234
|
+
return destination;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export async function runInit(options: {
|
|
238
|
+
input: InitInput;
|
|
239
|
+
cwd: string;
|
|
240
|
+
templateDir: string;
|
|
241
|
+
packageRoot: string;
|
|
242
|
+
interactive: boolean;
|
|
243
|
+
ask: Ask;
|
|
244
|
+
run: Run;
|
|
245
|
+
log: (message: string) => void;
|
|
246
|
+
}) {
|
|
247
|
+
const answers = await resolveInitAnswers(
|
|
248
|
+
options.input,
|
|
249
|
+
options.interactive ? options.ask : async (_label, fallback) => fallback,
|
|
250
|
+
);
|
|
251
|
+
const directory = await createProject({
|
|
252
|
+
answers,
|
|
253
|
+
cwd: options.cwd,
|
|
254
|
+
templateDir: options.templateDir,
|
|
255
|
+
packageRoot: options.packageRoot,
|
|
256
|
+
run: options.run,
|
|
257
|
+
});
|
|
258
|
+
options.log(`\n Created ${answers.name} at ${directory}\n`);
|
|
259
|
+
return directory;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function starterPaths(moduleUrl = import.meta.url) {
|
|
263
|
+
return {
|
|
264
|
+
templateDir: fileUrl(new URL("../../examples/starter", moduleUrl)),
|
|
265
|
+
packageRoot: fileUrl(new URL("../..", moduleUrl)),
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export async function askOnTerminal(label: string, fallback: string) {
|
|
270
|
+
const rl = createInterface({
|
|
271
|
+
input: process.stdin,
|
|
272
|
+
output: process.stdout,
|
|
273
|
+
});
|
|
274
|
+
try {
|
|
275
|
+
return await rl.question(` ${label} (${fallback}): `);
|
|
276
|
+
} finally {
|
|
277
|
+
rl.close();
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export function runCommand(command: string, args: string[], cwd: string) {
|
|
282
|
+
const visible = command === "bun" && args[0] === "install";
|
|
283
|
+
return new Promise<number>((resolve, reject) => {
|
|
284
|
+
const child = spawn(command, args, {
|
|
285
|
+
cwd,
|
|
286
|
+
stdio: visible ? "inherit" : "ignore",
|
|
287
|
+
windowsHide: true,
|
|
288
|
+
});
|
|
289
|
+
child.on("error", reject);
|
|
290
|
+
child.on("close", (code) => resolve(code ?? 1));
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async function requireBun(run: Run, cwd: string) {
|
|
295
|
+
try {
|
|
296
|
+
const code = await run("bun", ["--version"], cwd);
|
|
297
|
+
if (code !== 0) {
|
|
298
|
+
throw new Error("Bun is required to create a project.");
|
|
299
|
+
}
|
|
300
|
+
} catch (error) {
|
|
301
|
+
if (
|
|
302
|
+
error instanceof Error &&
|
|
303
|
+
error.message === "Bun is required to create a project."
|
|
304
|
+
) {
|
|
305
|
+
throw error;
|
|
306
|
+
}
|
|
307
|
+
throw new Error("Bun is required to create a project.");
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async function requireGit(run: Run, cwd: string) {
|
|
312
|
+
try {
|
|
313
|
+
const code = await run("git", ["--version"], cwd);
|
|
314
|
+
if (code !== 0) {
|
|
315
|
+
throw new Error("Git is required to create a project.");
|
|
316
|
+
}
|
|
317
|
+
} catch (error) {
|
|
318
|
+
if (
|
|
319
|
+
error instanceof Error &&
|
|
320
|
+
error.message === "Git is required to create a project."
|
|
321
|
+
) {
|
|
322
|
+
throw error;
|
|
323
|
+
}
|
|
324
|
+
throw new Error("Git is required to create a project.");
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function valueOrAsk(
|
|
329
|
+
given: string | undefined,
|
|
330
|
+
label: string,
|
|
331
|
+
fallback: string,
|
|
332
|
+
ask: Ask,
|
|
333
|
+
) {
|
|
334
|
+
if (typeof given === "string" && given.trim() !== "") {
|
|
335
|
+
return given.trim();
|
|
336
|
+
}
|
|
337
|
+
const answered = (await ask(label, fallback)).trim();
|
|
338
|
+
return answered === "" ? fallback : answered;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function routeFolder(value: string, label: string) {
|
|
342
|
+
const cleaned = value
|
|
343
|
+
.trim()
|
|
344
|
+
.replaceAll("\\", "/")
|
|
345
|
+
.replace(/^\.\//, "")
|
|
346
|
+
.replace(/\/+$/, "");
|
|
347
|
+
const parts = cleaned.split("/");
|
|
348
|
+
if (
|
|
349
|
+
cleaned === "" ||
|
|
350
|
+
cleaned === "." ||
|
|
351
|
+
path.isAbsolute(value.trim()) ||
|
|
352
|
+
/^[A-Za-z]:/.test(value.trim()) ||
|
|
353
|
+
parts.some((part) => part.length === 0 || part === "..")
|
|
354
|
+
) {
|
|
355
|
+
throw new Error(`${label} must be a path inside the project.`);
|
|
356
|
+
}
|
|
357
|
+
return cleaned;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async function readKejaPackage(packageRoot: string) {
|
|
361
|
+
const source = await readFile(path.join(packageRoot, "package.json"), "utf8");
|
|
362
|
+
const parsed = JSON.parse(source) as {
|
|
363
|
+
version?: unknown;
|
|
364
|
+
name?: unknown;
|
|
365
|
+
publishConfig?: { registry?: unknown };
|
|
366
|
+
};
|
|
367
|
+
if (typeof parsed.version !== "string" || parsed.version.trim() === "") {
|
|
368
|
+
throw new Error("Keja package.json is missing a version.");
|
|
369
|
+
}
|
|
370
|
+
const name =
|
|
371
|
+
typeof parsed.name === "string" && parsed.name.trim() !== ""
|
|
372
|
+
? parsed.name.trim()
|
|
373
|
+
: "@veducio/keja";
|
|
374
|
+
const registry =
|
|
375
|
+
typeof parsed.publishConfig?.registry === "string"
|
|
376
|
+
? parsed.publishConfig.registry
|
|
377
|
+
: "";
|
|
378
|
+
return { version: parsed.version.trim(), name, registry };
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function usesGitHubPackages(keja: { name: string; registry: string }) {
|
|
382
|
+
return (
|
|
383
|
+
keja.name.startsWith("@veducio/") &&
|
|
384
|
+
!keja.registry.includes("registry.npmjs.org")
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function sourceFor(name: string, source: string) {
|
|
389
|
+
return source.replaceAll("@veducio/keja", name);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function configSource(publicDir: string, serviceDir: string) {
|
|
393
|
+
return `import { defineConfig } from "@veducio/keja";
|
|
394
|
+
|
|
395
|
+
export default defineConfig({
|
|
396
|
+
public_path: ${JSON.stringify(publicDir)},
|
|
397
|
+
service_path: ${JSON.stringify(serviceDir)},
|
|
398
|
+
db: { enabled: true },
|
|
399
|
+
});
|
|
400
|
+
`;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
async function writeRoute(
|
|
404
|
+
destination: string,
|
|
405
|
+
folder: string,
|
|
406
|
+
file: string,
|
|
407
|
+
source: string,
|
|
408
|
+
) {
|
|
409
|
+
const directory = path.join(destination, ...folder.split("/"));
|
|
410
|
+
await mkdir(directory, { recursive: true });
|
|
411
|
+
await writeFile(path.join(directory, file), source);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function fileUrl(url: URL) {
|
|
415
|
+
return fileURLToPath(url);
|
|
416
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { migrateDatabase } from "../db/postgres.ts";
|
|
2
|
+
import { DependencyNotMet } from "../errors/framework-errors.ts";
|
|
3
|
+
import { loadConfig } from "../project/config.ts";
|
|
4
|
+
import { printDatabase } from "./banner.ts";
|
|
5
|
+
import { formatProjectFailure, readProjectFailure } from "./run-project.ts";
|
|
6
|
+
|
|
7
|
+
export async function runMigrate(root = process.cwd()) {
|
|
8
|
+
try {
|
|
9
|
+
const project = await loadConfig(root);
|
|
10
|
+
if (!project.database.enabled) {
|
|
11
|
+
throw new DependencyNotMet("migrate", "db");
|
|
12
|
+
}
|
|
13
|
+
printDatabase(
|
|
14
|
+
await migrateDatabase(
|
|
15
|
+
project.database.migrations,
|
|
16
|
+
Bun.env.DATABASE_URL ?? "",
|
|
17
|
+
),
|
|
18
|
+
);
|
|
19
|
+
} catch (error) {
|
|
20
|
+
console.error(
|
|
21
|
+
formatProjectFailure(readProjectFailure(error), {
|
|
22
|
+
wait: false,
|
|
23
|
+
color: process.stderr.isTTY === true,
|
|
24
|
+
}),
|
|
25
|
+
);
|
|
26
|
+
process.exitCode = 1;
|
|
27
|
+
}
|
|
28
|
+
if (process.exitCode === 1) {
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { Chalk } from "chalk";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { projectError } from "../errors/framework-errors.ts";
|
|
4
|
+
import { formatProjectFailure, readProjectFailure } from "./run-project.ts";
|
|
5
|
+
|
|
6
|
+
const unknownVersion = readProjectFailure(
|
|
7
|
+
projectError("unknownRouteVersion", "GET /posts uses unknown version v12"),
|
|
8
|
+
);
|
|
9
|
+
|
|
10
|
+
describe("formatProjectFailure", () => {
|
|
11
|
+
it("prints the scope, code, and name, then waits in dev", () => {
|
|
12
|
+
expect(formatProjectFailure(unknownVersion, { wait: true })).toBe(
|
|
13
|
+
[
|
|
14
|
+
"",
|
|
15
|
+
" ERROR Project 2001 UnknownRouteVersion",
|
|
16
|
+
" GET /posts uses unknown version v12",
|
|
17
|
+
" A route uses a version this project does not define.",
|
|
18
|
+
" Add the version to keja.versioning.ts, or change the route version.",
|
|
19
|
+
"",
|
|
20
|
+
" waiting for changes",
|
|
21
|
+
"",
|
|
22
|
+
].join("\n"),
|
|
23
|
+
);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("prints the project error without waiting for start", () => {
|
|
27
|
+
expect(formatProjectFailure(unknownVersion, { wait: false })).toBe(
|
|
28
|
+
[
|
|
29
|
+
"",
|
|
30
|
+
" ERROR Project 2001 UnknownRouteVersion",
|
|
31
|
+
" GET /posts uses unknown version v12",
|
|
32
|
+
" A route uses a version this project does not define.",
|
|
33
|
+
" Add the version to keja.versioning.ts, or change the route version.",
|
|
34
|
+
"",
|
|
35
|
+
].join("\n"),
|
|
36
|
+
);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("colors the badge, scope, code, and name", () => {
|
|
40
|
+
const style = new Chalk({ level: 1 });
|
|
41
|
+
const printed = formatProjectFailure(unknownVersion, {
|
|
42
|
+
wait: true,
|
|
43
|
+
color: true,
|
|
44
|
+
});
|
|
45
|
+
expect(printed).toContain(style.bgRed.white.bold(" ERROR "));
|
|
46
|
+
expect(printed).toContain(style.bold("Project"));
|
|
47
|
+
expect(printed).toContain(style.dim("2001"));
|
|
48
|
+
expect(printed).toContain(style.bold("UnknownRouteVersion"));
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { Chalk } from "chalk";
|
|
2
|
+
import { ProjectError } from "../errors/framework-errors.ts";
|
|
3
|
+
import { listen } from "../server/index.ts";
|
|
4
|
+
import {
|
|
5
|
+
printAddresses,
|
|
6
|
+
printDatabase,
|
|
7
|
+
printIdentity,
|
|
8
|
+
printProjectPaths,
|
|
9
|
+
printProjectReady,
|
|
10
|
+
} from "./banner.ts";
|
|
11
|
+
|
|
12
|
+
export type ProjectFailure = {
|
|
13
|
+
scope: string;
|
|
14
|
+
code?: number;
|
|
15
|
+
name?: string;
|
|
16
|
+
detail: string;
|
|
17
|
+
description?: string;
|
|
18
|
+
tip?: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function printServers(servers: Awaited<ReturnType<typeof listen>>) {
|
|
22
|
+
printProjectPaths(servers.paths);
|
|
23
|
+
printIdentity(
|
|
24
|
+
servers.identity,
|
|
25
|
+
servers.argus.enabled ? servers.argus : null,
|
|
26
|
+
servers.vault.enabled
|
|
27
|
+
? { address: servers.vault.address, mount: servers.vault.mount }
|
|
28
|
+
: null,
|
|
29
|
+
);
|
|
30
|
+
printDatabase(servers.database);
|
|
31
|
+
printAddresses(
|
|
32
|
+
`http://${servers.publicServer.hostname}:${servers.publicServer.port}`,
|
|
33
|
+
`http://${servers.serviceServer.hostname}:${servers.serviceServer.port}`,
|
|
34
|
+
);
|
|
35
|
+
printProjectReady();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function runProject(options?: { wait?: boolean }) {
|
|
39
|
+
try {
|
|
40
|
+
printServers(await listen());
|
|
41
|
+
} catch (error) {
|
|
42
|
+
console.error(
|
|
43
|
+
formatProjectFailure(readProjectFailure(error), {
|
|
44
|
+
wait: options?.wait === true,
|
|
45
|
+
color: process.stderr.isTTY === true,
|
|
46
|
+
}),
|
|
47
|
+
);
|
|
48
|
+
if (options?.wait) {
|
|
49
|
+
await new Promise(() => {});
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function readProjectFailure(error: unknown): ProjectFailure {
|
|
57
|
+
if (error instanceof ProjectError) {
|
|
58
|
+
return {
|
|
59
|
+
scope: error.scope,
|
|
60
|
+
code: error.code,
|
|
61
|
+
name: error.errorName,
|
|
62
|
+
detail: error.message,
|
|
63
|
+
description: error.description,
|
|
64
|
+
tip: error.tip,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
scope: "Project",
|
|
69
|
+
detail: error instanceof Error ? error.message : "Failed to start",
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function formatProjectFailure(
|
|
74
|
+
failure: ProjectFailure,
|
|
75
|
+
options: { wait: boolean; color?: boolean },
|
|
76
|
+
) {
|
|
77
|
+
const style = new Chalk({ level: options.color ? 1 : 0 });
|
|
78
|
+
const badge = style.bgRed.white.bold(" ERROR ");
|
|
79
|
+
const identity =
|
|
80
|
+
failure.code !== undefined && failure.name
|
|
81
|
+
? ` ${style.dim(String(failure.code))} ${style.bold(failure.name)}`
|
|
82
|
+
: "";
|
|
83
|
+
const details = [failure.detail, failure.description, failure.tip]
|
|
84
|
+
.filter((line) => line !== undefined)
|
|
85
|
+
.join("\n")
|
|
86
|
+
.split("\n")
|
|
87
|
+
.map((line) => ` ${line}`)
|
|
88
|
+
.join("\n");
|
|
89
|
+
const waiting = options.wait
|
|
90
|
+
? `\n\n ${style.dim("waiting for changes")}\n`
|
|
91
|
+
: "\n";
|
|
92
|
+
return `\n ${badge} ${style.bold(failure.scope)}${identity}\n${details}${waiting}`;
|
|
93
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { DBCall, setDatabase } from "../db/client.ts";
|
|
3
|
+
import { connectDatabase } from "../db/postgres.ts";
|
|
4
|
+
import { DependencyNotMet } from "../errors/framework-errors.ts";
|
|
5
|
+
import { loadConfig } from "../project/config.ts";
|
|
6
|
+
import { applySeeder } from "../seed/apply.ts";
|
|
7
|
+
import { loadSeeders } from "../seed/load.ts";
|
|
8
|
+
import { formatSeed } from "../seed/report.ts";
|
|
9
|
+
import { formatProjectFailure, readProjectFailure } from "./run-project.ts";
|
|
10
|
+
|
|
11
|
+
export async function runSeed(root = process.cwd()) {
|
|
12
|
+
let opened = false;
|
|
13
|
+
try {
|
|
14
|
+
const project = await loadConfig(root);
|
|
15
|
+
if (!project.database.enabled) {
|
|
16
|
+
throw new DependencyNotMet("seed", "db");
|
|
17
|
+
}
|
|
18
|
+
await connectDatabase(Bun.env.DATABASE_URL ?? "");
|
|
19
|
+
opened = true;
|
|
20
|
+
const seeders = await loadSeeders(path.join(root, "seed"));
|
|
21
|
+
const results = [];
|
|
22
|
+
for (const seeder of seeders) {
|
|
23
|
+
results.push({
|
|
24
|
+
table: seeder.table,
|
|
25
|
+
inserted: await applySeeder(seeder),
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
console.log(formatSeed(results, process.stdout.isTTY === true));
|
|
29
|
+
} catch (error) {
|
|
30
|
+
console.error(
|
|
31
|
+
formatProjectFailure(readProjectFailure(error), {
|
|
32
|
+
wait: false,
|
|
33
|
+
color: process.stderr.isTTY === true,
|
|
34
|
+
}),
|
|
35
|
+
);
|
|
36
|
+
process.exitCode = 1;
|
|
37
|
+
} finally {
|
|
38
|
+
if (opened) {
|
|
39
|
+
await DBCall.close();
|
|
40
|
+
setDatabase(null);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (process.exitCode === 1) {
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { DBCall, setDatabase } from "./client.ts";
|
|
3
|
+
|
|
4
|
+
describe("DBCall", () => {
|
|
5
|
+
it("forwards queries to the connected client", async () => {
|
|
6
|
+
const calls: unknown[][] = [];
|
|
7
|
+
const client = Object.assign(
|
|
8
|
+
(...args: unknown[]) => {
|
|
9
|
+
calls.push(args);
|
|
10
|
+
return ["ok"];
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
close() {
|
|
14
|
+
return "closed";
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
);
|
|
18
|
+
setDatabase(client as never);
|
|
19
|
+
try {
|
|
20
|
+
expect(await DBCall`select 1`).toEqual(["ok"]);
|
|
21
|
+
expect(calls).toHaveLength(1);
|
|
22
|
+
expect(DBCall.close()).toBe("closed");
|
|
23
|
+
} finally {
|
|
24
|
+
setDatabase(null);
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("rejects an insert that is not an owned object", async () => {
|
|
29
|
+
const calls: unknown[][] = [];
|
|
30
|
+
setDatabase(((...args: unknown[]) => {
|
|
31
|
+
calls.push(args);
|
|
32
|
+
return [];
|
|
33
|
+
}) as never);
|
|
34
|
+
try {
|
|
35
|
+
expect(() => DBCall`insert into notes ${{ message: "hi" }}`).toThrow(
|
|
36
|
+
"OwnedObjects _id must be a typeid",
|
|
37
|
+
);
|
|
38
|
+
expect(calls).toHaveLength(0);
|
|
39
|
+
expect(await DBCall`select * from notes`).toEqual([]);
|
|
40
|
+
} finally {
|
|
41
|
+
setDatabase(null);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("fails when the database is disabled", () => {
|
|
46
|
+
setDatabase(null);
|
|
47
|
+
expect(() => DBCall`select 1`).toThrow("DBCall requires db");
|
|
48
|
+
});
|
|
49
|
+
});
|