@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,79 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { z } from "zod";
3
+ import { defineRoute, response } from "../route/define.ts";
4
+ import {
5
+ defineVersions,
6
+ parseReleasedAt,
7
+ resolveVersionToken,
8
+ } from "./define.ts";
9
+
10
+ const versions = defineVersions({
11
+ v1: { releasedAt: "2020-01-15" },
12
+ v2: { releasedAt: "01-06-2024" },
13
+ }).versions;
14
+
15
+ describe("parseReleasedAt", () => {
16
+ it("accepts calendar dates and unix timestamps", () => {
17
+ expect(parseReleasedAt("2020-09-20")).toBe(Date.UTC(2020, 8, 20));
18
+ expect(parseReleasedAt("20-09-2020")).toBe(Date.UTC(2020, 8, 20));
19
+ expect(parseReleasedAt(1_600_560_000)).toBe(1_600_560_000_000);
20
+ expect(parseReleasedAt(1_600_560_000_000)).toBe(1_600_560_000_000);
21
+ expect(parseReleasedAt("2020-13-40")).toBeNull();
22
+ });
23
+ });
24
+
25
+ describe("defineVersions", () => {
26
+ it("accepts v followed by a whole number", () => {
27
+ expect(
28
+ defineVersions({
29
+ v0: { releasedAt: "2020-01-15" },
30
+ v10: { releasedAt: "2024-06-01" },
31
+ }).versions.map((version) => version.number),
32
+ ).toEqual([0, 10]);
33
+ });
34
+
35
+ it("rejects a version that is not v followed by a whole number", () => {
36
+ expect(() =>
37
+ defineVersions({
38
+ "v1.2": { releasedAt: "2020-01-15" },
39
+ } as never),
40
+ ).toThrow("Version v1.2 must use the form v1, v2, v3");
41
+ expect(() =>
42
+ defineVersions({
43
+ v01: { releasedAt: "2020-01-15" },
44
+ } as never),
45
+ ).toThrow("Version v01 must use the form v1, v2, v3");
46
+ });
47
+ });
48
+
49
+ describe("defineRoute version", () => {
50
+ it("rejects a version that is not v followed by a whole number", () => {
51
+ expect(() =>
52
+ defineRoute({
53
+ method: "get",
54
+ path: "/users",
55
+ version: "v1.2",
56
+ responses: {
57
+ Ok: response({ code: 200, json: z.object({}) }),
58
+ },
59
+ handler: () => new Response(),
60
+ } as never),
61
+ ).toThrow("Version v1.2 must use the form v1, v2, v3");
62
+ });
63
+ });
64
+
65
+ describe("resolveVersionToken", () => {
66
+ it("selects the newest version that is not after the requested date", () => {
67
+ expect(resolveVersionToken("2020-09-20", versions)?.id).toBe("v1");
68
+ expect(resolveVersionToken("2024-06-01", versions)?.id).toBe("v2");
69
+ expect(resolveVersionToken("15-01-2020", versions)?.id).toBe("v1");
70
+ expect(resolveVersionToken("2019-01-01", versions)).toBeNull();
71
+ });
72
+
73
+ it("selects named versions and the latest keyword", () => {
74
+ expect(resolveVersionToken("v1", versions)?.id).toBe("v1");
75
+ expect(resolveVersionToken("v9", versions)).toBeNull();
76
+ expect(resolveVersionToken("latest", versions)?.id).toBe("v2");
77
+ expect(resolveVersionToken("CURRENT", versions)?.id).toBe("v2");
78
+ });
79
+ });
@@ -0,0 +1,173 @@
1
+ import { projectError } from "../errors/framework-errors.ts";
2
+
3
+ export type VersionDefinition = {
4
+ id: string;
5
+ number: number;
6
+ releasedAt: number;
7
+ };
8
+
9
+ const versionKey = /^v(0|[1-9]\d*)$/;
10
+ const isoDate = /^(\d{4})-(\d{2})-(\d{2})$/;
11
+ const dayMonthYear = /^(\d{2})-(\d{2})-(\d{4})$/;
12
+
13
+ export function parseReleasedAt(value: string | number): number | null {
14
+ if (typeof value === "number") {
15
+ return unixMilliseconds(value);
16
+ }
17
+ const text = value.trim();
18
+ if (/^\d+$/.test(text)) {
19
+ return unixMilliseconds(Number(text));
20
+ }
21
+ const iso = isoDate.exec(text);
22
+ if (iso) {
23
+ return calendarUtc(Number(iso[1]), Number(iso[2]), Number(iso[3]));
24
+ }
25
+ const dmy = dayMonthYear.exec(text);
26
+ if (dmy) {
27
+ return calendarUtc(Number(dmy[3]), Number(dmy[2]), Number(dmy[1]));
28
+ }
29
+ return null;
30
+ }
31
+
32
+ type WholeNumber<Value extends string> = Value extends `${bigint}`
33
+ ? Value extends `-${string}` | `0${string}`
34
+ ? Value extends "0"
35
+ ? Value
36
+ : never
37
+ : Value
38
+ : never;
39
+
40
+ export type VersionId<Value extends string> = Value extends `v${infer Rest}`
41
+ ? WholeNumber<Rest> extends never
42
+ ? never
43
+ : Value
44
+ : never;
45
+
46
+ type VersionRecord = Record<string, { releasedAt: string | number }>;
47
+
48
+ type CheckedVersions<Versions extends VersionRecord> = {
49
+ [Key in keyof Versions]: Key extends string
50
+ ? VersionId<Key> extends never
51
+ ? "Version must use the form v1, v2, v3"
52
+ : Versions[Key]
53
+ : never;
54
+ };
55
+
56
+ export function readVersionNumber(id: string): number | null {
57
+ const match = versionKey.exec(id);
58
+ if (!match) {
59
+ return null;
60
+ }
61
+ const number = Number(match[1]);
62
+ if (!Number.isSafeInteger(number) || String(number) !== match[1]) {
63
+ return null;
64
+ }
65
+ return number;
66
+ }
67
+
68
+ export function defineVersions<const Versions extends VersionRecord>(
69
+ versions: Versions & CheckedVersions<Versions>,
70
+ ) {
71
+ const parsed = Object.entries(versions).map(([id, version]) => {
72
+ const number = readVersionNumber(id);
73
+ if (number === null) {
74
+ throw projectError(
75
+ "invalidVersionId",
76
+ `Version ${id} must use the form v1, v2, v3`,
77
+ );
78
+ }
79
+ const releasedAt = parseReleasedAt(version.releasedAt);
80
+ if (releasedAt === null) {
81
+ throw projectError(
82
+ "invalidReleaseDate",
83
+ `Version ${id} has an invalid release date`,
84
+ );
85
+ }
86
+ return {
87
+ id,
88
+ number,
89
+ releasedAt,
90
+ };
91
+ });
92
+ return {
93
+ versions: parsed.sort(compareVersions),
94
+ };
95
+ }
96
+
97
+ export function isVersions(
98
+ value: unknown,
99
+ ): value is { versions: VersionDefinition[] } {
100
+ return (
101
+ typeof value === "object" &&
102
+ value !== null &&
103
+ "versions" in value &&
104
+ Array.isArray(value.versions)
105
+ );
106
+ }
107
+
108
+ export function newestVersion(versions: readonly VersionDefinition[]) {
109
+ return [...versions].sort(compareVersions).at(-1) ?? null;
110
+ }
111
+
112
+ export function resolveVersionToken(
113
+ token: string,
114
+ versions: readonly VersionDefinition[],
115
+ ) {
116
+ if (versions.length === 0) {
117
+ return null;
118
+ }
119
+ const keyword = token.toLowerCase();
120
+ if (keyword === "latest" || keyword === "current") {
121
+ return newestVersion(versions);
122
+ }
123
+ if (versionKey.test(token)) {
124
+ return versions.find((version) => version.id === token) ?? null;
125
+ }
126
+ const releasedAt = parseReleasedAt(token);
127
+ if (releasedAt === null) {
128
+ return null;
129
+ }
130
+ const eligible = versions.filter(
131
+ (version) => version.releasedAt <= releasedAt,
132
+ );
133
+ return newestVersion(eligible);
134
+ }
135
+
136
+ export function isAfterVersion(
137
+ candidate: VersionDefinition,
138
+ requested: VersionDefinition,
139
+ ) {
140
+ if (candidate.releasedAt !== requested.releasedAt) {
141
+ return candidate.releasedAt > requested.releasedAt;
142
+ }
143
+ return candidate.number > requested.number;
144
+ }
145
+
146
+ function unixMilliseconds(value: number) {
147
+ if (!Number.isFinite(value) || value < 0) {
148
+ return null;
149
+ }
150
+ return value >= 1_000_000_000_000 ? value : value * 1000;
151
+ }
152
+
153
+ function calendarUtc(year: number, month: number, day: number) {
154
+ if (month < 1 || month > 12 || day < 1 || day > 31) {
155
+ return null;
156
+ }
157
+ const date = new Date(Date.UTC(year, month - 1, day));
158
+ if (
159
+ date.getUTCFullYear() !== year ||
160
+ date.getUTCMonth() !== month - 1 ||
161
+ date.getUTCDate() !== day
162
+ ) {
163
+ return null;
164
+ }
165
+ return date.getTime();
166
+ }
167
+
168
+ function compareVersions(left: VersionDefinition, right: VersionDefinition) {
169
+ if (left.releasedAt !== right.releasedAt) {
170
+ return left.releasedAt - right.releasedAt;
171
+ }
172
+ return left.number - right.number;
173
+ }
@@ -0,0 +1,79 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { z } from "zod";
3
+ import { defineRoute, response } from "../route/define.ts";
4
+ import { defineVersions } from "./define.ts";
5
+ import { selectRoute } from "./select.ts";
6
+
7
+ const versions = defineVersions({
8
+ v1: { releasedAt: "2020-01-15" },
9
+ v2: { releasedAt: "2024-06-01" },
10
+ }).versions;
11
+
12
+ const user = z.object({ id: z.string() });
13
+
14
+ const routes = [
15
+ defineRoute({
16
+ method: "get",
17
+ path: "/users/:id",
18
+ version: "v1",
19
+ request: { params: user },
20
+ responses: {
21
+ User: response({ code: 200, json: user }),
22
+ },
23
+ handler: ({ params, respond }) => respond.User(params),
24
+ }),
25
+ defineRoute({
26
+ method: "get",
27
+ path: "/reports",
28
+ version: "v2",
29
+ responses: {
30
+ Ready: response({ code: 200, json: z.object({ ok: z.literal(true) }) }),
31
+ },
32
+ handler: ({ respond }) => respond.Ready({ ok: true }),
33
+ }),
34
+ ];
35
+
36
+ describe("selectRoute", () => {
37
+ it("uses the newest handler that is not after the requested version", () => {
38
+ const v2 = versions.find((version) => version.id === "v2");
39
+ const v1 = versions.find((version) => version.id === "v1");
40
+ if (!v2 || !v1) {
41
+ throw new Error("missing versions");
42
+ }
43
+ expect(
44
+ selectRoute(routes, versions, "GET", "/users/1", v2)?.route.version,
45
+ ).toBe("v1");
46
+ expect(selectRoute(routes, versions, "GET", "/reports", v1)).toBeNull();
47
+ expect(
48
+ selectRoute(routes, versions, "GET", "/reports", v2)?.route.version,
49
+ ).toBe("v2");
50
+ });
51
+
52
+ it("matches a route nested several segments deep", () => {
53
+ const v1 = versions.find((version) => version.id === "v1");
54
+ if (!v1) {
55
+ throw new Error("missing versions");
56
+ }
57
+ const deep = defineRoute({
58
+ method: "get",
59
+ path: "/orgs/:orgId/teams/:teamId/members/:memberId",
60
+ version: "v1",
61
+ responses: {
62
+ Member: response({ code: 200, json: z.object({ id: z.string() }) }),
63
+ },
64
+ handler: ({ respond }) => respond.Member({ id: "ada" }),
65
+ });
66
+ const selected = selectRoute(
67
+ [deep],
68
+ versions,
69
+ "GET",
70
+ "/orgs/acme/teams/core/members/ada",
71
+ v1,
72
+ );
73
+ expect(selected?.params).toEqual({
74
+ orgId: "acme",
75
+ teamId: "core",
76
+ memberId: "ada",
77
+ });
78
+ });
79
+ });
@@ -0,0 +1,62 @@
1
+ import type { RouteDefinition } from "../route/define.ts";
2
+ import {
3
+ isAfterVersion,
4
+ type VersionDefinition,
5
+ } from "../versioning/define.ts";
6
+
7
+ export function matchPath(pattern: string, pathname: string) {
8
+ const expected = segments(pattern);
9
+ const actual = segments(pathname);
10
+ if (expected.length !== actual.length) {
11
+ return null;
12
+ }
13
+ const params: Record<string, string> = {};
14
+ for (const [index, part] of expected.entries()) {
15
+ const value = actual[index] ?? "";
16
+ if (part.startsWith(":")) {
17
+ params[part.slice(1)] = decodeURIComponent(value);
18
+ continue;
19
+ }
20
+ if (part !== value) {
21
+ return null;
22
+ }
23
+ }
24
+ return params;
25
+ }
26
+
27
+ export function selectRoute(
28
+ routes: readonly RouteDefinition[],
29
+ versions: readonly VersionDefinition[],
30
+ method: string,
31
+ pathname: string,
32
+ requested: VersionDefinition,
33
+ ) {
34
+ const matches = [];
35
+ for (const route of routes) {
36
+ if (route.method !== method.toLowerCase()) {
37
+ continue;
38
+ }
39
+ const params = matchPath(route.path, pathname);
40
+ if (!params) {
41
+ continue;
42
+ }
43
+ const version = versions.find((item) => item.id === route.version);
44
+ if (!version || isAfterVersion(version, requested)) {
45
+ continue;
46
+ }
47
+ matches.push({ route, params, version });
48
+ }
49
+ matches.sort((left, right) => {
50
+ if (left.version.releasedAt !== right.version.releasedAt) {
51
+ return right.version.releasedAt - left.version.releasedAt;
52
+ }
53
+ return right.version.number - left.version.number;
54
+ });
55
+ return matches[0] ?? null;
56
+ }
57
+
58
+ function segments(value: string) {
59
+ const normalized =
60
+ value.length > 1 && value.endsWith("/") ? value.slice(0, -1) : value;
61
+ return normalized.split("/").filter((part) => part.length > 0);
62
+ }