create-svc 0.1.9 → 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.
- package/README.md +138 -16
- package/bin/create-service.mjs +2 -0
- package/package.json +19 -11
- package/src/cli.test.ts +46 -7
- package/src/cli.ts +282 -84
- package/src/git-bootstrap.test.ts +40 -0
- package/src/git-bootstrap.ts +110 -0
- package/src/naming.test.ts +5 -2
- package/src/naming.ts +32 -1
- package/src/neon.ts +10 -8
- package/src/post-scaffold.test.ts +19 -0
- package/src/post-scaffold.ts +18 -26
- package/src/profiles.ts +25 -0
- package/src/scaffold.test.ts +320 -18
- package/src/scaffold.ts +154 -28
- package/src/vault.test.ts +94 -10
- package/src/vault.ts +81 -18
- package/templates/shared/.github/workflows/ci.yml +2 -1
- package/templates/shared/.github/workflows/deploy.yml +2 -0
- package/templates/shared/README.md +217 -29
- package/templates/shared/docker-compose.yml +19 -0
- package/templates/shared/grafana/alerts.yaml +54 -0
- package/templates/shared/grafana/waitlist-dashboard.json +63 -0
- package/templates/shared/scripts/authctl.ts +231 -0
- package/templates/shared/scripts/cloudrun/bootstrap.ts +24 -42
- package/templates/shared/scripts/cloudrun/cleanup.ts +81 -35
- package/templates/shared/scripts/cloudrun/cli.ts +324 -7
- package/templates/shared/scripts/cloudrun/config.ts +21 -19
- package/templates/shared/scripts/cloudrun/deploy.ts +16 -11
- package/templates/shared/scripts/cloudrun/lib.ts +232 -123
- package/templates/shared/scripts/cloudrun/neon.ts +127 -13
- package/templates/shared/scripts/dev.ts +22 -0
- package/templates/shared/scripts/ensure-local-db.ts +3 -0
- package/templates/shared/scripts/local-docker.ts +63 -0
- package/templates/shared/scripts/local-env.ts +27 -0
- package/templates/shared/scripts/seed.ts +73 -0
- package/templates/shared/scripts/wait-for-db.ts +32 -0
- package/templates/shared/service.config.ts +59 -0
- package/templates/shared/service.yaml +24 -1
- package/templates/targets/workers/.github/workflows/ci.yml +19 -0
- package/templates/targets/workers/.github/workflows/deploy.yml +19 -0
- package/templates/targets/workers/Makefile +33 -0
- package/templates/targets/workers/README.md +75 -0
- package/templates/targets/workers/package.json +35 -0
- package/templates/targets/workers/scripts/workers/cli.ts +397 -0
- package/templates/targets/workers/src/auth.ts +178 -0
- package/templates/targets/workers/src/index.ts +198 -0
- package/templates/targets/workers/src/storage.ts +370 -0
- package/templates/targets/workers/test/app.test.ts +108 -0
- package/templates/targets/workers/tsconfig.json +11 -0
- package/templates/targets/workers/wrangler.toml +24 -0
- package/templates/variants/bun-connectrpc/Dockerfile +1 -0
- package/templates/variants/bun-connectrpc/Makefile +17 -8
- package/templates/variants/bun-connectrpc/gen/protos/waitlist/v1/waitlist_pb.ts +424 -0
- package/templates/variants/bun-connectrpc/migrations/0000_init.sql +20 -0
- package/templates/variants/bun-connectrpc/package.json +25 -1
- package/templates/variants/bun-connectrpc/protos/waitlist/v1/waitlist.proto +91 -0
- package/templates/variants/bun-connectrpc/scripts/codegen.ts +31 -1
- package/templates/variants/bun-connectrpc/scripts/migrate.ts +49 -0
- package/templates/variants/bun-connectrpc/src/auth.ts +200 -0
- package/templates/variants/bun-connectrpc/src/db/client.ts +15 -0
- package/templates/variants/bun-connectrpc/src/db/repository.ts +126 -0
- package/templates/variants/bun-connectrpc/src/db/schema.ts +26 -0
- package/templates/variants/bun-connectrpc/src/index.ts +194 -22
- package/templates/variants/bun-connectrpc/src/temporal/activities.ts +14 -0
- package/templates/variants/bun-connectrpc/src/temporal/worker.ts +38 -0
- package/templates/variants/bun-connectrpc/src/temporal/workflows.ts +10 -0
- package/templates/variants/bun-connectrpc/src/waitlist/service.ts +172 -0
- package/templates/variants/bun-connectrpc/src/waitlist/types.ts +45 -0
- package/templates/variants/bun-connectrpc/test/app.test.ts +14 -13
- package/templates/variants/bun-connectrpc/test/waitlist.integration.test.ts +71 -0
- package/templates/variants/bun-connectrpc/tsconfig.json +2 -1
- package/templates/variants/bun-hono/Makefile +17 -8
- package/templates/variants/bun-hono/migrations/0000_init.sql +20 -0
- package/templates/variants/bun-hono/package.json +21 -1
- package/templates/variants/bun-hono/scripts/migrate.ts +49 -0
- package/templates/variants/bun-hono/src/auth.ts +181 -0
- package/templates/variants/bun-hono/src/db/client.ts +15 -0
- package/templates/variants/bun-hono/src/db/repository.ts +126 -0
- package/templates/variants/bun-hono/src/db/schema.ts +26 -0
- package/templates/variants/bun-hono/src/index.ts +141 -10
- package/templates/variants/bun-hono/src/temporal/activities.ts +14 -0
- package/templates/variants/bun-hono/src/temporal/worker.ts +38 -0
- package/templates/variants/bun-hono/src/temporal/workflows.ts +10 -0
- package/templates/variants/bun-hono/src/waitlist/service.ts +166 -0
- package/templates/variants/bun-hono/src/waitlist/types.ts +50 -0
- package/templates/variants/bun-hono/test/app.test.ts +90 -5
- package/templates/variants/bun-hono/test/waitlist.integration.test.ts +102 -0
- package/templates/variants/bun-hono/tsconfig.json +1 -0
- package/templates/variants/go-chi/Makefile +30 -10
- package/templates/variants/go-chi/atlas.hcl +8 -0
- package/templates/variants/go-chi/cmd/server/main.go +25 -13
- package/templates/variants/go-chi/go.mod +3 -2
- package/templates/variants/go-chi/internal/app/service.go +279 -70
- package/templates/variants/go-chi/internal/auth/middleware.go +289 -0
- package/templates/variants/go-chi/internal/auth/middleware_test.go +38 -0
- package/templates/variants/go-chi/internal/config/config.go +38 -7
- package/templates/variants/go-chi/internal/httpapi/routes.go +170 -47
- package/templates/variants/go-chi/internal/httpapi/waitlist_integration_test.go +199 -0
- package/templates/variants/go-chi/internal/temporal/activities.go +27 -0
- package/templates/variants/go-chi/internal/temporal/worker.go +42 -0
- package/templates/variants/go-chi/internal/temporal/workflows.go +18 -0
- package/templates/variants/go-chi/migrations/0000_init.sql +20 -0
- package/templates/variants/go-chi/migrations/atlas.sum +2 -0
- package/templates/variants/go-chi/package.json +7 -1
- package/templates/variants/go-chi/test/go.test.ts +4 -1
- package/templates/variants/go-connectrpc/Makefile +29 -8
- package/templates/variants/go-connectrpc/atlas.hcl +8 -0
- package/templates/variants/go-connectrpc/buf.gen.yaml +2 -0
- package/templates/variants/go-connectrpc/cmd/server/main.go +44 -9
- package/templates/variants/go-connectrpc/gen/waitlist/v1/waitlist.pb.go +960 -0
- package/templates/variants/go-connectrpc/gen/waitlist/v1/waitlistv1connect/waitlist.connect.go +283 -0
- package/templates/variants/go-connectrpc/go.mod +4 -0
- package/templates/variants/go-connectrpc/internal/app/service.go +279 -70
- package/templates/variants/go-connectrpc/internal/auth/middleware.go +289 -0
- package/templates/variants/go-connectrpc/internal/auth/middleware_test.go +38 -0
- package/templates/variants/go-connectrpc/internal/config/config.go +38 -7
- package/templates/variants/go-connectrpc/internal/connectapi/handler.go +129 -40
- package/templates/variants/go-connectrpc/internal/connectapi/waitlist_integration_test.go +122 -0
- package/templates/variants/go-connectrpc/internal/httpapi/routes.go +170 -47
- package/templates/variants/go-connectrpc/internal/temporal/activities.go +27 -0
- package/templates/variants/go-connectrpc/internal/temporal/worker.go +42 -0
- package/templates/variants/go-connectrpc/internal/temporal/workflows.go +18 -0
- package/templates/variants/go-connectrpc/migrations/0000_init.sql +20 -0
- package/templates/variants/go-connectrpc/migrations/atlas.sum +2 -0
- package/templates/variants/go-connectrpc/package.json +7 -1
- package/templates/variants/go-connectrpc/protos/waitlist/v1/waitlist.proto +93 -0
- package/templates/root/.github/workflows/buf-publish.yml +0 -19
- package/templates/root/.github/workflows/ci.yml +0 -26
- package/templates/root/.github/workflows/deploy.yml +0 -22
- package/templates/root/Dockerfile +0 -23
- package/templates/root/README.md +0 -69
- package/templates/root/buf.gen.yaml +0 -10
- package/templates/root/buf.yaml +0 -9
- package/templates/root/cmd/server/main.go +0 -44
- package/templates/root/gen/dns/v1/dns.pb.go +0 -623
- package/templates/root/gen/dns/v1/dnsv1connect/dns.connect.go +0 -192
- package/templates/root/go.mod +0 -10
- package/templates/root/internal/app/service.go +0 -152
- package/templates/root/internal/app/token_source.go +0 -50
- package/templates/root/internal/cloudflare/client.go +0 -160
- package/templates/root/internal/config/config.go +0 -55
- package/templates/root/internal/connectapi/handler.go +0 -79
- package/templates/root/internal/httpapi/routes.go +0 -93
- package/templates/root/internal/vault/client.go +0 -148
- package/templates/root/package.json +0 -12
- package/templates/root/protos/dns/v1/dns.proto +0 -58
- package/templates/root/scripts/cloudrun/bootstrap.ts +0 -65
- package/templates/root/scripts/cloudrun/config.ts +0 -50
- package/templates/root/scripts/cloudrun/deploy.ts +0 -41
- package/templates/root/scripts/cloudrun/lib.ts +0 -244
- package/templates/root/service.yaml +0 -50
- package/templates/root/test/go.test.ts +0 -19
- package/templates/shared/.env.example +0 -10
- package/templates/variants/go-chi/buf.gen.yaml +0 -10
- package/templates/variants/go-chi/buf.yaml +0 -9
- package/templates/variants/go-chi/gen/dns/v1/dns.pb.go +0 -623
- package/templates/variants/go-chi/gen/dns/v1/dnsv1connect/dns.connect.go +0 -192
- package/templates/variants/go-chi/internal/connectapi/handler.go +0 -79
- package/templates/variants/go-chi/protos/dns/v1/dns.proto +0 -58
- package/templates/variants/go-connectrpc/gen/dns/v1/dns.pb.go +0 -623
- package/templates/variants/go-connectrpc/gen/dns/v1/dnsv1connect/dns.connect.go +0 -192
- package/templates/variants/go-connectrpc/protos/dns/v1/dns.proto +0 -58
|
@@ -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 = ""
|