@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.
- 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/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 +140 -0
- package/src/project/config.ts +413 -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 +169 -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 +122 -0
- package/src/vault/client.ts +191 -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
package/src/db/client.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { SQL } from "bun";
|
|
2
|
+
import { DependencyNotMet } from "../errors/framework-errors.ts";
|
|
3
|
+
import { processValue } from "../process/slot.ts";
|
|
4
|
+
import { assertOwnedCall } from "../typeid/owned.ts";
|
|
5
|
+
|
|
6
|
+
const state = processValue("database", () => ({
|
|
7
|
+
client: null as SQL | null,
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
function connected() {
|
|
11
|
+
if (!state.client) {
|
|
12
|
+
throw new DependencyNotMet("DBCall", "db");
|
|
13
|
+
}
|
|
14
|
+
return state.client;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const DBCall = new Proxy(function DBCall() {}, {
|
|
18
|
+
apply(_target, _thisArg, args) {
|
|
19
|
+
const client = connected();
|
|
20
|
+
const text = templateText(args[0]);
|
|
21
|
+
if (text !== null) {
|
|
22
|
+
assertOwnedCall(text, args.slice(1));
|
|
23
|
+
}
|
|
24
|
+
return Reflect.apply(
|
|
25
|
+
client as unknown as (...values: unknown[]) => unknown,
|
|
26
|
+
client,
|
|
27
|
+
args,
|
|
28
|
+
);
|
|
29
|
+
},
|
|
30
|
+
get(_target, property) {
|
|
31
|
+
const client = connected();
|
|
32
|
+
const value = Reflect.get(client, property, client);
|
|
33
|
+
if (property === "unsafe" && typeof value === "function") {
|
|
34
|
+
return (statement: unknown, ...rest: unknown[]) => {
|
|
35
|
+
if (typeof statement === "string") {
|
|
36
|
+
assertOwnedCall(statement, []);
|
|
37
|
+
}
|
|
38
|
+
return value.call(client, statement, ...rest);
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
return typeof value === "function" ? value.bind(client) : value;
|
|
42
|
+
},
|
|
43
|
+
}) as unknown as SQL;
|
|
44
|
+
|
|
45
|
+
export function setDatabase(client: SQL | null) {
|
|
46
|
+
state.client = client;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function templateText(value: unknown) {
|
|
50
|
+
if (!value || typeof value !== "object") {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
const strings = Array.from(value as ArrayLike<unknown>);
|
|
54
|
+
if (
|
|
55
|
+
strings.length === 0 ||
|
|
56
|
+
strings.some((part) => typeof part !== "string")
|
|
57
|
+
) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
return strings.join(" ");
|
|
61
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
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 {
|
|
6
|
+
applyMigrations,
|
|
7
|
+
loadMigrations,
|
|
8
|
+
type MigrationClient,
|
|
9
|
+
pendingMigrations,
|
|
10
|
+
} from "./migrate.ts";
|
|
11
|
+
|
|
12
|
+
describe("loadMigrations", () => {
|
|
13
|
+
it("reads numbered sql files in order", async () => {
|
|
14
|
+
const directory = mkdtempSync(path.join(tmpdir(), "keja-migrations-"));
|
|
15
|
+
writeFileSync(path.join(directory, "0002_jobs.sql"), "select 2;\n");
|
|
16
|
+
writeFileSync(path.join(directory, "0001_init.sql"), "select 1;\n");
|
|
17
|
+
const files = await loadMigrations(directory);
|
|
18
|
+
expect(files.map((file) => file.file)).toEqual([
|
|
19
|
+
"0001_init.sql",
|
|
20
|
+
"0002_jobs.sql",
|
|
21
|
+
]);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("rejects a file that is not 0001_name.sql", async () => {
|
|
25
|
+
const directory = mkdtempSync(path.join(tmpdir(), "keja-migrations-"));
|
|
26
|
+
writeFileSync(path.join(directory, "init.sql"), "select 1;\n");
|
|
27
|
+
await expect(loadMigrations(directory)).rejects.toThrow(
|
|
28
|
+
"init.sql must use the form 0001_name.sql",
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("rejects two files with the same number", async () => {
|
|
33
|
+
const directory = mkdtempSync(path.join(tmpdir(), "keja-migrations-"));
|
|
34
|
+
mkdirSync(directory, { recursive: true });
|
|
35
|
+
writeFileSync(path.join(directory, "0001_init.sql"), "select 1;\n");
|
|
36
|
+
writeFileSync(path.join(directory, "0001_again.sql"), "select 1;\n");
|
|
37
|
+
await expect(loadMigrations(directory)).rejects.toThrow(
|
|
38
|
+
"0001_again.sql and 0001_init.sql use migration number 0001",
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("pendingMigrations", () => {
|
|
44
|
+
it("skips an applied file and refuses a changed one", async () => {
|
|
45
|
+
const directory = mkdtempSync(path.join(tmpdir(), "keja-migrations-"));
|
|
46
|
+
writeFileSync(path.join(directory, "0001_init.sql"), "select 1;\n");
|
|
47
|
+
const [file] = await loadMigrations(directory);
|
|
48
|
+
if (!file) {
|
|
49
|
+
throw new Error("missing migration");
|
|
50
|
+
}
|
|
51
|
+
expect(pendingMigrations([file], [])).toEqual([file]);
|
|
52
|
+
expect(
|
|
53
|
+
pendingMigrations([file], [{ id: file.file, checksum: file.checksum }]),
|
|
54
|
+
).toEqual([]);
|
|
55
|
+
expect(() =>
|
|
56
|
+
pendingMigrations([file], [{ id: file.file, checksum: "stale" }]),
|
|
57
|
+
).toThrow("0001_init.sql changed after it was applied");
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("applyMigrations", () => {
|
|
62
|
+
it("runs only migrations that are not recorded", async () => {
|
|
63
|
+
const ran: string[] = [];
|
|
64
|
+
const applied = [{ id: "0001_init.sql", checksum: "same" }];
|
|
65
|
+
const client: MigrationClient = {
|
|
66
|
+
async unsafe(sql) {
|
|
67
|
+
ran.push(sql);
|
|
68
|
+
},
|
|
69
|
+
async query() {
|
|
70
|
+
return applied;
|
|
71
|
+
},
|
|
72
|
+
async begin(fn) {
|
|
73
|
+
return fn({
|
|
74
|
+
async unsafe(sql) {
|
|
75
|
+
ran.push(sql);
|
|
76
|
+
},
|
|
77
|
+
async query() {
|
|
78
|
+
return [];
|
|
79
|
+
},
|
|
80
|
+
async begin(inner) {
|
|
81
|
+
return inner(client);
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
const directory = mkdtempSync(path.join(tmpdir(), "keja-migrations-"));
|
|
87
|
+
writeFileSync(path.join(directory, "0001_init.sql"), "select 1;\n");
|
|
88
|
+
writeFileSync(path.join(directory, "0002_jobs.sql"), "select 2;\n");
|
|
89
|
+
const files = await loadMigrations(directory);
|
|
90
|
+
const first = files[0];
|
|
91
|
+
if (!first) {
|
|
92
|
+
throw new Error("missing migration");
|
|
93
|
+
}
|
|
94
|
+
applied[0] = { id: first.file, checksum: first.checksum };
|
|
95
|
+
await expect(applyMigrations(files, client)).resolves.toEqual([
|
|
96
|
+
"0002_jobs.sql",
|
|
97
|
+
]);
|
|
98
|
+
expect(ran.some((sql) => sql.includes("select 2;"))).toBe(true);
|
|
99
|
+
expect(ran.some((sql) => sql.includes("select 1;"))).toBe(false);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("rolls back a migration that creates a table without an owned object", async () => {
|
|
103
|
+
const ran: string[] = [];
|
|
104
|
+
const client: MigrationClient = {
|
|
105
|
+
async unsafe(sql) {
|
|
106
|
+
ran.push(sql);
|
|
107
|
+
},
|
|
108
|
+
async query() {
|
|
109
|
+
return [];
|
|
110
|
+
},
|
|
111
|
+
async begin(fn) {
|
|
112
|
+
return fn({
|
|
113
|
+
async unsafe(sql) {
|
|
114
|
+
ran.push(sql);
|
|
115
|
+
if (sql.includes("keja_owned_object_violations()")) {
|
|
116
|
+
return [{ table_name: "notes", detail: "missing _id" }];
|
|
117
|
+
}
|
|
118
|
+
return [];
|
|
119
|
+
},
|
|
120
|
+
async query() {
|
|
121
|
+
return [];
|
|
122
|
+
},
|
|
123
|
+
async begin(inner) {
|
|
124
|
+
return inner(client);
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
const directory = mkdtempSync(path.join(tmpdir(), "keja-migrations-"));
|
|
130
|
+
writeFileSync(
|
|
131
|
+
path.join(directory, "0001_init.sql"),
|
|
132
|
+
"create table notes (message text);\n",
|
|
133
|
+
);
|
|
134
|
+
await expect(
|
|
135
|
+
applyMigrations(await loadMigrations(directory), client),
|
|
136
|
+
).rejects.toThrow("notes missing _id");
|
|
137
|
+
expect(ran.some((sql) => sql.includes("insert into keja_migrations"))).toBe(
|
|
138
|
+
false,
|
|
139
|
+
);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { ProjectError, projectError } from "../errors/framework-errors.ts";
|
|
6
|
+
import { ownedObjectSql, ownedObjectViolations } from "./owned-sql.ts";
|
|
7
|
+
|
|
8
|
+
const migrationFile = /^(\d{4,})_([A-Za-z0-9][A-Za-z0-9_-]*)\.sql$/;
|
|
9
|
+
|
|
10
|
+
export type MigrationFile = {
|
|
11
|
+
id: string;
|
|
12
|
+
number: number;
|
|
13
|
+
file: string;
|
|
14
|
+
sql: string;
|
|
15
|
+
checksum: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type AppliedMigration = {
|
|
19
|
+
id: string;
|
|
20
|
+
checksum: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type MigrationClient = {
|
|
24
|
+
unsafe(sql: string): Promise<unknown>;
|
|
25
|
+
query(sql: string): Promise<AppliedMigration[]>;
|
|
26
|
+
begin<T>(fn: (tx: MigrationClient) => Promise<T>): Promise<T>;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export async function loadMigrations(directory: string) {
|
|
30
|
+
if (!existsSync(directory)) {
|
|
31
|
+
throw projectError("missingMigrations", `Missing ${directory}`);
|
|
32
|
+
}
|
|
33
|
+
const names = await readdir(directory);
|
|
34
|
+
const files: MigrationFile[] = [];
|
|
35
|
+
for (const name of names) {
|
|
36
|
+
const parsed = parseMigrationFile(name);
|
|
37
|
+
if (!parsed || !(await stat(path.join(directory, name))).isFile()) {
|
|
38
|
+
throw projectError(
|
|
39
|
+
"invalidMigrationFile",
|
|
40
|
+
`${name} must use the form 0001_name.sql`,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
const sql = await readFile(path.join(directory, name), "utf8");
|
|
44
|
+
files.push({
|
|
45
|
+
id: parsed.id,
|
|
46
|
+
number: parsed.number,
|
|
47
|
+
file: name,
|
|
48
|
+
sql,
|
|
49
|
+
checksum: createHash("sha256").update(sql).digest("hex"),
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
files.sort(
|
|
53
|
+
(left, right) =>
|
|
54
|
+
left.number - right.number || left.file.localeCompare(right.file),
|
|
55
|
+
);
|
|
56
|
+
const seen = new Map<string, string>();
|
|
57
|
+
for (const file of files) {
|
|
58
|
+
const previous = seen.get(file.id);
|
|
59
|
+
if (previous) {
|
|
60
|
+
throw projectError(
|
|
61
|
+
"duplicateMigration",
|
|
62
|
+
`${previous} and ${file.file} use migration number ${file.id}`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
seen.set(file.id, file.file);
|
|
66
|
+
}
|
|
67
|
+
return files;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function pendingMigrations(
|
|
71
|
+
files: readonly MigrationFile[],
|
|
72
|
+
applied: readonly AppliedMigration[],
|
|
73
|
+
) {
|
|
74
|
+
const checksums = new Map(applied.map((item) => [item.id, item.checksum]));
|
|
75
|
+
const pending: MigrationFile[] = [];
|
|
76
|
+
for (const file of files) {
|
|
77
|
+
const checksum = checksums.get(file.file);
|
|
78
|
+
if (checksum === undefined) {
|
|
79
|
+
pending.push(file);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (checksum !== file.checksum) {
|
|
83
|
+
throw projectError(
|
|
84
|
+
"migrationChanged",
|
|
85
|
+
`${file.file} changed after it was applied`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return pending;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function applyMigrations(
|
|
93
|
+
files: readonly MigrationFile[],
|
|
94
|
+
client: MigrationClient,
|
|
95
|
+
) {
|
|
96
|
+
await client.unsafe(ownedObjectSql);
|
|
97
|
+
await client.unsafe(
|
|
98
|
+
`create table if not exists keja_migrations (
|
|
99
|
+
id text primary key,
|
|
100
|
+
checksum text not null,
|
|
101
|
+
applied_at timestamptz not null default now()
|
|
102
|
+
)`,
|
|
103
|
+
);
|
|
104
|
+
const applied = await client.query(
|
|
105
|
+
"select id, checksum from keja_migrations",
|
|
106
|
+
);
|
|
107
|
+
const pending = pendingMigrations(files, applied);
|
|
108
|
+
for (const file of pending) {
|
|
109
|
+
try {
|
|
110
|
+
await client.begin(async (tx) => {
|
|
111
|
+
await tx.unsafe(file.sql);
|
|
112
|
+
const violations = ownedObjectViolations(
|
|
113
|
+
await tx.unsafe(
|
|
114
|
+
"select table_name, detail from keja_owned_object_violations()",
|
|
115
|
+
),
|
|
116
|
+
);
|
|
117
|
+
if (violations.length > 0) {
|
|
118
|
+
throw projectError("ownedObjects", violations.join("; "));
|
|
119
|
+
}
|
|
120
|
+
await tx.unsafe(
|
|
121
|
+
`insert into keja_migrations (id, checksum) values ('${sqlLiteral(file.file)}', '${sqlLiteral(file.checksum)}')`,
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
} catch (error) {
|
|
125
|
+
if (error instanceof ProjectError) {
|
|
126
|
+
throw error;
|
|
127
|
+
}
|
|
128
|
+
const detail =
|
|
129
|
+
error instanceof Error ? error.message : "migration failed";
|
|
130
|
+
throw projectError("migrationFailed", `${file.file} failed: ${detail}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return pending.map((file) => file.file);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function parseMigrationFile(file: string) {
|
|
137
|
+
const match = migrationFile.exec(file);
|
|
138
|
+
if (!match) {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
return { id: match[1], number: Number(match[1]), name: match[2] };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function sqlLiteral(value: string) {
|
|
145
|
+
return value.replaceAll("'", "''");
|
|
146
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
export const ownedObjectSql = `
|
|
2
|
+
create or replace function keja_is_typeid(value text)
|
|
3
|
+
returns boolean
|
|
4
|
+
language sql
|
|
5
|
+
immutable
|
|
6
|
+
as $$
|
|
7
|
+
select value ~ '^[a-z]([a-z_]{0,61}[a-z])?_[0-9a-hjkmnp-tv-z]{26}$'
|
|
8
|
+
$$;
|
|
9
|
+
|
|
10
|
+
create or replace function keja_is_object_type(value text)
|
|
11
|
+
returns boolean
|
|
12
|
+
language sql
|
|
13
|
+
immutable
|
|
14
|
+
as $$
|
|
15
|
+
select value ~ '^v[0-9]+(\\.[a-z]([a-z_]{0,61}[a-z])?)+$'
|
|
16
|
+
$$;
|
|
17
|
+
|
|
18
|
+
create or replace function keja_is_tenant(value text)
|
|
19
|
+
returns boolean
|
|
20
|
+
language sql
|
|
21
|
+
immutable
|
|
22
|
+
as $$
|
|
23
|
+
select value is not null and length(btrim(value)) > 0
|
|
24
|
+
$$;
|
|
25
|
+
|
|
26
|
+
create or replace function keja_timestamps(created_at timestamptz, updated_at timestamptz)
|
|
27
|
+
returns boolean
|
|
28
|
+
language sql
|
|
29
|
+
immutable
|
|
30
|
+
as $$
|
|
31
|
+
select updated_at >= created_at
|
|
32
|
+
$$;
|
|
33
|
+
|
|
34
|
+
create or replace function keja_owned_object_violations()
|
|
35
|
+
returns table(table_name text, detail text)
|
|
36
|
+
language plpgsql
|
|
37
|
+
stable
|
|
38
|
+
as $keja$
|
|
39
|
+
declare
|
|
40
|
+
rel record;
|
|
41
|
+
id_type text;
|
|
42
|
+
object_type text;
|
|
43
|
+
tenant_type text;
|
|
44
|
+
archived_type text;
|
|
45
|
+
created_type text;
|
|
46
|
+
updated_type text;
|
|
47
|
+
id_required boolean;
|
|
48
|
+
object_required boolean;
|
|
49
|
+
tenant_required boolean;
|
|
50
|
+
archived_required boolean;
|
|
51
|
+
created_required boolean;
|
|
52
|
+
updated_required boolean;
|
|
53
|
+
primary_key text;
|
|
54
|
+
checks text;
|
|
55
|
+
begin
|
|
56
|
+
for rel in
|
|
57
|
+
select c.oid, c.relname
|
|
58
|
+
from pg_class c
|
|
59
|
+
join pg_namespace n on n.oid = c.relnamespace
|
|
60
|
+
where n.nspname = 'public'
|
|
61
|
+
and c.relkind = 'r'
|
|
62
|
+
and c.relname <> 'keja_migrations'
|
|
63
|
+
loop
|
|
64
|
+
select t.typname, a.attnotnull
|
|
65
|
+
into id_type, id_required
|
|
66
|
+
from pg_attribute a
|
|
67
|
+
join pg_type t on t.oid = a.atttypid
|
|
68
|
+
where a.attrelid = rel.oid
|
|
69
|
+
and a.attname = '_id'
|
|
70
|
+
and a.attnum > 0
|
|
71
|
+
and not a.attisdropped;
|
|
72
|
+
|
|
73
|
+
select t.typname, a.attnotnull
|
|
74
|
+
into object_type, object_required
|
|
75
|
+
from pg_attribute a
|
|
76
|
+
join pg_type t on t.oid = a.atttypid
|
|
77
|
+
where a.attrelid = rel.oid
|
|
78
|
+
and a.attname = '_object'
|
|
79
|
+
and a.attnum > 0
|
|
80
|
+
and not a.attisdropped;
|
|
81
|
+
|
|
82
|
+
select t.typname, a.attnotnull
|
|
83
|
+
into tenant_type, tenant_required
|
|
84
|
+
from pg_attribute a
|
|
85
|
+
join pg_type t on t.oid = a.atttypid
|
|
86
|
+
where a.attrelid = rel.oid
|
|
87
|
+
and a.attname = '_tenant'
|
|
88
|
+
and a.attnum > 0
|
|
89
|
+
and not a.attisdropped;
|
|
90
|
+
|
|
91
|
+
select t.typname, a.attnotnull
|
|
92
|
+
into archived_type, archived_required
|
|
93
|
+
from pg_attribute a
|
|
94
|
+
join pg_type t on t.oid = a.atttypid
|
|
95
|
+
where a.attrelid = rel.oid
|
|
96
|
+
and a.attname = '_archived'
|
|
97
|
+
and a.attnum > 0
|
|
98
|
+
and not a.attisdropped;
|
|
99
|
+
|
|
100
|
+
select t.typname, a.attnotnull
|
|
101
|
+
into created_type, created_required
|
|
102
|
+
from pg_attribute a
|
|
103
|
+
join pg_type t on t.oid = a.atttypid
|
|
104
|
+
where a.attrelid = rel.oid
|
|
105
|
+
and a.attname = '_createdAt'
|
|
106
|
+
and a.attnum > 0
|
|
107
|
+
and not a.attisdropped;
|
|
108
|
+
|
|
109
|
+
select t.typname, a.attnotnull
|
|
110
|
+
into updated_type, updated_required
|
|
111
|
+
from pg_attribute a
|
|
112
|
+
join pg_type t on t.oid = a.atttypid
|
|
113
|
+
where a.attrelid = rel.oid
|
|
114
|
+
and a.attname = '_updatedAt'
|
|
115
|
+
and a.attnum > 0
|
|
116
|
+
and not a.attisdropped;
|
|
117
|
+
|
|
118
|
+
select string_agg(a.attname, ',' order by array_position(i.indkey, a.attnum))
|
|
119
|
+
into primary_key
|
|
120
|
+
from pg_index i
|
|
121
|
+
join pg_attribute a
|
|
122
|
+
on a.attrelid = i.indrelid
|
|
123
|
+
and a.attnum = any(i.indkey)
|
|
124
|
+
where i.indrelid = rel.oid
|
|
125
|
+
and i.indisprimary;
|
|
126
|
+
|
|
127
|
+
select string_agg(pg_get_constraintdef(pg_constraint.oid), ' ')
|
|
128
|
+
into checks
|
|
129
|
+
from pg_constraint
|
|
130
|
+
where conrelid = rel.oid
|
|
131
|
+
and contype = 'c';
|
|
132
|
+
|
|
133
|
+
table_name := rel.relname;
|
|
134
|
+
if id_type is null then
|
|
135
|
+
detail := 'missing _id';
|
|
136
|
+
return next;
|
|
137
|
+
elsif id_type <> 'text' or id_required is not true or primary_key is distinct from '_id' then
|
|
138
|
+
detail := '_id must be a text primary key';
|
|
139
|
+
return next;
|
|
140
|
+
elsif checks is null or checks !~* 'keja_is_typeid[[:space:]]*\\([[:space:]]*"?_id"?' then
|
|
141
|
+
detail := '_id must be checked with keja_is_typeid';
|
|
142
|
+
return next;
|
|
143
|
+
elsif object_type is null then
|
|
144
|
+
detail := 'missing _object';
|
|
145
|
+
return next;
|
|
146
|
+
elsif object_type <> 'text' or object_required is not true then
|
|
147
|
+
detail := '_object must be text not null';
|
|
148
|
+
return next;
|
|
149
|
+
elsif checks !~* 'keja_is_object_type[[:space:]]*\\([[:space:]]*"?_object"?' then
|
|
150
|
+
detail := '_object must be checked with keja_is_object_type';
|
|
151
|
+
return next;
|
|
152
|
+
elsif tenant_type is null then
|
|
153
|
+
detail := 'missing _tenant';
|
|
154
|
+
return next;
|
|
155
|
+
elsif tenant_type <> 'text' or tenant_required is not true then
|
|
156
|
+
detail := '_tenant must be text not null';
|
|
157
|
+
return next;
|
|
158
|
+
elsif checks !~* 'keja_is_tenant[[:space:]]*\\([[:space:]]*"?_tenant"?' then
|
|
159
|
+
detail := '_tenant must be checked with keja_is_tenant';
|
|
160
|
+
return next;
|
|
161
|
+
elsif archived_type is null then
|
|
162
|
+
detail := 'missing _archived';
|
|
163
|
+
return next;
|
|
164
|
+
elsif archived_type <> 'bool' or archived_required is not true then
|
|
165
|
+
detail := '_archived must be boolean not null';
|
|
166
|
+
return next;
|
|
167
|
+
elsif created_type is null then
|
|
168
|
+
detail := 'missing _createdAt';
|
|
169
|
+
return next;
|
|
170
|
+
elsif created_type <> 'timestamptz' or created_required is not true then
|
|
171
|
+
detail := '_createdAt must be timestamptz not null';
|
|
172
|
+
return next;
|
|
173
|
+
elsif updated_type is null then
|
|
174
|
+
detail := 'missing _updatedAt';
|
|
175
|
+
return next;
|
|
176
|
+
elsif updated_type <> 'timestamptz' or updated_required is not true then
|
|
177
|
+
detail := '_updatedAt must be timestamptz not null';
|
|
178
|
+
return next;
|
|
179
|
+
elsif checks !~* 'keja_timestamps[[:space:]]*\\([[:space:]]*"_createdAt"[[:space:]]*,[[:space:]]*"_updatedAt"' then
|
|
180
|
+
detail := '_createdAt and _updatedAt must be checked with keja_timestamps';
|
|
181
|
+
return next;
|
|
182
|
+
end if;
|
|
183
|
+
end loop;
|
|
184
|
+
end;
|
|
185
|
+
$keja$;
|
|
186
|
+
`;
|
|
187
|
+
|
|
188
|
+
export function ownedObjectViolations(value: unknown) {
|
|
189
|
+
if (!Array.isArray(value)) {
|
|
190
|
+
return [];
|
|
191
|
+
}
|
|
192
|
+
return value.flatMap((item) => {
|
|
193
|
+
if (!item || typeof item !== "object") {
|
|
194
|
+
return [];
|
|
195
|
+
}
|
|
196
|
+
const row = item as { table_name?: unknown; detail?: unknown };
|
|
197
|
+
if (typeof row.table_name !== "string" || typeof row.detail !== "string") {
|
|
198
|
+
return [];
|
|
199
|
+
}
|
|
200
|
+
return [`${row.table_name} ${row.detail}`];
|
|
201
|
+
});
|
|
202
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { SQL } from "bun";
|
|
2
|
+
import { ProjectError, projectError } from "../errors/framework-errors.ts";
|
|
3
|
+
import { setDatabase } from "./client.ts";
|
|
4
|
+
import {
|
|
5
|
+
type AppliedMigration,
|
|
6
|
+
applyMigrations,
|
|
7
|
+
loadMigrations,
|
|
8
|
+
type MigrationClient,
|
|
9
|
+
} from "./migrate.ts";
|
|
10
|
+
|
|
11
|
+
export function readDatabaseUrl(value: string | undefined) {
|
|
12
|
+
const url = value?.trim() ?? "";
|
|
13
|
+
if (url === "") {
|
|
14
|
+
throw projectError("databaseUrl", "DATABASE_URL is required");
|
|
15
|
+
}
|
|
16
|
+
try {
|
|
17
|
+
const parsed = new URL(url);
|
|
18
|
+
if (parsed.protocol === "postgres:" || parsed.protocol === "postgresql:") {
|
|
19
|
+
return url;
|
|
20
|
+
}
|
|
21
|
+
} catch {
|
|
22
|
+
throw projectError(
|
|
23
|
+
"databaseUrl",
|
|
24
|
+
"DATABASE_URL must be a postgres connection string",
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
throw projectError(
|
|
28
|
+
"databaseUrl",
|
|
29
|
+
"DATABASE_URL must be a postgres connection string",
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function connectDatabase(databaseUrl: string) {
|
|
34
|
+
const sql = await openClient(databaseUrl);
|
|
35
|
+
try {
|
|
36
|
+
await sql`select 1`;
|
|
37
|
+
} catch (error) {
|
|
38
|
+
await sql.close();
|
|
39
|
+
throw asDatabaseError(error);
|
|
40
|
+
}
|
|
41
|
+
setDatabase(sql);
|
|
42
|
+
return sql;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function migrateDatabase(directory: string, databaseUrl: string) {
|
|
46
|
+
const sql = await openClient(databaseUrl);
|
|
47
|
+
try {
|
|
48
|
+
const applied = await applyMigrations(
|
|
49
|
+
await loadMigrations(directory),
|
|
50
|
+
adapter(sql),
|
|
51
|
+
);
|
|
52
|
+
return { migrations: directory, applied };
|
|
53
|
+
} catch (error) {
|
|
54
|
+
throw asDatabaseError(error);
|
|
55
|
+
} finally {
|
|
56
|
+
await sql.close();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function openClient(databaseUrl: string) {
|
|
61
|
+
const { SQL } = await import("bun");
|
|
62
|
+
try {
|
|
63
|
+
return new SQL(readDatabaseUrl(databaseUrl));
|
|
64
|
+
} catch (error) {
|
|
65
|
+
throw asDatabaseError(error);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function asDatabaseError(error: unknown): never {
|
|
70
|
+
if (error instanceof ProjectError) {
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
73
|
+
const detail =
|
|
74
|
+
error instanceof Error ? error.message : "Database connection failed";
|
|
75
|
+
throw projectError("databaseInit", detail);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function adapter(sql: SQL): MigrationClient {
|
|
79
|
+
return {
|
|
80
|
+
unsafe(statement) {
|
|
81
|
+
return sql.unsafe(statement);
|
|
82
|
+
},
|
|
83
|
+
query(statement: string) {
|
|
84
|
+
return sql.unsafe(statement) as Promise<AppliedMigration[]>;
|
|
85
|
+
},
|
|
86
|
+
begin(fn) {
|
|
87
|
+
return sql.begin((tx) => fn(adapter(tx)));
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
readOpenApiServers,
|
|
4
|
+
readRequiredEnv,
|
|
5
|
+
requiredEnv,
|
|
6
|
+
validateOpenApiServer,
|
|
7
|
+
} from "./required-env.ts";
|
|
8
|
+
|
|
9
|
+
describe("requiredEnv", () => {
|
|
10
|
+
it("accepts a usable host and ports", () => {
|
|
11
|
+
expect(
|
|
12
|
+
readRequiredEnv({
|
|
13
|
+
HOST: "127.0.0.1",
|
|
14
|
+
HTTP_PORT: "3000",
|
|
15
|
+
HTTP_SERVICE_PORT: "3001",
|
|
16
|
+
}),
|
|
17
|
+
).toEqual({
|
|
18
|
+
host: "127.0.0.1",
|
|
19
|
+
httpPort: 3000,
|
|
20
|
+
httpServicePort: 3001,
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("rejects a blank host and an out of range port", () => {
|
|
25
|
+
expect(requiredEnv[0].validate(" ")).toBe("HOST is required");
|
|
26
|
+
expect(requiredEnv[1].validate("70000")).toBe(
|
|
27
|
+
"HTTP_PORT must be an integer from 1 to 65535",
|
|
28
|
+
);
|
|
29
|
+
expect(requiredEnv[2].validate("0")).toBe(
|
|
30
|
+
"HTTP_SERVICE_PORT must be an integer from 1 to 65535",
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("reads optional openapi servers and rejects a bad url", () => {
|
|
35
|
+
expect(validateOpenApiServer("OPENAPI_SERVER", undefined)).toBeNull();
|
|
36
|
+
expect(validateOpenApiServer("OPENAPI_SERVER", " ")).toBeNull();
|
|
37
|
+
expect(
|
|
38
|
+
readOpenApiServers({
|
|
39
|
+
OPENAPI_SERVER: "http://127.0.0.1:3500",
|
|
40
|
+
OPENAPI_SERVICE_SERVER: "https://service.example/api",
|
|
41
|
+
}),
|
|
42
|
+
).toEqual({
|
|
43
|
+
openapiServer: "http://127.0.0.1:3500",
|
|
44
|
+
openapiServiceServer: "https://service.example/api",
|
|
45
|
+
});
|
|
46
|
+
expect(validateOpenApiServer("OPENAPI_SERVER", "notaurl")).toBe(
|
|
47
|
+
"OPENAPI_SERVER must be an absolute http or https URL",
|
|
48
|
+
);
|
|
49
|
+
expect(() =>
|
|
50
|
+
readOpenApiServers({ OPENAPI_SERVICE_SERVER: "ftp://files.example" }),
|
|
51
|
+
).toThrow("OPENAPI_SERVICE_SERVER must be an absolute http or https URL");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("defines a validator for every required variable", () => {
|
|
55
|
+
expect(requiredEnv.map((requirement) => requirement.name)).toEqual([
|
|
56
|
+
"HOST",
|
|
57
|
+
"HTTP_PORT",
|
|
58
|
+
"HTTP_SERVICE_PORT",
|
|
59
|
+
]);
|
|
60
|
+
for (const requirement of requiredEnv) {
|
|
61
|
+
expect(typeof requirement.validate).toBe("function");
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
});
|