@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,105 @@
1
+ import { Chalk, type ChalkInstance } from "chalk";
2
+ import {
3
+ type BunCheck,
4
+ type BunRuntime,
5
+ checkBun,
6
+ checkEnv,
7
+ checkMachine,
8
+ type MachineCheck,
9
+ type MachineInfo,
10
+ } from "./checks.ts";
11
+
12
+ export type SelfTestResult =
13
+ | {
14
+ ok: false;
15
+ step: "bun" | "machine" | "env";
16
+ error: string;
17
+ }
18
+ | {
19
+ ok: true;
20
+ bun: Extract<BunCheck, { ok: true }>;
21
+ machine: Extract<MachineCheck, { ok: true }>;
22
+ };
23
+
24
+ export type SelfTestOptions = {
25
+ runtime?: BunRuntime | undefined;
26
+ machine?: MachineInfo;
27
+ env?: Record<string, string | undefined>;
28
+ };
29
+
30
+ export function runSelfTest(options: SelfTestOptions = {}): SelfTestResult {
31
+ const runtime = Object.hasOwn(options, "runtime")
32
+ ? options.runtime
33
+ : globalThis.Bun;
34
+ const bun = checkBun(runtime);
35
+ if (!bun.ok) {
36
+ return { ok: false, step: "bun", error: bun.error };
37
+ }
38
+
39
+ const machine = checkMachine(options.machine);
40
+ if (!machine.ok) {
41
+ return { ok: false, step: "machine", error: machine.error };
42
+ }
43
+
44
+ const env = options.env ?? runtime?.env ?? process.env;
45
+ const checkedEnv = checkEnv(env);
46
+ if (!checkedEnv.ok) {
47
+ return { ok: false, step: "env", error: checkedEnv.error };
48
+ }
49
+
50
+ return { ok: true, bun, machine };
51
+ }
52
+
53
+ export function formatSelfTest(result: SelfTestResult, color = false): string {
54
+ const style = ink(color);
55
+ const heading = ` ${style.dim("SELF TEST")}`;
56
+ if (!result.ok) {
57
+ const details = result.error
58
+ .split("\n")
59
+ .map((line) => ` ${line}`)
60
+ .join("\n");
61
+ return `\n${heading}\n\n ${style.red(`x ${stepLabel(result.step)}`)}\n${details}\n\n ${style.red("failed, exiting")}\n`;
62
+ }
63
+ return [
64
+ "",
65
+ heading,
66
+ "",
67
+ row("Bun", result.bun.version, style),
68
+ row(
69
+ "Machine",
70
+ `${result.machine.platform} ${result.machine.arch} ${result.machine.hostname}`,
71
+ style,
72
+ ),
73
+ row("Env", "ready", style),
74
+ "",
75
+ ` ${style.green("successful")}`,
76
+ "",
77
+ ].join("\n");
78
+ }
79
+
80
+ function stepLabel(step: "bun" | "machine" | "env") {
81
+ if (step === "bun") {
82
+ return "Bun";
83
+ }
84
+ if (step === "machine") {
85
+ return "Machine";
86
+ }
87
+ return "Env";
88
+ }
89
+
90
+ function row(label: string, value: string, style: ChalkInstance) {
91
+ return ` ${style.dim(`- ${label.padEnd(8)}`)} ${value}`;
92
+ }
93
+
94
+ function ink(color: boolean) {
95
+ return new Chalk({ level: color ? 1 : 0 });
96
+ }
97
+
98
+ if (import.meta.main) {
99
+ const result = runSelfTest();
100
+ if (!result.ok) {
101
+ console.error(formatSelfTest(result, process.stderr.isTTY === true));
102
+ process.exit(1);
103
+ }
104
+ console.log(formatSelfTest(result, process.stdout.isTTY === true));
105
+ }
@@ -0,0 +1,57 @@
1
+ import { 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
+ accessibleHosts,
7
+ buildIdentity,
8
+ readServiceName,
9
+ serviceName,
10
+ } from "./identity.ts";
11
+
12
+ const lan = {
13
+ Ethernet: [
14
+ { address: "192.168.1.10", family: "IPv4", internal: false },
15
+ { address: "fe80::1", family: "IPv6", internal: false },
16
+ { address: "127.0.0.1", family: "IPv4", internal: true },
17
+ ],
18
+ Tailscale: [{ address: "100.96.0.5", family: "IPv4", internal: false }],
19
+ };
20
+
21
+ describe("service identity", () => {
22
+ it("uses the package name without a scope", () => {
23
+ expect(serviceName("@veducio/billing", "app")).toBe("billing");
24
+ expect(serviceName(undefined, "notes")).toBe("notes");
25
+ });
26
+
27
+ it("prefers the configured name over package.json", () => {
28
+ const root = mkdtempSync(path.join(tmpdir(), "keja-identity-"));
29
+ writeFileSync(
30
+ path.join(root, "package.json"),
31
+ JSON.stringify({ name: "@veducio/billing" }),
32
+ );
33
+ expect(readServiceName(root, null)).toBe("billing");
34
+ expect(readServiceName(root, "payments")).toBe("payments");
35
+ });
36
+
37
+ it("lists reachable addresses for both API surfaces", () => {
38
+ expect(accessibleHosts("127.0.0.1", lan)).toEqual(["127.0.0.1"]);
39
+ expect(accessibleHosts("0.0.0.0", lan)).toEqual([
40
+ "192.168.1.10",
41
+ "100.96.0.5",
42
+ ]);
43
+ expect(
44
+ buildIdentity({
45
+ name: "billing",
46
+ bindHost: "0.0.0.0",
47
+ publicPort: 3000,
48
+ servicePort: 3001,
49
+ interfaces: lan,
50
+ }),
51
+ ).toEqual({
52
+ name: "billing",
53
+ public: ["http://192.168.1.10:3000", "http://100.96.0.5:3000"],
54
+ service: ["http://192.168.1.10:3001", "http://100.96.0.5:3001"],
55
+ });
56
+ });
57
+ });
@@ -0,0 +1,116 @@
1
+ import { readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+
4
+ export type NetworkAddress = {
5
+ address: string;
6
+ family: string | number;
7
+ internal: boolean;
8
+ };
9
+
10
+ export type ServiceIdentity = {
11
+ name: string;
12
+ public: string[];
13
+ service: string[];
14
+ };
15
+
16
+ export function serviceName(
17
+ packageName: string | undefined,
18
+ directoryName: string,
19
+ ) {
20
+ const trimmed = packageName?.trim() ?? "";
21
+ if (!trimmed) {
22
+ return directoryName;
23
+ }
24
+ const slash = trimmed.lastIndexOf("/");
25
+ return slash === -1 ? trimmed : trimmed.slice(slash + 1);
26
+ }
27
+
28
+ export function readServiceName(root: string, configured: string | null) {
29
+ if (configured) {
30
+ return configured;
31
+ }
32
+ return serviceName(readPackage(root).name, path.basename(root));
33
+ }
34
+
35
+ export function readPackageVersion(root: string) {
36
+ const version = readPackage(root).version?.trim();
37
+ return version ? version : undefined;
38
+ }
39
+
40
+ export function accessibleHosts(
41
+ bindHost: string,
42
+ interfaces: Record<string, readonly NetworkAddress[] | undefined>,
43
+ ) {
44
+ const bind = bindHost.trim();
45
+ if (bind !== "0.0.0.0" && bind !== "::" && bind !== "[::]") {
46
+ return [bind];
47
+ }
48
+ const found: string[] = [];
49
+ for (const entries of Object.values(interfaces)) {
50
+ for (const entry of entries ?? []) {
51
+ if (!usableAddress(entry) || found.includes(entry.address)) {
52
+ continue;
53
+ }
54
+ found.push(entry.address);
55
+ }
56
+ }
57
+ const ipv4 = found.filter((address) => !address.includes(":"));
58
+ const chosen = ipv4.length > 0 ? ipv4 : found;
59
+ return chosen.length > 0 ? chosen : ["127.0.0.1"];
60
+ }
61
+
62
+ export function buildIdentity(input: {
63
+ name: string;
64
+ bindHost: string;
65
+ publicPort: number;
66
+ servicePort: number;
67
+ interfaces: Record<string, readonly NetworkAddress[] | undefined>;
68
+ }): ServiceIdentity {
69
+ const hosts = accessibleHosts(input.bindHost, input.interfaces);
70
+ return {
71
+ name: input.name,
72
+ public: hosts.map((host) => endpoint(host, input.publicPort)),
73
+ service: hosts.map((host) => endpoint(host, input.servicePort)),
74
+ };
75
+ }
76
+
77
+ function usableAddress(entry: NetworkAddress) {
78
+ if (entry.internal || entry.address.includes("%")) {
79
+ return false;
80
+ }
81
+ if (
82
+ entry.address.startsWith("169.254.") ||
83
+ entry.address.startsWith("fe80:")
84
+ ) {
85
+ return false;
86
+ }
87
+ return (
88
+ entry.family === 4 ||
89
+ entry.family === "IPv4" ||
90
+ entry.family === 6 ||
91
+ entry.family === "IPv6"
92
+ );
93
+ }
94
+
95
+ function endpoint(host: string, port: number) {
96
+ const formatted =
97
+ host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
98
+ return `http://${formatted}:${port}`;
99
+ }
100
+
101
+ function readPackage(root: string) {
102
+ try {
103
+ const parsed = JSON.parse(
104
+ readFileSync(path.join(root, "package.json"), "utf8"),
105
+ ) as {
106
+ name?: unknown;
107
+ version?: unknown;
108
+ };
109
+ return {
110
+ name: typeof parsed.name === "string" ? parsed.name : undefined,
111
+ version: typeof parsed.version === "string" ? parsed.version : undefined,
112
+ };
113
+ } catch {
114
+ return { name: undefined, version: undefined };
115
+ }
116
+ }
@@ -0,0 +1,15 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { createHttpApp } from "../http/create-app.ts";
3
+ import { defineVersions } from "../versioning/define.ts";
4
+
5
+ describe("createServer import", () => {
6
+ it("does not listen when the http app is created", async () => {
7
+ const app = createHttpApp({
8
+ title: "Public",
9
+ versions: defineVersions({ v1: { releasedAt: "2020-01-15" } }).versions,
10
+ routes: [],
11
+ });
12
+ const response = await app.request("/_health");
13
+ expect(response.status).toBe(200);
14
+ });
15
+ });
@@ -0,0 +1,171 @@
1
+ import { networkInterfaces } from "node:os";
2
+ import { setArgus } from "../argus/call.ts";
3
+ import { startArgusHeartbeat } from "../argus/heartbeat.ts";
4
+ import { argusRegistrations, publishArgus } from "../argus/register.ts";
5
+ import { connectDatabase } from "../db/postgres.ts";
6
+ import { readOpenApiServers, readRequiredEnv } from "../env/required-env.ts";
7
+ import { projectError } from "../errors/framework-errors.ts";
8
+ import { createHttpApp } from "../http/create-app.ts";
9
+ import { type LoadedProject, loadProject } from "../project/load.ts";
10
+ import { formatSelfTest, runSelfTest } from "../self-test/run.ts";
11
+ import { connectVault, setVault } from "../vault/client.ts";
12
+ import {
13
+ buildIdentity,
14
+ readPackageVersion,
15
+ readServiceName,
16
+ } from "./identity.ts";
17
+ import { swapProjectHandlers } from "./swap.ts";
18
+
19
+ export type { AccessIdentity } from "../access/cloudflare.ts";
20
+ export { cloudflareAccess, verifyAccessJwt } from "../access/cloudflare.ts";
21
+ export { callService, ServiceCallError } from "../argus/call.ts";
22
+ export { DBCall } from "../db/client.ts";
23
+ export {
24
+ DependencyNotMet,
25
+ frameworkErrors,
26
+ } from "../errors/framework-errors.ts";
27
+ export type { Grant } from "../grant/grant.ts";
28
+ export { grant, verifyGrant } from "../grant/grant.ts";
29
+ export { defineConfig } from "../project/config.ts";
30
+ export { defineRepositories } from "../repository/define.ts";
31
+ export { repository } from "../repository/runtime.ts";
32
+ export { defineRoute, response } from "../route/define.ts";
33
+ export { defineMiddleware } from "../route/middleware.ts";
34
+ export { defineSeeder } from "../seed/define.ts";
35
+ export type {
36
+ CreateObjectRowOptions,
37
+ ObjectRowMeta,
38
+ ParsedTypeId,
39
+ } from "../typeid/id.ts";
40
+ export {
41
+ assertObjectType,
42
+ createObjectRow,
43
+ createTypeId,
44
+ idPrefixFromObjectType,
45
+ isObjectType,
46
+ isTypeId,
47
+ objectTypeFromRoute,
48
+ parseTypeId,
49
+ TypeIdError,
50
+ } from "../typeid/id.ts";
51
+ export type { OwnedObject } from "../typeid/owned.ts";
52
+ export { assertOwnedObject } from "../typeid/owned.ts";
53
+ export { createVault, VaultError, vaultSecret } from "../vault/client.ts";
54
+ export { defineVersions } from "../versioning/define.ts";
55
+
56
+ export async function listen(
57
+ root = process.cwd(),
58
+ options?: { generation?: string },
59
+ ) {
60
+ const project = await loadProject(root, options);
61
+ const result = runSelfTest();
62
+ if (!result.ok) {
63
+ console.error(formatSelfTest(result, process.stderr.isTTY === true));
64
+ process.exit(1);
65
+ }
66
+ console.log(formatSelfTest(result, process.stdout.isTTY === true));
67
+
68
+ const env = readRequiredEnv(Bun.env);
69
+ const openApiServers = readOpenApiServers(Bun.env);
70
+ if (project.database.enabled) {
71
+ await connectDatabase(Bun.env.DATABASE_URL ?? "");
72
+ }
73
+ if (project.vault.enabled) {
74
+ await connectVault(project.vault);
75
+ } else {
76
+ setVault(null);
77
+ }
78
+ const mounted = mountProject(root, project, env, openApiServers);
79
+ const identity = mounted.identity;
80
+ if (project.argus.enabled) {
81
+ const errors = await publishArgus({
82
+ url: project.argus.url,
83
+ username: project.argus.username,
84
+ password: project.argus.password,
85
+ registrations: argusRegistrations({
86
+ identity,
87
+ argus: project.argus,
88
+ version: readPackageVersion(root),
89
+ }),
90
+ });
91
+ if (errors.length > 0) {
92
+ throw projectError("argusInit", errors.join("; "));
93
+ }
94
+ }
95
+ const publicServer = Bun.serve({
96
+ hostname: env.host,
97
+ port: env.httpPort,
98
+ fetch(request) {
99
+ return mounted.publicApp.fetch(request);
100
+ },
101
+ });
102
+ const serviceServer = Bun.serve({
103
+ hostname: env.host,
104
+ port: env.httpServicePort,
105
+ fetch(request) {
106
+ return mounted.serviceApp.fetch(request);
107
+ },
108
+ });
109
+ setArgus(project.argus.enabled ? project.argus : null);
110
+ if (project.argus.enabled) {
111
+ startArgusHeartbeat({
112
+ argus: project.argus,
113
+ identity,
114
+ version: readPackageVersion(root),
115
+ lead: false,
116
+ });
117
+ }
118
+ return {
119
+ publicServer,
120
+ serviceServer,
121
+ paths: project.paths,
122
+ identity,
123
+ argus: project.argus,
124
+ vault: project.vault,
125
+ database: project.database.enabled ? { connected: true as const } : null,
126
+ reload(next: LoadedProject) {
127
+ swapProjectHandlers(
128
+ { publicServer, serviceServer },
129
+ mountProject(root, next, env, openApiServers),
130
+ );
131
+ },
132
+ };
133
+ }
134
+
135
+ function mountProject(
136
+ root: string,
137
+ project: LoadedProject,
138
+ env: { host: string; httpPort: number; httpServicePort: number },
139
+ openApiServers: {
140
+ openapiServer: string | null;
141
+ openapiServiceServer: string | null;
142
+ },
143
+ ) {
144
+ const identity = buildIdentity({
145
+ name: readServiceName(root, project.name),
146
+ bindHost: env.host,
147
+ publicPort: env.httpPort,
148
+ servicePort: env.httpServicePort,
149
+ interfaces: networkInterfaces(),
150
+ });
151
+ return {
152
+ identity,
153
+ publicApp: createHttpApp({
154
+ title: "Public",
155
+ versions: project.versions,
156
+ routes: project.publicRoutes,
157
+ middleware: project.publicMiddleware,
158
+ server: openApiServers.openapiServer,
159
+ identity,
160
+ }),
161
+ serviceApp: createHttpApp({
162
+ title: "Service",
163
+ versions: project.versions,
164
+ routes: project.serviceRoutes,
165
+ middleware: project.serviceMiddleware,
166
+ server: openApiServers.openapiServiceServer,
167
+ identity,
168
+ postOnly: true,
169
+ }),
170
+ };
171
+ }
@@ -0,0 +1,89 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { z } from "zod";
3
+ import { createHttpApp } from "../http/create-app.ts";
4
+ import { defineRoute, response } from "../route/define.ts";
5
+ import { defineVersions } from "../versioning/define.ts";
6
+ import { swapProjectHandlers } from "./swap.ts";
7
+
8
+ const versions = defineVersions({
9
+ v1: { releasedAt: "2026-01-01" },
10
+ v2: { releasedAt: "2026-06-01" },
11
+ }).versions;
12
+
13
+ describe("project handler swap", () => {
14
+ it("reloads openapi and docs on both servers", async () => {
15
+ const first = apps("/notes", "v1");
16
+ const second = apps("/posts", "v2");
17
+ const fetches: Array<(request: Request) => Response | Promise<Response>> =
18
+ [];
19
+ const server = () => ({
20
+ reload(options: {
21
+ fetch: (request: Request) => Response | Promise<Response>;
22
+ }) {
23
+ fetches.push(options.fetch);
24
+ },
25
+ });
26
+ swapProjectHandlers(
27
+ { publicServer: server(), serviceServer: server() },
28
+ second,
29
+ );
30
+ expect(
31
+ await (await first.publicApp.request("/_openapi.json")).json(),
32
+ ).toMatchObject({
33
+ paths: { "/v1/notes": { get: expect.any(Object) } },
34
+ });
35
+ const publicSpec = await fetches[0]?.(
36
+ new Request("http://localhost/_openapi.json"),
37
+ );
38
+ const serviceSpec = await fetches[1]?.(
39
+ new Request("http://localhost/_openapi.json"),
40
+ );
41
+ const publicDocs = await fetches[0]?.(new Request("http://localhost/docs"));
42
+ expect(publicSpec?.status).toBe(200);
43
+ expect(await publicSpec?.json()).toMatchObject({
44
+ paths: { "/v2/posts": { get: expect.any(Object) } },
45
+ });
46
+ expect(serviceSpec?.status).toBe(200);
47
+ expect(await serviceSpec?.json()).toMatchObject({
48
+ paths: { "/v2/posts": { post: expect.any(Object) } },
49
+ });
50
+ expect(publicDocs?.status).toBe(200);
51
+ expect(await publicDocs?.text()).toContain("/_openapi.json");
52
+ });
53
+ });
54
+
55
+ function apps(routePath: string, version: "v1" | "v2") {
56
+ const routes = [
57
+ defineRoute({
58
+ method: "get",
59
+ path: routePath,
60
+ version,
61
+ responses: {
62
+ Ok: response({ code: 200, json: z.object({ ok: z.literal(true) }) }),
63
+ },
64
+ handler: ({ respond }) => respond.Ok({ ok: true }),
65
+ }),
66
+ defineRoute({
67
+ method: "post",
68
+ path: routePath,
69
+ version,
70
+ responses: {
71
+ Ok: response({ code: 200, json: z.object({ ok: z.literal(true) }) }),
72
+ },
73
+ handler: ({ respond }) => respond.Ok({ ok: true }),
74
+ }),
75
+ ];
76
+ return {
77
+ publicApp: createHttpApp({
78
+ title: "Public",
79
+ versions,
80
+ routes: routes.filter((route) => route.method === "get"),
81
+ }),
82
+ serviceApp: createHttpApp({
83
+ title: "Service",
84
+ versions,
85
+ routes: routes.filter((route) => route.method === "post"),
86
+ postOnly: true,
87
+ }),
88
+ };
89
+ }
@@ -0,0 +1,29 @@
1
+ export function swapProjectHandlers(
2
+ servers: {
3
+ publicServer: {
4
+ reload: (options: {
5
+ fetch: (request: Request) => Response | Promise<Response>;
6
+ }) => void;
7
+ };
8
+ serviceServer: {
9
+ reload: (options: {
10
+ fetch: (request: Request) => Response | Promise<Response>;
11
+ }) => void;
12
+ };
13
+ },
14
+ apps: {
15
+ publicApp: {
16
+ fetch: (request: Request) => Response | Promise<Response>;
17
+ };
18
+ serviceApp: {
19
+ fetch: (request: Request) => Response | Promise<Response>;
20
+ };
21
+ },
22
+ ) {
23
+ servers.publicServer.reload({
24
+ fetch: (request) => apps.publicApp.fetch(request),
25
+ });
26
+ servers.serviceServer.reload({
27
+ fetch: (request) => apps.serviceApp.fetch(request),
28
+ });
29
+ }
@@ -0,0 +1,46 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ createObjectRow,
4
+ createTypeId,
5
+ idPrefixFromObjectType,
6
+ isObjectType,
7
+ isTypeId,
8
+ objectTypeFromRoute,
9
+ parseTypeId,
10
+ } from "./id.ts";
11
+
12
+ describe("typeids", () => {
13
+ it("creates a prefixed typeid and reads it back", () => {
14
+ const id = createTypeId("user");
15
+ expect(isTypeId(id, "user")).toBe(true);
16
+ expect(parseTypeId(id)).toEqual({
17
+ prefix: "user",
18
+ suffix: id.slice("user_".length),
19
+ });
20
+ expect(id.slice("user_".length)).toHaveLength(26);
21
+ expect(parseTypeId("User_abc")).toBeNull();
22
+ expect(() => createTypeId("User")).toThrow("invalid type prefix: User");
23
+ });
24
+
25
+ it("derives an object type from a route and builds an OwnedObjects row", () => {
26
+ expect(objectTypeFromRoute(2, "/core/users")).toBe("v2.core.users");
27
+ expect(idPrefixFromObjectType("v2.core.users")).toBe("users");
28
+ expect(isObjectType("v2.core.users")).toBe(true);
29
+ expect(isObjectType("users")).toBe(false);
30
+ const row = createObjectRow("v2.core.users", { tenant: "acme" });
31
+ expect(row._object).toBe("v2.core.users");
32
+ expect(row._tenant).toBe("acme");
33
+ expect(row._archived).toBe(false);
34
+ expect(row._createdAt).toEqual(row._updatedAt);
35
+ expect(isTypeId(row._id, "users")).toBe(true);
36
+ });
37
+
38
+ it("rejects a route that cannot become an object type", () => {
39
+ expect(() => objectTypeFromRoute(0, "/users")).toThrow(
40
+ "invalid API version: 0",
41
+ );
42
+ expect(() => objectTypeFromRoute(1, "/users/:id")).toThrow(
43
+ 'invalid route segment ":id" in /users/:id',
44
+ );
45
+ });
46
+ });