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.
- package/README.md +6 -0
- package/package.json +1 -1
- package/src/scaffold.test.ts +30 -8
- package/src/scaffold.ts +23 -0
- package/src/service-runtime/cloudrun/cli.ts +10 -0
- package/src/service-runtime/cloudrun/config.ts +3 -0
- package/src/service-runtime/cloudrun/observability.ts +18 -0
- package/templates/shared/.github/workflows/deploy.yml +1 -1
- package/templates/shared/.github/workflows/preview.yml +4 -3
- package/templates/shared/README.md +37 -0
- package/templates/shared/service.jsonc +8 -0
- package/templates/variants/bun-connectrpc/Makefile +4 -1
- package/templates/variants/bun-connectrpc/migrations/0000_init.sql +10 -0
- package/templates/variants/bun-connectrpc/package.json +1 -0
- package/templates/variants/bun-connectrpc/src/db/repository.ts +52 -3
- package/templates/variants/bun-connectrpc/src/db/schema.ts +13 -0
- package/templates/variants/bun-connectrpc/src/index.ts +47 -4
- package/templates/variants/bun-connectrpc/src/waitlist/service.ts +22 -0
- package/templates/variants/bun-connectrpc/src/waitlist/types.ts +16 -0
- package/templates/variants/bun-hono/Makefile +4 -1
- package/templates/variants/bun-hono/migrations/0000_init.sql +10 -0
- package/templates/variants/bun-hono/package.json +1 -0
- package/templates/variants/bun-hono/src/db/repository.ts +52 -3
- package/templates/variants/bun-hono/src/db/schema.ts +13 -0
- package/templates/variants/bun-hono/src/index.ts +34 -8
- package/templates/variants/bun-hono/src/waitlist/service.ts +22 -0
- package/templates/variants/bun-hono/src/waitlist/types.ts +16 -0
- package/templates/variants/bun-hono/test/app.test.ts +13 -0
- package/templates/variants/go-chi/Makefile +4 -1
- package/templates/variants/go-chi/internal/app/service.go +96 -0
- package/templates/variants/go-chi/internal/httpapi/routes.go +56 -7
- package/templates/variants/go-chi/migrations/0000_init.sql +10 -0
- package/templates/variants/go-chi/migrations/atlas.sum +2 -2
- package/templates/variants/go-connectrpc/Makefile +4 -1
- package/templates/variants/go-connectrpc/internal/app/service.go +96 -0
- package/templates/variants/go-connectrpc/internal/httpapi/routes.go +56 -7
- package/templates/variants/go-connectrpc/migrations/0000_init.sql +10 -0
- 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
package/src/scaffold.test.ts
CHANGED
|
@@ -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
|
-
|
|
284
|
-
expect(
|
|
285
|
-
expect(readme).
|
|
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:
|
|
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
|
-
|
|
5
|
-
|
|
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:
|
|
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
|
+
);
|
|
@@ -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
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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
|
+
);
|