create-svc 0.1.62 → 0.1.64

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 (67) hide show
  1. package/README.md +9 -6
  2. package/package.json +2 -1
  3. package/src/cli.test.ts +1 -0
  4. package/src/cli.ts +17 -6
  5. package/src/naming.test.ts +6 -0
  6. package/src/naming.ts +1 -1
  7. package/src/scaffold.test.ts +19 -3
  8. package/src/scaffold.ts +10 -0
  9. package/src/service-runtime/authctl.ts +32 -0
  10. package/src/service-runtime/cloudrun/bootstrap.ts +3 -4
  11. package/src/service-runtime/cloudrun/cleanup.ts +6 -1
  12. package/src/service-runtime/cloudrun/cli.ts +52 -12
  13. package/src/service-runtime/cloudrun/config.ts +3 -0
  14. package/src/service-runtime/cloudrun/deploy-args.ts +17 -0
  15. package/src/service-runtime/cloudrun/deploy.ts +25 -0
  16. package/src/service-runtime/cloudrun/lib.test.ts +12 -1
  17. package/src/service-runtime/cloudrun/lib.ts +55 -15
  18. package/src/service-runtime/cloudrun/temporal-config.test.ts +66 -0
  19. package/src/service-runtime/cloudrun/temporal-config.ts +84 -0
  20. package/src/service-runtime/workers/cli.ts +88 -0
  21. package/src/service.test.ts +15 -2
  22. package/src/service.ts +31 -1
  23. package/templates/shared/.env.example +1 -1
  24. package/templates/shared/README.md +41 -9
  25. package/templates/shared/scripts/dev.ts +37 -5
  26. package/templates/shared/service.jsonc +8 -2
  27. package/templates/shared/service.yaml +4 -1
  28. package/templates/targets/workers/.github/workflows/deploy.yml +3 -0
  29. package/templates/targets/workers/.github/workflows/preview.yml +3 -0
  30. package/templates/targets/workers/README.md +28 -0
  31. package/templates/targets/workers/package.json +6 -1
  32. package/templates/targets/workers/src/index.ts +36 -25
  33. package/templates/targets/workers/src/trigger.ts +81 -0
  34. package/templates/targets/workers/test/app.test.ts +46 -1
  35. package/templates/targets/workers/trigger/waitlist-follow-up.ts +24 -0
  36. package/templates/targets/workers/trigger.config.ts +24 -0
  37. package/templates/targets/workers/tsconfig.json +1 -1
  38. package/templates/targets/workers/wrangler.toml +2 -0
  39. package/templates/variants/bun-connectrpc/package.json +1 -1
  40. package/templates/variants/bun-connectrpc/src/index.ts +2 -6
  41. package/templates/variants/bun-connectrpc/src/temporal/client.ts +28 -0
  42. package/templates/variants/bun-connectrpc/src/temporal.ts +56 -0
  43. package/templates/variants/bun-connectrpc/src/waitlist/service.ts +16 -1
  44. package/templates/variants/bun-connectrpc/src/worker.ts +21 -0
  45. package/templates/variants/bun-connectrpc/test/app.test.ts +22 -0
  46. package/templates/variants/bun-hono/package.json +1 -1
  47. package/templates/variants/bun-hono/src/index.ts +2 -6
  48. package/templates/variants/bun-hono/src/temporal/client.ts +28 -0
  49. package/templates/variants/bun-hono/src/temporal.ts +56 -0
  50. package/templates/variants/bun-hono/src/waitlist/service.ts +10 -1
  51. package/templates/variants/bun-hono/src/worker.ts +21 -0
  52. package/templates/variants/go-chi/Dockerfile +2 -0
  53. package/templates/variants/go-chi/Makefile +1 -1
  54. package/templates/variants/go-chi/cmd/server/main.go +5 -4
  55. package/templates/variants/go-chi/cmd/worker/main.go +56 -0
  56. package/templates/variants/go-chi/internal/app/service.go +35 -3
  57. package/templates/variants/go-chi/internal/config/config.go +34 -3
  58. package/templates/variants/go-chi/internal/config/config_test.go +63 -0
  59. package/templates/variants/go-chi/internal/temporal/client.go +55 -0
  60. package/templates/variants/go-connectrpc/Dockerfile +2 -0
  61. package/templates/variants/go-connectrpc/Makefile +1 -1
  62. package/templates/variants/go-connectrpc/cmd/server/main.go +5 -4
  63. package/templates/variants/go-connectrpc/cmd/worker/main.go +56 -0
  64. package/templates/variants/go-connectrpc/internal/app/service.go +35 -3
  65. package/templates/variants/go-connectrpc/internal/config/config.go +34 -3
  66. package/templates/variants/go-connectrpc/internal/config/config_test.go +63 -0
  67. package/templates/variants/go-connectrpc/internal/temporal/client.go +55 -0
@@ -0,0 +1,56 @@
1
+ export type TemporalRuntimeConfig = {
2
+ enabled: boolean;
3
+ address: string;
4
+ namespace: string;
5
+ taskQueue: string;
6
+ };
7
+
8
+ type Env = Record<string, string | undefined>;
9
+
10
+ export function resolveTemporalRuntimeConfig(env: Env = Bun.env): TemporalRuntimeConfig {
11
+ const enabled = readBoolean(env.TEMPORAL_ENABLED, true);
12
+ const cloudRun = isCloudRun(env);
13
+
14
+ return {
15
+ enabled,
16
+ address: readString(env.TEMPORAL_ADDRESS, cloudRun ? "" : "localhost:7233"),
17
+ namespace: readString(env.TEMPORAL_NAMESPACE, cloudRun ? "" : "default"),
18
+ taskQueue: readString(env.TEMPORAL_TASK_QUEUE, "{{SERVICE_NAME}}"),
19
+ };
20
+ }
21
+
22
+ export function assertTemporalRuntimeConfig(config = resolveTemporalRuntimeConfig()) {
23
+ if (!config.enabled) {
24
+ return config;
25
+ }
26
+
27
+ const missing = [
28
+ config.address ? "" : "TEMPORAL_ADDRESS",
29
+ config.namespace ? "" : "TEMPORAL_NAMESPACE",
30
+ ].filter(Boolean);
31
+
32
+ if (missing.length > 0) {
33
+ throw new Error(
34
+ `Temporal is enabled, but ${missing.join(" and ")} ${missing.length === 1 ? "is" : "are"} required. Set Temporal Cloud connection settings or TEMPORAL_ENABLED=false.`
35
+ );
36
+ }
37
+
38
+ return config;
39
+ }
40
+
41
+ function readBoolean(value: string | undefined, fallback: boolean) {
42
+ const normalized = value?.trim().toLowerCase();
43
+ if (!normalized) {
44
+ return fallback;
45
+ }
46
+ return !["0", "false", "no", "off"].includes(normalized);
47
+ }
48
+
49
+ function readString(value: string | undefined, fallback: string) {
50
+ const normalized = value?.trim();
51
+ return normalized || fallback;
52
+ }
53
+
54
+ function isCloudRun(env: Env) {
55
+ return Boolean(env.K_SERVICE?.trim());
56
+ }
@@ -1,5 +1,6 @@
1
1
  import { createDb } from "../db/client";
2
2
  import { WaitlistRepository } from "../db/repository";
3
+ import { startWaitlistFollowUpWorkflow } from "../temporal/client";
3
4
  import type {
4
5
  JoinWaitlistInput,
5
6
  ListWaitlistEntriesInput,
@@ -109,12 +110,20 @@ export class DefaultWaitlistService implements WaitlistService {
109
110
  await this.getWaitlistEntry(input.entryId);
110
111
  }
111
112
 
112
- return this.repository.createTrigger({
113
+ const trigger = await this.repository.createTrigger({
113
114
  id: crypto.randomUUID(),
114
115
  type,
115
116
  entryId: input.entryId?.trim() || null,
116
117
  payload: input.payload ?? {},
117
118
  });
119
+
120
+ startWaitlistFollowUpWorkflow({
121
+ triggerId: trigger.id,
122
+ email: trigger.payload && typeof trigger.payload === "object" && "email" in trigger.payload ? String(trigger.payload.email) : undefined,
123
+ type: trigger.type,
124
+ }).catch((error) => console.error("failed to start waitlist follow-up workflow", error));
125
+
126
+ return trigger;
118
127
  }
119
128
 
120
129
  async recordWebhookEvent(input: RecordWebhookEventInput) {
@@ -0,0 +1,21 @@
1
+ import { assertTemporalRuntimeConfig } from "./temporal";
2
+ import { startTemporalWorker } from "./temporal/worker";
3
+
4
+ assertTemporalRuntimeConfig();
5
+ const temporalWorker = await startTemporalWorker();
6
+ if (!temporalWorker) {
7
+ throw new Error("Temporal worker is disabled. Set TEMPORAL_ENABLED=true or do not run the worker process.");
8
+ }
9
+
10
+ console.log(`Temporal worker polling ${temporalWorker.taskQueue}`);
11
+
12
+ Bun.serve({
13
+ port: Number(Bun.env.PORT ?? 8080),
14
+ fetch: (request) => {
15
+ const path = new URL(request.url).pathname;
16
+ if (path === "/healthz" || path === "/readyz") {
17
+ return Response.json({ status: "ok", worker: "temporal" });
18
+ }
19
+ return Response.json({ status: "ok", worker: "temporal" });
20
+ },
21
+ });
@@ -8,12 +8,14 @@ COPY cmd ./cmd
8
8
 
9
9
  RUN go mod download
10
10
  RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/server ./cmd/server
11
+ RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/worker ./cmd/worker
11
12
 
12
13
  FROM gcr.io/distroless/base-debian12
13
14
 
14
15
  WORKDIR /app
15
16
 
16
17
  COPY --from=builder /out/server /app/server
18
+ COPY --from=builder /out/worker /app/worker
17
19
 
18
20
  ENV PORT=8080
19
21
 
@@ -5,7 +5,7 @@ WITH_ENV := set -a; [ ! -f .env.local ] || . ./.env.local; set +a;
5
5
  ATLAS ?= atlas
6
6
 
7
7
  dev:
8
- @bun run ./scripts/dev.ts go run ./cmd/server
8
+ @bun run ./scripts/dev.ts go run ./cmd/server --worker go run ./cmd/worker
9
9
 
10
10
  migrate:
11
11
  @bun run ./scripts/ensure-local-db.ts
@@ -31,17 +31,18 @@ func main() {
31
31
 
32
32
  service := app.NewWaitlistService(db)
33
33
  if cfg.TemporalEnabled {
34
- stopTemporal, err := temporalapp.StartWorker(temporalapp.WorkerConfig{
34
+ temporalConfig := temporalapp.WorkerConfig{
35
35
  Address: cfg.TemporalAddress,
36
36
  Namespace: cfg.TemporalNamespace,
37
37
  TaskQueue: cfg.TemporalTaskQueue,
38
38
  APIKey: cfg.TemporalAPIKey,
39
- })
39
+ }
40
+ dispatcher, err := temporalapp.NewTriggerDispatcher(temporalConfig)
40
41
  if err != nil {
41
42
  log.Fatal(err)
42
43
  }
43
- defer stopTemporal()
44
- log.Printf("Temporal worker polling %s", cfg.TemporalTaskQueue)
44
+ defer dispatcher.Close()
45
+ service.SetTriggerDispatcher(dispatcher)
45
46
  }
46
47
 
47
48
  router := chi.NewRouter()
@@ -0,0 +1,56 @@
1
+ package main
2
+
3
+ import (
4
+ "log"
5
+ "net/http"
6
+ "time"
7
+
8
+ "{{MODULE_PATH}}/internal/config"
9
+ temporalapp "{{MODULE_PATH}}/internal/temporal"
10
+ )
11
+
12
+ func main() {
13
+ cfg, err := config.Load()
14
+ if err != nil {
15
+ log.Fatal(err)
16
+ }
17
+ if !cfg.TemporalEnabled {
18
+ log.Fatal("Temporal worker is disabled. Set TEMPORAL_ENABLED=true or do not run the worker process.")
19
+ }
20
+
21
+ stopTemporal, err := temporalapp.StartWorker(temporalapp.WorkerConfig{
22
+ Address: cfg.TemporalAddress,
23
+ Namespace: cfg.TemporalNamespace,
24
+ TaskQueue: cfg.TemporalTaskQueue,
25
+ APIKey: cfg.TemporalAPIKey,
26
+ })
27
+ if err != nil {
28
+ log.Fatal(err)
29
+ }
30
+ defer stopTemporal()
31
+ log.Printf("Temporal worker polling %s", cfg.TemporalTaskQueue)
32
+
33
+ mux := http.NewServeMux()
34
+ mux.HandleFunc("/", func(w http.ResponseWriter, request *http.Request) {
35
+ writeWorkerHealth(w)
36
+ })
37
+ mux.HandleFunc("/healthz", func(w http.ResponseWriter, request *http.Request) {
38
+ writeWorkerHealth(w)
39
+ })
40
+ mux.HandleFunc("/readyz", func(w http.ResponseWriter, request *http.Request) {
41
+ writeWorkerHealth(w)
42
+ })
43
+
44
+ server := &http.Server{
45
+ Addr: ":" + cfg.Port,
46
+ ReadHeaderTimeout: 10 * time.Second,
47
+ Handler: mux,
48
+ }
49
+ log.Printf("worker health listening on %s", server.Addr)
50
+ log.Fatal(server.ListenAndServe())
51
+ }
52
+
53
+ func writeWorkerHealth(w http.ResponseWriter) {
54
+ w.Header().Set("Content-Type", "application/json")
55
+ _, _ = w.Write([]byte(`{"status":"ok","worker":"temporal"}`))
56
+ }
@@ -12,6 +12,8 @@ import (
12
12
  "strings"
13
13
  "time"
14
14
 
15
+ "github.com/jackc/pgx/v5"
16
+ "github.com/jackc/pgx/v5/stdlib"
15
17
  "github.com/jmoiron/sqlx"
16
18
  )
17
19
 
@@ -94,20 +96,39 @@ type AppError struct {
94
96
  func (e *AppError) Error() string { return e.Err.Error() }
95
97
 
96
98
  type WaitlistService struct {
97
- db *sqlx.DB
99
+ db *sqlx.DB
100
+ triggerDispatcher TriggerDispatcher
101
+ }
102
+
103
+ type TriggerDispatcher interface {
104
+ DispatchWaitlistFollowUp(context.Context, WaitlistTrigger) error
98
105
  }
99
106
 
100
107
  func OpenDatabase(ctx context.Context, databaseURL string) (*sqlx.DB, error) {
101
108
  if strings.TrimSpace(databaseURL) == "" {
102
109
  return nil, errors.New("DATABASE_URL is required")
103
110
  }
104
- return sqlx.ConnectContext(ctx, "pgx", databaseURL)
111
+ cfg, err := pgx.ParseConfig(databaseURL)
112
+ if err != nil {
113
+ return nil, err
114
+ }
115
+ cfg.DefaultQueryExecMode = pgx.QueryExecModeSimpleProtocol
116
+ db := sqlx.NewDb(stdlib.OpenDB(*cfg), "pgx")
117
+ if err := db.PingContext(ctx); err != nil {
118
+ _ = db.Close()
119
+ return nil, err
120
+ }
121
+ return db, nil
105
122
  }
106
123
 
107
124
  func NewWaitlistService(db *sqlx.DB) *WaitlistService {
108
125
  return &WaitlistService{db: db}
109
126
  }
110
127
 
128
+ func (s *WaitlistService) SetTriggerDispatcher(dispatcher TriggerDispatcher) {
129
+ s.triggerDispatcher = dispatcher
130
+ }
131
+
111
132
  func (s *WaitlistService) JoinWaitlist(ctx context.Context, input JoinWaitlistInput) (JoinWaitlistResult, error) {
112
133
  email, err := normalizeEmail(input.Email)
113
134
  if err != nil {
@@ -270,7 +291,18 @@ returning id, type, coalesce(entry_id, '') as entry_id, status, payload_json, cr
270
291
  if err != nil {
271
292
  return WaitlistTrigger{}, err
272
293
  }
273
- return row.toTrigger()
294
+ trigger, err := row.toTrigger()
295
+ if err != nil {
296
+ return WaitlistTrigger{}, err
297
+ }
298
+ if s.triggerDispatcher != nil {
299
+ go func() {
300
+ if err := s.triggerDispatcher.DispatchWaitlistFollowUp(context.Background(), trigger); err != nil {
301
+ fmt.Printf("failed to start waitlist follow-up workflow: %v\n", err)
302
+ }
303
+ }()
304
+ }
305
+ return trigger, nil
274
306
  }
275
307
 
276
308
  func (s *WaitlistService) RecordWebhookEvent(ctx context.Context, input RecordWebhookEventInput) (RecordWebhookEventResult, error) {
@@ -24,9 +24,9 @@ func Load() (Config, error) {
24
24
  cfg := Config{
25
25
  Port: envOr("PORT", "8080"),
26
26
  DatabaseURL: strings.TrimSpace(os.Getenv("DATABASE_URL")),
27
- TemporalEnabled: envBool("TEMPORAL_ENABLED"),
28
- TemporalAddress: envOr("TEMPORAL_ADDRESS", "localhost:7233"),
29
- TemporalNamespace: envOr("TEMPORAL_NAMESPACE", "default"),
27
+ TemporalEnabled: envBoolDefault("TEMPORAL_ENABLED", true),
28
+ TemporalAddress: envOrRuntime("TEMPORAL_ADDRESS", "localhost:7233"),
29
+ TemporalNamespace: envOrRuntime("TEMPORAL_NAMESPACE", "default"),
30
30
  TemporalTaskQueue: envOr("TEMPORAL_TASK_QUEUE", "{{SERVICE_NAME}}"),
31
31
  TemporalAPIKey: strings.TrimSpace(os.Getenv("TEMPORAL_API_KEY")),
32
32
  AuthEnabled: envBool("AUTH_ENABLED"),
@@ -37,6 +37,18 @@ func Load() (Config, error) {
37
37
  if cfg.DatabaseURL == "" {
38
38
  return Config{}, errors.New("DATABASE_URL is required")
39
39
  }
40
+ if cfg.TemporalEnabled {
41
+ missing := make([]string, 0, 2)
42
+ if cfg.TemporalAddress == "" {
43
+ missing = append(missing, "TEMPORAL_ADDRESS")
44
+ }
45
+ if cfg.TemporalNamespace == "" {
46
+ missing = append(missing, "TEMPORAL_NAMESPACE")
47
+ }
48
+ if len(missing) > 0 {
49
+ return Config{}, errors.New(strings.Join(missing, " and ") + " required when Temporal is enabled")
50
+ }
51
+ }
40
52
  return cfg, nil
41
53
  }
42
54
 
@@ -45,6 +57,14 @@ func envBool(key string) bool {
45
57
  return value == "1" || value == "true" || value == "yes" || value == "on"
46
58
  }
47
59
 
60
+ func envBoolDefault(key string, fallback bool) bool {
61
+ value := strings.TrimSpace(strings.ToLower(os.Getenv(key)))
62
+ if value == "" {
63
+ return fallback
64
+ }
65
+ return value == "1" || value == "true" || value == "yes" || value == "on"
66
+ }
67
+
48
68
  func envOr(key string, fallback string) string {
49
69
  value := os.Getenv(key)
50
70
  if value != "" {
@@ -52,3 +72,14 @@ func envOr(key string, fallback string) string {
52
72
  }
53
73
  return fallback
54
74
  }
75
+
76
+ func envOrRuntime(key string, localFallback string) string {
77
+ value := os.Getenv(key)
78
+ if value != "" {
79
+ return value
80
+ }
81
+ if strings.TrimSpace(os.Getenv("K_SERVICE")) != "" {
82
+ return ""
83
+ }
84
+ return localFallback
85
+ }
@@ -0,0 +1,63 @@
1
+ package config
2
+
3
+ import (
4
+ "strings"
5
+ "testing"
6
+ )
7
+
8
+ func TestLoadTemporalDefaultsToLocalDevelopment(t *testing.T) {
9
+ setRequiredEnv(t)
10
+
11
+ cfg, err := Load()
12
+ if err != nil {
13
+ t.Fatal(err)
14
+ }
15
+
16
+ if !cfg.TemporalEnabled {
17
+ t.Fatal("Temporal should default to enabled")
18
+ }
19
+ if cfg.TemporalAddress != "localhost:7233" {
20
+ t.Fatalf("unexpected Temporal address: %s", cfg.TemporalAddress)
21
+ }
22
+ if cfg.TemporalNamespace != "default" {
23
+ t.Fatalf("unexpected Temporal namespace: %s", cfg.TemporalNamespace)
24
+ }
25
+ if cfg.TemporalTaskQueue != "{{SERVICE_NAME}}" {
26
+ t.Fatalf("unexpected Temporal task queue: %s", cfg.TemporalTaskQueue)
27
+ }
28
+ }
29
+
30
+ func TestLoadTemporalOptOut(t *testing.T) {
31
+ setRequiredEnv(t)
32
+ t.Setenv("K_SERVICE", "svc")
33
+ t.Setenv("TEMPORAL_ENABLED", "false")
34
+
35
+ cfg, err := Load()
36
+ if err != nil {
37
+ t.Fatal(err)
38
+ }
39
+
40
+ if cfg.TemporalEnabled {
41
+ t.Fatal("Temporal should be disabled")
42
+ }
43
+ }
44
+
45
+ func TestLoadTemporalCloudRunFailsWithoutConnectionSettings(t *testing.T) {
46
+ setRequiredEnv(t)
47
+ t.Setenv("K_SERVICE", "svc")
48
+
49
+ _, err := Load()
50
+ if err == nil {
51
+ t.Fatal("expected Temporal configuration error")
52
+ }
53
+ if !strings.Contains(err.Error(), "TEMPORAL_ADDRESS and TEMPORAL_NAMESPACE") {
54
+ t.Fatalf("unexpected error: %v", err)
55
+ }
56
+ }
57
+
58
+ func setRequiredEnv(t *testing.T) {
59
+ t.Helper()
60
+ t.Setenv("DATABASE_URL", "postgres://postgres:postgres@127.0.0.1:5432/app")
61
+ t.Setenv("ATTACHMENT_BUCKET", "bucket")
62
+ t.Setenv("ATTACHMENT_PUBLIC_BASE_URL", "https://storage.test")
63
+ }
@@ -0,0 +1,55 @@
1
+ package temporalapp
2
+
3
+ import (
4
+ "context"
5
+
6
+ "{{MODULE_PATH}}/internal/app"
7
+
8
+ "go.temporal.io/sdk/client"
9
+ )
10
+
11
+ type TriggerDispatcher struct {
12
+ client client.Client
13
+ taskQueue string
14
+ }
15
+
16
+ func NewTriggerDispatcher(cfg WorkerConfig) (*TriggerDispatcher, error) {
17
+ options := client.Options{
18
+ HostPort: cfg.Address,
19
+ Namespace: cfg.Namespace,
20
+ }
21
+ if cfg.APIKey != "" {
22
+ options.Credentials = client.NewAPIKeyStaticCredentials(cfg.APIKey)
23
+ }
24
+
25
+ temporalClient, err := client.Dial(options)
26
+ if err != nil {
27
+ return nil, err
28
+ }
29
+ return &TriggerDispatcher{client: temporalClient, taskQueue: cfg.TaskQueue}, nil
30
+ }
31
+
32
+ func (d *TriggerDispatcher) Close() {
33
+ d.client.Close()
34
+ }
35
+
36
+ func (d *TriggerDispatcher) DispatchWaitlistFollowUp(ctx context.Context, trigger app.WaitlistTrigger) error {
37
+ _, err := d.client.ExecuteWorkflow(ctx, client.StartWorkflowOptions{
38
+ ID: "waitlist-follow-up-" + trigger.ID,
39
+ TaskQueue: d.taskQueue,
40
+ }, WaitlistFollowUpWorkflow, WaitlistFollowUpInput{
41
+ TriggerID: trigger.ID,
42
+ Email: triggerEmail(trigger.Payload),
43
+ Type: trigger.Type,
44
+ })
45
+ return err
46
+ }
47
+
48
+ func triggerEmail(payload any) string {
49
+ values, ok := payload.(map[string]any)
50
+ if !ok {
51
+ return ""
52
+ }
53
+ email, _ := values["email"].(string)
54
+ return email
55
+ }
@@ -9,12 +9,14 @@ COPY cmd ./cmd
9
9
 
10
10
  RUN go mod download
11
11
  RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/server ./cmd/server
12
+ RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/worker ./cmd/worker
12
13
 
13
14
  FROM gcr.io/distroless/base-debian12
14
15
 
15
16
  WORKDIR /app
16
17
 
17
18
  COPY --from=builder /out/server /app/server
19
+ COPY --from=builder /out/worker /app/worker
18
20
 
19
21
  ENV PORT=8080
20
22
 
@@ -5,7 +5,7 @@ WITH_ENV := set -a; [ ! -f .env.local ] || . ./.env.local; set +a;
5
5
  ATLAS ?= atlas
6
6
 
7
7
  dev:
8
- @bun run ./scripts/dev.ts go run ./cmd/server
8
+ @bun run ./scripts/dev.ts go run ./cmd/server --worker go run ./cmd/worker
9
9
 
10
10
  migrate:
11
11
  @bun run ./scripts/ensure-local-db.ts
@@ -35,17 +35,18 @@ func main() {
35
35
  }
36
36
  service := app.NewWaitlistService(db)
37
37
  if cfg.TemporalEnabled {
38
- stopTemporal, err := temporalapp.StartWorker(temporalapp.WorkerConfig{
38
+ temporalConfig := temporalapp.WorkerConfig{
39
39
  Address: cfg.TemporalAddress,
40
40
  Namespace: cfg.TemporalNamespace,
41
41
  TaskQueue: cfg.TemporalTaskQueue,
42
42
  APIKey: cfg.TemporalAPIKey,
43
- })
43
+ }
44
+ dispatcher, err := temporalapp.NewTriggerDispatcher(temporalConfig)
44
45
  if err != nil {
45
46
  log.Fatal(err)
46
47
  }
47
- defer stopTemporal()
48
- log.Printf("Temporal worker polling %s", cfg.TemporalTaskQueue)
48
+ defer dispatcher.Close()
49
+ service.SetTriggerDispatcher(dispatcher)
49
50
  }
50
51
 
51
52
  router := chi.NewRouter()
@@ -0,0 +1,56 @@
1
+ package main
2
+
3
+ import (
4
+ "log"
5
+ "net/http"
6
+ "time"
7
+
8
+ "{{MODULE_PATH}}/internal/config"
9
+ temporalapp "{{MODULE_PATH}}/internal/temporal"
10
+ )
11
+
12
+ func main() {
13
+ cfg, err := config.Load()
14
+ if err != nil {
15
+ log.Fatal(err)
16
+ }
17
+ if !cfg.TemporalEnabled {
18
+ log.Fatal("Temporal worker is disabled. Set TEMPORAL_ENABLED=true or do not run the worker process.")
19
+ }
20
+
21
+ stopTemporal, err := temporalapp.StartWorker(temporalapp.WorkerConfig{
22
+ Address: cfg.TemporalAddress,
23
+ Namespace: cfg.TemporalNamespace,
24
+ TaskQueue: cfg.TemporalTaskQueue,
25
+ APIKey: cfg.TemporalAPIKey,
26
+ })
27
+ if err != nil {
28
+ log.Fatal(err)
29
+ }
30
+ defer stopTemporal()
31
+ log.Printf("Temporal worker polling %s", cfg.TemporalTaskQueue)
32
+
33
+ mux := http.NewServeMux()
34
+ mux.HandleFunc("/", func(w http.ResponseWriter, request *http.Request) {
35
+ writeWorkerHealth(w)
36
+ })
37
+ mux.HandleFunc("/healthz", func(w http.ResponseWriter, request *http.Request) {
38
+ writeWorkerHealth(w)
39
+ })
40
+ mux.HandleFunc("/readyz", func(w http.ResponseWriter, request *http.Request) {
41
+ writeWorkerHealth(w)
42
+ })
43
+
44
+ server := &http.Server{
45
+ Addr: ":" + cfg.Port,
46
+ ReadHeaderTimeout: 10 * time.Second,
47
+ Handler: mux,
48
+ }
49
+ log.Printf("worker health listening on %s", server.Addr)
50
+ log.Fatal(server.ListenAndServe())
51
+ }
52
+
53
+ func writeWorkerHealth(w http.ResponseWriter) {
54
+ w.Header().Set("Content-Type", "application/json")
55
+ _, _ = w.Write([]byte(`{"status":"ok","worker":"temporal"}`))
56
+ }
@@ -12,6 +12,8 @@ import (
12
12
  "strings"
13
13
  "time"
14
14
 
15
+ "github.com/jackc/pgx/v5"
16
+ "github.com/jackc/pgx/v5/stdlib"
15
17
  "github.com/jmoiron/sqlx"
16
18
  )
17
19
 
@@ -94,20 +96,39 @@ type AppError struct {
94
96
  func (e *AppError) Error() string { return e.Err.Error() }
95
97
 
96
98
  type WaitlistService struct {
97
- db *sqlx.DB
99
+ db *sqlx.DB
100
+ triggerDispatcher TriggerDispatcher
101
+ }
102
+
103
+ type TriggerDispatcher interface {
104
+ DispatchWaitlistFollowUp(context.Context, WaitlistTrigger) error
98
105
  }
99
106
 
100
107
  func OpenDatabase(ctx context.Context, databaseURL string) (*sqlx.DB, error) {
101
108
  if strings.TrimSpace(databaseURL) == "" {
102
109
  return nil, errors.New("DATABASE_URL is required")
103
110
  }
104
- return sqlx.ConnectContext(ctx, "pgx", databaseURL)
111
+ cfg, err := pgx.ParseConfig(databaseURL)
112
+ if err != nil {
113
+ return nil, err
114
+ }
115
+ cfg.DefaultQueryExecMode = pgx.QueryExecModeSimpleProtocol
116
+ db := sqlx.NewDb(stdlib.OpenDB(*cfg), "pgx")
117
+ if err := db.PingContext(ctx); err != nil {
118
+ _ = db.Close()
119
+ return nil, err
120
+ }
121
+ return db, nil
105
122
  }
106
123
 
107
124
  func NewWaitlistService(db *sqlx.DB) *WaitlistService {
108
125
  return &WaitlistService{db: db}
109
126
  }
110
127
 
128
+ func (s *WaitlistService) SetTriggerDispatcher(dispatcher TriggerDispatcher) {
129
+ s.triggerDispatcher = dispatcher
130
+ }
131
+
111
132
  func (s *WaitlistService) JoinWaitlist(ctx context.Context, input JoinWaitlistInput) (JoinWaitlistResult, error) {
112
133
  email, err := normalizeEmail(input.Email)
113
134
  if err != nil {
@@ -270,7 +291,18 @@ returning id, type, coalesce(entry_id, '') as entry_id, status, payload_json, cr
270
291
  if err != nil {
271
292
  return WaitlistTrigger{}, err
272
293
  }
273
- return row.toTrigger()
294
+ trigger, err := row.toTrigger()
295
+ if err != nil {
296
+ return WaitlistTrigger{}, err
297
+ }
298
+ if s.triggerDispatcher != nil {
299
+ go func() {
300
+ if err := s.triggerDispatcher.DispatchWaitlistFollowUp(context.Background(), trigger); err != nil {
301
+ fmt.Printf("failed to start waitlist follow-up workflow: %v\n", err)
302
+ }
303
+ }()
304
+ }
305
+ return trigger, nil
274
306
  }
275
307
 
276
308
  func (s *WaitlistService) RecordWebhookEvent(ctx context.Context, input RecordWebhookEventInput) (RecordWebhookEventResult, error) {