create-svc 0.1.53 → 0.1.54

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 (38) hide show
  1. package/README.md +6 -0
  2. package/package.json +1 -1
  3. package/src/scaffold.test.ts +30 -8
  4. package/src/scaffold.ts +23 -0
  5. package/src/service-runtime/cloudrun/cli.ts +10 -0
  6. package/src/service-runtime/cloudrun/config.ts +3 -0
  7. package/src/service-runtime/cloudrun/observability.ts +18 -0
  8. package/templates/shared/.github/workflows/deploy.yml +1 -1
  9. package/templates/shared/.github/workflows/preview.yml +4 -3
  10. package/templates/shared/README.md +37 -0
  11. package/templates/shared/service.jsonc +8 -0
  12. package/templates/variants/bun-connectrpc/Makefile +4 -1
  13. package/templates/variants/bun-connectrpc/migrations/0000_init.sql +10 -0
  14. package/templates/variants/bun-connectrpc/package.json +1 -0
  15. package/templates/variants/bun-connectrpc/src/db/repository.ts +52 -3
  16. package/templates/variants/bun-connectrpc/src/db/schema.ts +13 -0
  17. package/templates/variants/bun-connectrpc/src/index.ts +47 -4
  18. package/templates/variants/bun-connectrpc/src/waitlist/service.ts +22 -0
  19. package/templates/variants/bun-connectrpc/src/waitlist/types.ts +16 -0
  20. package/templates/variants/bun-hono/Makefile +4 -1
  21. package/templates/variants/bun-hono/migrations/0000_init.sql +10 -0
  22. package/templates/variants/bun-hono/package.json +1 -0
  23. package/templates/variants/bun-hono/src/db/repository.ts +52 -3
  24. package/templates/variants/bun-hono/src/db/schema.ts +13 -0
  25. package/templates/variants/bun-hono/src/index.ts +34 -8
  26. package/templates/variants/bun-hono/src/waitlist/service.ts +22 -0
  27. package/templates/variants/bun-hono/src/waitlist/types.ts +16 -0
  28. package/templates/variants/bun-hono/test/app.test.ts +13 -0
  29. package/templates/variants/go-chi/Makefile +4 -1
  30. package/templates/variants/go-chi/internal/app/service.go +96 -0
  31. package/templates/variants/go-chi/internal/httpapi/routes.go +56 -7
  32. package/templates/variants/go-chi/migrations/0000_init.sql +10 -0
  33. package/templates/variants/go-chi/migrations/atlas.sum +2 -2
  34. package/templates/variants/go-connectrpc/Makefile +4 -1
  35. package/templates/variants/go-connectrpc/internal/app/service.go +96 -0
  36. package/templates/variants/go-connectrpc/internal/httpapi/routes.go +56 -7
  37. package/templates/variants/go-connectrpc/migrations/0000_init.sql +10 -0
  38. package/templates/variants/go-connectrpc/migrations/atlas.sum +2 -2
package/README.md CHANGED
@@ -115,6 +115,7 @@ bun run lint
115
115
  bun run test
116
116
  service create
117
117
  service deploy
118
+ service observability-bootstrap
118
119
  service dev down
119
120
  service destroy
120
121
  ```
@@ -129,6 +130,7 @@ make lint
129
130
  make test
130
131
  service create
131
132
  service deploy
133
+ service observability-bootstrap
132
134
  service dev down
133
135
  service destroy
134
136
  ```
@@ -136,6 +138,10 @@ service destroy
136
138
  Language-specific tasks such as local running, linting, formatting, testing, and building stay in package scripts or Make targets. Service lifecycle operations are exposed through the generated `service` CLI.
137
139
  `service destroy --force` also stops local dev and runs Docker Compose cleanup for generated Cloud Run services.
138
140
 
141
+ `service observability-bootstrap` enables the Google Cloud Logging, Monitoring,
142
+ and Trace APIs for the generated GCP project. It does not create dashboards,
143
+ alerts, log-based metrics, or SLOs; those stay explicit follow-up work.
144
+
139
145
  After `service create` has provisioned auth, the generated repo can mint a
140
146
  client-credentials bearer token for smoke checks:
141
147
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-svc",
3
- "version": "0.1.53",
3
+ "version": "0.1.54",
4
4
  "description": "Local microservice bootstrap CLI for Cloud Run and Workers services with Neon-backed data.",
5
5
  "module": "index.ts",
6
6
  "type": "module",
@@ -133,12 +133,24 @@ test("scaffolds all runtime/framework variants with shared cloudrun config", asy
133
133
  expect(localEnv).toContain("VAULT_CLOUDFLARE_API_TOKEN_PATH=prod/providers/cloudflare");
134
134
  expect(localEnv).not.toContain("ATTACHMENT_PUBLIC_BASE_URL=");
135
135
 
136
- const ciWorkflow = await Bun.file(join(generatedRoot, ".github", "workflows", "ci.yml")).text();
137
- expect(ciWorkflow).toContain("bun run dashboards");
138
- expect(ciWorkflow).toContain("GCX_ENABLED");
139
136
  expect(await Bun.file(join(generatedRoot, "grafana", "waitlist-dashboard.json")).exists()).toBeTrue();
140
137
  expect(await Bun.file(join(generatedRoot, "grafana", "alerts.yaml")).exists()).toBeTrue();
141
138
 
139
+ const previewWorkflow = await Bun.file(join(generatedRoot, ".github", "workflows", "preview.yml")).text();
140
+ expect(previewWorkflow).toContain("push:");
141
+ expect(previewWorkflow).toContain("branches-ignore:");
142
+ expect(previewWorkflow).toContain("- main");
143
+ expect(previewWorkflow).toContain("github.ref_name");
144
+ expect(previewWorkflow).toContain("service deploy --ci --environment preview --name");
145
+ expect(previewWorkflow).toContain("NEON_API_KEY");
146
+
147
+ const deployWorkflow = await Bun.file(join(generatedRoot, ".github", "workflows", "deploy.yml")).text();
148
+ expect(deployWorkflow).toContain("branches:");
149
+ expect(deployWorkflow).toContain("- main");
150
+ expect(deployWorkflow).toContain("service deploy --ci");
151
+ expect(deployWorkflow).toContain("bun run dashboards");
152
+ expect(deployWorkflow).toContain("GCX_ENABLED");
153
+
142
154
  if (variant.runtime === "go") {
143
155
  const goMod = await Bun.file(join(generatedRoot, "go.mod")).text();
144
156
  const goSumExists = await Bun.file(join(generatedRoot, "go.sum")).exists();
@@ -198,6 +210,7 @@ test("scaffolds all runtime/framework variants with shared cloudrun config", asy
198
210
  expect(packageJson).toContain('"create": "service create"');
199
211
  expect(packageJson).toContain('"deploy": "service deploy"');
200
212
  expect(packageJson).toContain('"dashboards": "service dashboards"');
213
+ expect(packageJson).toContain('"observability-bootstrap": "service observability-bootstrap"');
201
214
  expect(packageJson).toContain('"auth": "service auth"');
202
215
  expect(packageJson).toContain('"destroy": "service destroy"');
203
216
  expect(await Bun.file(join(generatedRoot, "scripts", "cloudrun", "cli.ts")).exists()).toBeFalse();
@@ -206,12 +219,15 @@ test("scaffolds all runtime/framework variants with shared cloudrun config", asy
206
219
  expect(serviceConfig).toContain('"service_id": "dns-api"');
207
220
  expect(serviceConfig).toContain('"project_id": "anmho-dns-api"');
208
221
  expect(serviceConfig).toContain('"database_name": "dns_api"');
222
+ expect(serviceConfig).toContain('"observability"');
223
+ expect(serviceConfig).toContain("logging.googleapis.com");
209
224
  const authScript = await Bun.file(join(generatedRoot, "src", "auth.ts")).text();
210
225
  expect(authScript).toContain('"Ed25519"');
211
226
 
212
227
  const makefile = await Bun.file(join(generatedRoot, "Makefile")).text();
213
228
  expect(makefile).toContain("SERVICE := service");
214
229
  expect(makefile).toContain("dashboards:");
230
+ expect(makefile).toContain("observability-bootstrap:");
215
231
  expect(makefile).toContain("auth:");
216
232
  expect(makefile).toContain("bun run dev");
217
233
  const devScript = await Bun.file(join(generatedRoot, "scripts", "dev.ts")).text();
@@ -249,8 +265,6 @@ test("scaffolds all runtime/framework variants with shared cloudrun config", asy
249
265
  expect(readme).toContain("service auth resource-server");
250
266
  }
251
267
 
252
- const deployWorkflow = await Bun.file(join(generatedRoot, ".github", "workflows", "deploy.yml")).text();
253
- expect(deployWorkflow).toContain("bun run dashboards");
254
268
  }
255
269
  }, 30000);
256
270
 
@@ -275,14 +289,20 @@ test("scaffolds a backend package cleanly into a nested monorepo-style directory
275
289
  expect(readme).toContain("known-good CLIs");
276
290
  expect(readme).toContain("service create");
277
291
  expect(readme).toContain("service deploy");
292
+ expect(readme).toContain("Google observability bootstrap");
293
+ expect(readme).toContain("Google Cloud Logging, Monitoring, and Trace APIs");
278
294
  expect(readme).toContain("one-command production create");
279
295
  expect(readme).toContain("waitlist/launch service");
280
296
  expect(readme).toContain("Terraform is optional");
281
297
  expect(readme).toContain("waitlist/launch service");
282
298
  expect(readme).not.toContain("Neon main, preview, and personal branch provisioning");
283
- const ciWorkflow = await Bun.file(join(generatedRoot, ".github", "workflows", "ci.yml")).text();
284
- expect(ciWorkflow).toContain("bun run dashboards");
285
- expect(readme).not.toContain("repository");
299
+ expect(readme).toContain("GitHub Actions deployment");
300
+ expect(readme).toContain(".github/workflows/preview.yml");
301
+ expect(readme).toContain(".github/workflows/deploy.yml");
302
+ expect(readme).toContain("pushes to non-main branches");
303
+ expect(readme).toContain("GCP_WIF_PROVIDER");
304
+ expect(readme).toContain("GCP_DEPLOYER_SERVICE_ACCOUNT");
305
+ expect(readme).toContain("NEON_API_KEY");
286
306
 
287
307
  const packageJson = await Bun.file(join(generatedRoot, "package.json")).text();
288
308
  expect(packageJson).toContain('"hono"');
@@ -290,6 +310,8 @@ test("scaffolds a backend package cleanly into a nested monorepo-style directory
290
310
  const entrypoint = await Bun.file(join(generatedRoot, "src", "index.ts")).text();
291
311
  expect(entrypoint).toContain("/v1/waitlist");
292
312
  expect(entrypoint).toContain("/v1/admin/waitlist");
313
+ expect(await Bun.file(join(generatedRoot, ".github", "workflows", "preview.yml")).exists()).toBeTrue();
314
+ expect(await Bun.file(join(generatedRoot, ".github", "workflows", "deploy.yml")).exists()).toBeTrue();
293
315
  }, 15000);
294
316
 
295
317
  test("scaffolds the workers target with wrangler lifecycle commands", async () => {
package/src/scaffold.ts CHANGED
@@ -12,6 +12,11 @@ import {
12
12
  import { exampleForProfile, type Profile } from "./profiles";
13
13
  import type { GitBootstrapConfig } from "./git-bootstrap";
14
14
 
15
+ const GENERATED_GITHUB_ACTION_WORKFLOWS = new Set([
16
+ ".github/workflows/preview.yml",
17
+ ".github/workflows/deploy.yml",
18
+ ]);
19
+
15
20
  export type ScaffoldConfig = {
16
21
  directory: string;
17
22
  serviceName: string;
@@ -153,12 +158,23 @@ async function collectTemplateFiles(root: string, relative = ""): Promise<string
153
158
  files.push(...(await collectTemplateFiles(root, nextRelative)));
154
159
  continue;
155
160
  }
161
+ if (shouldSkipTemplateFile(nextRelative)) {
162
+ continue;
163
+ }
156
164
  files.push(nextRelative);
157
165
  }
158
166
 
159
167
  return files.sort();
160
168
  }
161
169
 
170
+ function shouldSkipTemplateFile(relativePath: string) {
171
+ if (!relativePath.startsWith(".github/")) {
172
+ return false;
173
+ }
174
+
175
+ return !GENERATED_GITHUB_ACTION_WORKFLOWS.has(relativePath);
176
+ }
177
+
162
178
  function buildReplacements(config: ScaffoldConfig) {
163
179
  const example = exampleForProfile(config.profile);
164
180
  const serviceAccountBase = compactIdentifier(config.serviceName, 21);
@@ -219,6 +235,13 @@ function buildReplacements(config: ScaffoldConfig) {
219
235
  COMMAND_DEV_DOWN: "service dev down",
220
236
  COMMAND_BOOTSTRAP: "service create",
221
237
  COMMAND_DEPLOY: "service deploy",
238
+ COMMAND_OBSERVABILITY_BOOTSTRAP:
239
+ config.runtime === "bun" ? "bun run observability-bootstrap" : "make observability-bootstrap",
240
+ WORKFLOW_DEPLOY_MAIN_COMMAND: "npm install -g create-svc@latest && service deploy --ci",
241
+ WORKFLOW_DEPLOY_PREVIEW_COMMAND:
242
+ "npm install -g create-svc@latest && service deploy --ci --environment preview --name ${{ github.ref_name }}",
243
+ WORKFLOW_DEPLOY_MAIN_DOC_COMMAND: "service deploy --ci",
244
+ WORKFLOW_DEPLOY_PREVIEW_DOC_COMMAND: "service deploy --ci --environment preview --name <branch-name>",
222
245
  COMMAND_AUTH_RESOURCE: "service auth resource-server",
223
246
  COMMAND_AUTH_CLIENT: "service auth client create",
224
247
  COMMAND_AUTH_TOKEN: "service auth token",
@@ -6,6 +6,7 @@ import { stopLocalDev } from "../local-dev";
6
6
  import { bootstrap, prepareGcpProject } from "./bootstrap";
7
7
  import { cleanup } from "./cleanup";
8
8
  import { deploy } from "./deploy";
9
+ import { observabilityBootstrap } from "./observability";
9
10
  import { config } from "./config";
10
11
  import {
11
12
  accessSecretVersion,
@@ -69,6 +70,14 @@ export async function main(argv = Bun.argv.slice(2)) {
69
70
  return;
70
71
  }
71
72
 
73
+ if (command === "observability-bootstrap") {
74
+ await runMain("Google observability bootstrap", async () => {
75
+ await observabilityBootstrap();
76
+ return `Google observability bootstrap finished for ${config.serviceName}`;
77
+ });
78
+ return;
79
+ }
80
+
72
81
  if (command === "dev") {
73
82
  if (rest[0] !== "down") {
74
83
  throw new Error(`Unknown dev command: ${rest[0] || ""}\n\n${formatHelp()}`);
@@ -125,6 +134,7 @@ function formatHelp() {
125
134
  " sdk Build or publish generated SDK artifacts",
126
135
  " dns Repair or inspect DNS mappings",
127
136
  " dev down Stop local dev and Docker Compose containers",
137
+ " observability-bootstrap Enable Google observability APIs",
128
138
  " dashboards Publish Grafana resources",
129
139
  " destroy Remove service-managed cloud resources",
130
140
  ].join("\n");
@@ -55,6 +55,9 @@ export const config = {
55
55
  repository: serviceConfig.git?.repository || serviceConfig.service_id,
56
56
  deleteOnDestroy: Boolean(serviceConfig.git?.delete_on_destroy),
57
57
  },
58
+ observability: {
59
+ requiredApis: serviceConfig.observability?.required_apis ?? ["logging.googleapis.com", "monitoring.googleapis.com", "cloudtrace.googleapis.com"],
60
+ },
58
61
  requiredApis: cloudrun.required_apis,
59
62
  } as const;
60
63
 
@@ -0,0 +1,18 @@
1
+ import { config } from "./config";
2
+ import { gcloud, requireCommand, requireGcloudAuth, runMain, runStep } from "./lib";
3
+
4
+ export async function observabilityBootstrap() {
5
+ requireCommand("gcloud");
6
+ requireGcloudAuth();
7
+
8
+ await runStep("Enabling Google observability APIs", () =>
9
+ gcloud(["services", "enable", ...config.observability.requiredApis, "--project", config.project.id])
10
+ );
11
+ }
12
+
13
+ if (import.meta.main) {
14
+ await runMain("Google observability bootstrap", async () => {
15
+ await observabilityBootstrap();
16
+ return `Google observability bootstrap finished for ${config.serviceName}`;
17
+ });
18
+ }
@@ -25,7 +25,7 @@ jobs:
25
25
  service_account: ${{ vars.GCP_DEPLOYER_SERVICE_ACCOUNT }}
26
26
  - uses: google-github-actions/setup-gcloud@v2
27
27
  - run: bun install
28
- - run: bun run deploy -- --ci
28
+ - run: {{WORKFLOW_DEPLOY_MAIN_COMMAND}}
29
29
  env:
30
30
  NEON_API_KEY: ${{ secrets.NEON_API_KEY }}
31
31
  - if: ${{ vars.GCX_ENABLED == 'true' }}
@@ -1,8 +1,9 @@
1
1
  name: Preview
2
2
 
3
3
  on:
4
- pull_request:
5
- types: [opened, synchronize, reopened]
4
+ push:
5
+ branches-ignore:
6
+ - main
6
7
 
7
8
  permissions:
8
9
  contents: read
@@ -24,6 +25,6 @@ jobs:
24
25
  service_account: ${{ vars.GCP_DEPLOYER_SERVICE_ACCOUNT }}
25
26
  - uses: google-github-actions/setup-gcloud@v2
26
27
  - run: bun install
27
- - run: bun run deploy -- --ci --environment preview --name ${{ github.event.pull_request.number }}
28
+ - run: {{WORKFLOW_DEPLOY_PREVIEW_COMMAND}}
28
29
  env:
29
30
  NEON_API_KEY: ${{ secrets.NEON_API_KEY }}
@@ -9,6 +9,7 @@ This `{{PROFILE}}` profile targets `{{RUNTIME}} + {{FRAMEWORK}}` on Cloud Run wi
9
9
  - local Docker Compose Postgres for first-run development
10
10
  - the `service` CLI for create, deploy, doctor, dashboards, and destroy
11
11
  - shared GCP project deployment with quota-project-aware `gcloud` calls
12
+ - Google Cloud Observability API bootstrap through `service observability-bootstrap`
12
13
  - Neon-backed remote database provisioning during create and deploy
13
14
  - Better Auth client-credentials resource-server registration through `authctl`
14
15
  - stage-aware waitlist data and trigger ingestion
@@ -77,6 +78,20 @@ Build to build the image remotely.
77
78
  Local Docker builds target `linux/amd64` so images built on Apple Silicon run on
78
79
  Cloud Run.
79
80
 
81
+ ## Google observability bootstrap
82
+
83
+ Run this after the first production create when you want the project ready for
84
+ Google Cloud Observability follow-up work:
85
+
86
+ ```bash
87
+ {{COMMAND_OBSERVABILITY_BOOTSTRAP}}
88
+ ```
89
+
90
+ The command authenticates through `gcloud`, targets the generated GCP project,
91
+ and enables the Google Cloud Logging, Monitoring, and Trace APIs. It does not
92
+ create dashboards, alerts, log-based metrics, or SLOs; those remain explicit
93
+ follow-up work.
94
+
80
95
  Authenticate `gcloud` on the machine before running provisioning commands:
81
96
 
82
97
  ```bash
@@ -211,6 +226,28 @@ secrets only when you add a provider adapter. A generic adapter can honor:
211
226
 
212
227
  - `WEBHOOK_<PROVIDER>_SECRET`
213
228
 
229
+ ## GitHub Actions deployment
230
+
231
+ The scaffold emits a minimal deployment workflow slice for Cloud Run:
232
+
233
+ - [.github/workflows/preview.yml](.github/workflows/preview.yml) deploys a preview environment on pushes to non-main branches. It runs `{{WORKFLOW_DEPLOY_PREVIEW_DOC_COMMAND}}`.
234
+ - [.github/workflows/deploy.yml](.github/workflows/deploy.yml) deploys the main production environment on pushes to `main`. It runs `{{WORKFLOW_DEPLOY_MAIN_DOC_COMMAND}}`.
235
+
236
+ These workflows intentionally assume only GitHub OIDC, Google Cloud Workload Identity Federation, the generated `service` CLI, and Neon.
237
+ They do not use a long-lived Google service account key.
238
+
239
+ Before enabling the workflows, set these GitHub repository variables:
240
+
241
+ - `GCP_WIF_PROVIDER`: full Workload Identity Provider resource name that trusts this repository
242
+ - `GCP_DEPLOYER_SERVICE_ACCOUNT`: deployer service account email
243
+
244
+ Set this GitHub repository secret:
245
+
246
+ - `NEON_API_KEY`: Neon admin API key used to create preview branches and resolve the main database
247
+
248
+ The deployer service account needs enough access in the generated GCP project to run `service deploy`, including Cloud Run, Artifact Registry, Secret Manager, IAM service account usage, and storage operations for this service.
249
+ Run `{{COMMAND_BOOTSTRAP}}` once before relying on the production workflow for a fresh project.
250
+
214
251
  ## One-command production create
215
252
 
216
253
  The one-command production create path is designed for a fresh standalone service.
@@ -85,6 +85,14 @@
85
85
  "module": "buf.build/anmho/{{SERVICE_ID}}"
86
86
  },
87
87
 
88
+ "observability": {
89
+ "required_apis": [
90
+ "logging.googleapis.com",
91
+ "monitoring.googleapis.com",
92
+ "cloudtrace.googleapis.com"
93
+ ]
94
+ },
95
+
88
96
  "cloudrun": {
89
97
  "project_id": "{{PROJECT_ID}}",
90
98
  "project_name": "{{PROJECT_NAME}}",
@@ -1,4 +1,4 @@
1
- .PHONY: dev migrate gen lint test create deploy dashboards auth destroy
1
+ .PHONY: dev migrate gen lint test create deploy dashboards observability-bootstrap auth destroy
2
2
 
3
3
  SERVICE := service
4
4
 
@@ -26,6 +26,9 @@ deploy:
26
26
  dashboards:
27
27
  $(SERVICE) dashboards
28
28
 
29
+ observability-bootstrap:
30
+ $(SERVICE) observability-bootstrap
31
+
29
32
  auth:
30
33
  $(SERVICE) auth $(ARGS)
31
34
 
@@ -18,3 +18,13 @@ create table if not exists waitlist_triggers (
18
18
  created_at timestamptz not null default now(),
19
19
  processed_at timestamptz
20
20
  );
21
+
22
+ create table if not exists webhook_events (
23
+ id text primary key,
24
+ provider text not null,
25
+ external_event_id text not null,
26
+ payload_json text not null,
27
+ headers_json text not null,
28
+ received_at timestamptz not null default now(),
29
+ unique (provider, external_event_id)
30
+ );
@@ -12,6 +12,7 @@
12
12
  "create": "service create",
13
13
  "deploy": "service deploy",
14
14
  "dashboards": "service dashboards",
15
+ "observability-bootstrap": "service observability-bootstrap",
15
16
  "auth": "service auth",
16
17
  "destroy": "service destroy"
17
18
  },
@@ -1,7 +1,7 @@
1
- import { desc, eq } from "drizzle-orm";
1
+ import { and, desc, eq } from "drizzle-orm";
2
2
  import type { createDb } from "./client";
3
- import { waitlistEntries, waitlistTriggers } from "./schema";
4
- import type { WaitlistEntry, WaitlistTrigger } from "../waitlist/types";
3
+ import { waitlistEntries, waitlistTriggers, webhookEvents } from "./schema";
4
+ import type { WaitlistEntry, WaitlistTrigger, WebhookEvent } from "../waitlist/types";
5
5
 
6
6
  type Database = ReturnType<typeof createDb>;
7
7
  type WaitlistEntryRow = typeof waitlistEntries.$inferSelect;
@@ -21,6 +21,14 @@ type CreateTriggerRecord = {
21
21
  payloadJson: string;
22
22
  };
23
23
 
24
+ type CreateWebhookEventRecord = {
25
+ id: string;
26
+ provider: string;
27
+ externalEventId: string;
28
+ payload: unknown;
29
+ headers: Record<string, string>;
30
+ };
31
+
24
32
  export class WaitlistRepository {
25
33
  constructor(private readonly db: Database) {}
26
34
 
@@ -91,6 +99,36 @@ export class WaitlistRepository {
91
99
  .returning();
92
100
  return toWaitlistTrigger(row);
93
101
  }
102
+
103
+ async recordWebhookEvent(input: CreateWebhookEventRecord): Promise<{ event: WebhookEvent; duplicate: boolean }> {
104
+ const [inserted] = await this.db
105
+ .insert(webhookEvents)
106
+ .values({
107
+ id: input.id,
108
+ provider: input.provider,
109
+ externalEventId: input.externalEventId,
110
+ payloadJson: JSON.stringify(input.payload ?? {}),
111
+ headersJson: JSON.stringify(input.headers),
112
+ receivedAt: new Date(),
113
+ })
114
+ .onConflictDoNothing({
115
+ target: [webhookEvents.provider, webhookEvents.externalEventId],
116
+ })
117
+ .returning();
118
+ if (inserted) {
119
+ return { event: toWebhookEvent(inserted), duplicate: false };
120
+ }
121
+
122
+ const [row] = await this.db
123
+ .select()
124
+ .from(webhookEvents)
125
+ .where(and(eq(webhookEvents.provider, input.provider), eq(webhookEvents.externalEventId, input.externalEventId)))
126
+ .limit(1);
127
+ if (!row) {
128
+ throw new Error("webhook event was not inserted and could not be read");
129
+ }
130
+ return { event: toWebhookEvent(row), duplicate: true };
131
+ }
94
132
  }
95
133
 
96
134
  function clampLimit(value: number | null | undefined) {
@@ -124,3 +162,14 @@ function toWaitlistTrigger(row: typeof waitlistTriggers.$inferSelect): WaitlistT
124
162
  processedAt: row.processedAt?.toISOString() ?? null,
125
163
  };
126
164
  }
165
+
166
+ function toWebhookEvent(row: typeof webhookEvents.$inferSelect): WebhookEvent {
167
+ return {
168
+ id: row.id,
169
+ provider: row.provider,
170
+ externalEventId: row.externalEventId,
171
+ payload: JSON.parse(row.payloadJson),
172
+ headers: JSON.parse(row.headersJson),
173
+ receivedAt: row.receivedAt.toISOString(),
174
+ };
175
+ }
@@ -24,3 +24,16 @@ export const waitlistTriggers = pgTable("waitlist_triggers", {
24
24
  createdAt: timestamp("created_at", { withTimezone: true, mode: "date" }).notNull().defaultNow(),
25
25
  processedAt: timestamp("processed_at", { withTimezone: true, mode: "date" }),
26
26
  });
27
+
28
+ export const webhookEvents = pgTable(
29
+ "webhook_events",
30
+ {
31
+ id: text("id").primaryKey(),
32
+ provider: text("provider").notNull(),
33
+ externalEventId: text("external_event_id").notNull(),
34
+ payloadJson: text("payload_json").notNull(),
35
+ headersJson: text("headers_json").notNull(),
36
+ receivedAt: timestamp("received_at", { withTimezone: true, mode: "date" }).notNull().defaultNow(),
37
+ },
38
+ (table) => [uniqueIndex("webhook_events_provider_external_event_id_key").on(table.provider, table.externalEventId)]
39
+ );
@@ -96,11 +96,21 @@ export function createHandler(service: WaitlistService) {
96
96
  try {
97
97
  const provider = path.split("/").filter(Boolean)[1] ?? "generic";
98
98
  const rawBody = await readRawBody(request);
99
- const trigger = await service.recordTrigger({
100
- type: `webhook.${provider}`,
101
- payloadJson: JSON.stringify({ headers: request.headers, rawBody }),
99
+ const headers = headersPayload(request.headers);
100
+ const payload = parseWebhookPayload(rawBody);
101
+ const result = await service.recordWebhookEvent({
102
+ provider,
103
+ externalEventId: webhookEventId(payload, request.headers),
104
+ payload,
105
+ headers,
102
106
  });
103
- respondJson(response, 202, { trigger });
107
+ if (!result.duplicate) {
108
+ await service.recordTrigger({
109
+ type: `webhook.${provider}`,
110
+ payloadJson: JSON.stringify({ headers, rawBody }),
111
+ });
112
+ }
113
+ respondJson(response, result.duplicate ? 200 : 202, result);
104
114
  } catch (error) {
105
115
  respondAppError(response, error);
106
116
  }
@@ -192,6 +202,39 @@ function readRawBody(request: Parameters<FallbackHandler>[0]) {
192
202
  });
193
203
  }
194
204
 
205
+ function parseWebhookPayload(rawBody: string) {
206
+ try {
207
+ return rawBody ? JSON.parse(rawBody) : {};
208
+ } catch {
209
+ return { rawBody };
210
+ }
211
+ }
212
+
213
+ function webhookEventId(payload: unknown, headers: Parameters<FallbackHandler>[0]["headers"]) {
214
+ if (payload && typeof payload === "object" && "id" in payload && typeof payload.id === "string") {
215
+ return payload.id;
216
+ }
217
+ const header = headers["x-webhook-event-id"];
218
+ if (Array.isArray(header)) {
219
+ return header[0] ?? crypto.randomUUID();
220
+ }
221
+ return header ?? crypto.randomUUID();
222
+ }
223
+
224
+ function headersPayload(headers: Parameters<FallbackHandler>[0]["headers"]) {
225
+ const out: Record<string, string> = {};
226
+ for (const [key, value] of Object.entries(headers)) {
227
+ if (Array.isArray(value)) {
228
+ out[key] = value.join(", ");
229
+ continue;
230
+ }
231
+ if (typeof value === "string") {
232
+ out[key] = value;
233
+ }
234
+ }
235
+ return out;
236
+ }
237
+
195
238
  if (import.meta.main) {
196
239
  const temporalWorker = await startTemporalWorker();
197
240
  if (temporalWorker) {
@@ -3,8 +3,10 @@ import { WaitlistRepository } from "../db/repository";
3
3
  import type {
4
4
  JoinWaitlistInput,
5
5
  ListWaitlistEntriesInput,
6
+ RecordWebhookEventInput,
6
7
  RecordTriggerInput,
7
8
  UpdateWaitlistEntryInput,
9
+ WebhookEvent,
8
10
  WaitlistEntry,
9
11
  WaitlistEntryStatus,
10
12
  } from "./types";
@@ -27,6 +29,7 @@ export type WaitlistService = {
27
29
  updateWaitlistEntry(input: UpdateWaitlistEntryInput): Promise<WaitlistEntry>;
28
30
  exportWaitlistEntries(input?: ListWaitlistEntriesInput): Promise<string>;
29
31
  recordTrigger(input: RecordTriggerInput): Promise<unknown>;
32
+ recordWebhookEvent(input: RecordWebhookEventInput): Promise<{ event: WebhookEvent; duplicate: boolean }>;
30
33
  };
31
34
 
32
35
  export class DefaultWaitlistService implements WaitlistService {
@@ -113,6 +116,25 @@ export class DefaultWaitlistService implements WaitlistService {
113
116
  payloadJson: normalizePayloadJson(input.payloadJson),
114
117
  });
115
118
  }
119
+
120
+ async recordWebhookEvent(input: RecordWebhookEventInput) {
121
+ const provider = input.provider.trim();
122
+ const externalEventId = input.externalEventId.trim();
123
+ if (!provider) {
124
+ throw new AppError(400, "invalid_webhook_provider", "webhook provider is required");
125
+ }
126
+ if (!externalEventId) {
127
+ throw new AppError(400, "invalid_webhook_event_id", "webhook event id is required");
128
+ }
129
+
130
+ return this.repository.recordWebhookEvent({
131
+ id: crypto.randomUUID(),
132
+ provider,
133
+ externalEventId,
134
+ payload: input.payload ?? {},
135
+ headers: input.headers,
136
+ });
137
+ }
116
138
  }
117
139
 
118
140
  export function createDefaultWaitlistService() {
@@ -19,6 +19,15 @@ export type WaitlistTrigger = {
19
19
  processedAt: string | null;
20
20
  };
21
21
 
22
+ export type WebhookEvent = {
23
+ id: string;
24
+ provider: string;
25
+ externalEventId: string;
26
+ payload: unknown;
27
+ headers: Record<string, string>;
28
+ receivedAt: string;
29
+ };
30
+
22
31
  export type JoinWaitlistInput = {
23
32
  email: string;
24
33
  name?: string | null;
@@ -43,3 +52,10 @@ export type RecordTriggerInput = {
43
52
  entryId?: string | null;
44
53
  payloadJson?: string | null;
45
54
  };
55
+
56
+ export type RecordWebhookEventInput = {
57
+ provider: string;
58
+ externalEventId: string;
59
+ payload: unknown;
60
+ headers: Record<string, string>;
61
+ };
@@ -1,4 +1,4 @@
1
- .PHONY: dev migrate gen lint test create deploy dashboards auth destroy
1
+ .PHONY: dev migrate gen lint test create deploy dashboards observability-bootstrap auth destroy
2
2
 
3
3
  SERVICE := service
4
4
 
@@ -26,6 +26,9 @@ deploy:
26
26
  dashboards:
27
27
  $(SERVICE) dashboards
28
28
 
29
+ observability-bootstrap:
30
+ $(SERVICE) observability-bootstrap
31
+
29
32
  auth:
30
33
  $(SERVICE) auth $(ARGS)
31
34
 
@@ -18,3 +18,13 @@ create table if not exists waitlist_triggers (
18
18
  created_at timestamptz not null default now(),
19
19
  processed_at timestamptz
20
20
  );
21
+
22
+ create table if not exists webhook_events (
23
+ id text primary key,
24
+ provider text not null,
25
+ external_event_id text not null,
26
+ payload_json text not null,
27
+ headers_json text not null,
28
+ received_at timestamptz not null default now(),
29
+ unique (provider, external_event_id)
30
+ );
@@ -12,6 +12,7 @@
12
12
  "create": "service create",
13
13
  "deploy": "service deploy",
14
14
  "dashboards": "service dashboards",
15
+ "observability-bootstrap": "service observability-bootstrap",
15
16
  "auth": "service auth",
16
17
  "destroy": "service destroy"
17
18
  },