create-svc 0.1.61 → 0.1.63
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 +9 -6
- package/package.json +2 -1
- package/src/cli.test.ts +1 -0
- package/src/cli.ts +17 -6
- package/src/naming.test.ts +6 -0
- package/src/naming.ts +1 -1
- package/src/scaffold.test.ts +19 -3
- package/src/scaffold.ts +10 -0
- package/src/service-runtime/authctl.ts +32 -0
- package/src/service-runtime/cloudrun/cleanup.ts +6 -1
- package/src/service-runtime/cloudrun/cli.ts +52 -12
- package/src/service-runtime/cloudrun/deploy-args.ts +17 -0
- package/src/service-runtime/cloudrun/deploy.ts +25 -0
- package/src/service-runtime/cloudrun/lib.test.ts +12 -1
- package/src/service-runtime/cloudrun/lib.ts +26 -3
- package/src/service-runtime/workers/cli.ts +88 -0
- package/src/service.test.ts +15 -2
- package/src/service.ts +31 -1
- package/templates/shared/.env.example +1 -1
- package/templates/shared/README.md +41 -9
- package/templates/shared/scripts/dev.ts +37 -5
- package/templates/shared/service.jsonc +8 -2
- package/templates/shared/service.yaml +4 -1
- package/templates/targets/workers/.github/workflows/deploy.yml +3 -0
- package/templates/targets/workers/.github/workflows/preview-cleanup.yml +1 -1
- package/templates/targets/workers/.github/workflows/preview.yml +3 -0
- package/templates/targets/workers/README.md +28 -0
- package/templates/targets/workers/package.json +6 -1
- package/templates/targets/workers/src/index.ts +36 -25
- package/templates/targets/workers/src/trigger.ts +81 -0
- package/templates/targets/workers/test/app.test.ts +46 -1
- package/templates/targets/workers/trigger/waitlist-follow-up.ts +24 -0
- package/templates/targets/workers/trigger.config.ts +24 -0
- package/templates/targets/workers/tsconfig.json +1 -1
- package/templates/targets/workers/wrangler.toml +2 -0
- package/templates/variants/bun-connectrpc/package.json +1 -1
- package/templates/variants/bun-connectrpc/src/index.ts +2 -6
- package/templates/variants/bun-connectrpc/src/temporal/client.ts +28 -0
- package/templates/variants/bun-connectrpc/src/temporal.ts +56 -0
- package/templates/variants/bun-connectrpc/src/waitlist/service.ts +16 -1
- package/templates/variants/bun-connectrpc/src/worker.ts +21 -0
- package/templates/variants/bun-connectrpc/test/app.test.ts +22 -0
- package/templates/variants/bun-hono/package.json +1 -1
- package/templates/variants/bun-hono/src/index.ts +2 -6
- package/templates/variants/bun-hono/src/temporal/client.ts +28 -0
- package/templates/variants/bun-hono/src/temporal.ts +56 -0
- package/templates/variants/bun-hono/src/waitlist/service.ts +10 -1
- package/templates/variants/bun-hono/src/worker.ts +21 -0
- package/templates/variants/go-chi/Dockerfile +2 -0
- package/templates/variants/go-chi/Makefile +1 -1
- package/templates/variants/go-chi/cmd/server/main.go +5 -4
- package/templates/variants/go-chi/cmd/worker/main.go +56 -0
- package/templates/variants/go-chi/internal/app/service.go +35 -3
- package/templates/variants/go-chi/internal/config/config.go +34 -3
- package/templates/variants/go-chi/internal/config/config_test.go +63 -0
- package/templates/variants/go-chi/internal/temporal/client.go +55 -0
- package/templates/variants/go-connectrpc/Dockerfile +2 -0
- package/templates/variants/go-connectrpc/Makefile +1 -1
- package/templates/variants/go-connectrpc/cmd/server/main.go +5 -4
- package/templates/variants/go-connectrpc/cmd/worker/main.go +56 -0
- package/templates/variants/go-connectrpc/internal/app/service.go +35 -3
- package/templates/variants/go-connectrpc/internal/config/config.go +34 -3
- package/templates/variants/go-connectrpc/internal/config/config_test.go +63 -0
- package/templates/variants/go-connectrpc/internal/temporal/client.go +55 -0
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
28
|
-
TemporalAddress:
|
|
29
|
-
TemporalNamespace:
|
|
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
|
-
|
|
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
|
|
48
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
28
|
-
TemporalAddress:
|
|
29
|
-
TemporalNamespace:
|
|
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
|
+
}
|