Go Engine Implementation Plan
Go Engine Implementation Plan
Section titled “Go Engine Implementation Plan”Status: Draft
Created: 2026-08-04
Replaces: index.mjs (JS entrypoint, ~1300 lines) + shunt-engine.mjs (JS engine, ~800 lines)
This document describes the plan to replace the JavaScript Cloudflare Worker (index.mjs + shunt-engine.mjs) with a native Go Workers compilation targeting WASM. The Go engine uses the shunt merge queue engine directly via mq.New(cfg, forge, stager).Reconcile(ctx).
1. Architecture
Section titled “1. Architecture”1.1 Overview
Section titled “1.1 Overview”┌─────────────────────────────────────────────────────────────────────┐│ Go Cloudflare Worker ││ ││ main.go (HTTP handler) ─────────────────────────────────┐ ││ │ ││ Routes: │ ││ POST /api/v1/webhooks/forgejo → webhook handler │ ││ GET/POST /api/v1/tenants/* → API handlers (auth) │ ││ GET/POST /auth/forgejo → OAuth redirect │ ││ GET/GET /auth/callback → OAuth callback │ ││ POST /api/v1/stripe/webhook → Stripe webhook │ ││ ─────────────────────────────────────────────────── │ ││ Durable Object: │ ││ LeaseDO (unchanged — still serves per-tenant lease) │ ││ ─────────────────────────────────────────────────── │ ││ Cron: cron.go — calls engine.ReconcileAll(ctx) │ ││ │ ││ Engine (internal/engine/engine.go): │ ││ mq.New(&mq.Config{...}, forgeClient, stager) │ ││ mq.Engine.Reconcile(ctx) │ ││ │ ││ Support packages: │ ││ internal/forge/ — mq.ForgeClient implementation │ ││ internal/gitops/ — mq.Stager implementation │ ││ internal/db/ — Supabase REST client │ ││ internal/lease/ — QueueLease wrapper (DO or D1) │ ││ internal/checkpoint/ — D1-based CheckpointStore │ │└─────────────────────────────────────────────────────────────────────┘1.2 Key Design Decisions
Section titled “1.2 Key Design Decisions”- Single entrypoint:
main.goserves all HTTP requests via Go’snet/http.ServeMux. No more JS route dispatcher. - Engine per-tick: Each cron tick (or webhook-triggered reconciliation) creates a
mq.Engineviamq.New()and callsReconcile(ctx). The engine is short-lived — no persistent state in the Worker. - CheckpointStore = D1: Queue state persists in a D1 database table (not LeaseDO storage), so the engine survives Worker cold starts.
- QueueLease = LeaseDO: The LeaseDO’s existing
acquire/releaseHTTP interface is called from Go viafetch()inside the Workers runtime. - LeaseDO unchanged: The LeaseDO still handles webhook events (
process_event,/lease,/reconcile). The Go Worker sends events to it via HTTP. - No
net/httpon Workers: For Forge API calls, use Workers’fetch()through the Go Workers polyfill. The existinginternal/forge/forge.gouseshttp.Client— this works because Cloudflare Workers Go runtime polyfillsnet/httpto usefetch().
1.3 What the Go Engine Handles vs What JS Still Handles
Section titled “1.3 What the Go Engine Handles vs What JS Still Handles”| Concern | Go | JS (remaining) |
|---|---|---|
| HTTP routing | main.go — all routes |
N/A |
| Tenant CRUD | API handlers | N/A |
| Auth middleware | internal/api/auth.go |
N/A |
| Shunt engine | mq.New() + Reconcile() |
N/A |
| Checkpoint store | D1 table | N/A |
| Lease management | LeaseDO via fetch() | N/A |
| Cron reconciliation | internal/cron/cron.go |
N/A |
| Webhook HMAC verification | internal/api/webhook.go |
N/A |
| LeaseDO (DO class) | — | lease-do.mjs (unchanged) |
| KV rate limiter | KV binding in main.go | — |
2. File Layout
Section titled “2. File Layout”main.go — HTTP router, auth, webhook, cron entrypoint, worker fetch handlerinternal/worker/ worker.go — Workers-specific adapter: HTTP handler → worker fetch wrapper d1store.go — D1-based CheckpointStore implementation leaseclient.go — LeaseDO client: acquire/release via fetch() to LeaseDO rate.go — KV-based rate limiter (simple in-memory with KV fallback)internal/api/ handler.go — Tenant CRUD handlers (existing, minimal updates) api.go — Route registration (existing) auth.go — API key auth middleware webhook.go — Webhook HMAC verification + event routinginternal/engine/ engine.go — mq.New() wrapper (existing, needs mq.Config fix) tenant.go — TenantConfig from DBinternal/forge/ forge.go — mq.ForgeClient implementation (existing, unchanged)internal/gitops/ stager.go — mq.Stager implementation (existing, unchanged)internal/db/ db.go — Supabase REST client (existing, unchanged)internal/lease/ lease.go — QueueLease interface + DO wrapper (existing, extends for D1)internal/checkpoint/ checkpoint.go — D1-based CheckpointStore (new)internal/cron/ cron.go — Cron reconciliation entrypoint (existing or new)internal/tenant/ tenant.go — Tenant model + DB query helpersshunt-engine.mjs — DELETED (replaced by shunt/mq engine)index.mjs — DELETED (replaced by main.go)lease-do.mjs — KEPT (LeaseDO class, unchanged)wrangler.toml — Updated: main = "src/main.go" or build commandgo.mod — Added: cfworker build tag, D1/LeaseDO dependencies2.1 What to Keep, What to Change, What to Delete
Section titled “2.1 What to Keep, What to Change, What to Delete”| File | Action | Notes |
|---|---|---|
index.mjs |
Delete | Replaced by main.go |
shunt-engine.mjs |
Delete | Replaced by shunt/mq engine |
lease-do.mjs |
Keep | LeaseDO class unchanged |
wrangler.toml |
Update | Change main = "index.mjs" → Go build config |
internal/engine/engine.go |
Update | Fix to use mq.Config (public), not engine.Config (internal) |
internal/forge/forge.go |
Keep | Already implements mq.ForgeClient |
internal/gitops/stager.go |
Keep | Already implements mq.Stager |
internal/db/db.go |
Keep | Supabase client, unchanged |
internal/lease/lease.go |
Update | Add D1-backed QueueLease impl |
internal/checkpoint/checkpoint.go |
New | D1-backed CheckpointStore |
internal/api/handler.go |
Keep | Tenant CRUD handlers |
internal/api/api.go |
Keep | Route registration |
main.go |
New | HTTP router, fetch handler, cron |
3. Route Mapping
Section titled “3. Route Mapping”All JS routes mapped to Go handlers. The Go Worker uses Go 1.22+ net/http.ServeMux with pattern-based routing.
3.1 API Routes (authenticated via X-API-Key)
Section titled “3.1 API Routes (authenticated via X-API-Key)”| Route | JS Handler | Go Handler | Notes |
|---|---|---|---|
GET /api/v1/tenants |
supabase → tenants | Handler.ListTenants() |
Admin only |
POST /api/v1/tenants |
supabase → tenants | Handler.CreateTenant() |
No auth (first tenant) |
GET /api/v1/tenants/:id |
supabase → tenants | Handler.GetTenant() |
|
PUT /api/v1/tenants/:id |
supabase → tenants | Handler.UpdateTenant() |
|
DELETE /api/v1/tenants/:id |
supabase → tenants | Handler.DeleteTenant() |
|
GET /api/v1/tenants/:id/connections |
supabase → forge_connections | Handler.ListConnections() |
|
POST /api/v1/tenants/:id/connections |
supabase → forge_connections | Handler.CreateConnection() |
Encrypt token |
DELETE /api/v1/tenants/:id/connections/:connId |
supabase → forge_connections | Handler.DeleteConnection() |
|
GET /api/v1/tenants/:id/connections/:connId/repos |
supabase → managed_repos | Handler.ListRepos() |
|
POST /api/v1/tenants/:id/connections/:connId/repos |
supabase → managed_repos | Handler.CreateRepo() |
|
DELETE /api/v1/tenants/:id/connections/:connId/repos/:repoId |
supabase → managed_repos | Handler.DeleteRepo() |
|
GET /api/v1/tenants/:id/queue |
stub response | Handler.GetQueueState() |
Query LeaseDO for state |
GET /api/v1/tenants/:id/audit |
supabase → audit_log | Handler.GetAuditLog() |
Paginated |
3.2 Auth Routes (no API key required)
Section titled “3.2 Auth Routes (no API key required)”| Route | JS Handler | Go Handler | Notes |
|---|---|---|---|
GET /auth/forgejo |
OAuth redirect | Handler.AuthRedirect() |
Stub — redirect to forge |
GET /auth/callback |
JWT session | Handler.AuthCallback() |
Stub — exchange code → session |
GET /api/v1/me |
JWT verify | Handler.Me() |
Session cookie → user info |
POST /api/v1/logout |
Cookie clear | Handler.Logout() |
Clear session cookie |
3.3 Webhook Routes (no API key, HMAC verified)
Section titled “3.3 Webhook Routes (no API key, HMAC verified)”| Route | JS Handler | Go Handler | Notes |
|---|---|---|---|
POST /api/v1/webhooks/forgejo |
HMAC verify → LeaseDO | Handler.WebhookForgejo() |
Lookup tenant, verify HMAC, call LeaseDO |
3.4 Billing Routes (authenticated)
Section titled “3.4 Billing Routes (authenticated)”| Route | JS Handler | Go Handler | Notes |
|---|---|---|---|
POST /api/v1/billing/checkout |
stub | Handler.Checkout() |
Stub → real Stripe |
POST /api/v1/billing/portal |
stub | Handler.BillingPortal() |
Stub → real Stripe |
POST /api/v1/stripe/webhook |
stub | Handler.StripeWebhook() |
Stub → real Stripe |
3.5 LeaseDO Routes (no auth — DO handles its own security)
Section titled “3.5 LeaseDO Routes (no auth — DO handles its own security)”| Route | JS Handler | Go Handler | Notes |
|---|---|---|---|
GET /api/v1/tenants/:id/repos/:name/lease |
fetch → LeaseDO | Handler.LeaseCheck() |
Forward to LeaseDO |
POST /api/v1/tenants/:id/repos/:name/lease |
fetch → LeaseDO | Handler.LeaseAcquire() |
Forward to LeaseDO |
DELETE /api/v1/tenants/:id/repos/:name/lease |
fetch → LeaseDO | Handler.LeaseRelease() |
Forward to LeaseDO |
4. Engine Wiring
Section titled “4. Engine Wiring”4.1 The Engine Core
Section titled “4.1 The Engine Core”// internal/engine/engine.go — updated
package engine
import ( "context" "log/slog"
"github.com/rbtr/shunt/mq"
"git.rbtr.dev/laputacloudco/gondolier/internal/forge" "git.rbtr.dev/laputacloudco/gondolier/internal/gitops" "git.rbtr.dev/laputacloudco/gondolier/internal/lease")
// Engine wraps shunt's mq.Engine with a per-tenant config layer.type Engine struct { cfg Config forge forge.ForgeClient stager gitops.Stager engine *mq.Engine logger *slog.Logger}
// Config holds the configuration for a single tenant's engine tick.type Config struct { TenantID string RepoOwner string RepoName string BaseBranch string QueueName string Token string InstanceURL string StatusCtx string // default: "merge-queue" MergeStyle string // default: "squash" MaxBatch int // default: 5 LeaseTTL int // seconds, default: 45 BotUser string // default: "mq-bot"}
// New creates a new Engine for the given config.func New(cfg Config) *Engine { return &Engine{ cfg: cfg, logger: slog.Default().With("tenant", cfg.TenantID, "repo", cfg.RepoName), }}
// Reconcile runs one cycle of the merge queue.func (e *Engine) Reconcile(ctx context.Context) (*ReconcileResult, error) { result := &ReconcileResult{QueueName: e.cfg.QueueName}
// Build the shunt engine shuntCfg := &mq.Config{ Owner: e.cfg.RepoOwner, Repo: e.cfg.RepoName, Base: e.cfg.BaseBranch, StatusCtx: e.cfg.StatusCtx, MergeStyle: e.cfg.MergeStyle, StagingBranch: "mq/" + e.cfg.BaseBranch + "/staging", InstanceURL: e.cfg.InstanceURL, PublicURL: e.cfg.InstanceURL, MaxBatch: e.cfg.MaxBatch, BotUser: e.cfg.BotUser, LeaseTTL: time.Duration(e.cfg.LeaseTTL) * time.Second, }
e.engine = mq.New(shuntCfg, e.forge, e.stager)
err := e.engine.Reconcile(ctx) if err != nil { result.Errors = append(result.Errors, err.Error()) return result, err }
return result, nil}4.2 ForgeClient — Already Implemented
Section titled “4.2 ForgeClient — Already Implemented”internal/forge/forge.go already implements mq.ForgeClient. It needs no changes.
Interface verification:
// internal/forge/forge.go implements:// - ListOpenPRs(ctx, owner, repo, base) ([]PullRequest, error)// - GetPR(ctx, owner, repo, index) (PullRequest, error)// - AutomergeState(ctx, owner, repo, index) (AutomergeState, error)// - LatestCommitStatus(ctx, owner, repo, sha, ctx) (CommitStatus, bool, error)// - RunStatus(ctx, owner, repo, sha, branch) (string, error)// - RunTargetURL(ctx, owner, repo, sha, branch) (string, error)// - SetCommitStatus(ctx, owner, repo, sha, ctx, state, desc, targetURL) error// - ScheduleAutomerge(ctx, owner, repo, index, style, headSHA) (ScheduleAutomergeResult, error)// - CancelAutomerge(ctx, owner, repo, index) (bool, error)// - DeleteBranch(ctx, owner, repo, branch) error// - UpsertComment(ctx, owner, repo, index, marker, botUser, body) error ← EXISTS4.3 Stager — Already Implemented
Section titled “4.3 Stager — Already Implemented”internal/gitops/stager.go already implements mq.Stager:
// internal/gitops/stager.go implements:// - BuildStaging(ctx, base, stagingBranch, refs []mq.MergedRef) (sha string, conflictPR int, err error)4.4 CheckpointStore — D1-Based (New)
Section titled “4.4 CheckpointStore — D1-Based (New)”The shunt engine needs a CheckpointStore for queue persistence. In Workers, bbolt (used by shunt’s default checkpoint) is unavailable. Use D1:
// internal/checkpoint/d1store.go — NEW FILE
package checkpoint
import ( "context" "database/sql" "encoding/json"
"github.com/rbtr/shunt/internal/checkpoint")
// D1Store implements checkpoint.CheckpointStore using D1.type D1Store struct { db *sql.DB // D1 bound as sql database}
func NewD1Store(db *sql.DB) *D1Store { return &D1Store{db: db}}
func (s *D1Store) LoadQueue(ctx context.Context, key checkpoint.QueueKey) (checkpoint.QueueSnapshot, bool, error) { var data string err := s.db.QueryRowContext(ctx, `SELECT data FROM queue_checkpoint WHERE owner = ? AND repo = ? AND base = ?`, key.Owner, key.Repo, key.Base, ).Scan(&data) if err == sql.ErrNoRows { return checkpoint.QueueSnapshot{}, false, nil } if err != nil { return checkpoint.QueueSnapshot{}, false, err } var snap checkpoint.QueueSnapshot if err := json.Unmarshal([]byte(data), &snap); err != nil { return checkpoint.QueueSnapshot{}, false, err } return snap, true, nil}
func (s *D1Store) SaveQueue(ctx context.Context, snap checkpoint.QueueSnapshot) error { data, err := json.Marshal(snap) if err != nil { return err } _, err = s.db.ExecContext(ctx, `INSERT OR REPLACE INTO queue_checkpoint (owner, repo, base, data) VALUES (?, ?, ?, ?)`, snap.Key.Owner, snap.Key.Repo, snap.Key.Base, data, ) return err}
func (s *D1Store) DeleteQueue(ctx context.Context, key checkpoint.QueueKey) error { _, err := s.db.ExecContext(ctx, `DELETE FROM queue_checkpoint WHERE owner = ? AND repo = ? AND base = ?`, key.Owner, key.Repo, key.Base, ) return err}Migration SQL (supabase/migrations/004_queue_checkpoint.sql):
CREATE TABLE IF NOT EXISTS queue_checkpoint ( owner TEXT NOT NULL, repo TEXT NOT NULL, base TEXT NOT NULL, data TEXT NOT NULL, updated INTEGER NOT NULL DEFAULT (unixepoch()), PRIMARY KEY (owner, repo, base));4.5 QueueLease — LeaseDO-Based
Section titled “4.5 QueueLease — LeaseDO-Based”Wrap LeaseDO’s HTTP interface as QueueLease:
// internal/lease/dolease.go — EXTEND existing lease.go
package lease
import ( "bytes" "context" "encoding/json" "fmt" "net/http" "time"
"github.com/rbtr/shunt/internal/checkpoint")
// DOLease implements QueueLease by calling LeaseDO via fetch().type DOLease struct { doURL string // e.g., "https://<do-name>.<tenant>.gondolier.workers.dev"}
func NewDOLease(doURL string) *DOLease { return &DOLease{doURL: doURL}}
type leaseResponse struct { Acquired bool `json:"acquired,omitempty"` Released bool `json:"released,omitempty"` Error string `json:"error,omitempty"`}
func (l *DOLease) AcquireLease(ctx context.Context, key checkpoint.QueueKey, holderID string, ttl time.Duration) (bool, error) { body, _ := json.Marshal(map[string]string{ "holder_id": holderID, "key": key.String(), }) req, _ := http.NewRequestWithContext(ctx, "POST", l.doURL+"/lease", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req) if err != nil { return false, err } defer resp.Body.Close()
var result leaseResponse json.NewDecoder(resp.Body).Decode(&result)
if resp.StatusCode == 409 || result.Error != "" { return false, fmt.Errorf("lease locked: %s", result.Error) } return result.Acquired, nil}4.6 Engine Initialization in main.go
Section titled “4.6 Engine Initialization in main.go”// main.go — engine init
func newEngine(ctx context.Context, tenantID, connID, repoID string) (*engine.Engine, error) { // 1. Load repo config from DB repo, conn, tenant := db.GetRepoConfig(ctx, repoID) if repo == nil { return nil, fmt.Errorf("repo not found") }
// 2. Build engine config cfg := engine.Config{ TenantID: tenant.ID, RepoOwner: repo.Owner, RepoName: repo.Slug, BaseBranch: repo.BaseBranch, QueueName: "default", Token: decryptToken(conn.TokenEncrypted, conn.TokenNonce), InstanceURL: conn.InstanceURL, StatusCtx: repo.StatusContext, MergeStyle: repo.MergeStyle, MaxBatch: repo.MaxBatch, LeaseTTL: 45, BotUser: conn.BotLogin, }
// 3. Create engine e := engine.New(cfg)
// 4. Attach checkpoint store (D1) // e.engine.SetCheckpoint(checkpoint.NewD1Store(d1DB))
// 5. Attach lease (LeaseDO) // e.engine.SetLease(lease.NewDOLease(doURL))
return e, nil}5. main.go — Entry Point
Section titled “5. main.go — Entry Point”package main
import ( "context" "database/sql" "fmt" "log/slog" "net/http" "os"
"cloud.google.com/go/cloudsqlconn" // or pgx pool for D1 "git.rbtr.dev/laputacloudco/gondolier/internal/api" "git.rbtr.dev/laputacloudco/gondolier/internal/cron" "git.rbtr.dev/laputacloudco/gondolier/internal/db" "git.rbtr.dev/laputacloudco/gondolier/internal/engine" "git.rbtr.dev/laputacloudco/gondolier/internal/forge" "git.rbtr.dev/laputacloudco/gondolier/internal/gitops" "git.rbtr.dev/laputacloudco/gondolier/internal/tenant")
func main() { log := slog.New(slog.NewTextHandler(os.Stdout, nil))
// DB connections (via Cloudflare Secrets or D1 bindings) supabaseURL := os.Getenv("SUPABASE_URL") supabaseKey := os.Getenv("SUPABASE_SERVICE_ROLE_KEY") adminKey := os.Getenv("ADMIN_API_KEY")
dbClient := db.New(supabaseURL, supabaseKey)
// API handler apiHandler := api.NewHandler(dbClient, log, adminKey)
// HTTP router mux := http.NewServeMux()
// API routes (with auth middleware) apiHandler.RegisterRoutes(mux)
// Webhook route (no auth, HMAC verified) mux.HandleFunc("POST /api/v1/webhooks/forgejo", func(w http.ResponseWriter, r *http.Request) { handleWebhook(w, r, dbClient, log) })
// Auth routes mux.HandleFunc("GET /auth/forgejo", handleAuthRedirect) mux.HandleFunc("GET /auth/callback", handleAuthCallback) mux.HandleFunc("GET /api/v1/me", handleMe) mux.HandleFunc("POST /api/v1/logout", handleLogout)
// Billing stubs mux.HandleFunc("POST /api/v1/billing/checkout", handleBillingCheckout) mux.HandleFunc("POST /api/v1/billing/portal", handleBillingPortal) mux.HandleFunc("POST /api/v1/stripe/webhook", handleStripeWebhook)
// Lease forwarding mux.HandleFunc("GET /api/v1/tenants/*/repos/*/lease", handleLease) mux.HandleFunc("POST /api/v1/tenants/*/repos/*/lease", handleLease) mux.HandleFunc("DELETE /api/v1/tenants/*/repos/*/lease", handleLease)
log.Info("gondolier starting") http.ListenAndServe(":8787", mux) // local dev; Workers uses fetch()}
// fetch is the Workers entrypoint — called by Cloudflare Workers runtimefunc fetch(request *http.Request, env map[string]any, ctx context.Context) (*http.Response, error) { // Same mux as main(), reused for both local and Workers return nil, http.DefaultServeMux.ServeHTTP}5.1 Workers Go Integration
Section titled “5.1 Workers Go Integration”Cloudflare Workers Go uses go build -target=cfworker. The compiled WASM is served via wrangler:
name = "gondolier"compatibility_date = "2026-08-03"compatibility_flags = ["go_worker"]
build = { command = "go build -target=cfworker -o ../dist/_worker.js" }
[durable_objects]bindings = [{ name = "LEASE_DO", class_name = "LeaseDO" }]
[[migrations]]tag = "v1"new_sqlite_classes = ["LeaseDO"]
[triggers]crons = ["*/5 * * * *"]The Workers Go runtime provides these polyfills:
net/http→ Workersfetch()os.Getenv→ Cloudflare Secrets / environment variablestime.Sleep→ Workers-compatible sleepdatabase/sql→ NOT polyfilled (use D1 binding directly)
5.2 D1 Access from Workers Go
Section titled “5.2 D1 Access from Workers Go”// In main.go, use the D1 binding:import "database/sql"
var d1 *sql.DB // bound by wrangler
// Use with context:stmt, err := d1.PrepareContext(ctx, "SELECT ...")6. Build & Deploy
Section titled “6. Build & Deploy”6.1 Build
Section titled “6.1 Build”# Local devgo build -o bin/gondolier ./cmd/gondolier
# Workers WASM buildgo build -target=cfworker -o dist/_worker.js ./cmd/gondolier
# Verifygo vet ./...go test ./...6.2 wrangler.toml Changes
Section titled “6.2 wrangler.toml Changes”# Before:main = "index.mjs"
# After:name = "gondolier"compatibility_date = "2026-08-03"compatibility_flags = ["go_worker"]
build = { command = "go build -target=cfworker -o dist/_worker.js", watch_dir = "." }
[durable_objects]bindings = [{ name = "LEASE_DO", class_name = "LeaseDO" }]
[[migrations]]tag = "v1"new_sqlite_classes = ["LeaseDO"]
[triggers]crons = ["*/5 * * * *"]
[observability]enabled = truehead_sampling_rate = 16.3 Deploy
Section titled “6.3 Deploy”wrangler deploy6.4 CI/CD Update (.forgejo/workflows/ci.yaml)
Section titled “6.4 CI/CD Update (.forgejo/workflows/ci.yaml)”name: CI
on: pull_request: branches: [main] push: branches: [main]
jobs: vet: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version: '1.25' - run: go vet ./...
test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version: '1.25' - run: go test ./...
build: runs-on: ubuntu-latest needs: [vet, test] steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version: '1.25' - run: go build -target=cfworker -o dist/_worker.js ./cmd/gondolier
deploy-preview: runs-on: ubuntu-latest needs: [build] if: github.event_name == 'pull_request' steps: - uses: cloudflare/wrangler-action@v3 with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} command: deploy --env preview
deploy-main: runs-on: ubuntu-latest needs: [build] if: github.event_name == 'push' && github.ref == 'refs/heads/main' steps: - uses: cloudflare/wrangler-action@v3 with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} command: deploy7. Migration Plan
Section titled “7. Migration Plan”Phase 1: Build Go Engine (Parallel)
Section titled “Phase 1: Build Go Engine (Parallel)”Goal: Go engine compiles and runs locally, calls shunt engine, reads from DB.
- Update
internal/engine/engine.goto usemq.Config(public) instead ofengine.Config(internal) - Create
internal/checkpoint/d1store.go(D1 checkpoint store) - Create
internal/lease/dolease.go(LeaseDO QueueLease) - Write
main.gostub — HTTP server with no-op handlers - Update
wrangler.tomlfor Go build - Build with
go build -target=cfworker - Run
go vet ./... && go test ./...
Keep JS running: JS index.mjs remains deployed. No changes to existing routes.
Phase 2: Replace JS API Routes (Blue-Green)
Section titled “Phase 2: Replace JS API Routes (Blue-Green)”Goal: Go handles API routes; JS still handles webhooks + LeaseDO.
- Implement all API route handlers in Go (
internal/api/handler.go) - Wire
main.goto handle/api/v1/*routes - Deploy Go Worker alongside JS Worker (different subdomain or path prefix)
- Use Cloudflare Workers routing to send
/api/v1/*→ Go, everything else → JS - Test all CRUD endpoints via curl
- Monitor error rates
Routing config (Cloudflare dashboard or wrangler):
# Two workers, routed by path:# Worker 1 (go): handles /api/v1/*, /auth/*, /webhooks/*# Worker 2 (js): handles everything else (LeaseDO still JS)Phase 3: Replace Webhooks & Cron
Section titled “Phase 3: Replace Webhooks & Cron”Goal: Go handles webhooks and cron; JS deleted.
- Implement webhook HMAC verification in Go (
internal/api/webhook.go) - Implement cron handler in Go (
internal/cron/cron.go) - Go Worker sends webhook events to LeaseDO via fetch()
- Go Worker’s cron calls
engine.ReconcileAll() - Delete
index.mjsandshunt-engine.mjs - Keep
lease-do.mjs(LeaseDO must be JS — Durable Objects can’t be Go)
Phase 4: Cleanup
Section titled “Phase 4: Cleanup”Goal: Remove JS dependencies, finalize.
- Remove
shunt-engine.mjsfrom repo - Remove
index.mjsfrom repo - Remove
node_modules/if no longer needed for tests - Update
go.mod— rungo mod tidy - Update CI/CD pipeline
- Update
wrangler.toml— remove JS-specific config - Final
wrangler deploy - Update
docs/ROADMAP.md - Run
go vet ./... && go test ./... && go build ./cmd/gondolier
8. Risks & Mitigations
Section titled “8. Risks & Mitigations”| Risk | Severity | Mitigation |
|---|---|---|
net/http not polyfilled on Workers Go |
High | Test with a simple http.Get() call first. Cloudflare Workers Go has supported net/http polyfill since v1.0. If it fails, replace with raw fetch() via syscall/js or js/wasm bindings. |
No database/sql on Workers |
High | D1 Workers Go SDK uses a custom connection. Use github.com/cloudflare/cloudflare-go or the D1 binding directly. If unavailable, use fetch() to call D1 REST API. |
| WASM import latency | Medium | Go WASM startup is ~100-300ms. Keep handler logic minimal — DB queries and forge calls dominate. Engine initialization happens per-tick, not per-request. |
| No background goroutines | Medium | All reconciliation must complete within the request timeout (~5s for HTTP, ~30s for cron). Use context.WithTimeout. The engine’s Reconcile() should be called once per tick, not in a loop. |
| CheckpointStore D1 performance | Medium | D1 reads/writes are ~10-50ms. Checkpoint is read once per reconcile and written once per reconcile — acceptable. If slow, fall back to in-memory checkpoint (state re-derived from forge each tick). |
| LeaseDO fetch() from Go | Low | LeaseDO is accessed via its HTTP interface. The Go Worker calls it via fetch() polyfill. This is the same pattern used in JS. No special handling needed. |
| Supabase REST API from Go Worker | Low | The existing internal/db/db.go uses http.Client → Workers fetch(). Should work transparently. Test connection from Workers Go first. |
| Token encryption missing | Medium | internal/api/handler.go has a TODO for envelope encryption. Implement before Phase 2. Use Cloudflare Secrets for the master key. |
| Cron payload size | Low | Cron iterates all repos and calls LeaseDO for each. At 50 repos max, this is 50 sequential fetches. Consider batching or limiting to active repos only. |
| Rollback complexity | Medium | Keep JS worker deployed during migration. Use Cloudflare Workers routing to switch traffic back to JS if Go fails. |
9. Dependencies
Section titled “9. Dependencies”New/Updated go.mod entries
Section titled “New/Updated go.mod entries”require ( github.com/rbtr/shunt v0.0.0-20260804010323-cd6a1373842b // already present golang.org/x/crypto v0.31.0 // SHA-256 for API key hashing github.com/stripe/stripe-go/v81 v81.4.0 // already present)
// No new deps needed — all existing deps polyfill on Workers Go:// net/http → fetch()// time → Workers timer// encoding/json → standardCloudflare Wrangler Environment Variables
Section titled “Cloudflare Wrangler Environment Variables”| Variable | Used By | Source |
|---|---|---|
SUPABASE_URL |
internal/db |
Cloudflare Secrets |
SUPABASE_SERVICE_ROLE_KEY |
internal/db |
Cloudflare Secrets |
ADMIN_API_KEY |
internal/api |
Cloudflare Secrets |
KMS_MASTER_KEY |
Envelope encryption (Phase 2) | Cloudflare Secrets |
10. Testing Strategy
Section titled “10. Testing Strategy”| Test | How | Scope |
|---|---|---|
Unit: mq.New() |
Go test, mock forge + stager | Engine initialization |
Unit: D1Store |
In-memory SQLite via github.com/mattn/go-sqlite3 |
Checkpoint load/save |
Unit: DOLease |
Mock HTTP server | Lease acquire/release |
| Unit: API handlers | Go net/http/httptest |
CRUD, auth |
| Unit: Webhook HMAC | Go test | Signature verification |
| Integration: full reconcile | Workers local (wrangler dev) |
Engine → forge → stager |
| Integration: cron tick | Workers local (wrangler dev --cors) |
Cron → engine |
| Rubber-duck: diff vs JS | Manual | Route parity, behavior match |
11. Validation Checklist
Section titled “11. Validation Checklist”Before declaring done:
-
go build -target=cfworker -o dist/_worker.jssucceeds -
go vet ./... && go test ./...clean - All API routes respond identically to JS versions
- Webhook HMAC verification works
- Cron reconciliation calls engine.ReconcileAll()
- LeaseDO acquire/release works from Go
- Checkpoint persists across reconcile calls
- No tokens/credentials in logs
-
wrangler deploysucceeds - Rollback plan documented (switch Workers routing back to JS)