@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,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
+ });