create-svc 0.1.10 → 0.1.12

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 (171) hide show
  1. package/README.md +51 -47
  2. package/index.ts +2 -2
  3. package/package.json +10 -9
  4. package/src/cli.test.ts +28 -10
  5. package/src/cli.ts +196 -33
  6. package/src/git-bootstrap.test.ts +40 -0
  7. package/src/git-bootstrap.ts +110 -0
  8. package/src/naming.test.ts +1 -0
  9. package/src/naming.ts +23 -0
  10. package/src/post-scaffold.test.ts +19 -0
  11. package/src/post-scaffold.ts +17 -4
  12. package/src/profiles.ts +2 -5
  13. package/src/scaffold.test.ts +232 -41
  14. package/src/scaffold.ts +81 -36
  15. package/src/service.test.ts +30 -0
  16. package/src/service.ts +65 -0
  17. package/src/vault.test.ts +61 -1
  18. package/src/vault.ts +77 -15
  19. package/templates/shared/.github/workflows/ci.yml +2 -1
  20. package/templates/shared/.github/workflows/deploy.yml +2 -0
  21. package/templates/shared/README.md +124 -47
  22. package/templates/shared/grafana/alerts.yaml +54 -0
  23. package/templates/shared/grafana/waitlist-dashboard.json +63 -0
  24. package/templates/shared/scripts/authctl.ts +231 -0
  25. package/templates/shared/scripts/cloudrun/bootstrap.ts +14 -5
  26. package/templates/shared/scripts/cloudrun/cleanup.ts +64 -4
  27. package/templates/shared/scripts/cloudrun/cli.ts +329 -7
  28. package/templates/shared/scripts/cloudrun/config.ts +11 -4
  29. package/templates/shared/scripts/cloudrun/deploy.ts +0 -4
  30. package/templates/shared/scripts/cloudrun/lib.ts +174 -41
  31. package/templates/shared/scripts/cloudrun/neon.ts +45 -0
  32. package/templates/shared/scripts/dev.ts +22 -0
  33. package/templates/shared/scripts/ensure-local-db.ts +3 -0
  34. package/templates/shared/scripts/local-docker.ts +63 -0
  35. package/templates/shared/scripts/local-env.ts +27 -0
  36. package/templates/shared/scripts/seed.ts +73 -0
  37. package/templates/shared/scripts/wait-for-db.ts +32 -0
  38. package/templates/shared/service.config.ts +59 -0
  39. package/templates/shared/service.yaml +24 -44
  40. package/templates/targets/workers/.github/workflows/ci.yml +19 -0
  41. package/templates/targets/workers/.github/workflows/deploy.yml +19 -0
  42. package/templates/targets/workers/Makefile +33 -0
  43. package/templates/targets/workers/README.md +75 -0
  44. package/templates/targets/workers/package.json +35 -0
  45. package/templates/targets/workers/scripts/workers/cli.ts +402 -0
  46. package/templates/targets/workers/src/auth.ts +178 -0
  47. package/templates/targets/workers/src/index.ts +198 -0
  48. package/templates/targets/workers/src/storage.ts +370 -0
  49. package/templates/targets/workers/test/app.test.ts +108 -0
  50. package/templates/targets/workers/tsconfig.json +11 -0
  51. package/templates/targets/workers/wrangler.toml +24 -0
  52. package/templates/variants/bun-connectrpc/Makefile +14 -8
  53. package/templates/variants/bun-connectrpc/gen/protos/waitlist/v1/waitlist_pb.ts +424 -0
  54. package/templates/variants/bun-connectrpc/migrations/0000_init.sql +12 -55
  55. package/templates/variants/bun-connectrpc/package.json +12 -5
  56. package/templates/variants/bun-connectrpc/protos/waitlist/v1/waitlist.proto +91 -0
  57. package/templates/variants/bun-connectrpc/scripts/codegen.ts +1 -1
  58. package/templates/variants/bun-connectrpc/scripts/migrate.ts +4 -1
  59. package/templates/variants/bun-connectrpc/src/auth.ts +200 -0
  60. package/templates/variants/bun-connectrpc/src/db/repository.ts +67 -420
  61. package/templates/variants/bun-connectrpc/src/db/schema.ts +15 -64
  62. package/templates/variants/bun-connectrpc/src/index.ts +76 -176
  63. package/templates/variants/bun-connectrpc/src/temporal/activities.ts +14 -0
  64. package/templates/variants/bun-connectrpc/src/temporal/worker.ts +38 -0
  65. package/templates/variants/bun-connectrpc/src/temporal/workflows.ts +10 -0
  66. package/templates/variants/bun-connectrpc/src/waitlist/service.ts +172 -0
  67. package/templates/variants/bun-connectrpc/src/waitlist/types.ts +45 -0
  68. package/templates/variants/bun-connectrpc/test/app.test.ts +4 -4
  69. package/templates/variants/bun-connectrpc/test/waitlist.integration.test.ts +71 -0
  70. package/templates/variants/bun-hono/Makefile +14 -8
  71. package/templates/variants/bun-hono/migrations/0000_init.sql +12 -55
  72. package/templates/variants/bun-hono/package.json +12 -5
  73. package/templates/variants/bun-hono/scripts/migrate.ts +4 -1
  74. package/templates/variants/bun-hono/src/auth.ts +181 -0
  75. package/templates/variants/bun-hono/src/db/repository.ts +68 -421
  76. package/templates/variants/bun-hono/src/db/schema.ts +15 -64
  77. package/templates/variants/bun-hono/src/index.ts +65 -180
  78. package/templates/variants/bun-hono/src/temporal/activities.ts +14 -0
  79. package/templates/variants/bun-hono/src/temporal/worker.ts +38 -0
  80. package/templates/variants/bun-hono/src/temporal/workflows.ts +10 -0
  81. package/templates/variants/bun-hono/src/waitlist/service.ts +166 -0
  82. package/templates/variants/bun-hono/src/waitlist/types.ts +50 -0
  83. package/templates/variants/bun-hono/test/app.test.ts +72 -41
  84. package/templates/variants/bun-hono/test/waitlist.integration.test.ts +102 -0
  85. package/templates/variants/go-chi/Makefile +27 -11
  86. package/templates/variants/go-chi/atlas.hcl +8 -0
  87. package/templates/variants/go-chi/cmd/server/main.go +21 -10
  88. package/templates/variants/go-chi/go.mod +1 -3
  89. package/templates/variants/go-chi/internal/app/service.go +202 -685
  90. package/templates/variants/go-chi/internal/auth/middleware.go +289 -0
  91. package/templates/variants/go-chi/internal/auth/middleware_test.go +38 -0
  92. package/templates/variants/go-chi/internal/config/config.go +27 -11
  93. package/templates/variants/go-chi/internal/httpapi/routes.go +78 -157
  94. package/templates/variants/go-chi/internal/httpapi/waitlist_integration_test.go +199 -0
  95. package/templates/variants/go-chi/internal/temporal/activities.go +27 -0
  96. package/templates/variants/go-chi/internal/temporal/worker.go +42 -0
  97. package/templates/variants/go-chi/internal/temporal/workflows.go +18 -0
  98. package/templates/variants/go-chi/migrations/0000_init.sql +12 -55
  99. package/templates/variants/go-chi/migrations/atlas.sum +2 -0
  100. package/templates/variants/go-chi/package.json +7 -1
  101. package/templates/variants/go-connectrpc/Makefile +26 -9
  102. package/templates/variants/go-connectrpc/atlas.hcl +8 -0
  103. package/templates/variants/go-connectrpc/buf.gen.yaml +2 -2
  104. package/templates/variants/go-connectrpc/cmd/server/main.go +23 -12
  105. package/templates/variants/go-connectrpc/gen/waitlist/v1/waitlist.pb.go +960 -0
  106. package/templates/variants/go-connectrpc/gen/waitlist/v1/waitlistv1connect/waitlist.connect.go +283 -0
  107. package/templates/variants/go-connectrpc/go.mod +1 -1
  108. package/templates/variants/go-connectrpc/internal/app/service.go +202 -685
  109. package/templates/variants/go-connectrpc/internal/auth/middleware.go +289 -0
  110. package/templates/variants/go-connectrpc/internal/auth/middleware_test.go +38 -0
  111. package/templates/variants/go-connectrpc/internal/config/config.go +27 -11
  112. package/templates/variants/go-connectrpc/internal/connectapi/handler.go +78 -201
  113. package/templates/variants/go-connectrpc/internal/connectapi/waitlist_integration_test.go +122 -0
  114. package/templates/variants/go-connectrpc/internal/httpapi/routes.go +147 -9
  115. package/templates/variants/go-connectrpc/internal/temporal/activities.go +27 -0
  116. package/templates/variants/go-connectrpc/internal/temporal/worker.go +42 -0
  117. package/templates/variants/go-connectrpc/internal/temporal/workflows.go +18 -0
  118. package/templates/variants/go-connectrpc/migrations/0000_init.sql +12 -55
  119. package/templates/variants/go-connectrpc/migrations/atlas.sum +2 -0
  120. package/templates/variants/go-connectrpc/package.json +7 -1
  121. package/templates/variants/go-connectrpc/protos/waitlist/v1/waitlist.proto +93 -0
  122. package/templates/root/.github/workflows/buf-publish.yml +0 -19
  123. package/templates/root/.github/workflows/ci.yml +0 -26
  124. package/templates/root/.github/workflows/deploy.yml +0 -22
  125. package/templates/root/Dockerfile +0 -23
  126. package/templates/root/README.md +0 -69
  127. package/templates/root/buf.gen.yaml +0 -10
  128. package/templates/root/buf.yaml +0 -9
  129. package/templates/root/cmd/server/main.go +0 -44
  130. package/templates/root/gen/dns/v1/dns.pb.go +0 -623
  131. package/templates/root/gen/dns/v1/dnsv1connect/dns.connect.go +0 -192
  132. package/templates/root/go.mod +0 -10
  133. package/templates/root/internal/app/service.go +0 -152
  134. package/templates/root/internal/app/token_source.go +0 -50
  135. package/templates/root/internal/cloudflare/client.go +0 -160
  136. package/templates/root/internal/config/config.go +0 -55
  137. package/templates/root/internal/connectapi/handler.go +0 -79
  138. package/templates/root/internal/httpapi/routes.go +0 -93
  139. package/templates/root/internal/vault/client.go +0 -148
  140. package/templates/root/package.json +0 -12
  141. package/templates/root/protos/dns/v1/dns.proto +0 -58
  142. package/templates/root/scripts/cloudrun/bootstrap.ts +0 -65
  143. package/templates/root/scripts/cloudrun/config.ts +0 -50
  144. package/templates/root/scripts/cloudrun/deploy.ts +0 -41
  145. package/templates/root/scripts/cloudrun/lib.ts +0 -244
  146. package/templates/root/service.yaml +0 -50
  147. package/templates/root/test/go.test.ts +0 -19
  148. package/templates/shared/scripts/cloudrun/integrations.ts +0 -111
  149. package/templates/variants/bun-connectrpc/gen/protos/chat/v1/chat_pb.ts +0 -1078
  150. package/templates/variants/bun-connectrpc/protos/chat/v1/chat.proto +0 -228
  151. package/templates/variants/bun-connectrpc/src/chat/service.ts +0 -384
  152. package/templates/variants/bun-connectrpc/src/chat/types.ts +0 -142
  153. package/templates/variants/bun-connectrpc/src/storage.ts +0 -72
  154. package/templates/variants/bun-connectrpc/src/webhooks.ts +0 -35
  155. package/templates/variants/bun-connectrpc/test/list-messages.integration.test.ts +0 -182
  156. package/templates/variants/bun-hono/src/chat/service.ts +0 -384
  157. package/templates/variants/bun-hono/src/chat/types.ts +0 -142
  158. package/templates/variants/bun-hono/src/storage.ts +0 -72
  159. package/templates/variants/bun-hono/src/webhooks.ts +0 -35
  160. package/templates/variants/bun-hono/test/list-messages.integration.test.ts +0 -256
  161. package/templates/variants/go-chi/buf.gen.yaml +0 -12
  162. package/templates/variants/go-chi/buf.yaml +0 -9
  163. package/templates/variants/go-chi/cmd/migrate/main.go +0 -101
  164. package/templates/variants/go-chi/internal/httpapi/list_messages_integration_test.go +0 -298
  165. package/templates/variants/go-chi/protos/chat/v1/chat.proto +0 -219
  166. package/templates/variants/go-connectrpc/cmd/migrate/main.go +0 -101
  167. package/templates/variants/go-connectrpc/gen/chat/v1/chat.pb.go +0 -2512
  168. package/templates/variants/go-connectrpc/gen/chat/v1/chatv1connect/chat.connect.go +0 -571
  169. package/templates/variants/go-connectrpc/internal/connectapi/list_messages_integration_test.go +0 -216
  170. package/templates/variants/go-connectrpc/protos/chat/v1/chat.proto +0 -232
  171. /package/bin/{create-svc.mjs → service.mjs} +0 -0
@@ -0,0 +1,198 @@
1
+ import { Hono } from "hono";
2
+ import { authMiddleware } from "./auth";
3
+ import { createStorage } from "./storage";
4
+
5
+ type Env = {
6
+ HYPERDRIVE?: Hyperdrive;
7
+ AUTH_ENABLED?: string;
8
+ AUTH_ISSUER?: string;
9
+ AUTH_AUDIENCE?: string;
10
+ AUTH_JWKS_URL?: string;
11
+ };
12
+
13
+ export function createApp() {
14
+ const app = new Hono<{ Bindings: Env }>();
15
+
16
+ app.get("/healthz", (context) => context.json({ status: "ok" }));
17
+ app.get("/readyz", (context) => context.json({ status: "ok" }));
18
+ app.get("/", (context) =>
19
+ context.json({
20
+ service: "{{SERVICE_NAME}}",
21
+ domain: "waitlist",
22
+ apiOrigin: "https://api.{{SERVICE_NAME}}.anmho.com",
23
+ })
24
+ );
25
+
26
+ app.use("/v1/*", authMiddleware());
27
+
28
+ app.post("/v1/waitlist", async (context) => {
29
+ const body = await context.req.json().catch(() => ({}));
30
+ const email = String(body.email ?? "").trim().toLowerCase();
31
+ if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
32
+ return context.json({ error: "valid email is required", code: "invalid_email" }, 400);
33
+ }
34
+
35
+ const result = await createStorage(context.env).joinWaitlist({
36
+ email,
37
+ name: body.name ? String(body.name) : null,
38
+ company: body.company ? String(body.company) : null,
39
+ source: body.source ? String(body.source) : null,
40
+ });
41
+ return context.json(result, result.created ? 201 : 200);
42
+ });
43
+
44
+ app.get("/v1/waitlist", async (context) => {
45
+ const email = String(context.req.query("email") ?? "").trim().toLowerCase();
46
+ if (!email) {
47
+ return context.json({ error: "email is required", code: "missing_email" }, 400);
48
+ }
49
+ return context.json({ entry: await createStorage(context.env).getWaitlistEntryByEmail(email) });
50
+ });
51
+
52
+ app.get("/v1/waitlist/:entryId", async (context) => {
53
+ const entry = await createStorage(context.env).getWaitlistEntry(context.req.param("entryId"));
54
+ if (!entry) {
55
+ return context.json({ error: "waitlist entry not found", code: "not_found" }, 404);
56
+ }
57
+ return context.json({ entry });
58
+ });
59
+
60
+ app.get("/v1/admin/waitlist", async (context) => {
61
+ try {
62
+ const status = context.req.query("status");
63
+ return context.json({
64
+ entries: await createStorage(context.env).listWaitlistEntries({
65
+ status: status ? normalizeStatus(status) : null,
66
+ limit: parseOptionalNumber(context.req.query("limit")),
67
+ }),
68
+ });
69
+ } catch (error) {
70
+ return writeError(context, error);
71
+ }
72
+ });
73
+
74
+ app.get("/v1/admin/waitlist/export", async (context) => {
75
+ try {
76
+ const status = context.req.query("status");
77
+ const entries = await createStorage(context.env).listWaitlistEntries({
78
+ status: status ? normalizeStatus(status) : null,
79
+ limit: parseOptionalNumber(context.req.query("limit")),
80
+ });
81
+ return new Response(entriesToCsv(entries), {
82
+ headers: {
83
+ "Content-Type": "text/csv; charset=utf-8",
84
+ "Content-Disposition": 'attachment; filename="waitlist.csv"',
85
+ },
86
+ });
87
+ } catch (error) {
88
+ return writeError(context, error);
89
+ }
90
+ });
91
+
92
+ app.patch("/v1/admin/waitlist/:entryId", async (context) => {
93
+ try {
94
+ const body = await context.req.json().catch(() => ({}));
95
+ const status = normalizeStatus(String(body.status ?? ""));
96
+ const entry = await createStorage(context.env).updateWaitlistEntryStatus(context.req.param("entryId"), status);
97
+ if (!entry) {
98
+ return context.json({ error: "waitlist entry not found", code: "not_found" }, 404);
99
+ }
100
+ return context.json({ entry });
101
+ } catch (error) {
102
+ return writeError(context, error);
103
+ }
104
+ });
105
+
106
+ app.post("/v1/triggers/waitlist", async (context) => {
107
+ const body = await context.req.json().catch(() => ({}));
108
+ const trigger = await createStorage(context.env).recordTrigger({
109
+ type: String(body.type ?? "manual"),
110
+ entryId: body.entry_id ?? body.entryId ?? null,
111
+ payload: body,
112
+ });
113
+ return context.json({ trigger }, 202);
114
+ });
115
+
116
+ app.post("/webhooks/:provider", async (context) => {
117
+ const rawBody = await context.req.text();
118
+ const trigger = await createStorage(context.env).recordTrigger({
119
+ type: `webhook.${context.req.param("provider")}`,
120
+ entryId: null,
121
+ payload: {
122
+ headers: Object.fromEntries(context.req.raw.headers),
123
+ rawBody,
124
+ },
125
+ });
126
+ return context.json({ trigger }, 202);
127
+ });
128
+
129
+ app.get("/webhooks/:provider/health", (context) => context.json({ status: "ok", provider: context.req.param("provider") }));
130
+ return app;
131
+ }
132
+
133
+ const app = createApp();
134
+
135
+ class ValidationError extends Error {
136
+ constructor(
137
+ readonly code: string,
138
+ message: string
139
+ ) {
140
+ super(message);
141
+ }
142
+ }
143
+
144
+ function normalizeStatus(value: string) {
145
+ const status = value.trim().toLowerCase();
146
+ if (status === "joined" || status === "invited" || status === "converted" || status === "archived") {
147
+ return status;
148
+ }
149
+ throw new ValidationError("invalid_status", "status must be one of joined, invited, converted, archived");
150
+ }
151
+
152
+ function writeError(context: any, error: unknown) {
153
+ if (error instanceof ValidationError) {
154
+ return context.json({ error: error.message, code: error.code }, 400);
155
+ }
156
+ console.error(error);
157
+ return context.json({ error: "internal server error", code: "internal" }, 500);
158
+ }
159
+
160
+ function parseOptionalNumber(value: string | undefined) {
161
+ return value ? Number(value) : null;
162
+ }
163
+
164
+ function entriesToCsv(entries: Awaited<ReturnType<ReturnType<typeof createStorage>["listWaitlistEntries"]>>) {
165
+ const headers = ["id", "email", "name", "company", "source", "status", "created_at", "updated_at"];
166
+ return [
167
+ headers.join(","),
168
+ ...entries.map((entry) =>
169
+ [
170
+ entry.id,
171
+ entry.email,
172
+ entry.name ?? "",
173
+ entry.company ?? "",
174
+ entry.source ?? "",
175
+ entry.status,
176
+ entry.created_at,
177
+ entry.updated_at,
178
+ ]
179
+ .map(csvCell)
180
+ .join(",")
181
+ ),
182
+ ].join("\n");
183
+ }
184
+
185
+ function csvCell(value: string) {
186
+ return `"${value.replaceAll('"', '""')}"`;
187
+ }
188
+
189
+ export default {
190
+ fetch: app.fetch,
191
+ async scheduled(_event: ScheduledEvent, env: Env, context: ExecutionContext) {
192
+ context.waitUntil(
193
+ createStorage(env).claimQueuedTriggers(10).then((triggers) => {
194
+ console.log("processed waitlist triggers", triggers.length);
195
+ })
196
+ );
197
+ },
198
+ };
@@ -0,0 +1,370 @@
1
+ import { Client } from "pg";
2
+
3
+ export type WaitlistEntry = {
4
+ id: string;
5
+ email: string;
6
+ name: string | null;
7
+ company: string | null;
8
+ source: string | null;
9
+ status: string;
10
+ created_at: string;
11
+ updated_at: string;
12
+ };
13
+
14
+ export type WaitlistTrigger = {
15
+ id: string;
16
+ type: string;
17
+ entry_id: string | null;
18
+ status: string;
19
+ payload_json: string;
20
+ created_at: string;
21
+ processed_at: string | null;
22
+ };
23
+
24
+ export type WaitlistStorage = {
25
+ joinWaitlist(input: {
26
+ email: string;
27
+ name: string | null;
28
+ company: string | null;
29
+ source: string | null;
30
+ }): Promise<{ entry: WaitlistEntry; created: boolean }>;
31
+ getWaitlistEntryByEmail(email: string): Promise<WaitlistEntry | null>;
32
+ getWaitlistEntry(entryId: string): Promise<WaitlistEntry | null>;
33
+ listWaitlistEntries(input?: { status?: string | null; limit?: number | null }): Promise<WaitlistEntry[]>;
34
+ updateWaitlistEntryStatus(entryId: string, status: string): Promise<WaitlistEntry | null>;
35
+ recordTrigger(input: {
36
+ type: string;
37
+ entryId: string | null;
38
+ payload: unknown;
39
+ }): Promise<WaitlistTrigger>;
40
+ claimQueuedTriggers(limit: number): Promise<WaitlistTrigger[]>;
41
+ };
42
+
43
+ type Env = {
44
+ HYPERDRIVE?: Hyperdrive;
45
+ };
46
+
47
+ const memoryStorage = createMemoryStorage();
48
+
49
+ export function createStorage(env: Env = {}): WaitlistStorage {
50
+ if (env.HYPERDRIVE?.connectionString) {
51
+ return createPostgresStorage(env.HYPERDRIVE.connectionString);
52
+ }
53
+ return memoryStorage;
54
+ }
55
+
56
+ function createPostgresStorage(connectionString: string): WaitlistStorage {
57
+ return {
58
+ async joinWaitlist(input) {
59
+ const client = new Client({ connectionString });
60
+ await client.connect();
61
+ try {
62
+ await ensureSchema(client);
63
+ const now = new Date().toISOString();
64
+ const result = await client.query<WaitlistEntry>(
65
+ `
66
+ insert into waitlist_entries (id, email, name, company, source, status, created_at, updated_at)
67
+ values ($1, $2, $3, $4, $5, 'joined', $6, $6)
68
+ on conflict (email) do update
69
+ set
70
+ name = coalesce(excluded.name, waitlist_entries.name),
71
+ company = coalesce(excluded.company, waitlist_entries.company),
72
+ source = coalesce(excluded.source, waitlist_entries.source),
73
+ updated_at = excluded.updated_at
74
+ returning id, email, name, company, source, status, created_at, updated_at, (xmax = 0) as created
75
+ `,
76
+ [crypto.randomUUID(), input.email, input.name, input.company, input.source, now]
77
+ );
78
+ const row = result.rows[0] as WaitlistEntry & { created?: boolean };
79
+ return { entry: normalizeEntry(row), created: Boolean(row.created) };
80
+ } finally {
81
+ await client.end();
82
+ }
83
+ },
84
+
85
+ async getWaitlistEntryByEmail(email) {
86
+ const client = new Client({ connectionString });
87
+ await client.connect();
88
+ try {
89
+ await ensureSchema(client);
90
+ const result = await client.query<WaitlistEntry>(
91
+ "select id, email, name, company, source, status, created_at, updated_at from waitlist_entries where email = $1",
92
+ [email]
93
+ );
94
+ return result.rows[0] ? normalizeEntry(result.rows[0]) : null;
95
+ } finally {
96
+ await client.end();
97
+ }
98
+ },
99
+
100
+ async getWaitlistEntry(entryId) {
101
+ const client = new Client({ connectionString });
102
+ await client.connect();
103
+ try {
104
+ await ensureSchema(client);
105
+ const result = await client.query<WaitlistEntry>(
106
+ "select id, email, name, company, source, status, created_at, updated_at from waitlist_entries where id = $1",
107
+ [entryId]
108
+ );
109
+ return result.rows[0] ? normalizeEntry(result.rows[0]) : null;
110
+ } finally {
111
+ await client.end();
112
+ }
113
+ },
114
+
115
+ async listWaitlistEntries(input = {}) {
116
+ const client = new Client({ connectionString });
117
+ await client.connect();
118
+ try {
119
+ await ensureSchema(client);
120
+ const limit = clampLimit(input.limit);
121
+ const result = input.status
122
+ ? await client.query<WaitlistEntry>(
123
+ `
124
+ select id, email, name, company, source, status, created_at, updated_at
125
+ from waitlist_entries
126
+ where status = $1
127
+ order by created_at desc
128
+ limit $2
129
+ `,
130
+ [input.status, limit]
131
+ )
132
+ : await client.query<WaitlistEntry>(
133
+ `
134
+ select id, email, name, company, source, status, created_at, updated_at
135
+ from waitlist_entries
136
+ order by created_at desc
137
+ limit $1
138
+ `,
139
+ [limit]
140
+ );
141
+ return result.rows.map(normalizeEntry);
142
+ } finally {
143
+ await client.end();
144
+ }
145
+ },
146
+
147
+ async updateWaitlistEntryStatus(entryId, status) {
148
+ const client = new Client({ connectionString });
149
+ await client.connect();
150
+ try {
151
+ await ensureSchema(client);
152
+ const result = await client.query<WaitlistEntry>(
153
+ `
154
+ update waitlist_entries
155
+ set status = $2, updated_at = now()
156
+ where id = $1
157
+ returning id, email, name, company, source, status, created_at, updated_at
158
+ `,
159
+ [entryId, status]
160
+ );
161
+ return result.rows[0] ? normalizeEntry(result.rows[0]) : null;
162
+ } finally {
163
+ await client.end();
164
+ }
165
+ },
166
+
167
+ async recordTrigger(input) {
168
+ const client = new Client({ connectionString });
169
+ await client.connect();
170
+ try {
171
+ await ensureSchema(client);
172
+ const now = new Date().toISOString();
173
+ const result = await client.query<WaitlistTrigger>(
174
+ `
175
+ insert into waitlist_triggers (id, type, entry_id, status, payload_json, created_at)
176
+ values ($1, $2, $3, 'queued', $4, $5)
177
+ returning id, type, entry_id, status, payload_json, created_at, processed_at
178
+ `,
179
+ [crypto.randomUUID(), input.type, input.entryId, JSON.stringify(input.payload ?? {}), now]
180
+ );
181
+ return normalizeTrigger(result.rows[0]);
182
+ } finally {
183
+ await client.end();
184
+ }
185
+ },
186
+
187
+ async claimQueuedTriggers(limit) {
188
+ const client = new Client({ connectionString });
189
+ await client.connect();
190
+ try {
191
+ await ensureSchema(client);
192
+ const result = await client.query<WaitlistTrigger>(
193
+ `
194
+ update waitlist_triggers
195
+ set status = 'processed', processed_at = now()
196
+ where id in (
197
+ select id
198
+ from waitlist_triggers
199
+ where status = 'queued'
200
+ order by created_at asc
201
+ limit $1
202
+ for update skip locked
203
+ )
204
+ returning id, type, entry_id, status, payload_json, created_at, processed_at
205
+ `,
206
+ [limit]
207
+ );
208
+ return result.rows.map(normalizeTrigger);
209
+ } finally {
210
+ await client.end();
211
+ }
212
+ },
213
+ };
214
+ }
215
+
216
+ async function ensureSchema(client: Client) {
217
+ await client.query(`
218
+ create table if not exists waitlist_entries (
219
+ id text primary key,
220
+ email text not null unique,
221
+ name text,
222
+ company text,
223
+ source text,
224
+ status text not null default 'joined',
225
+ created_at timestamptz not null default now(),
226
+ updated_at timestamptz not null default now()
227
+ );
228
+
229
+ create table if not exists waitlist_triggers (
230
+ id text primary key,
231
+ type text not null,
232
+ entry_id text references waitlist_entries(id) on delete set null,
233
+ status text not null default 'queued',
234
+ payload_json text not null default '{}',
235
+ created_at timestamptz not null default now(),
236
+ processed_at timestamptz
237
+ );
238
+
239
+ create index if not exists waitlist_triggers_status_created_idx
240
+ on waitlist_triggers (status, created_at);
241
+ `);
242
+ }
243
+
244
+ function createMemoryStorage(): WaitlistStorage {
245
+ const entries = new Map<string, WaitlistEntry>();
246
+ const entriesByEmail = new Map<string, string>();
247
+ const triggers = new Map<string, WaitlistTrigger>();
248
+
249
+ return {
250
+ async joinWaitlist(input) {
251
+ const existingId = entriesByEmail.get(input.email);
252
+ if (existingId) {
253
+ const existing = entries.get(existingId)!;
254
+ const updated = {
255
+ ...existing,
256
+ name: input.name ?? existing.name,
257
+ company: input.company ?? existing.company,
258
+ source: input.source ?? existing.source,
259
+ updated_at: new Date().toISOString(),
260
+ };
261
+ entries.set(existing.id, updated);
262
+ return { entry: updated, created: false };
263
+ }
264
+
265
+ const now = new Date().toISOString();
266
+ const entry = {
267
+ id: crypto.randomUUID(),
268
+ email: input.email,
269
+ name: input.name,
270
+ company: input.company,
271
+ source: input.source,
272
+ status: "joined",
273
+ created_at: now,
274
+ updated_at: now,
275
+ };
276
+ entries.set(entry.id, entry);
277
+ entriesByEmail.set(entry.email, entry.id);
278
+ return { entry, created: true };
279
+ },
280
+
281
+ async getWaitlistEntryByEmail(email) {
282
+ const id = entriesByEmail.get(email);
283
+ return id ? entries.get(id) ?? null : null;
284
+ },
285
+
286
+ async getWaitlistEntry(entryId) {
287
+ return entries.get(entryId) ?? null;
288
+ },
289
+
290
+ async listWaitlistEntries(input = {}) {
291
+ return [...entries.values()]
292
+ .filter((entry) => !input.status || entry.status === input.status)
293
+ .sort((left, right) => right.created_at.localeCompare(left.created_at))
294
+ .slice(0, clampLimit(input.limit));
295
+ },
296
+
297
+ async updateWaitlistEntryStatus(entryId, status) {
298
+ const entry = entries.get(entryId);
299
+ if (!entry) {
300
+ return null;
301
+ }
302
+ const updated = {
303
+ ...entry,
304
+ status,
305
+ updated_at: new Date().toISOString(),
306
+ };
307
+ entries.set(entry.id, updated);
308
+ return updated;
309
+ },
310
+
311
+ async recordTrigger(input) {
312
+ const now = new Date().toISOString();
313
+ const trigger = {
314
+ id: crypto.randomUUID(),
315
+ type: input.type,
316
+ entry_id: input.entryId,
317
+ status: "queued",
318
+ payload_json: JSON.stringify(input.payload ?? {}),
319
+ created_at: now,
320
+ processed_at: null,
321
+ };
322
+ triggers.set(trigger.id, trigger);
323
+ return trigger;
324
+ },
325
+
326
+ async claimQueuedTriggers(limit) {
327
+ const claimed = [...triggers.values()]
328
+ .filter((trigger) => trigger.status === "queued")
329
+ .sort((left, right) => left.created_at.localeCompare(right.created_at))
330
+ .slice(0, limit)
331
+ .map((trigger) => ({
332
+ ...trigger,
333
+ status: "processed",
334
+ processed_at: new Date().toISOString(),
335
+ }));
336
+
337
+ for (const trigger of claimed) {
338
+ triggers.set(trigger.id, trigger);
339
+ }
340
+ return claimed;
341
+ },
342
+ };
343
+ }
344
+
345
+ function normalizeEntry(row: WaitlistEntry): WaitlistEntry {
346
+ return {
347
+ ...row,
348
+ created_at: normalizeDate(row.created_at),
349
+ updated_at: normalizeDate(row.updated_at),
350
+ };
351
+ }
352
+
353
+ function normalizeTrigger(row: WaitlistTrigger): WaitlistTrigger {
354
+ return {
355
+ ...row,
356
+ created_at: normalizeDate(row.created_at),
357
+ processed_at: row.processed_at ? normalizeDate(row.processed_at) : null,
358
+ };
359
+ }
360
+
361
+ function clampLimit(value: number | null | undefined) {
362
+ if (!value || !Number.isFinite(value)) {
363
+ return 100;
364
+ }
365
+ return Math.min(Math.max(Math.trunc(value), 1), 500);
366
+ }
367
+
368
+ function normalizeDate(value: string | Date) {
369
+ return value instanceof Date ? value.toISOString() : new Date(value).toISOString();
370
+ }
@@ -0,0 +1,108 @@
1
+ import { expect, test } from "bun:test";
2
+ import { createApp } from "../src/index";
3
+
4
+ type JoinResponse = {
5
+ entry: {
6
+ id: string;
7
+ email: string;
8
+ name?: string | null;
9
+ source?: string | null;
10
+ status?: string;
11
+ };
12
+ created: boolean;
13
+ };
14
+
15
+ test("health endpoint returns ok", async () => {
16
+ const response = await createApp().request("/healthz");
17
+ expect(response.status).toBe(200);
18
+ await expect(response.json()).resolves.toMatchObject({ status: "ok" });
19
+ });
20
+
21
+ test("waitlist join persists an idempotent entry", async () => {
22
+ const app = createApp();
23
+ const response = await app.request("/v1/waitlist", {
24
+ method: "POST",
25
+ body: JSON.stringify({ email: "Workers@Example.com", name: "Workers Example" }),
26
+ headers: { "content-type": "application/json" },
27
+ });
28
+
29
+ expect(response.status).toBe(201);
30
+ const created = (await response.json()) as JoinResponse;
31
+ expect(created).toMatchObject({
32
+ entry: {
33
+ email: "workers@example.com",
34
+ name: "Workers Example",
35
+ status: "joined",
36
+ },
37
+ created: true,
38
+ });
39
+
40
+ const duplicate = await app.request("/v1/waitlist", {
41
+ method: "POST",
42
+ body: JSON.stringify({ email: "workers@example.com", source: "repeat" }),
43
+ headers: { "content-type": "application/json" },
44
+ });
45
+ expect(duplicate.status).toBe(200);
46
+ await expect(duplicate.json()).resolves.toMatchObject({
47
+ entry: {
48
+ id: created.entry.id,
49
+ email: "workers@example.com",
50
+ source: "repeat",
51
+ },
52
+ created: false,
53
+ });
54
+
55
+ const lookup = await app.request("/v1/waitlist?email=workers@example.com");
56
+ expect(lookup.status).toBe(200);
57
+ await expect(lookup.json()).resolves.toMatchObject({
58
+ entry: {
59
+ id: created.entry.id,
60
+ email: "workers@example.com",
61
+ },
62
+ });
63
+
64
+ const updated = await app.request(`/v1/admin/waitlist/${created.entry.id}`, {
65
+ method: "PATCH",
66
+ body: JSON.stringify({ status: "invited" }),
67
+ headers: { "content-type": "application/json" },
68
+ });
69
+ expect(updated.status).toBe(200);
70
+ await expect(updated.json()).resolves.toMatchObject({
71
+ entry: {
72
+ id: created.entry.id,
73
+ status: "invited",
74
+ },
75
+ });
76
+
77
+ const list = await app.request("/v1/admin/waitlist?status=invited");
78
+ expect(list.status).toBe(200);
79
+ await expect(list.json()).resolves.toMatchObject({
80
+ entries: [
81
+ {
82
+ id: created.entry.id,
83
+ email: "workers@example.com",
84
+ },
85
+ ],
86
+ });
87
+
88
+ const exported = await app.request("/v1/admin/waitlist/export?status=invited");
89
+ expect(exported.status).toBe(200);
90
+ expect(exported.headers.get("content-type")).toContain("text/csv");
91
+ expect(await exported.text()).toContain("workers@example.com");
92
+ });
93
+
94
+ test("waitlist trigger is queued for cron processing", async () => {
95
+ const response = await createApp().request("/v1/triggers/waitlist", {
96
+ method: "POST",
97
+ body: JSON.stringify({ type: "cron.digest" }),
98
+ headers: { "content-type": "application/json" },
99
+ });
100
+
101
+ expect(response.status).toBe(202);
102
+ await expect(response.json()).resolves.toMatchObject({
103
+ trigger: {
104
+ type: "cron.digest",
105
+ status: "queued",
106
+ },
107
+ });
108
+ });
@@ -0,0 +1,11 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "strict": true,
7
+ "skipLibCheck": true,
8
+ "types": ["bun", "@cloudflare/workers-types"]
9
+ },
10
+ "include": ["src/**/*.ts", "test/**/*.ts", "scripts/**/*.ts"]
11
+ }
@@ -0,0 +1,24 @@
1
+ name = "{{SERVICE_ID}}"
2
+ main = "src/index.ts"
3
+ compatibility_date = "2026-05-14"
4
+ compatibility_flags = ["nodejs_compat"]
5
+
6
+ [observability]
7
+ enabled = true
8
+
9
+ [vars]
10
+ AUTH_ENABLED = "true"
11
+ AUTH_ISSUER = "{{AUTH_ISSUER}}"
12
+ AUTH_AUDIENCE = "{{AUTH_AUDIENCE}}"
13
+ AUTH_JWKS_URL = "{{AUTH_JWKS_URL}}"
14
+
15
+ [triggers]
16
+ crons = ["*/15 * * * *"]
17
+
18
+ [[routes]]
19
+ pattern = "{{API_HOSTNAME}}/*"
20
+ custom_domain = true
21
+
22
+ [[hyperdrive]]
23
+ binding = "HYPERDRIVE"
24
+ id = ""