-- name: CreateTaskMessage :one INSERT INTO task_message (id, task_id, seq, type, tool, content, input, output, output_truncated, call_id) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING *; -- name: CreateTaskMessages :many -- Batch variant of CreateTaskMessage: persists a whole daemon-reported batch in -- ONE statement — therefore one round trip and, more importantly, one commit -- instead of one per message. Commit acknowledgement (IO:XactSync) is ~94% of -- this SQL's load in production, so the commit count is the thing being -- optimized; the round trip is a bonus. -- -- The rows arrive as parallel arrays rather than as one jsonb document, even -- though a jsonb document is the tidier Go side. content and output routinely -- carry tens of KB and occasionally megabytes, and wrapping them in JSON makes -- the server escape every byte a second time and Postgres parse the whole -- envelope back out — measured at 1.2x the old per-row insert at 128KB and -- ~1.8x at 1MB, i.e. a regression on exactly the most expensive requests, which -- are also the ones least likely to be batched. Native text[] elements are -- length-prefixed by the wire protocol, so they cost neither pass. -- -- NULLIF is what makes per-row NULL expressible through a non-nullable []string -- (the Go type sqlc gives a text[] parameter): it reproduces, exactly, the -- `pgtype.Text{Valid: x != ""}` mapping the single-row query carries — empty -- string means SQL NULL. input is passed as text and cast here for the same -- reason; it is the one column that genuinely has to be parsed as JSON, because -- it is a jsonb column. created_at also rides this transport so an older daemon -- can omit its event time and keep the database-time fallback. output_truncated -- uses the same trick because it is a genuinely tri-state boolean (NULL = the -- reporting daemon did not measure it), and a bool[] parameter becomes a Go -- []bool, which has no way to spell the third state. -- -- Callers MUST still run the Postgres text sanitizer first. A NUL anywhere in -- the batch fails the whole statement (GH #7098) — that is inherent to batching -- into one statement, not to the parameter shape. -- -- Atomicity is a deliberate side effect, not just a speedup: the per-message -- loop this replaces could persist part of a batch and then fail, leaving the -- transcript with a prefix of the batch and no way to complete it — the daemon -- does not retry this endpoint. One statement makes the batch all-or-nothing, -- which buys consistency. Callers that supply a task-scoped idempotency key -- derive a stable message UUID; the primary-key conflict below deduplicates -- retries without changing the append-only behavior of legacy random IDs. -- -- The ORDER BY is a contract, not decoration. A bare `INSERT ... RETURNING` -- has no defined row order, and the caller republishes these rows as realtime -- events in the order they arrive — the per-row loop this replaces implicitly -- published in request order, so the ordering has to be restored explicitly or -- subscribers can see a batch out of order. seq is assigned by the daemon and -- increases within a batch, so it is the request order. WITH incoming AS ( -- Several single-argument unnest calls in one SELECT list expand in -- lockstep (PostgreSQL 10+ set-returning-function semantics), which is the -- same row-wise zip the multi-argument unnest(a, b, ...) form gives — but -- sqlc's analyzer only knows the single-argument signature, so this is the -- shape that survives code generation. SELECT unnest(sqlc.arg('ids')::uuid[]) AS id, unnest(sqlc.arg('seqs')::int4[]) AS seq, unnest(sqlc.arg('types')::text[]) AS type, unnest(sqlc.arg('tools')::text[]) AS tool, unnest(sqlc.arg('call_ids')::text[]) AS call_id, unnest(sqlc.arg('contents')::text[]) AS content, unnest(sqlc.arg('inputs')::text[]) AS input, unnest(sqlc.arg('outputs')::text[]) AS output, unnest(sqlc.arg('created_ats')::text[]) AS created_at, unnest(sqlc.arg('output_truncations')::text[]) AS output_truncated ), inserted AS ( INSERT INTO task_message (id, task_id, seq, type, tool, content, input, output, created_at, output_truncated, call_id) SELECT m.id, sqlc.arg('task_id')::uuid, m.seq, m.type, NULLIF(m.tool, ''), NULLIF(m.content, ''), NULLIF(m.input, '')::jsonb, NULLIF(m.output, ''), COALESCE(NULLIF(m.created_at, '')::timestamptz, now()), NULLIF(m.output_truncated, '')::bool, NULLIF(m.call_id, '') FROM incoming AS m ON CONFLICT (id) DO NOTHING RETURNING * ) SELECT * FROM inserted ORDER BY seq ASC; -- name: ListTaskMessages :many SELECT * FROM task_message WHERE task_id = $1 ORDER BY seq ASC; -- name: ListTaskMessagesSince :many SELECT * FROM task_message WHERE task_id = $1 AND seq > $2 ORDER BY seq ASC; -- name: ListTaskProgressSince :many -- The rows a WeCom bubble shows while a run works: tool calls, reasoning and -- errors past the last seq the bubble has folded in. Read by the replica that -- holds the bubble, which is how a step reported to any replica reaches it -- without the step's text crossing the realtime relay. Only the columns the -- bubble renders: output (a tool's result) is the block the bubble never -- shows, and routinely the largest column in the table. SELECT seq, type, tool, content, input FROM task_message WHERE task_id = @task_id AND seq > @after_seq AND type IN ('tool_use', 'thinking', 'error') ORDER BY seq ASC; -- name: DeleteTaskMessages :exec DELETE FROM task_message WHERE task_id = $1;