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:
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user