create-svc 0.1.10 → 0.1.11

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 (168) hide show
  1. package/README.md +46 -43
  2. package/bin/create-service.mjs +2 -0
  3. package/package.json +12 -9
  4. package/src/cli.test.ts +28 -10
  5. package/src/cli.ts +195 -30
  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 +231 -40
  14. package/src/scaffold.ts +84 -29
  15. package/src/vault.test.ts +61 -1
  16. package/src/vault.ts +77 -15
  17. package/templates/shared/.github/workflows/ci.yml +2 -1
  18. package/templates/shared/.github/workflows/deploy.yml +2 -0
  19. package/templates/shared/README.md +124 -47
  20. package/templates/shared/grafana/alerts.yaml +54 -0
  21. package/templates/shared/grafana/waitlist-dashboard.json +63 -0
  22. package/templates/shared/scripts/authctl.ts +231 -0
  23. package/templates/shared/scripts/cloudrun/bootstrap.ts +14 -5
  24. package/templates/shared/scripts/cloudrun/cleanup.ts +64 -4
  25. package/templates/shared/scripts/cloudrun/cli.ts +324 -7
  26. package/templates/shared/scripts/cloudrun/config.ts +11 -4
  27. package/templates/shared/scripts/cloudrun/deploy.ts +0 -4
  28. package/templates/shared/scripts/cloudrun/lib.ts +174 -41
  29. package/templates/shared/scripts/cloudrun/neon.ts +45 -0
  30. package/templates/shared/scripts/dev.ts +22 -0
  31. package/templates/shared/scripts/ensure-local-db.ts +3 -0
  32. package/templates/shared/scripts/local-docker.ts +63 -0
  33. package/templates/shared/scripts/local-env.ts +27 -0
  34. package/templates/shared/scripts/seed.ts +73 -0
  35. package/templates/shared/scripts/wait-for-db.ts +32 -0
  36. package/templates/shared/service.config.ts +59 -0
  37. package/templates/shared/service.yaml +24 -44
  38. package/templates/targets/workers/.github/workflows/ci.yml +19 -0
  39. package/templates/targets/workers/.github/workflows/deploy.yml +19 -0
  40. package/templates/targets/workers/Makefile +33 -0
  41. package/templates/targets/workers/README.md +75 -0
  42. package/templates/targets/workers/package.json +35 -0
  43. package/templates/targets/workers/scripts/workers/cli.ts +397 -0
  44. package/templates/targets/workers/src/auth.ts +178 -0
  45. package/templates/targets/workers/src/index.ts +198 -0
  46. package/templates/targets/workers/src/storage.ts +370 -0
  47. package/templates/targets/workers/test/app.test.ts +108 -0
  48. package/templates/targets/workers/tsconfig.json +11 -0
  49. package/templates/targets/workers/wrangler.toml +24 -0
  50. package/templates/variants/bun-connectrpc/Makefile +14 -8
  51. package/templates/variants/bun-connectrpc/gen/protos/waitlist/v1/waitlist_pb.ts +424 -0
  52. package/templates/variants/bun-connectrpc/migrations/0000_init.sql +12 -55
  53. package/templates/variants/bun-connectrpc/package.json +12 -5
  54. package/templates/variants/bun-connectrpc/protos/waitlist/v1/waitlist.proto +91 -0
  55. package/templates/variants/bun-connectrpc/scripts/codegen.ts +1 -1
  56. package/templates/variants/bun-connectrpc/scripts/migrate.ts +4 -1
  57. package/templates/variants/bun-connectrpc/src/auth.ts +200 -0
  58. package/templates/variants/bun-connectrpc/src/db/repository.ts +67 -420
  59. package/templates/variants/bun-connectrpc/src/db/schema.ts +15 -64
  60. package/templates/variants/bun-connectrpc/src/index.ts +76 -176
  61. package/templates/variants/bun-connectrpc/src/temporal/activities.ts +14 -0
  62. package/templates/variants/bun-connectrpc/src/temporal/worker.ts +38 -0
  63. package/templates/variants/bun-connectrpc/src/temporal/workflows.ts +10 -0
  64. package/templates/variants/bun-connectrpc/src/waitlist/service.ts +172 -0
  65. package/templates/variants/bun-connectrpc/src/waitlist/types.ts +45 -0
  66. package/templates/variants/bun-connectrpc/test/app.test.ts +4 -4
  67. package/templates/variants/bun-connectrpc/test/waitlist.integration.test.ts +71 -0
  68. package/templates/variants/bun-hono/Makefile +14 -8
  69. package/templates/variants/bun-hono/migrations/0000_init.sql +12 -55
  70. package/templates/variants/bun-hono/package.json +12 -5
  71. package/templates/variants/bun-hono/scripts/migrate.ts +4 -1
  72. package/templates/variants/bun-hono/src/auth.ts +181 -0
  73. package/templates/variants/bun-hono/src/db/repository.ts +68 -421
  74. package/templates/variants/bun-hono/src/db/schema.ts +15 -64
  75. package/templates/variants/bun-hono/src/index.ts +65 -180
  76. package/templates/variants/bun-hono/src/temporal/activities.ts +14 -0
  77. package/templates/variants/bun-hono/src/temporal/worker.ts +38 -0
  78. package/templates/variants/bun-hono/src/temporal/workflows.ts +10 -0
  79. package/templates/variants/bun-hono/src/waitlist/service.ts +166 -0
  80. package/templates/variants/bun-hono/src/waitlist/types.ts +50 -0
  81. package/templates/variants/bun-hono/test/app.test.ts +72 -41
  82. package/templates/variants/bun-hono/test/waitlist.integration.test.ts +102 -0
  83. package/templates/variants/go-chi/Makefile +27 -11
  84. package/templates/variants/go-chi/atlas.hcl +8 -0
  85. package/templates/variants/go-chi/cmd/server/main.go +21 -10
  86. package/templates/variants/go-chi/go.mod +1 -3
  87. package/templates/variants/go-chi/internal/app/service.go +202 -685
  88. package/templates/variants/go-chi/internal/auth/middleware.go +289 -0
  89. package/templates/variants/go-chi/internal/auth/middleware_test.go +38 -0
  90. package/templates/variants/go-chi/internal/config/config.go +27 -11
  91. package/templates/variants/go-chi/internal/httpapi/routes.go +78 -157
  92. package/templates/variants/go-chi/internal/httpapi/waitlist_integration_test.go +199 -0
  93. package/templates/variants/go-chi/internal/temporal/activities.go +27 -0
  94. package/templates/variants/go-chi/internal/temporal/worker.go +42 -0
  95. package/templates/variants/go-chi/internal/temporal/workflows.go +18 -0
  96. package/templates/variants/go-chi/migrations/0000_init.sql +12 -55
  97. package/templates/variants/go-chi/migrations/atlas.sum +2 -0
  98. package/templates/variants/go-chi/package.json +7 -1
  99. package/templates/variants/go-connectrpc/Makefile +26 -9
  100. package/templates/variants/go-connectrpc/atlas.hcl +8 -0
  101. package/templates/variants/go-connectrpc/buf.gen.yaml +2 -2
  102. package/templates/variants/go-connectrpc/cmd/server/main.go +23 -12
  103. package/templates/variants/go-connectrpc/gen/waitlist/v1/waitlist.pb.go +960 -0
  104. package/templates/variants/go-connectrpc/gen/waitlist/v1/waitlistv1connect/waitlist.connect.go +283 -0
  105. package/templates/variants/go-connectrpc/go.mod +1 -1
  106. package/templates/variants/go-connectrpc/internal/app/service.go +202 -685
  107. package/templates/variants/go-connectrpc/internal/auth/middleware.go +289 -0
  108. package/templates/variants/go-connectrpc/internal/auth/middleware_test.go +38 -0
  109. package/templates/variants/go-connectrpc/internal/config/config.go +27 -11
  110. package/templates/variants/go-connectrpc/internal/connectapi/handler.go +78 -201
  111. package/templates/variants/go-connectrpc/internal/connectapi/waitlist_integration_test.go +122 -0
  112. package/templates/variants/go-connectrpc/internal/httpapi/routes.go +147 -9
  113. package/templates/variants/go-connectrpc/internal/temporal/activities.go +27 -0
  114. package/templates/variants/go-connectrpc/internal/temporal/worker.go +42 -0
  115. package/templates/variants/go-connectrpc/internal/temporal/workflows.go +18 -0
  116. package/templates/variants/go-connectrpc/migrations/0000_init.sql +12 -55
  117. package/templates/variants/go-connectrpc/migrations/atlas.sum +2 -0
  118. package/templates/variants/go-connectrpc/package.json +7 -1
  119. package/templates/variants/go-connectrpc/protos/waitlist/v1/waitlist.proto +93 -0
  120. package/templates/root/.github/workflows/buf-publish.yml +0 -19
  121. package/templates/root/.github/workflows/ci.yml +0 -26
  122. package/templates/root/.github/workflows/deploy.yml +0 -22
  123. package/templates/root/Dockerfile +0 -23
  124. package/templates/root/README.md +0 -69
  125. package/templates/root/buf.gen.yaml +0 -10
  126. package/templates/root/buf.yaml +0 -9
  127. package/templates/root/cmd/server/main.go +0 -44
  128. package/templates/root/gen/dns/v1/dns.pb.go +0 -623
  129. package/templates/root/gen/dns/v1/dnsv1connect/dns.connect.go +0 -192
  130. package/templates/root/go.mod +0 -10
  131. package/templates/root/internal/app/service.go +0 -152
  132. package/templates/root/internal/app/token_source.go +0 -50
  133. package/templates/root/internal/cloudflare/client.go +0 -160
  134. package/templates/root/internal/config/config.go +0 -55
  135. package/templates/root/internal/connectapi/handler.go +0 -79
  136. package/templates/root/internal/httpapi/routes.go +0 -93
  137. package/templates/root/internal/vault/client.go +0 -148
  138. package/templates/root/package.json +0 -12
  139. package/templates/root/protos/dns/v1/dns.proto +0 -58
  140. package/templates/root/scripts/cloudrun/bootstrap.ts +0 -65
  141. package/templates/root/scripts/cloudrun/config.ts +0 -50
  142. package/templates/root/scripts/cloudrun/deploy.ts +0 -41
  143. package/templates/root/scripts/cloudrun/lib.ts +0 -244
  144. package/templates/root/service.yaml +0 -50
  145. package/templates/root/test/go.test.ts +0 -19
  146. package/templates/shared/scripts/cloudrun/integrations.ts +0 -111
  147. package/templates/variants/bun-connectrpc/gen/protos/chat/v1/chat_pb.ts +0 -1078
  148. package/templates/variants/bun-connectrpc/protos/chat/v1/chat.proto +0 -228
  149. package/templates/variants/bun-connectrpc/src/chat/service.ts +0 -384
  150. package/templates/variants/bun-connectrpc/src/chat/types.ts +0 -142
  151. package/templates/variants/bun-connectrpc/src/storage.ts +0 -72
  152. package/templates/variants/bun-connectrpc/src/webhooks.ts +0 -35
  153. package/templates/variants/bun-connectrpc/test/list-messages.integration.test.ts +0 -182
  154. package/templates/variants/bun-hono/src/chat/service.ts +0 -384
  155. package/templates/variants/bun-hono/src/chat/types.ts +0 -142
  156. package/templates/variants/bun-hono/src/storage.ts +0 -72
  157. package/templates/variants/bun-hono/src/webhooks.ts +0 -35
  158. package/templates/variants/bun-hono/test/list-messages.integration.test.ts +0 -256
  159. package/templates/variants/go-chi/buf.gen.yaml +0 -12
  160. package/templates/variants/go-chi/buf.yaml +0 -9
  161. package/templates/variants/go-chi/cmd/migrate/main.go +0 -101
  162. package/templates/variants/go-chi/internal/httpapi/list_messages_integration_test.go +0 -298
  163. package/templates/variants/go-chi/protos/chat/v1/chat.proto +0 -219
  164. package/templates/variants/go-connectrpc/cmd/migrate/main.go +0 -101
  165. package/templates/variants/go-connectrpc/gen/chat/v1/chat.pb.go +0 -2512
  166. package/templates/variants/go-connectrpc/gen/chat/v1/chatv1connect/chat.connect.go +0 -571
  167. package/templates/variants/go-connectrpc/internal/connectapi/list_messages_integration_test.go +0 -216
  168. package/templates/variants/go-connectrpc/protos/chat/v1/chat.proto +0 -232
@@ -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 = ""