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.
- 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/bootstrap.ts +3 -4
- package/src/service-runtime/cloudrun/cleanup.ts +6 -1
- package/src/service-runtime/cloudrun/cli.ts +52 -12
- package/src/service-runtime/cloudrun/config.ts +3 -0
- 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 +55 -15
- package/src/service-runtime/cloudrun/temporal-config.test.ts +66 -0
- package/src/service-runtime/cloudrun/temporal-config.ts +84 -0
- 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.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
|
+
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
|
-
|
|
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
|
-
|
|
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
|
|
44
|
-
|
|
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
|
|
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) {
|