@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.
Files changed (96) 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/grant/grant.test.ts +130 -0
  46. package/src/grant/grant.ts +268 -0
  47. package/src/http/create-app.test.ts +340 -0
  48. package/src/http/create-app.ts +196 -0
  49. package/src/http/openapi.ts +111 -0
  50. package/src/process/slot.ts +18 -0
  51. package/src/project/config.test.ts +143 -0
  52. package/src/project/config.ts +450 -0
  53. package/src/project/load.test.ts +60 -0
  54. package/src/project/load.ts +158 -0
  55. package/src/project/reload-graph.test.ts +52 -0
  56. package/src/project/reload-graph.ts +175 -0
  57. package/src/repository/define.ts +123 -0
  58. package/src/repository/generate.test.ts +58 -0
  59. package/src/repository/generate.ts +117 -0
  60. package/src/repository/query.test.ts +99 -0
  61. package/src/repository/query.ts +287 -0
  62. package/src/repository/runtime.test.ts +154 -0
  63. package/src/repository/runtime.ts +434 -0
  64. package/src/repository/sql.test.ts +78 -0
  65. package/src/repository/sql.ts +282 -0
  66. package/src/route/define.test.ts +65 -0
  67. package/src/route/define.ts +307 -0
  68. package/src/route/middleware.test.ts +63 -0
  69. package/src/route/middleware.ts +144 -0
  70. package/src/seed/apply.test.ts +65 -0
  71. package/src/seed/apply.ts +11 -0
  72. package/src/seed/define.test.ts +51 -0
  73. package/src/seed/define.ts +55 -0
  74. package/src/seed/load.test.ts +54 -0
  75. package/src/seed/load.ts +67 -0
  76. package/src/seed/report.test.ts +16 -0
  77. package/src/seed/report.ts +15 -0
  78. package/src/self-test/checks.ts +88 -0
  79. package/src/self-test/run.test.ts +128 -0
  80. package/src/self-test/run.ts +105 -0
  81. package/src/server/identity.test.ts +57 -0
  82. package/src/server/identity.ts +116 -0
  83. package/src/server/index.test.ts +15 -0
  84. package/src/server/index.ts +171 -0
  85. package/src/server/swap.test.ts +89 -0
  86. package/src/server/swap.ts +29 -0
  87. package/src/typeid/id.test.ts +46 -0
  88. package/src/typeid/id.ts +136 -0
  89. package/src/typeid/owned.test.ts +73 -0
  90. package/src/typeid/owned.ts +193 -0
  91. package/src/vault/client.test.ts +128 -0
  92. package/src/vault/client.ts +282 -0
  93. package/src/versioning/define.test.ts +79 -0
  94. package/src/versioning/define.ts +173 -0
  95. package/src/versioning/select.test.ts +79 -0
  96. package/src/versioning/select.ts +62 -0
@@ -0,0 +1,196 @@
1
+ import { Scalar } from "@scalar/hono-api-reference";
2
+ import { Hono } from "hono";
3
+ import { frameworkError, projectError } from "../errors/framework-errors.ts";
4
+ import type { RouteDefinition } from "../route/define.ts";
5
+ import {
6
+ type MiddlewareDefinition,
7
+ runMiddleware,
8
+ } from "../route/middleware.ts";
9
+ import type { ServiceIdentity } from "../server/identity.ts";
10
+ import {
11
+ resolveVersionToken,
12
+ type VersionDefinition,
13
+ } from "../versioning/define.ts";
14
+ import { selectRoute } from "../versioning/select.ts";
15
+ import { createOpenApiDocument } from "./openapi.ts";
16
+
17
+ export function createHttpApp(options: {
18
+ title: string;
19
+ versions: readonly VersionDefinition[];
20
+ routes: readonly RouteDefinition[];
21
+ middleware?: readonly MiddlewareDefinition<object>[];
22
+ server?: string | null;
23
+ identity?: ServiceIdentity;
24
+ postOnly?: boolean;
25
+ }) {
26
+ validateRoutes(options.versions, options.routes, options.postOnly === true);
27
+ const document = createOpenApiDocument(
28
+ options.title,
29
+ options.routes,
30
+ options.server,
31
+ );
32
+ const app = new Hono();
33
+ app.get("/_health", (c) => c.json({ status: "ok" }));
34
+ if (options.identity) {
35
+ app.get("/_identity", (c) => c.json(options.identity));
36
+ }
37
+ app.get("/_openapi.json", (c) => c.json(document));
38
+ app.get(
39
+ "/docs",
40
+ Scalar({
41
+ url: "/_openapi.json",
42
+ hideClientButton: true,
43
+ showDeveloperTools: "never",
44
+ }),
45
+ );
46
+ app.all("/*", (c) =>
47
+ dispatch(
48
+ c.req.raw,
49
+ options.versions,
50
+ options.routes,
51
+ options.middleware ?? [],
52
+ options.postOnly === true,
53
+ ),
54
+ );
55
+ return app;
56
+ }
57
+
58
+ async function dispatch(
59
+ request: Request,
60
+ versions: readonly VersionDefinition[],
61
+ routes: readonly RouteDefinition[],
62
+ middleware: readonly MiddlewareDefinition<object>[],
63
+ postOnly: boolean,
64
+ ) {
65
+ if (postOnly && request.method !== "POST") {
66
+ return Response.json(frameworkError("methodNotAllowed"), { status: 405 });
67
+ }
68
+ const url = new URL(request.url);
69
+ const pathname = url.pathname;
70
+ const slash = pathname.indexOf("/", 1);
71
+ const token = slash === -1 ? pathname.slice(1) : pathname.slice(1, slash);
72
+ const remainder = slash === -1 ? "/" : pathname.slice(slash);
73
+ if (token.length === 0) {
74
+ return Response.json(frameworkError("notFound"), { status: 404 });
75
+ }
76
+ const requested = resolveVersionToken(token, versions);
77
+ if (!requested) {
78
+ return Response.json(frameworkError("unknownVersion"), { status: 404 });
79
+ }
80
+ const selected = selectRoute(
81
+ routes,
82
+ versions,
83
+ request.method,
84
+ remainder,
85
+ requested,
86
+ );
87
+ if (!selected) {
88
+ return Response.json(frameworkError("notFound"), { status: 404 });
89
+ }
90
+ return runMiddleware(
91
+ [
92
+ ...middleware.map((definition) => ({
93
+ definition,
94
+ allowFields: false,
95
+ })),
96
+ ...selected.route.middleware.map((definition) => ({
97
+ definition,
98
+ allowFields: true,
99
+ })),
100
+ ],
101
+ {
102
+ request,
103
+ version: requested.id,
104
+ params: selected.params,
105
+ },
106
+ async (fields) => {
107
+ const parsed = await parseRequest(
108
+ selected.route,
109
+ selected.params,
110
+ url,
111
+ request,
112
+ );
113
+ if (!parsed.ok) {
114
+ return Response.json(
115
+ frameworkError("invalidRequest", { issues: parsed.issues }),
116
+ { status: 400 },
117
+ );
118
+ }
119
+ return selected.route.handle({ ...parsed.input, fields });
120
+ },
121
+ );
122
+ }
123
+
124
+ async function parseRequest(
125
+ route: RouteDefinition,
126
+ params: Record<string, string>,
127
+ url: URL,
128
+ request: Request,
129
+ ) {
130
+ const input: {
131
+ params?: unknown;
132
+ query?: unknown;
133
+ body?: unknown;
134
+ } = {};
135
+ if (route.request.params) {
136
+ const parsed = route.request.params.safeParse(params);
137
+ if (!parsed.success) {
138
+ return { ok: false as const, issues: parsed.error.issues };
139
+ }
140
+ input.params = parsed.data;
141
+ }
142
+ if (route.request.query) {
143
+ const parsed = route.request.query.safeParse(
144
+ Object.fromEntries(url.searchParams),
145
+ );
146
+ if (!parsed.success) {
147
+ return { ok: false as const, issues: parsed.error.issues };
148
+ }
149
+ input.query = parsed.data;
150
+ }
151
+ if (route.request.body) {
152
+ let body: unknown;
153
+ try {
154
+ body = await request.json();
155
+ } catch {
156
+ return {
157
+ ok: false as const,
158
+ issues: [{ message: "Request body must be JSON" }],
159
+ };
160
+ }
161
+ const parsed = route.request.body.safeParse(body);
162
+ if (!parsed.success) {
163
+ return { ok: false as const, issues: parsed.error.issues };
164
+ }
165
+ input.body = parsed.data;
166
+ }
167
+ return { ok: true as const, input };
168
+ }
169
+
170
+ function validateRoutes(
171
+ versions: readonly VersionDefinition[],
172
+ routes: readonly RouteDefinition[],
173
+ postOnly: boolean,
174
+ ) {
175
+ const known = new Set(versions.map((version) => version.id));
176
+ const seen = new Set<string>();
177
+ for (const route of routes) {
178
+ if (postOnly && route.method !== "post") {
179
+ throw projectError(
180
+ "serviceMethod",
181
+ `${route.method.toUpperCase()} ${route.path} must use POST`,
182
+ );
183
+ }
184
+ if (!known.has(route.version)) {
185
+ throw projectError(
186
+ "unknownRouteVersion",
187
+ `${route.method.toUpperCase()} ${route.path} uses unknown version ${route.version}`,
188
+ );
189
+ }
190
+ const key = `${route.method} ${route.path} ${route.version}`;
191
+ if (seen.has(key)) {
192
+ throw projectError("duplicateRoute", `Duplicate route ${key}`);
193
+ }
194
+ seen.add(key);
195
+ }
196
+ }
@@ -0,0 +1,111 @@
1
+ import { createRoute, OpenAPIHono } from "@hono/zod-openapi";
2
+ import { type ZodType, z } from "zod";
3
+ import type { RouteDefinition, RouteResponse } from "../route/define.ts";
4
+
5
+ export function createOpenApiDocument(
6
+ title: string,
7
+ routes: readonly RouteDefinition[],
8
+ server?: string | null,
9
+ ) {
10
+ const spec = new OpenAPIHono();
11
+ for (const route of routes) {
12
+ spec.openapi(
13
+ createRoute({
14
+ method: route.method,
15
+ path: openApiPath(route.version, route.path),
16
+ request: openApiRequest(route) as never,
17
+ responses: openApiResponses(route.responses) as never,
18
+ }),
19
+ (c) => c.json({}, 200) as never,
20
+ );
21
+ }
22
+ return spec.getOpenAPIDocument({
23
+ openapi: "3.0.0",
24
+ info: {
25
+ title,
26
+ version: "0.1.0",
27
+ },
28
+ ...(server ? { servers: [{ url: server }] } : {}),
29
+ });
30
+ }
31
+
32
+ function openApiPath(version: string, path: string) {
33
+ const converted = path.replaceAll(/:([A-Za-z0-9_]+)/g, "{$1}");
34
+ return `/${version}${converted.startsWith("/") ? converted : `/${converted}`}`;
35
+ }
36
+
37
+ function openApiRequest(route: RouteDefinition) {
38
+ const request: {
39
+ params?: ZodType;
40
+ query?: ZodType;
41
+ body?: {
42
+ content: {
43
+ "application/json": {
44
+ schema: ZodType;
45
+ };
46
+ };
47
+ required: true;
48
+ };
49
+ } = {};
50
+ if (route.request.params) {
51
+ request.params = route.request.params;
52
+ }
53
+ if (route.request.query) {
54
+ request.query = route.request.query;
55
+ }
56
+ if (route.request.body) {
57
+ request.body = {
58
+ content: {
59
+ "application/json": {
60
+ schema: route.request.body,
61
+ },
62
+ },
63
+ required: true,
64
+ };
65
+ }
66
+ return request;
67
+ }
68
+
69
+ function openApiResponses(responses: readonly RouteResponse[]) {
70
+ const documented: Record<
71
+ number,
72
+ {
73
+ description: string;
74
+ content: {
75
+ "application/json": {
76
+ schema: ZodType;
77
+ };
78
+ };
79
+ }
80
+ > = {};
81
+ const grouped = new Map<number, RouteResponse[]>();
82
+ for (const defined of responses) {
83
+ grouped.set(defined.code, [...(grouped.get(defined.code) ?? []), defined]);
84
+ }
85
+ for (const [status, group] of grouped) {
86
+ documented[status] = {
87
+ description: group.map((defined) => defined.name).join(", "),
88
+ content: {
89
+ "application/json": {
90
+ schema: group.length === 1 ? group[0].schema : unionSchema(group),
91
+ },
92
+ },
93
+ };
94
+ }
95
+ if (Object.keys(documented).length === 0) {
96
+ documented[200] = {
97
+ description: "200",
98
+ content: {
99
+ "application/json": {
100
+ schema: z.object({}),
101
+ },
102
+ },
103
+ };
104
+ }
105
+ return documented;
106
+ }
107
+
108
+ function unionSchema(group: RouteResponse[]) {
109
+ const schemas = group.map((defined) => defined.schema);
110
+ return z.union(schemas as [ZodType, ZodType, ...ZodType[]]);
111
+ }
@@ -0,0 +1,18 @@
1
+ export function processValue<Value extends object>(
2
+ name: string,
3
+ create: () => Value,
4
+ ) {
5
+ const key = Symbol.for(`keja.${name}`);
6
+ const host = globalThis as Record<symbol, Value | undefined>;
7
+ const existing = host[key];
8
+ if (existing) {
9
+ return existing;
10
+ }
11
+ const created = create();
12
+ host[key] = created;
13
+ return created;
14
+ }
15
+
16
+ export function processBrand(name: string) {
17
+ return Symbol.for(`keja.${name}`);
18
+ }
@@ -0,0 +1,143 @@
1
+ import { mkdtempSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import path from "node:path";
4
+ import { describe, expect, it } from "vitest";
5
+ import { defineConfig, loadConfig, resolveProjectPaths } from "./config.ts";
6
+
7
+ describe("defineConfig", () => {
8
+ it("uses the default project paths", () => {
9
+ expect(defineConfig()).toMatchObject({
10
+ versioning: "keja.versioning.ts",
11
+ public_path: "src/public",
12
+ service_path: "src/service",
13
+ });
14
+ });
15
+
16
+ it("keeps the paths that are set", () => {
17
+ expect(
18
+ defineConfig({
19
+ versioning: "config/versions.ts",
20
+ public_path: "routes/public",
21
+ }),
22
+ ).toMatchObject({
23
+ versioning: "config/versions.ts",
24
+ public_path: "routes/public",
25
+ service_path: "src/service",
26
+ });
27
+ });
28
+
29
+ it("keeps the database disabled until it is turned on", () => {
30
+ expect(defineConfig().database).toEqual({ enabled: false });
31
+ expect(defineConfig({ db: { enabled: false } }).database).toEqual({
32
+ enabled: false,
33
+ });
34
+ expect(defineConfig({ db: { enabled: true } }).database).toEqual({
35
+ enabled: true,
36
+ migrations: "migrations",
37
+ });
38
+ expect(
39
+ defineConfig({
40
+ db: { enabled: true, migrations: "db/migrations" },
41
+ }).database,
42
+ ).toEqual({
43
+ enabled: true,
44
+ migrations: "db/migrations",
45
+ });
46
+ });
47
+
48
+ it("rejects an empty path", () => {
49
+ expect(() => defineConfig({ service_path: " " })).toThrow(
50
+ "keja.config.ts service_path must be a non-empty path",
51
+ );
52
+ });
53
+
54
+ it("keeps an optional Argus registry", () => {
55
+ expect(
56
+ defineConfig({
57
+ name: "billing",
58
+ argus: {
59
+ enabled: true,
60
+ url: "https://argus.veducio.com/",
61
+ env: "staging",
62
+ username: "argus",
63
+ password: "secret",
64
+ public: "billing",
65
+ },
66
+ }).argus,
67
+ ).toEqual({
68
+ enabled: true,
69
+ url: "https://argus.veducio.com",
70
+ env: "staging",
71
+ username: "argus",
72
+ password: "secret",
73
+ public: "billing",
74
+ service: null,
75
+ });
76
+ expect(defineConfig({ argus: { enabled: false } }).argus).toEqual({
77
+ enabled: false,
78
+ });
79
+ expect(() =>
80
+ defineConfig({
81
+ argus: { enabled: true, url: "argus.veducio.com", env: "staging" },
82
+ }),
83
+ ).toThrow("keja.config.ts argus.url must be an absolute http or https URL");
84
+ });
85
+
86
+ it("reads Vault settings from config", () => {
87
+ expect(
88
+ defineConfig({
89
+ vault: {
90
+ enabled: true,
91
+ address: "https://vault.example/",
92
+ token: "s.token",
93
+ namespace: "apps",
94
+ mount: "kv",
95
+ },
96
+ }).vault,
97
+ ).toEqual({
98
+ enabled: true,
99
+ address: "https://vault.example",
100
+ token: "s.token",
101
+ namespace: "apps",
102
+ mount: "kv",
103
+ transit_key: null,
104
+ transit_mount: "transit",
105
+ grant_max_exp: 900,
106
+ });
107
+ expect(defineConfig().vault).toEqual({ enabled: false });
108
+ });
109
+ });
110
+
111
+ describe("loadConfig", () => {
112
+ it("resolves the default paths from the project root", async () => {
113
+ const root = mkdtempSync(path.join(tmpdir(), "keja-config-"));
114
+ await expect(loadConfig(root)).resolves.toEqual({
115
+ config: null,
116
+ name: null,
117
+ argus: { enabled: false },
118
+ database: { enabled: false },
119
+ vault: { enabled: false },
120
+ versioning: path.resolve(root, "keja.versioning.ts"),
121
+ public: path.resolve(root, "src/public"),
122
+ service: path.resolve(root, "src/service"),
123
+ });
124
+ });
125
+
126
+ it("resolves custom paths from the project root", () => {
127
+ const root = path.resolve("app");
128
+ expect(
129
+ resolveProjectPaths(
130
+ root,
131
+ defineConfig({
132
+ versioning: "versioning/api.ts",
133
+ public_path: "http/public",
134
+ service_path: "http/service",
135
+ }),
136
+ ),
137
+ ).toEqual({
138
+ versioning: path.resolve(root, "versioning/api.ts"),
139
+ public: path.resolve(root, "http/public"),
140
+ service: path.resolve(root, "http/service"),
141
+ });
142
+ });
143
+ });