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.
Files changed (163) hide show
  1. package/README.md +138 -16
  2. package/bin/create-service.mjs +2 -0
  3. package/package.json +19 -11
  4. package/src/cli.test.ts +46 -7
  5. package/src/cli.ts +282 -84
  6. package/src/git-bootstrap.test.ts +40 -0
  7. package/src/git-bootstrap.ts +110 -0
  8. package/src/naming.test.ts +5 -2
  9. package/src/naming.ts +32 -1
  10. package/src/neon.ts +10 -8
  11. package/src/post-scaffold.test.ts +19 -0
  12. package/src/post-scaffold.ts +18 -26
  13. package/src/profiles.ts +25 -0
  14. package/src/scaffold.test.ts +320 -18
  15. package/src/scaffold.ts +154 -28
  16. package/src/vault.test.ts +94 -10
  17. package/src/vault.ts +81 -18
  18. package/templates/shared/.github/workflows/ci.yml +2 -1
  19. package/templates/shared/.github/workflows/deploy.yml +2 -0
  20. package/templates/shared/README.md +217 -29
  21. package/templates/shared/docker-compose.yml +19 -0
  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 +24 -42
  26. package/templates/shared/scripts/cloudrun/cleanup.ts +81 -35
  27. package/templates/shared/scripts/cloudrun/cli.ts +324 -7
  28. package/templates/shared/scripts/cloudrun/config.ts +21 -19
  29. package/templates/shared/scripts/cloudrun/deploy.ts +16 -11
  30. package/templates/shared/scripts/cloudrun/lib.ts +232 -123
  31. package/templates/shared/scripts/cloudrun/neon.ts +127 -13
  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 -1
  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 +397 -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/Dockerfile +1 -0
  53. package/templates/variants/bun-connectrpc/Makefile +17 -8
  54. package/templates/variants/bun-connectrpc/gen/protos/waitlist/v1/waitlist_pb.ts +424 -0
  55. package/templates/variants/bun-connectrpc/migrations/0000_init.sql +20 -0
  56. package/templates/variants/bun-connectrpc/package.json +25 -1
  57. package/templates/variants/bun-connectrpc/protos/waitlist/v1/waitlist.proto +91 -0
  58. package/templates/variants/bun-connectrpc/scripts/codegen.ts +31 -1
  59. package/templates/variants/bun-connectrpc/scripts/migrate.ts +49 -0
  60. package/templates/variants/bun-connectrpc/src/auth.ts +200 -0
  61. package/templates/variants/bun-connectrpc/src/db/client.ts +15 -0
  62. package/templates/variants/bun-connectrpc/src/db/repository.ts +126 -0
  63. package/templates/variants/bun-connectrpc/src/db/schema.ts +26 -0
  64. package/templates/variants/bun-connectrpc/src/index.ts +194 -22
  65. package/templates/variants/bun-connectrpc/src/temporal/activities.ts +14 -0
  66. package/templates/variants/bun-connectrpc/src/temporal/worker.ts +38 -0
  67. package/templates/variants/bun-connectrpc/src/temporal/workflows.ts +10 -0
  68. package/templates/variants/bun-connectrpc/src/waitlist/service.ts +172 -0
  69. package/templates/variants/bun-connectrpc/src/waitlist/types.ts +45 -0
  70. package/templates/variants/bun-connectrpc/test/app.test.ts +14 -13
  71. package/templates/variants/bun-connectrpc/test/waitlist.integration.test.ts +71 -0
  72. package/templates/variants/bun-connectrpc/tsconfig.json +2 -1
  73. package/templates/variants/bun-hono/Makefile +17 -8
  74. package/templates/variants/bun-hono/migrations/0000_init.sql +20 -0
  75. package/templates/variants/bun-hono/package.json +21 -1
  76. package/templates/variants/bun-hono/scripts/migrate.ts +49 -0
  77. package/templates/variants/bun-hono/src/auth.ts +181 -0
  78. package/templates/variants/bun-hono/src/db/client.ts +15 -0
  79. package/templates/variants/bun-hono/src/db/repository.ts +126 -0
  80. package/templates/variants/bun-hono/src/db/schema.ts +26 -0
  81. package/templates/variants/bun-hono/src/index.ts +141 -10
  82. package/templates/variants/bun-hono/src/temporal/activities.ts +14 -0
  83. package/templates/variants/bun-hono/src/temporal/worker.ts +38 -0
  84. package/templates/variants/bun-hono/src/temporal/workflows.ts +10 -0
  85. package/templates/variants/bun-hono/src/waitlist/service.ts +166 -0
  86. package/templates/variants/bun-hono/src/waitlist/types.ts +50 -0
  87. package/templates/variants/bun-hono/test/app.test.ts +90 -5
  88. package/templates/variants/bun-hono/test/waitlist.integration.test.ts +102 -0
  89. package/templates/variants/bun-hono/tsconfig.json +1 -0
  90. package/templates/variants/go-chi/Makefile +30 -10
  91. package/templates/variants/go-chi/atlas.hcl +8 -0
  92. package/templates/variants/go-chi/cmd/server/main.go +25 -13
  93. package/templates/variants/go-chi/go.mod +3 -2
  94. package/templates/variants/go-chi/internal/app/service.go +279 -70
  95. package/templates/variants/go-chi/internal/auth/middleware.go +289 -0
  96. package/templates/variants/go-chi/internal/auth/middleware_test.go +38 -0
  97. package/templates/variants/go-chi/internal/config/config.go +38 -7
  98. package/templates/variants/go-chi/internal/httpapi/routes.go +170 -47
  99. package/templates/variants/go-chi/internal/httpapi/waitlist_integration_test.go +199 -0
  100. package/templates/variants/go-chi/internal/temporal/activities.go +27 -0
  101. package/templates/variants/go-chi/internal/temporal/worker.go +42 -0
  102. package/templates/variants/go-chi/internal/temporal/workflows.go +18 -0
  103. package/templates/variants/go-chi/migrations/0000_init.sql +20 -0
  104. package/templates/variants/go-chi/migrations/atlas.sum +2 -0
  105. package/templates/variants/go-chi/package.json +7 -1
  106. package/templates/variants/go-chi/test/go.test.ts +4 -1
  107. package/templates/variants/go-connectrpc/Makefile +29 -8
  108. package/templates/variants/go-connectrpc/atlas.hcl +8 -0
  109. package/templates/variants/go-connectrpc/buf.gen.yaml +2 -0
  110. package/templates/variants/go-connectrpc/cmd/server/main.go +44 -9
  111. package/templates/variants/go-connectrpc/gen/waitlist/v1/waitlist.pb.go +960 -0
  112. package/templates/variants/go-connectrpc/gen/waitlist/v1/waitlistv1connect/waitlist.connect.go +283 -0
  113. package/templates/variants/go-connectrpc/go.mod +4 -0
  114. package/templates/variants/go-connectrpc/internal/app/service.go +279 -70
  115. package/templates/variants/go-connectrpc/internal/auth/middleware.go +289 -0
  116. package/templates/variants/go-connectrpc/internal/auth/middleware_test.go +38 -0
  117. package/templates/variants/go-connectrpc/internal/config/config.go +38 -7
  118. package/templates/variants/go-connectrpc/internal/connectapi/handler.go +129 -40
  119. package/templates/variants/go-connectrpc/internal/connectapi/waitlist_integration_test.go +122 -0
  120. package/templates/variants/go-connectrpc/internal/httpapi/routes.go +170 -47
  121. package/templates/variants/go-connectrpc/internal/temporal/activities.go +27 -0
  122. package/templates/variants/go-connectrpc/internal/temporal/worker.go +42 -0
  123. package/templates/variants/go-connectrpc/internal/temporal/workflows.go +18 -0
  124. package/templates/variants/go-connectrpc/migrations/0000_init.sql +20 -0
  125. package/templates/variants/go-connectrpc/migrations/atlas.sum +2 -0
  126. package/templates/variants/go-connectrpc/package.json +7 -1
  127. package/templates/variants/go-connectrpc/protos/waitlist/v1/waitlist.proto +93 -0
  128. package/templates/root/.github/workflows/buf-publish.yml +0 -19
  129. package/templates/root/.github/workflows/ci.yml +0 -26
  130. package/templates/root/.github/workflows/deploy.yml +0 -22
  131. package/templates/root/Dockerfile +0 -23
  132. package/templates/root/README.md +0 -69
  133. package/templates/root/buf.gen.yaml +0 -10
  134. package/templates/root/buf.yaml +0 -9
  135. package/templates/root/cmd/server/main.go +0 -44
  136. package/templates/root/gen/dns/v1/dns.pb.go +0 -623
  137. package/templates/root/gen/dns/v1/dnsv1connect/dns.connect.go +0 -192
  138. package/templates/root/go.mod +0 -10
  139. package/templates/root/internal/app/service.go +0 -152
  140. package/templates/root/internal/app/token_source.go +0 -50
  141. package/templates/root/internal/cloudflare/client.go +0 -160
  142. package/templates/root/internal/config/config.go +0 -55
  143. package/templates/root/internal/connectapi/handler.go +0 -79
  144. package/templates/root/internal/httpapi/routes.go +0 -93
  145. package/templates/root/internal/vault/client.go +0 -148
  146. package/templates/root/package.json +0 -12
  147. package/templates/root/protos/dns/v1/dns.proto +0 -58
  148. package/templates/root/scripts/cloudrun/bootstrap.ts +0 -65
  149. package/templates/root/scripts/cloudrun/config.ts +0 -50
  150. package/templates/root/scripts/cloudrun/deploy.ts +0 -41
  151. package/templates/root/scripts/cloudrun/lib.ts +0 -244
  152. package/templates/root/service.yaml +0 -50
  153. package/templates/root/test/go.test.ts +0 -19
  154. package/templates/shared/.env.example +0 -10
  155. package/templates/variants/go-chi/buf.gen.yaml +0 -10
  156. package/templates/variants/go-chi/buf.yaml +0 -9
  157. package/templates/variants/go-chi/gen/dns/v1/dns.pb.go +0 -623
  158. package/templates/variants/go-chi/gen/dns/v1/dnsv1connect/dns.connect.go +0 -192
  159. package/templates/variants/go-chi/internal/connectapi/handler.go +0 -79
  160. package/templates/variants/go-chi/protos/dns/v1/dns.proto +0 -58
  161. package/templates/variants/go-connectrpc/gen/dns/v1/dns.pb.go +0 -623
  162. package/templates/variants/go-connectrpc/gen/dns/v1/dnsv1connect/dns.connect.go +0 -192
  163. package/templates/variants/go-connectrpc/protos/dns/v1/dns.proto +0 -58
@@ -0,0 +1,166 @@
1
+ import { createDb } from "../db/client";
2
+ import { WaitlistRepository } from "../db/repository";
3
+ import type {
4
+ JoinWaitlistInput,
5
+ ListWaitlistEntriesInput,
6
+ RecordTriggerInput,
7
+ UpdateWaitlistEntryInput,
8
+ WaitlistEntry,
9
+ WaitlistEntryStatus,
10
+ } from "./types";
11
+
12
+ export class AppError extends Error {
13
+ constructor(
14
+ readonly status: number,
15
+ readonly code: string,
16
+ message: string
17
+ ) {
18
+ super(message);
19
+ }
20
+ }
21
+
22
+ export type WaitlistService = {
23
+ joinWaitlist(input: JoinWaitlistInput): Promise<{ entry: WaitlistEntry; created: boolean }>;
24
+ getWaitlistEntry(entryId: string): Promise<WaitlistEntry>;
25
+ getWaitlistEntryByEmail(email: string): Promise<WaitlistEntry>;
26
+ listWaitlistEntries(input?: ListWaitlistEntriesInput): Promise<WaitlistEntry[]>;
27
+ updateWaitlistEntry(input: UpdateWaitlistEntryInput): Promise<WaitlistEntry>;
28
+ exportWaitlistEntries(input?: ListWaitlistEntriesInput): Promise<string>;
29
+ recordTrigger(input: RecordTriggerInput): Promise<unknown>;
30
+ };
31
+
32
+ export class DefaultWaitlistService implements WaitlistService {
33
+ constructor(private readonly repository: WaitlistRepository) {}
34
+
35
+ async joinWaitlist(input: JoinWaitlistInput) {
36
+ const email = normalizeEmail(input.email);
37
+ const existing = await this.repository.getEntryByEmail(email);
38
+ if (existing) {
39
+ return { entry: existing, created: false };
40
+ }
41
+
42
+ const entry = await this.repository.createEntry({
43
+ id: crypto.randomUUID(),
44
+ email,
45
+ name: normalizeNullableText(input.name),
46
+ company: normalizeNullableText(input.company),
47
+ source: normalizeNullableText(input.source),
48
+ });
49
+
50
+ await this.repository.createTrigger({
51
+ id: crypto.randomUUID(),
52
+ type: "waitlist.joined",
53
+ entryId: entry.id,
54
+ payload: { email: entry.email, source: entry.source },
55
+ });
56
+
57
+ return { entry, created: true };
58
+ }
59
+
60
+ async getWaitlistEntry(entryId: string) {
61
+ const entry = await this.repository.getEntryById(entryId.trim());
62
+ if (!entry) {
63
+ throw new AppError(404, "entry_not_found", `waitlist entry ${entryId} not found`);
64
+ }
65
+ return entry;
66
+ }
67
+
68
+ async getWaitlistEntryByEmail(email: string) {
69
+ const entry = await this.repository.getEntryByEmail(normalizeEmail(email));
70
+ if (!entry) {
71
+ throw new AppError(404, "entry_not_found", `waitlist entry for ${email} not found`);
72
+ }
73
+ return entry;
74
+ }
75
+
76
+ async listWaitlistEntries(input: ListWaitlistEntriesInput = {}) {
77
+ return this.repository.listEntries({
78
+ status: input.status ? normalizeStatus(input.status) : null,
79
+ limit: input.limit,
80
+ });
81
+ }
82
+
83
+ async updateWaitlistEntry(input: UpdateWaitlistEntryInput) {
84
+ const entryId = input.entryId.trim();
85
+ if (!entryId) {
86
+ throw new AppError(400, "invalid_entry_id", "entry id is required");
87
+ }
88
+ const entry = await this.repository.updateEntryStatus(entryId, normalizeStatus(input.status));
89
+ if (!entry) {
90
+ throw new AppError(404, "entry_not_found", `waitlist entry ${input.entryId} not found`);
91
+ }
92
+ return entry;
93
+ }
94
+
95
+ async exportWaitlistEntries(input: ListWaitlistEntriesInput = {}) {
96
+ return entriesToCsv(await this.listWaitlistEntries(input));
97
+ }
98
+
99
+ async recordTrigger(input: RecordTriggerInput) {
100
+ const type = input.type.trim();
101
+ if (!type) {
102
+ throw new AppError(400, "invalid_trigger_type", "trigger type is required");
103
+ }
104
+
105
+ if (input.entryId) {
106
+ await this.getWaitlistEntry(input.entryId);
107
+ }
108
+
109
+ return this.repository.createTrigger({
110
+ id: crypto.randomUUID(),
111
+ type,
112
+ entryId: input.entryId?.trim() || null,
113
+ payload: input.payload ?? {},
114
+ });
115
+ }
116
+ }
117
+
118
+ export function createDefaultWaitlistService() {
119
+ return new DefaultWaitlistService(new WaitlistRepository(createDb()));
120
+ }
121
+
122
+ function normalizeEmail(value: string) {
123
+ const email = value.trim().toLowerCase();
124
+ if (!email || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
125
+ throw new AppError(400, "invalid_email", "valid email is required");
126
+ }
127
+ return email;
128
+ }
129
+
130
+ function normalizeNullableText(value: string | null | undefined) {
131
+ const normalized = value?.trim();
132
+ return normalized ? normalized : null;
133
+ }
134
+
135
+ function normalizeStatus(value: string): WaitlistEntryStatus {
136
+ const status = value.trim().toLowerCase();
137
+ if (status === "joined" || status === "invited" || status === "converted" || status === "archived") {
138
+ return status;
139
+ }
140
+ throw new AppError(400, "invalid_status", "status must be one of joined, invited, converted, archived");
141
+ }
142
+
143
+ function entriesToCsv(entries: WaitlistEntry[]) {
144
+ const headers = ["id", "email", "name", "company", "source", "status", "created_at", "updated_at"];
145
+ return [
146
+ headers.join(","),
147
+ ...entries.map((entry) =>
148
+ [
149
+ entry.id,
150
+ entry.email,
151
+ entry.name ?? "",
152
+ entry.company ?? "",
153
+ entry.source ?? "",
154
+ entry.status,
155
+ entry.createdAt,
156
+ entry.updatedAt,
157
+ ]
158
+ .map(csvCell)
159
+ .join(",")
160
+ ),
161
+ ].join("\n");
162
+ }
163
+
164
+ function csvCell(value: string) {
165
+ return `"${value.replaceAll('"', '""')}"`;
166
+ }
@@ -0,0 +1,50 @@
1
+ export type WaitlistEntry = {
2
+ id: string;
3
+ email: string;
4
+ name: string | null;
5
+ company: string | null;
6
+ source: string | null;
7
+ status: WaitlistEntryStatus;
8
+ createdAt: string;
9
+ updatedAt: string;
10
+ };
11
+
12
+ export type WaitlistTrigger = {
13
+ id: string;
14
+ type: string;
15
+ entryId: string | null;
16
+ status: "queued" | "processed" | "failed";
17
+ payload: unknown;
18
+ createdAt: string;
19
+ processedAt: string | null;
20
+ };
21
+
22
+ export type JoinWaitlistInput = {
23
+ email: string;
24
+ name?: string | null;
25
+ company?: string | null;
26
+ source?: string | null;
27
+ };
28
+
29
+ export type JoinWaitlistResult = {
30
+ entry: WaitlistEntry;
31
+ created: boolean;
32
+ };
33
+
34
+ export type WaitlistEntryStatus = "joined" | "invited" | "converted" | "archived";
35
+
36
+ export type ListWaitlistEntriesInput = {
37
+ status?: string | null;
38
+ limit?: number | null;
39
+ };
40
+
41
+ export type UpdateWaitlistEntryInput = {
42
+ entryId: string;
43
+ status: string;
44
+ };
45
+
46
+ export type RecordTriggerInput = {
47
+ type: string;
48
+ entryId?: string | null;
49
+ payload?: unknown;
50
+ };
@@ -1,12 +1,97 @@
1
1
  import { expect, test } from "bun:test";
2
2
  import { createApp } from "../src/index";
3
+ import type { WaitlistService } from "../src/waitlist/service";
4
+ import type { WaitlistEntry } from "../src/waitlist/types";
3
5
 
4
6
  test("health endpoint returns ok", async () => {
5
- const response = await createApp().request("/healthz");
7
+ const response = await createApp(createMockService()).request("/healthz");
6
8
  expect(response.status).toBe(200);
7
- expect(await response.json()).toEqual({
8
- status: "ok",
9
- runtime: "bun",
10
- framework: "hono",
9
+ expect(await response.json()).toEqual({ status: "ok" });
10
+ });
11
+
12
+ test("webhook health endpoint returns ok", async () => {
13
+ const response = await createApp(createMockService()).request("/webhooks/generic/health");
14
+ expect(response.status).toBe(200);
15
+ expect(await response.json()).toEqual({ status: "ok", provider: "generic" });
16
+ });
17
+
18
+ test("waitlist join returns created entry", async () => {
19
+ const response = await createApp(createMockService()).request("/v1/waitlist", {
20
+ method: "POST",
21
+ headers: { "Content-Type": "application/json" },
22
+ body: JSON.stringify({ email: "founder@example.com", source: "test" }),
11
23
  });
24
+ expect(response.status).toBe(201);
25
+ expect(await response.json()).toMatchObject({
26
+ created: true,
27
+ entry: {
28
+ email: "founder@example.com",
29
+ status: "joined",
30
+ },
31
+ });
32
+ });
33
+
34
+ test("waitlist api requires a bearer token when service auth is enabled", async () => {
35
+ const previous = Bun.env.AUTH_ENABLED;
36
+ Bun.env.AUTH_ENABLED = "true";
37
+ try {
38
+ const response = await createApp(createMockService()).request("/v1/waitlist", {
39
+ method: "POST",
40
+ headers: { "Content-Type": "application/json" },
41
+ body: JSON.stringify({ email: "founder@example.com" }),
42
+ });
43
+ expect(response.status).toBe(401);
44
+ expect(await response.json()).toEqual({ error: "missing bearer token", code: "unauthorized" });
45
+ } finally {
46
+ if (previous === undefined) {
47
+ delete Bun.env.AUTH_ENABLED;
48
+ } else {
49
+ Bun.env.AUTH_ENABLED = previous;
50
+ }
51
+ }
12
52
  });
53
+
54
+ function createMockService(): WaitlistService {
55
+ const entry: WaitlistEntry = {
56
+ id: "entry_1",
57
+ email: "founder@example.com",
58
+ name: null,
59
+ company: null,
60
+ source: "test",
61
+ status: "joined",
62
+ createdAt: "2026-01-01T00:00:00.000Z",
63
+ updatedAt: "2026-01-01T00:00:00.000Z",
64
+ };
65
+
66
+ return {
67
+ async joinWaitlist() {
68
+ return { entry, created: true };
69
+ },
70
+ async getWaitlistEntry() {
71
+ return entry;
72
+ },
73
+ async getWaitlistEntryByEmail() {
74
+ return entry;
75
+ },
76
+ async listWaitlistEntries() {
77
+ return [entry];
78
+ },
79
+ async updateWaitlistEntry() {
80
+ return { ...entry, status: "invited" };
81
+ },
82
+ async exportWaitlistEntries() {
83
+ return "id,email,name,company,source,status,created_at,updated_at\nentry_1,founder@example.com,,,test,joined,2026-01-01T00:00:00.000Z,2026-01-01T00:00:00.000Z";
84
+ },
85
+ async recordTrigger() {
86
+ return {
87
+ id: "trigger_1",
88
+ type: "manual",
89
+ entryId: null,
90
+ status: "queued",
91
+ payload: {},
92
+ createdAt: "2026-01-01T00:00:00.000Z",
93
+ processedAt: null,
94
+ };
95
+ },
96
+ };
97
+ }
@@ -0,0 +1,102 @@
1
+ import { afterEach, beforeEach, expect, test } from "bun:test";
2
+ import { SQL } from "bun";
3
+ import { createApp } from "../src/index";
4
+ import { DefaultWaitlistService } from "../src/waitlist/service";
5
+ import { WaitlistRepository } from "../src/db/repository";
6
+ import { createDb } from "../src/db/client";
7
+
8
+ const databaseUrl = Bun.env.DATABASE_URL?.trim();
9
+ const integrationTest = databaseUrl ? test : test.skip;
10
+
11
+ let sql: SQL | null = null;
12
+
13
+ beforeEach(async () => {
14
+ if (!databaseUrl) {
15
+ return;
16
+ }
17
+ sql = new SQL(databaseUrl);
18
+ await sql.unsafe(`
19
+ truncate table
20
+ waitlist_triggers,
21
+ waitlist_entries
22
+ restart identity cascade
23
+ `);
24
+ });
25
+
26
+ afterEach(async () => {
27
+ await sql?.end();
28
+ sql = null;
29
+ });
30
+
31
+ integrationTest("waitlist join is idempotent and records triggers", async () => {
32
+ const app = createApp(new DefaultWaitlistService(new WaitlistRepository(createDb(databaseUrl))));
33
+
34
+ const first = await requestJson(app, "/v1/waitlist", {
35
+ method: "POST",
36
+ body: {
37
+ email: "Founder@Example.com",
38
+ name: "Founder",
39
+ company: "Example Co",
40
+ source: "homepage",
41
+ },
42
+ expectedStatus: 201,
43
+ });
44
+ expect(first.entry.email).toBe("founder@example.com");
45
+ expect(first.created).toBe(true);
46
+
47
+ const second = await requestJson(app, "/v1/waitlist", {
48
+ method: "POST",
49
+ body: {
50
+ email: "founder@example.com",
51
+ },
52
+ });
53
+ expect(second.entry.id).toBe(first.entry.id);
54
+ expect(second.created).toBe(false);
55
+
56
+ const trigger = await requestJson(app, "/v1/triggers/waitlist", {
57
+ method: "POST",
58
+ body: {
59
+ type: "cron.digest",
60
+ entry_id: first.entry.id,
61
+ },
62
+ expectedStatus: 202,
63
+ });
64
+ expect(trigger.trigger).toMatchObject({
65
+ type: "cron.digest",
66
+ entryId: first.entry.id,
67
+ status: "queued",
68
+ });
69
+
70
+ const updated = await requestJson(app, `/v1/admin/waitlist/${first.entry.id}`, {
71
+ method: "PATCH",
72
+ body: { status: "invited" },
73
+ });
74
+ expect(updated.entry).toMatchObject({ id: first.entry.id, status: "invited" });
75
+
76
+ const list = await requestJson(app, "/v1/admin/waitlist?status=invited");
77
+ expect(list.entries).toHaveLength(1);
78
+ expect(list.entries[0]).toMatchObject({ id: first.entry.id, status: "invited" });
79
+
80
+ const exportResponse = await app.request("/v1/admin/waitlist/export?status=invited");
81
+ expect(exportResponse.status).toBe(200);
82
+ expect(exportResponse.headers.get("content-type")).toContain("text/csv");
83
+ expect(await exportResponse.text()).toContain("founder@example.com");
84
+ });
85
+
86
+ async function requestJson(
87
+ app: ReturnType<typeof createApp>,
88
+ path: string,
89
+ input: {
90
+ method?: string;
91
+ body?: unknown;
92
+ expectedStatus?: number;
93
+ } = {}
94
+ ) {
95
+ const response = await app.request(path, {
96
+ method: input.method ?? "GET",
97
+ headers: input.body ? { "Content-Type": "application/json" } : undefined,
98
+ body: input.body ? JSON.stringify(input.body) : undefined,
99
+ });
100
+ expect(response.status).toBe(input.expectedStatus ?? 200);
101
+ return response.json();
102
+ }
@@ -4,6 +4,7 @@
4
4
  "module": "ESNext",
5
5
  "moduleResolution": "Bundler",
6
6
  "strict": true,
7
+ "skipLibCheck": true,
7
8
  "types": ["bun"]
8
9
  },
9
10
  "include": ["src/**/*.ts", "test/**/*.ts", "scripts/**/*.ts"]
@@ -1,25 +1,45 @@
1
- .PHONY: dev gen lint test bootstrap deploy cleanup
1
+ .PHONY: dev migrate migrate-lint gen lint test create deploy dashboards auth destroy
2
2
 
3
- CLOUDRUN := npx --no-install svc-cloudrun
3
+ SERVICE := npx --no-install service
4
+ WITH_ENV := set -a; [ ! -f .env.local ] || . ./.env.local; set +a;
5
+ ATLAS ?= atlas
4
6
 
5
7
  dev:
6
- go run ./cmd/server
8
+ @bun run ./scripts/dev.ts go run ./cmd/server
9
+
10
+ migrate:
11
+ @bun run ./scripts/ensure-local-db.ts
12
+ @bun run ./scripts/wait-for-db.ts
13
+ @$(WITH_ENV) $(ATLAS) migrate apply --env local
14
+
15
+ migrate-lint:
16
+ @bun run ./scripts/ensure-local-db.ts
17
+ @bun run ./scripts/wait-for-db.ts
18
+ @$(WITH_ENV) $(ATLAS) migrate lint --env local --latest 1
7
19
 
8
20
  gen:
9
- buf generate
21
+ @echo "no generated code for go + chi"
10
22
 
11
23
  lint:
12
24
  go vet ./...
13
- buf lint
25
+ @bun run ./scripts/ensure-local-db.ts
26
+ @bun run ./scripts/wait-for-db.ts
27
+ @$(WITH_ENV) $(ATLAS) migrate lint --env local --latest 1
14
28
 
15
29
  test:
16
30
  bun test ./test
17
31
 
18
- bootstrap:
19
- $(CLOUDRUN) bootstrap
32
+ create:
33
+ $(SERVICE) create
20
34
 
21
35
  deploy:
22
- $(CLOUDRUN) deploy $(ARGS)
36
+ $(SERVICE) deploy $(ARGS)
37
+
38
+ dashboards:
39
+ $(SERVICE) dashboards
40
+
41
+ auth:
42
+ $(SERVICE) auth $(ARGS)
23
43
 
24
- cleanup:
25
- $(CLOUDRUN) cleanup $(ARGS)
44
+ destroy:
45
+ $(SERVICE) destroy $(ARGS)
@@ -0,0 +1,8 @@
1
+ env "local" {
2
+ url = getenv("DATABASE_URL")
3
+ dev = "docker://postgres/16/dev?search_path=public"
4
+
5
+ migration {
6
+ dir = "file://migrations"
7
+ }
8
+ }
@@ -7,13 +7,15 @@ import (
7
7
  "time"
8
8
 
9
9
  "github.com/go-chi/chi/v5"
10
+ _ "github.com/jackc/pgx/v5/stdlib"
10
11
  "golang.org/x/net/http2"
11
12
  "golang.org/x/net/http2/h2c"
12
13
 
13
14
  "{{MODULE_PATH}}/internal/app"
15
+ "{{MODULE_PATH}}/internal/auth"
14
16
  "{{MODULE_PATH}}/internal/config"
15
- "{{MODULE_PATH}}/internal/connectapi"
16
17
  "{{MODULE_PATH}}/internal/httpapi"
18
+ temporalapp "{{MODULE_PATH}}/internal/temporal"
17
19
  )
18
20
 
19
21
  func main() {
@@ -22,25 +24,35 @@ func main() {
22
24
  log.Fatal(err)
23
25
  }
24
26
 
25
- service := app.NewDNSService()
26
- if cfg.DatabaseURL != "" {
27
- if _, err := service.CreateRecord(context.Background(), app.CreateRecordInput{
28
- Type: "TXT",
29
- Name: "bootstrap",
30
- Content: "database-configured",
31
- TTL: 60,
32
- Proxied: false,
33
- }); err != nil {
27
+ db, err := app.OpenDatabase(context.Background(), cfg.DatabaseURL)
28
+ if err != nil {
29
+ log.Fatal(err)
30
+ }
31
+
32
+ service := app.NewWaitlistService(db)
33
+ if cfg.TemporalEnabled {
34
+ stopTemporal, err := temporalapp.StartWorker(temporalapp.WorkerConfig{
35
+ Address: cfg.TemporalAddress,
36
+ Namespace: cfg.TemporalNamespace,
37
+ TaskQueue: cfg.TemporalTaskQueue,
38
+ APIKey: cfg.TemporalAPIKey,
39
+ })
40
+ if err != nil {
34
41
  log.Fatal(err)
35
42
  }
43
+ defer stopTemporal()
44
+ log.Printf("Temporal worker polling %s", cfg.TemporalTaskQueue)
36
45
  }
37
46
 
38
47
  router := chi.NewRouter()
48
+ router.Use(auth.Middleware(auth.Config{
49
+ Enabled: cfg.AuthEnabled,
50
+ Issuer: cfg.AuthIssuer,
51
+ Audience: cfg.AuthAudience,
52
+ JWKSURL: cfg.AuthJWKSURL,
53
+ }))
39
54
  httpapi.RegisterRoutes(router, service)
40
55
 
41
- connectPath, connectHandler := connectapi.NewHandler(service)
42
- router.Mount(connectPath, connectHandler)
43
-
44
56
  server := &http.Server{
45
57
  Addr: ":" + cfg.Port,
46
58
  ReadHeaderTimeout: 10 * time.Second,
@@ -3,8 +3,9 @@ module {{MODULE_PATH}}
3
3
  go 1.25.4
4
4
 
5
5
  require (
6
- connectrpc.com/connect v1.19.1
7
6
  github.com/go-chi/chi/v5 v5.2.2
7
+ github.com/jackc/pgx/v5 v5.7.5
8
+ github.com/jmoiron/sqlx v1.4.0
9
+ go.temporal.io/sdk v1.43.0
8
10
  golang.org/x/net v0.42.0
9
- google.golang.org/protobuf v1.36.10
10
11
  )