fix(db,topics): time.Time params for TIMESTAMP + comment-aware SQL split

Two fixes surfaced by sim e2e test (which otherwise passed full
lifecycle: created → signup_open → 3 signups → allocator → debating
→ arguments → verdict gate (409 early, 201 after debate_end_at) →
completed).

1) MySQL TIMESTAMP rejects RFC3339-with-Z strings — passing those as
   sqlx parameters fails with "Incorrect datetime value". Changed
   CreateTopicInput lifecycle fields from string to time.Time; the
   handler parses+UTCs in validateLifecycleTimes (which now returns
   the parsed array along with the validation result) and passes
   time.Time to the store. The mysql driver formats correctly.

2) splitSQL was naive `strings.Split(s, ";")` which split inside
   comments — the 001 migration had a few `--` lines containing `;`
   ("signup_close_at; immutable", etc) which broke. Migration text
   tidied to not use `;` inside comments, AND splitSQL upgraded to
   skip both `-- ...` and `/* ... */` comment regions before splitting.

Sim verified — clean apply on fresh MySQL.
This commit is contained in:
h z
2026-05-23 12:24:13 +01:00
parent 57a1fa1b33
commit 03b89a547c
4 changed files with 54 additions and 26 deletions

View File

@@ -105,10 +105,33 @@ func RunMigrations(ctx context.Context, d *sqlx.DB) error {
} }
func splitSQL(s string) []string { func splitSQL(s string) []string {
// Crude but adequate for our migrations (no string-literal semicolons). // Comment-aware splitter: skip `;` inside `-- ...` line comments
// If we ever need to embed semicolons inside strings, switch to a // and `/* ... */` block comments. Doesn't handle string-literal
// proper SQL tokenizer. // semicolons (we don't put any) — if we ever need that, swap in a
return strings.Split(s, ";") // real SQL tokenizer.
var b strings.Builder
i := 0
for i < len(s) {
if i+1 < len(s) && s[i] == '-' && s[i+1] == '-' {
// single-line comment — strip through end of line
for i < len(s) && s[i] != '\n' {
i++
}
continue
}
if i+1 < len(s) && s[i] == '/' && s[i+1] == '*' {
// block comment — strip through `*/`
i += 2
for i+1 < len(s) && !(s[i] == '*' && s[i+1] == '/') {
i++
}
i += 2
continue
}
b.WriteByte(s[i])
i++
}
return strings.Split(b.String(), ";")
} }
func firstLine(s string) string { func firstLine(s string) string {

View File

@@ -1,7 +1,7 @@
-- 001_init.sql — Dialectic v2 schema (greenfield, replaces Python v1). -- 001_init.sql — Dialectic v2 schema (greenfield, replaces Python v1).
-- See /home/hzhang/arch/DIALECTIC-V2-DESIGN.md for the design. -- See /home/hzhang/arch/DIALECTIC-V2-DESIGN.md for the design.
-- Verdict schemas — declared at topic-creation time; judge produces output matching. -- Verdict schemas — declared at topic-creation time, judge produces output matching.
CREATE TABLE verdict_schemas ( CREATE TABLE verdict_schemas (
id VARCHAR(64) NOT NULL PRIMARY KEY, id VARCHAR(64) NOT NULL PRIMARY KEY,
description TEXT NOT NULL, description TEXT NOT NULL,
@@ -60,7 +60,7 @@ CREATE TABLE signups (
-- Camps: the post-allocation assignment. One row per (topic, camp) with -- Camps: the post-allocation assignment. One row per (topic, camp) with
-- the locked-in agent. Written by camp-allocation algorithm at -- the locked-in agent. Written by camp-allocation algorithm at
-- signup_close_at; immutable afterwards (no drop-out / replacement in v1). -- signup_close_at immutable afterwards (no drop-out / replacement in v1).
CREATE TABLE camps ( CREATE TABLE camps (
id CHAR(36) NOT NULL PRIMARY KEY, id CHAR(36) NOT NULL PRIMARY KEY,
topic_id CHAR(36) NOT NULL, topic_id CHAR(36) NOT NULL,
@@ -73,7 +73,7 @@ CREATE TABLE camps (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Rounds: chronological partition of arguments. Each topic has N rounds -- Rounds: chronological partition of arguments. Each topic has N rounds
-- (typically 3-5); round 0 is the opening. Round transitions are driven -- (typically 3-5), round 0 is the opening. Round transitions are driven
-- by the orchestrator on a schedule (or all-participants-posted). -- by the orchestrator on a schedule (or all-participants-posted).
CREATE TABLE rounds ( CREATE TABLE rounds (
id CHAR(36) NOT NULL PRIMARY KEY, id CHAR(36) NOT NULL PRIMARY KEY,
@@ -86,7 +86,7 @@ CREATE TABLE rounds (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Arguments: an individual contribution within a round by a camp's agent. -- Arguments: an individual contribution within a round by a camp's agent.
-- For pro/con these are claims/rebuttals; for judge these are clarifying -- For pro/con these are claims/rebuttals for judge these are clarifying
-- questions (judge is silent observer in v1 except for clarifications). -- questions (judge is silent observer in v1 except for clarifications).
CREATE TABLE arguments ( CREATE TABLE arguments (
id CHAR(36) NOT NULL PRIMARY KEY, id CHAR(36) NOT NULL PRIMARY KEY,
@@ -118,7 +118,7 @@ CREATE TABLE verdicts (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Agent API keys: provisioned per agent at recruitment time. Stored as -- Agent API keys: provisioned per agent at recruitment time. Stored as
-- sha256(pepper || raw); pepper rotation invalidates all keys. -- sha256(pepper || raw) pepper rotation invalidates all keys.
CREATE TABLE agent_keys ( CREATE TABLE agent_keys (
agent_id VARCHAR(64) NOT NULL PRIMARY KEY, agent_id VARCHAR(64) NOT NULL PRIMARY KEY,
key_hash CHAR(64) NOT NULL UNIQUE, key_hash CHAR(64) NOT NULL UNIQUE,

View File

@@ -114,7 +114,8 @@ func (h *TopicsHandler) Create(w http.ResponseWriter, r *http.Request) {
if body.VerdictSchemaID == "" { if body.VerdictSchemaID == "" {
body.VerdictSchemaID = "free-form" body.VerdictSchemaID = "free-form"
} }
if err := validateLifecycleTimes(body); err != nil { parsed, err := validateLifecycleTimes(body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
return return
} }
@@ -123,10 +124,10 @@ func (h *TopicsHandler) Create(w http.ResponseWriter, r *http.Request) {
Summary: body.Summary, Summary: body.Summary,
Visibility: models.Visibility(body.Visibility), Visibility: models.Visibility(body.Visibility),
VerdictSchemaID: body.VerdictSchemaID, VerdictSchemaID: body.VerdictSchemaID,
SignupOpenAt: body.SignupOpenAt, SignupOpenAt: parsed[0],
SignupCloseAt: body.SignupCloseAt, SignupCloseAt: parsed[1],
DebateStartAt: body.DebateStartAt, DebateStartAt: parsed[2],
DebateEndAt: body.DebateEndAt, DebateEndAt: parsed[3],
CreatorUserID: caller.ID, CreatorUserID: caller.ID,
}) })
if err != nil { if err != nil {
@@ -141,7 +142,10 @@ func (h *TopicsHandler) Create(w http.ResponseWriter, r *http.Request) {
// signup_open < signup_close <= debate_start < debate_end // signup_open < signup_close <= debate_start < debate_end
// //
// All four timestamps must be parsable as RFC3339; failure → 400. // All four timestamps must be parsable as RFC3339; failure → 400.
func validateLifecycleTimes(b createTopicBody) error { // Returns the parsed times in order [signup_open, signup_close, debate_start, debate_end]
// so the caller can pass typed values to the store (MySQL TIMESTAMP doesn't
// accept ISO8601 strings directly; the driver handles time.Time properly).
func validateLifecycleTimes(b createTopicBody) ([4]time.Time, error) {
type p struct { type p struct {
name string name string
raw string raw string
@@ -152,24 +156,24 @@ func validateLifecycleTimes(b createTopicBody) error {
{"debate_start_at", b.DebateStartAt}, {"debate_start_at", b.DebateStartAt},
{"debate_end_at", b.DebateEndAt}, {"debate_end_at", b.DebateEndAt},
} }
parsed := make([]time.Time, 4) var parsed [4]time.Time
for i, x := range parts { for i, x := range parts {
t, err := time.Parse(time.RFC3339, x.raw) t, err := time.Parse(time.RFC3339, x.raw)
if err != nil { if err != nil {
return errors.New(x.name + ": must be RFC3339") return parsed, errors.New(x.name + ": must be RFC3339")
} }
parsed[i] = t parsed[i] = t.UTC()
} }
if !parsed[0].Before(parsed[1]) { if !parsed[0].Before(parsed[1]) {
return errors.New("signup_open_at must be before signup_close_at") return parsed, errors.New("signup_open_at must be before signup_close_at")
} }
if parsed[1].After(parsed[2]) { if parsed[1].After(parsed[2]) {
return errors.New("signup_close_at must be <= debate_start_at") return parsed, errors.New("signup_close_at must be <= debate_start_at")
} }
if !parsed[2].Before(parsed[3]) { if !parsed[2].Before(parsed[3]) {
return errors.New("debate_start_at must be before debate_end_at") return parsed, errors.New("debate_start_at must be before debate_end_at")
} }
return nil return parsed, nil
} }
// PUT /api/topics/{id}/visibility — admin-only flip (Phase 2 stub: any // PUT /api/topics/{id}/visibility — admin-only flip (Phase 2 stub: any

View File

@@ -6,6 +6,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"strings" "strings"
"time"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
@@ -26,10 +27,10 @@ type CreateTopicInput struct {
Summary string Summary string
Visibility models.Visibility Visibility models.Visibility
VerdictSchemaID string VerdictSchemaID string
SignupOpenAt string // RFC3339; parsed by SQL SignupOpenAt time.Time
SignupCloseAt string SignupCloseAt time.Time
DebateStartAt string DebateStartAt time.Time
DebateEndAt string DebateEndAt time.Time
CreatorUserID string CreatorUserID string
} }