@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,158 @@
1
+ import { existsSync } from "node:fs";
2
+ import { readdir } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+ import { projectError } from "../errors/framework-errors.ts";
6
+ import { isRoute, type RouteDefinition } from "../route/define.ts";
7
+ import {
8
+ type MiddlewareDefinition,
9
+ middlewareList,
10
+ } from "../route/middleware.ts";
11
+ import { isVersions, type VersionDefinition } from "../versioning/define.ts";
12
+ import {
13
+ type ArgusConfig,
14
+ type DatabaseConfig,
15
+ loadConfig,
16
+ type ProjectPaths,
17
+ type VaultConfig,
18
+ } from "./config.ts";
19
+ import { beginProjectReload, freshImportUrl } from "./reload-graph.ts";
20
+
21
+ export type LoadedProject = {
22
+ paths: ProjectPaths;
23
+ name: string | null;
24
+ argus: ArgusConfig;
25
+ vault: VaultConfig;
26
+ database: DatabaseConfig;
27
+ versions: VersionDefinition[];
28
+ publicRoutes: RouteDefinition[];
29
+ publicMiddleware: MiddlewareDefinition<object>[];
30
+ serviceRoutes: RouteDefinition[];
31
+ serviceMiddleware: MiddlewareDefinition<object>[];
32
+ };
33
+
34
+ export async function loadProject(
35
+ root: string,
36
+ options?: { generation?: string },
37
+ ): Promise<LoadedProject> {
38
+ const generation = options?.generation;
39
+ if (generation) {
40
+ beginProjectReload(root, generation);
41
+ }
42
+ const loaded = await loadConfig(root, generation);
43
+ const paths = loaded;
44
+ if (!existsSync(paths.versioning)) {
45
+ throw projectError("missingVersionFile", `Missing ${paths.versioning}`);
46
+ }
47
+ const versioning = await import(projectHref(paths.versioning, generation));
48
+ if (!isVersions(versioning.default)) {
49
+ throw projectError(
50
+ "invalidVersionFile",
51
+ `${paths.versioning} must default-export defineVersions(...)`,
52
+ );
53
+ }
54
+ const publicDirectory = paths.public;
55
+ const serviceDirectory = paths.service;
56
+ return {
57
+ paths,
58
+ name: loaded.name,
59
+ argus: loaded.argus,
60
+ vault: loaded.vault,
61
+ database: loaded.database,
62
+ versions: versioning.default.versions,
63
+ publicRoutes: await loadRoutes(publicDirectory, generation),
64
+ publicMiddleware: await loadServerMiddleware(publicDirectory, generation),
65
+ serviceRoutes: await loadRoutes(serviceDirectory, generation),
66
+ serviceMiddleware: await loadServerMiddleware(serviceDirectory, generation),
67
+ };
68
+ }
69
+
70
+ function projectHref(file: string, generation?: string) {
71
+ return generation
72
+ ? freshImportUrl(file, generation)
73
+ : pathToFileURL(file).href;
74
+ }
75
+
76
+ async function loadRoutes(directory: string, generation?: string) {
77
+ if (!existsSync(directory)) {
78
+ throw projectError("missingRouteDirectory", `Missing ${directory}`);
79
+ }
80
+ const routes: RouteDefinition[] = [];
81
+ for (const file of await collectRouteFiles(directory)) {
82
+ const loaded = await import(
83
+ projectHref(path.join(directory, file), generation)
84
+ );
85
+ const exported = routesFromModule(loaded);
86
+ if (exported.length === 0) {
87
+ throw projectError(
88
+ "invalidRouteFile",
89
+ `${file} must export defineRoute(...)`,
90
+ );
91
+ }
92
+ routes.push(...exported);
93
+ }
94
+ return routes;
95
+ }
96
+
97
+ async function loadServerMiddleware(directory: string, generation?: string) {
98
+ const file = path.join(directory, "_middleware.ts");
99
+ if (!existsSync(file)) {
100
+ return [];
101
+ }
102
+ const loaded = await import(projectHref(file, generation));
103
+ const exported = middlewareList(loaded.default);
104
+ if (!exported) {
105
+ throw projectError(
106
+ "invalidMiddlewareFile",
107
+ `${file} must default-export defineMiddleware(...)`,
108
+ );
109
+ }
110
+ return exported;
111
+ }
112
+
113
+ export function routesFromModule(module: object) {
114
+ const record = module as Record<string, unknown>;
115
+ const names = Object.keys(record).filter((name) => name !== "default");
116
+ const ordered = "default" in record ? ["default", ...names] : names;
117
+ const routes: RouteDefinition[] = [];
118
+ for (const name of ordered) {
119
+ const value = record[name];
120
+ if (isRoute(value)) {
121
+ routes.push(value);
122
+ }
123
+ }
124
+ return routes;
125
+ }
126
+
127
+ export async function collectRouteFiles(directory: string) {
128
+ const files = await walk(directory, "");
129
+ return files.filter(
130
+ (file) =>
131
+ file.endsWith(".ts") &&
132
+ !file.endsWith(".test.ts") &&
133
+ !isServerMiddlewareFile(file),
134
+ );
135
+ }
136
+
137
+ async function walk(directory: string, prefix: string): Promise<string[]> {
138
+ const entries = await readdir(directory, { withFileTypes: true });
139
+ const files: string[] = [];
140
+ for (const entry of entries) {
141
+ const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
142
+ if (entry.isDirectory()) {
143
+ files.push(...(await walk(path.join(directory, entry.name), relative)));
144
+ continue;
145
+ }
146
+ if (entry.isFile()) {
147
+ files.push(relative);
148
+ }
149
+ }
150
+ return files;
151
+ }
152
+
153
+ function isServerMiddlewareFile(file: string) {
154
+ const normalized = file.replaceAll("\\", "/");
155
+ return (
156
+ normalized === "_middleware.ts" || normalized.endsWith("/_middleware.ts")
157
+ );
158
+ }
@@ -0,0 +1,52 @@
1
+ import path from "node:path";
2
+ import { describe, expect, it } from "vitest";
3
+ import { freshImportUrl, resolveProjectImport } from "./reload-graph.ts";
4
+
5
+ describe("project reload imports", () => {
6
+ const root = path.resolve("keja-app");
7
+ const route = path.join(root, "src", "public", "posts.ts");
8
+
9
+ it("adds a generation to a project file import", () => {
10
+ expect(freshImportUrl(route, "4")).toBe(`keja:${route}?keja=4`);
11
+ });
12
+
13
+ it("follows a relative import out of the route directory", () => {
14
+ const resolved = resolveProjectImport({
15
+ importer: `keja:${route}`,
16
+ specifier: "../data.ts",
17
+ projectRoot: root,
18
+ generation: "4",
19
+ });
20
+ const file = path.join(root, "src", "data.ts");
21
+ expect(resolved).toEqual({
22
+ path: `${file}?keja=4`,
23
+ namespace: "keja",
24
+ file,
25
+ outside: false,
26
+ });
27
+ });
28
+
29
+ it("follows the next file imported by that dependency", () => {
30
+ const data = path.join(root, "src", "data.ts");
31
+ const resolved = resolveProjectImport({
32
+ importer: `keja:${data}?keja=4`,
33
+ specifier: "./schema.ts",
34
+ projectRoot: root,
35
+ generation: "4",
36
+ });
37
+ const file = path.join(root, "src", "schema.ts");
38
+ expect(resolved?.path).toBe(`${file}?keja=4`);
39
+ expect(resolved?.file).toBe(file);
40
+ });
41
+
42
+ it("leaves package imports cached", () => {
43
+ expect(
44
+ resolveProjectImport({
45
+ importer: `keja:${route}`,
46
+ specifier: "@veducio/keja",
47
+ projectRoot: root,
48
+ generation: "4",
49
+ }),
50
+ ).toBeNull();
51
+ });
52
+ });
@@ -0,0 +1,175 @@
1
+ import { readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ const namespace = "keja";
6
+ const generationKey = "keja";
7
+
8
+ let projectRoot: string | null = null;
9
+ let generation: string | null = null;
10
+ let pluginInstalled = false;
11
+ const outsideProject = new Set<string>();
12
+
13
+ export function freshImportUrl(file: string, token: string) {
14
+ return `${namespace}:${path.resolve(file)}?${generationKey}=${token}`;
15
+ }
16
+
17
+ export function resolveProjectImport(input: {
18
+ importer: string;
19
+ specifier: string;
20
+ projectRoot: string;
21
+ generation: string;
22
+ }) {
23
+ const specifier = input.specifier.split("?")[0] ?? input.specifier;
24
+ if (!isRelativeSpecifier(specifier)) {
25
+ return null;
26
+ }
27
+ const importerFile = filePath(input.importer);
28
+ if (!importerFile || isNodeModules(importerFile)) {
29
+ return null;
30
+ }
31
+ const inProject = isInside(importerFile, input.projectRoot);
32
+ const inGraph =
33
+ input.importer.startsWith(`${namespace}:`) ||
34
+ input.importer.includes(`${generationKey}=`);
35
+ if (!inProject && !inGraph) {
36
+ return null;
37
+ }
38
+ const resolved = path.resolve(path.dirname(importerFile), specifier);
39
+ if (isNodeModules(resolved)) {
40
+ return null;
41
+ }
42
+ return {
43
+ path: `${resolved}?${generationKey}=${input.generation}`,
44
+ namespace,
45
+ file: resolved,
46
+ outside: !isInside(resolved, input.projectRoot),
47
+ };
48
+ }
49
+
50
+ export function beginProjectReload(root: string, token: string) {
51
+ projectRoot = path.resolve(root);
52
+ generation = token;
53
+ outsideProject.clear();
54
+ installPlugin();
55
+ }
56
+
57
+ export function projectDependenciesOutsideRoot() {
58
+ return [...outsideProject];
59
+ }
60
+
61
+ function installPlugin() {
62
+ if (pluginInstalled) {
63
+ return;
64
+ }
65
+ const runtime = globalThis as {
66
+ Bun?: { plugin?: (plugin: BunPlugin) => void };
67
+ };
68
+ if (!runtime.Bun?.plugin) {
69
+ return;
70
+ }
71
+ pluginInstalled = true;
72
+ runtime.Bun.plugin({
73
+ name: "keja-dev-reload",
74
+ setup(build) {
75
+ build.onResolve({ filter: /^\.{1,2}(?:[\\/]|$)/ }, (args) => {
76
+ if (!projectRoot || !generation) {
77
+ return null;
78
+ }
79
+ if (!args.importer.startsWith(`${namespace}:`)) {
80
+ return null;
81
+ }
82
+ const resolved = resolveProjectImport({
83
+ importer: args.importer,
84
+ specifier: args.path,
85
+ projectRoot,
86
+ generation,
87
+ });
88
+ if (!resolved) {
89
+ return null;
90
+ }
91
+ if (resolved.outside) {
92
+ outsideProject.add(resolved.file);
93
+ }
94
+ return { path: resolved.path, namespace: resolved.namespace };
95
+ });
96
+ build.onLoad({ filter: /.*/, namespace }, (args) => {
97
+ const file = filePath(args.path);
98
+ if (!file) {
99
+ return null;
100
+ }
101
+ return {
102
+ contents: readFileSync(file, "utf8"),
103
+ loader: loaderFor(file),
104
+ };
105
+ });
106
+ },
107
+ });
108
+ }
109
+
110
+ type BunPlugin = {
111
+ name: string;
112
+ setup: (build: {
113
+ onResolve: (
114
+ constraints: { filter: RegExp },
115
+ callback: (args: { importer: string; path: string }) => {
116
+ path: string;
117
+ namespace: string;
118
+ } | null,
119
+ ) => void;
120
+ onLoad: (
121
+ constraints: { filter: RegExp; namespace: string },
122
+ callback: (args: { path: string }) => {
123
+ contents: string;
124
+ loader: "js" | "jsx" | "ts" | "tsx" | "json";
125
+ } | null,
126
+ ) => void;
127
+ }) => void;
128
+ };
129
+
130
+ function loaderFor(file: string) {
131
+ const extension = path.extname(file).toLowerCase();
132
+ if (extension === ".tsx") {
133
+ return "tsx" as const;
134
+ }
135
+ if (extension === ".jsx") {
136
+ return "jsx" as const;
137
+ }
138
+ if (extension === ".js" || extension === ".mjs" || extension === ".cjs") {
139
+ return "js" as const;
140
+ }
141
+ if (extension === ".json") {
142
+ return "json" as const;
143
+ }
144
+ return "ts" as const;
145
+ }
146
+
147
+ function isRelativeSpecifier(specifier: string) {
148
+ return specifier.startsWith("./") || specifier.startsWith("../");
149
+ }
150
+
151
+ function filePath(importer: string) {
152
+ const withoutQuery = importer.split("?")[0] ?? importer;
153
+ const clean = withoutQuery.startsWith(`${namespace}:`)
154
+ ? withoutQuery.slice(namespace.length + 1)
155
+ : withoutQuery;
156
+ if (clean.startsWith("file:")) {
157
+ return fileURLToPath(clean);
158
+ }
159
+ if (path.isAbsolute(clean)) {
160
+ return clean;
161
+ }
162
+ return null;
163
+ }
164
+
165
+ function isNodeModules(file: string) {
166
+ return file.split(/[\\/]/).includes("node_modules");
167
+ }
168
+
169
+ function isInside(file: string, root: string) {
170
+ const relative = path.relative(root, file);
171
+ return (
172
+ relative === "" ||
173
+ (!relative.startsWith("..") && !path.isAbsolute(relative))
174
+ );
175
+ }
@@ -0,0 +1,123 @@
1
+ import { projectError } from "../errors/framework-errors.ts";
2
+ import { processBrand } from "../process/slot.ts";
3
+
4
+ const repositoryBrand = processBrand("repositories");
5
+ const tableName = /^[a-z_][a-z0-9_]*$/;
6
+
7
+ export type ListConstraints = {
8
+ filters?: readonly string[];
9
+ limit?: number;
10
+ };
11
+
12
+ export type RepositoryFlags = {
13
+ list?: boolean | ListConstraints;
14
+ get?: boolean;
15
+ };
16
+
17
+ export type ResolvedList = boolean | ListConstraints;
18
+
19
+ export function defineRepositories<
20
+ const Tables extends Record<string, RepositoryFlags>,
21
+ >(tables: Tables) {
22
+ for (const [name, flags] of Object.entries(tables)) {
23
+ if (!tableName.test(name)) {
24
+ throw projectError(
25
+ "invalidRepositoryFile",
26
+ `${name} is not a table name`,
27
+ );
28
+ }
29
+ for (const [key, value] of Object.entries(flags)) {
30
+ if (key === "get") {
31
+ if (typeof value !== "boolean") {
32
+ throw projectError(
33
+ "invalidRepositoryFile",
34
+ `${name}.get is not a repository flag`,
35
+ );
36
+ }
37
+ continue;
38
+ }
39
+ if (key !== "list" || !isListFlag(value)) {
40
+ throw projectError(
41
+ "invalidRepositoryFile",
42
+ `${name}.${key} is not a repository flag`,
43
+ );
44
+ }
45
+ readList(name, value);
46
+ }
47
+ }
48
+ return Object.assign({}, tables, { [repositoryBrand]: true as const });
49
+ }
50
+
51
+ export function isRepositories(
52
+ value: unknown,
53
+ ): value is Record<string, RepositoryFlags> {
54
+ return (
55
+ typeof value === "object" &&
56
+ value !== null &&
57
+ repositoryBrand in value &&
58
+ (value as Record<symbol, unknown>)[repositoryBrand] === true
59
+ );
60
+ }
61
+
62
+ export function repositoryFlags(value: Record<string, RepositoryFlags>) {
63
+ const flags = new Map<string, { list: ResolvedList; get: boolean }>();
64
+ for (const [name, entry] of Object.entries(value)) {
65
+ if (name === "") {
66
+ continue;
67
+ }
68
+ flags.set(name, {
69
+ list: entry.list === undefined ? false : readList(name, entry.list),
70
+ get: entry.get === true,
71
+ });
72
+ }
73
+ return flags;
74
+ }
75
+
76
+ function isListFlag(value: unknown): value is boolean | ListConstraints {
77
+ return (
78
+ typeof value === "boolean" ||
79
+ (typeof value === "object" && value !== null && !Array.isArray(value))
80
+ );
81
+ }
82
+
83
+ function readList(
84
+ name: string,
85
+ value: boolean | ListConstraints,
86
+ ): ResolvedList {
87
+ if (typeof value === "boolean") {
88
+ return value;
89
+ }
90
+ for (const key of Object.keys(value)) {
91
+ if (key !== "filters" && key !== "limit") {
92
+ throw projectError(
93
+ "invalidRepositoryFile",
94
+ `${name}.list.${key} is not a list constraint`,
95
+ );
96
+ }
97
+ }
98
+ if (
99
+ value.filters !== undefined &&
100
+ (!Array.isArray(value.filters) ||
101
+ value.filters.some(
102
+ (filter) => typeof filter !== "string" || !tableName.test(filter),
103
+ ))
104
+ ) {
105
+ throw projectError(
106
+ "invalidRepositoryFile",
107
+ `${name}.list.filters must name columns`,
108
+ );
109
+ }
110
+ if (
111
+ value.limit !== undefined &&
112
+ (!Number.isInteger(value.limit) || value.limit < 1 || value.limit > 100)
113
+ ) {
114
+ throw projectError(
115
+ "invalidRepositoryFile",
116
+ `${name}.list.limit must be from 1 to 100`,
117
+ );
118
+ }
119
+ return {
120
+ ...(value.filters ? { filters: [...value.filters] } : {}),
121
+ ...(value.limit !== undefined ? { limit: value.limit } : {}),
122
+ };
123
+ }
@@ -0,0 +1,58 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { renderRepositories } from "./generate.ts";
3
+ import { readTables } from "./sql.ts";
4
+
5
+ const owned = `
6
+ _id text primary key,
7
+ _object text not null,
8
+ _tenant text not null,
9
+ _archived boolean not null,
10
+ "_createdAt" timestamptz not null,
11
+ "_updatedAt" timestamptz not null
12
+ `;
13
+
14
+ describe("renderRepositories", () => {
15
+ it("writes a repository and the processors that are enabled", () => {
16
+ const tables = readTables([
17
+ {
18
+ file: "0001_example.sql",
19
+ sql: `create table users (${owned}, name text not null); create table jobs (${owned}, name text not null);`,
20
+ },
21
+ ]);
22
+ const source = renderRepositories(
23
+ tables,
24
+ new Map([
25
+ ["users", { list: true, get: true }],
26
+ ["jobs", { list: false, get: false }],
27
+ ]),
28
+ );
29
+ expect(source).toContain("export const jobs = repository({");
30
+ expect(source).toContain("list: true,");
31
+ expect(source).toContain("get: true,");
32
+ expect(source.indexOf("export const jobs")).toBeLessThan(
33
+ source.indexOf("export const users"),
34
+ );
35
+ expect(
36
+ source.slice(
37
+ source.indexOf("export const jobs"),
38
+ source.indexOf("export const users"),
39
+ ),
40
+ ).not.toContain("list: true");
41
+ });
42
+
43
+ it("writes list filter and limit constraints", () => {
44
+ const tables = readTables([
45
+ {
46
+ file: "0001_example.sql",
47
+ sql: `create table users (${owned}, name text not null, email text not null);`,
48
+ },
49
+ ]);
50
+ const source = renderRepositories(
51
+ tables,
52
+ new Map([
53
+ ["users", { list: { filters: ["name"], limit: 20 }, get: false }],
54
+ ]),
55
+ );
56
+ expect(source).toContain('list: { filters: ["name"], limit: 20 },');
57
+ });
58
+ });
@@ -0,0 +1,117 @@
1
+ import { existsSync } from "node:fs";
2
+ import { mkdir, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+ import { loadMigrations } from "../db/migrate.ts";
6
+ import { projectError } from "../errors/framework-errors.ts";
7
+ import { loadConfig } from "../project/config.ts";
8
+ import {
9
+ isRepositories,
10
+ type ResolvedList,
11
+ repositoryFlags,
12
+ } from "./define.ts";
13
+ import { readTables, type TableSchema } from "./sql.ts";
14
+
15
+ export const repositoryModule = path.join("src", "db", "repositories.ts");
16
+
17
+ export function renderRepositories(
18
+ tables: readonly TableSchema[],
19
+ flags: ReadonlyMap<string, { list: ResolvedList; get: boolean }>,
20
+ ) {
21
+ const blocks = [...tables]
22
+ .sort((left, right) => left.name.localeCompare(right.name))
23
+ .map((table) => renderRepository(table, flags.get(table.name)));
24
+ return `import { repository } from "@veducio/keja";\n\n${blocks.join("\n\n")}\n`;
25
+ }
26
+
27
+ export async function generateRepositories(root: string) {
28
+ const project = await loadConfig(root);
29
+ const directory = project.database.enabled
30
+ ? project.database.migrations
31
+ : path.resolve(root, "migrations");
32
+ const tables = readTables(
33
+ (await loadMigrations(directory)).map((file) => ({
34
+ file: file.file,
35
+ sql: file.sql,
36
+ })),
37
+ );
38
+ const flags = await loadRepositoryFlags(root);
39
+ for (const [name, flag] of flags) {
40
+ const table = tables.find((item) => item.name === name);
41
+ if (!table) {
42
+ throw projectError("invalidRepositoryFile", `${name} is not a table`);
43
+ }
44
+ if (typeof flag.list === "object") {
45
+ for (const filter of flag.list.filters ?? []) {
46
+ const column = table.columns.find((item) => item.name === filter);
47
+ if (!column || column.type === "jsonb") {
48
+ throw projectError(
49
+ "invalidRepositoryFile",
50
+ `${name}.list.filters ${filter} is not a filter`,
51
+ );
52
+ }
53
+ }
54
+ }
55
+ }
56
+ const source = renderRepositories(tables, flags);
57
+ const output = path.join(root, repositoryModule);
58
+ await mkdir(path.dirname(output), { recursive: true });
59
+ await writeFile(output, source);
60
+ return { output, tables: tables.map((table) => table.name) };
61
+ }
62
+
63
+ async function loadRepositoryFlags(root: string) {
64
+ const file = path.join(root, "keja.repositories.ts");
65
+ if (!existsSync(file)) {
66
+ return new Map<string, { list: boolean; get: boolean }>();
67
+ }
68
+ const loaded = (await import(pathToFileURL(file).href)) as {
69
+ default?: unknown;
70
+ };
71
+ if (!isRepositories(loaded.default)) {
72
+ throw projectError(
73
+ "invalidRepositoryFile",
74
+ `${file} must default-export defineRepositories(...)`,
75
+ );
76
+ }
77
+ return repositoryFlags(loaded.default);
78
+ }
79
+
80
+ function renderRepository(
81
+ table: TableSchema,
82
+ flags: { list: ResolvedList; get: boolean } | undefined,
83
+ ) {
84
+ const columns = table.columns
85
+ .map(
86
+ (column) =>
87
+ ` ${column.name}: { type: "${column.type}", nullable: ${column.nullable} },`,
88
+ )
89
+ .join("\n");
90
+ const options = [
91
+ renderList(flags?.list),
92
+ flags?.get ? " get: true," : "",
93
+ ].filter((line) => line !== "");
94
+ const optionBlock = options.length > 0 ? `\n${options.join("\n")}` : "";
95
+ return `export const ${table.name} = repository({
96
+ table: "${table.name}",
97
+ columns: {
98
+ ${columns}
99
+ } as const,${optionBlock}
100
+ });`;
101
+ }
102
+
103
+ function renderList(list: ResolvedList | undefined) {
104
+ if (!list) {
105
+ return "";
106
+ }
107
+ if (list === true) {
108
+ return " list: true,";
109
+ }
110
+ const parts = [
111
+ list.filters
112
+ ? `filters: [${list.filters.map((name) => `"${name}"`).join(", ")}]`
113
+ : "",
114
+ list.limit !== undefined ? `limit: ${list.limit}` : "",
115
+ ].filter((part) => part !== "");
116
+ return ` list: { ${parts.join(", ")} },`;
117
+ }