v1.13.20-drop-legacy-cols: final phase of v1.13.0 strangler-fig
Removes the dual-write into messages.tool_calls / messages.tool_results JSON columns and drops the columns. message_parts is now the only source of truth for tool calls and tool results. 10 dual-write sites stripped (5 in tool-phase.ts, 2 in routes/skills.ts, 2 in routes/messages.ts, 1 in routes/chats.ts fork-clone). The recon-driven grep caught 2 sites beyond the original v1.13.2 roadmap inventory and an extra fixture file (tool_cost_stats.test.ts) with a direct legacy-column INSERT. messages_with_parts view rewritten to parts-only subselects (COALESCE fallbacks gone). View runs via CREATE OR REPLACE so it lands before the column DROPs in startup DDL — Postgres rejects column-drop on view-referenced cols. v1.12.1 cleanup DO block (DROP CONSTRAINT messages_status_check / messages_role_check) removed; those one-shots have done their work. Adversarial review caught a runtime bug the green test suite missed: the discard_stale endpoint (chats.ts) had a RETURNING ... tool_calls, tool_results clause that would have crashed on every 60s-no-token-activity recovery in production. Fixed by switching to two-step UPDATE returning id, then SELECT from messages_with_parts so parts-synthesized fields keep flowing on the wire. Message API type retains tool_calls? / tool_results? — the view synthesizes those keys from parts so the wire shape is unchanged; frontend reads need no update. Override on the original v1.13.2 plan, captured in the openspec proposal. 339/339 server tests passing (including 7 DB-integration tests that applied the schema migration to a live DB and ran the parts-only view end-to-end). tsc + web build clean. Pairs with v1.13.0-ai-sdk-v6 (introduced the dual-write) and v1.13.1-B (moved the read path to messages_with_parts). Umbrella v1.13 tag ships on this same commit, marking the strangler-fig closed. CLAUDE.md picks up Sam's pre-existing edits documenting tag-naming and CHANGELOG conventions — both already in use by v1.13.19 / v1.13.20. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -296,13 +296,13 @@ export function registerChatRoutes(
|
||||
`;
|
||||
await tx`
|
||||
INSERT INTO messages (
|
||||
session_id, chat_id, role, content, kind, tool_calls, tool_results,
|
||||
session_id, chat_id, role, content, kind,
|
||||
status, tokens_used, ctx_used, ctx_max, started_at, finished_at,
|
||||
created_at, metadata
|
||||
)
|
||||
SELECT
|
||||
${source.session_id}, ${chat!.id}, role, content, kind,
|
||||
tool_calls, tool_results, status,
|
||||
status,
|
||||
tokens_used, ctx_used, ctx_max, started_at, finished_at,
|
||||
clock_timestamp() + (
|
||||
ROW_NUMBER() OVER (ORDER BY created_at ASC, id ASC) * INTERVAL '1 microsecond'
|
||||
@@ -385,21 +385,25 @@ export function registerChatRoutes(
|
||||
reply.code(409);
|
||||
return { error: 'message is not stale yet', age_seconds: msg.age_seconds };
|
||||
}
|
||||
const updated = await sql<Message[]>`
|
||||
const updated = await sql<{ id: string }[]>`
|
||||
UPDATE messages
|
||||
SET status = 'failed',
|
||||
content = COALESCE(content, ''),
|
||||
finished_at = clock_timestamp()
|
||||
WHERE id = ${msg.id} AND status = 'streaming'
|
||||
RETURNING id, session_id, chat_id, role, content, kind, tool_calls, tool_results,
|
||||
status, last_seq, tokens_used, ctx_used, ctx_max, started_at, finished_at,
|
||||
created_at, metadata, summary, tail_start_id, compacted_at
|
||||
RETURNING id
|
||||
`;
|
||||
if (updated.length === 0) {
|
||||
// Race: the row flipped out of 'streaming' between our SELECT and UPDATE.
|
||||
reply.code(409);
|
||||
return { error: 'message status changed mid-request' };
|
||||
}
|
||||
// v1.13.20: re-fetch via messages_with_parts so the returned shape
|
||||
// carries parts-synthesized tool_calls / tool_results. The dropped
|
||||
// legacy columns can no longer be selected directly.
|
||||
const refreshed = await sql<Message[]>`
|
||||
SELECT * FROM messages_with_parts WHERE id = ${msg.id}
|
||||
`;
|
||||
broker.publishUserFrame('default', {
|
||||
type: 'chat_status',
|
||||
chat_id: msg.chat_id,
|
||||
@@ -411,7 +415,7 @@ export function registerChatRoutes(
|
||||
message_id: msg.id,
|
||||
chat_id: msg.chat_id,
|
||||
});
|
||||
return updated[0];
|
||||
return refreshed[0];
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -605,15 +605,11 @@ export function registerMessageRoutes(
|
||||
|
||||
const toolMessageId = toolRow.message_id;
|
||||
const result = await sql.begin(async (tx) => {
|
||||
await tx`
|
||||
UPDATE messages
|
||||
SET tool_results = ${tx.json(newToolResults as never)}
|
||||
WHERE id = ${toolMessageId}
|
||||
`;
|
||||
// v1.13.0: replace the pending tool_result part inserted at message
|
||||
// creation (tool-phase.ts) with the answered one. Delete-then-insert
|
||||
// is simpler than UPDATE because parts are append-style elsewhere;
|
||||
// the UNIQUE (message_id, sequence) constraint blocks plain insert.
|
||||
// v1.13.20: parts-only. Replace the pending tool_result part inserted
|
||||
// at message creation (tool-phase.ts) with the answered one. Delete-
|
||||
// then-insert is simpler than UPDATE because parts are append-style
|
||||
// elsewhere; the UNIQUE (message_id, sequence) constraint blocks
|
||||
// plain insert.
|
||||
await tx`DELETE FROM message_parts WHERE message_id = ${toolMessageId} AND kind = 'tool_result'`;
|
||||
await tx`
|
||||
INSERT INTO message_parts (message_id, sequence, kind, payload)
|
||||
@@ -796,13 +792,9 @@ export function registerMessageRoutes(
|
||||
};
|
||||
const toolMessageId = toolRow.message_id;
|
||||
const dbResult = await sql.begin(async (tx) => {
|
||||
await tx`
|
||||
UPDATE messages
|
||||
SET tool_results = ${tx.json(newToolResults as never)}
|
||||
WHERE id = ${toolMessageId}
|
||||
`;
|
||||
// Same delete+insert dance as /answer — UNIQUE (message_id, sequence)
|
||||
// blocks plain UPDATE on append-style parts.
|
||||
// v1.13.20: parts-only. Same delete+insert dance as /answer —
|
||||
// UNIQUE (message_id, sequence) blocks plain UPDATE on append-style
|
||||
// parts.
|
||||
await tx`DELETE FROM message_parts WHERE message_id = ${toolMessageId} AND kind = 'tool_result'`;
|
||||
await tx`
|
||||
INSERT INTO message_parts (message_id, sequence, kind, payload)
|
||||
|
||||
@@ -86,12 +86,12 @@ export function registerSkillsRoutes(
|
||||
|
||||
const result = await sql.begin(async (tx) => {
|
||||
const [synthAssistant] = await tx<{ id: string }[]>`
|
||||
INSERT INTO messages (session_id, chat_id, role, content, tool_calls, status, created_at)
|
||||
VALUES (${sessionId}, ${chat.id}, 'assistant', '', ${sql.json(toolCalls as never)}, 'complete', clock_timestamp())
|
||||
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||
VALUES (${sessionId}, ${chat.id}, 'assistant', '', 'complete', clock_timestamp())
|
||||
RETURNING id
|
||||
`;
|
||||
// v1.13.0: dual-write the synthetic assistant message's tool_call.
|
||||
// Single skill_use tool_call, no text content, so one part at seq 0.
|
||||
// v1.13.20: parts-only write. Single skill_use tool_call, no text
|
||||
// content, so one part at seq 0.
|
||||
await tx`
|
||||
INSERT INTO message_parts (message_id, sequence, kind, payload)
|
||||
VALUES (${synthAssistant!.id}, 0, 'tool_call', ${tx.json({
|
||||
@@ -101,11 +101,11 @@ export function registerSkillsRoutes(
|
||||
} as never)})
|
||||
`;
|
||||
const [toolMsg] = await tx<{ id: string }[]>`
|
||||
INSERT INTO messages (session_id, chat_id, role, content, tool_results, status, created_at)
|
||||
VALUES (${sessionId}, ${chat.id}, 'tool', '', ${sql.json(toolResults as never)}, 'complete', clock_timestamp())
|
||||
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||
VALUES (${sessionId}, ${chat.id}, 'tool', '', 'complete', clock_timestamp())
|
||||
RETURNING id
|
||||
`;
|
||||
// v1.13.0: dual-write the synthetic tool result (the skill body).
|
||||
// v1.13.20: parts-only write of the synthetic tool result (skill body).
|
||||
await tx`
|
||||
INSERT INTO message_parts (message_id, sequence, kind, payload)
|
||||
VALUES (${toolMsg!.id}, 0, 'tool_result', ${tx.json(toolResults as never)})
|
||||
|
||||
@@ -97,49 +97,42 @@ END $$;
|
||||
|
||||
-- v1.13.1-B: read-path view. Read sites SELECT FROM messages_with_parts
|
||||
-- instead of messages so tool_calls / tool_results / reasoning_parts come
|
||||
-- from the granular message_parts table. The COALESCE means pre-v1.13.0
|
||||
-- history (no parts rows) still resolves via the legacy JSON columns; the
|
||||
-- dual-write from v1.13.0 keeps both in sync for all rows written since.
|
||||
-- Writes continue to target `messages` directly — the view is read-only.
|
||||
-- Shapes match the in-memory ToolCall / ToolResult types: tool_calls is a
|
||||
-- jsonb array of {id, name, args}, tool_results is a single jsonb object
|
||||
-- {tool_call_id, output, truncated, error?}. reasoning_parts is new — only
|
||||
-- consumed by the inference history fetch (payload.ts) so v1.13.1-C can
|
||||
-- wire reasoning into the model payload. Not surfaced in external APIs yet.
|
||||
-- from the granular message_parts table.
|
||||
-- v1.13.20: post column-drop. The legacy COALESCE fallback over
|
||||
-- messages.tool_calls / messages.tool_results was removed because those
|
||||
-- columns no longer exist on the table (see the ALTER TABLE DROP COLUMN
|
||||
-- statements below). Writes continue to target `messages` directly — the
|
||||
-- view is read-only. Shapes match the in-memory ToolCall / ToolResult
|
||||
-- types: tool_calls is a jsonb array of {id, name, args}, tool_results is
|
||||
-- a single jsonb object {tool_call_id, output, truncated, error?}.
|
||||
-- reasoning_parts is consumed by the inference history fetch (payload.ts)
|
||||
-- for v1.13.1-C reasoning round-tripping. Not surfaced in external APIs.
|
||||
CREATE OR REPLACE VIEW messages_with_parts AS
|
||||
SELECT
|
||||
m.id, m.session_id, m.chat_id, m.role, m.content, m.kind, m.status,
|
||||
m.last_seq, m.tokens_used, m.ctx_used, m.ctx_max,
|
||||
m.started_at, m.finished_at, m.created_at, m.metadata,
|
||||
m.summary, m.tail_start_id, m.compacted_at,
|
||||
-- v1.13.4: prune semantics need to distinguish "no parts row exists"
|
||||
-- (pre-v1.13.0 fallback to legacy column) from "all parts hidden"
|
||||
-- (prune intended — return null/empty so the row drops from the model
|
||||
-- payload). A naive COALESCE would fall back to the legacy column when
|
||||
-- every part is hidden, undoing the prune. CASE on EXISTS(any kind)
|
||||
-- splits the two cases.
|
||||
CASE
|
||||
WHEN EXISTS (SELECT 1 FROM message_parts pp
|
||||
WHERE pp.message_id = m.id AND pp.kind = 'tool_call')
|
||||
THEN (SELECT jsonb_agg(p.payload ORDER BY p.sequence)
|
||||
FROM message_parts p
|
||||
WHERE p.message_id = m.id AND p.kind = 'tool_call' AND p.hidden_at IS NULL)
|
||||
ELSE m.tool_calls
|
||||
END AS tool_calls,
|
||||
CASE
|
||||
WHEN EXISTS (SELECT 1 FROM message_parts pp
|
||||
WHERE pp.message_id = m.id AND pp.kind = 'tool_result')
|
||||
THEN (SELECT p.payload
|
||||
FROM message_parts p
|
||||
WHERE p.message_id = m.id AND p.kind = 'tool_result' AND p.hidden_at IS NULL
|
||||
ORDER BY p.sequence LIMIT 1)
|
||||
ELSE m.tool_results
|
||||
END AS tool_results,
|
||||
(SELECT jsonb_agg(p.payload ORDER BY p.sequence)
|
||||
FROM message_parts p
|
||||
WHERE p.message_id = m.id AND p.kind = 'tool_call' AND p.hidden_at IS NULL) AS tool_calls,
|
||||
(SELECT p.payload
|
||||
FROM message_parts p
|
||||
WHERE p.message_id = m.id AND p.kind = 'tool_result' AND p.hidden_at IS NULL
|
||||
ORDER BY p.sequence LIMIT 1) AS tool_results,
|
||||
(SELECT jsonb_agg(p.payload ORDER BY p.sequence)
|
||||
FROM message_parts p
|
||||
WHERE p.message_id = m.id AND p.kind = 'reasoning' AND p.hidden_at IS NULL) AS reasoning_parts
|
||||
FROM messages m;
|
||||
|
||||
-- v1.13.20: drop legacy tool_calls/tool_results columns. Reads have routed
|
||||
-- through messages_with_parts since v1.13.1-B; dual-writes removed in this
|
||||
-- batch. The view above was simplified to remove COALESCE fallbacks before
|
||||
-- this drop (Postgres rejects column-drop on view-referenced columns).
|
||||
-- Idempotent via IF EXISTS.
|
||||
ALTER TABLE messages DROP COLUMN IF EXISTS tool_calls;
|
||||
ALTER TABLE messages DROP COLUMN IF EXISTS tool_results;
|
||||
|
||||
-- v1.13.10: per-tool token cost rolling window. Derives from
|
||||
-- messages_with_parts (the v1.13.1-B view that COALESCEs message_parts over
|
||||
-- the legacy JSON column) so this works whether the chat predates v1.13.0
|
||||
@@ -290,19 +283,6 @@ BEGIN
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- v1.12.1: drop stale inline CHECK constraints that were superseded by the
|
||||
-- named *_chk variants above. messages_status_check missed 'cancelled' and
|
||||
-- messages_role_check missed 'system' — both narrower than what's in use.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'messages_status_check') THEN
|
||||
ALTER TABLE messages DROP CONSTRAINT messages_status_check;
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'messages_role_check') THEN
|
||||
ALTER TABLE messages DROP CONSTRAINT messages_role_check;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- v1.2-project-ux: projects.status + projects.gitea_remote
|
||||
-- KEEP IN SYNC: apps/server/src/types/api.ts PROJECT_STATUSES
|
||||
ALTER TABLE projects ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'open';
|
||||
|
||||
@@ -78,16 +78,18 @@ describeFn('tool_cost_stats view (v1.13.10)', () => {
|
||||
args: {},
|
||||
}));
|
||||
const created = opts.createdAt ?? new Date();
|
||||
// v1.13.20: parts-only. messages.tool_calls column was dropped; the
|
||||
// tool_cost_stats view reads through messages_with_parts which derives
|
||||
// tool_calls from message_parts rows.
|
||||
const rows = await sql<{ id: string }[]>`
|
||||
INSERT INTO messages (
|
||||
session_id, chat_id, role, content, kind, status,
|
||||
tool_calls, tokens_used, ctx_used,
|
||||
tokens_used, ctx_used,
|
||||
metadata, created_at
|
||||
)
|
||||
VALUES (
|
||||
${sessionId}, ${chatId}, 'assistant', '', 'message',
|
||||
${opts.status ?? 'complete'},
|
||||
${sql.json(toolCalls as never)},
|
||||
${opts.tokensUsed},
|
||||
${opts.ctxUsed},
|
||||
${opts.metadata ? sql.json(opts.metadata as never) : null},
|
||||
@@ -95,7 +97,14 @@ describeFn('tool_cost_stats view (v1.13.10)', () => {
|
||||
)
|
||||
RETURNING id
|
||||
`;
|
||||
return rows[0]!.id;
|
||||
const messageId = rows[0]!.id;
|
||||
for (let i = 0; i < toolCalls.length; i++) {
|
||||
await sql`
|
||||
INSERT INTO message_parts (message_id, sequence, kind, payload)
|
||||
VALUES (${messageId}, ${i}, 'tool_call', ${sql.json(toolCalls[i] as never)})
|
||||
`;
|
||||
}
|
||||
return messageId;
|
||||
}
|
||||
|
||||
it('returns empty when no tool calls exist for a tool name', async () => {
|
||||
@@ -197,18 +206,17 @@ describeFn('tool_cost_stats view (v1.13.10)', () => {
|
||||
|
||||
it('reads tool_calls via messages_with_parts (parts-authoritative)', async () => {
|
||||
const t = tname('parts');
|
||||
// Insert an assistant row with messages.tool_calls=NULL but a
|
||||
// message_parts row carrying the tool_call. The view reads via
|
||||
// messages_with_parts, which COALESCEs the parts table over the legacy
|
||||
// column — so this row should still aggregate.
|
||||
// v1.13.20: post-column-drop the only source for tool_calls is
|
||||
// message_parts. This test asserts the same path the view always took
|
||||
// (parts-derived), now that the legacy column COALESCE fallback is gone.
|
||||
const rows = await sql<{ id: string }[]>`
|
||||
INSERT INTO messages (
|
||||
session_id, chat_id, role, content, kind, status,
|
||||
tool_calls, tokens_used, ctx_used
|
||||
tokens_used, ctx_used
|
||||
)
|
||||
VALUES (
|
||||
${sessionId}, ${chatId}, 'assistant', '', 'message', 'complete',
|
||||
NULL, 200, 5000
|
||||
200, 5000
|
||||
)
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
@@ -110,7 +110,6 @@ export async function executeToolPhase(
|
||||
UPDATE messages
|
||||
SET content = ${content},
|
||||
status = 'complete',
|
||||
tool_calls = ${ctx.sql.json(toolCalls as never)},
|
||||
tokens_used = ${completionTokens},
|
||||
ctx_used = ${promptTokens},
|
||||
ctx_max = ${nCtx},
|
||||
@@ -118,15 +117,11 @@ export async function executeToolPhase(
|
||||
WHERE id = ${assistantMessageId}
|
||||
RETURNING tokens_used, ctx_used, ctx_max, finished_at
|
||||
`;
|
||||
// v1.13.0: dual-write to message_parts. v1.13.1-B made parts authoritative
|
||||
// for reads via the messages_with_parts view; the JSON column write above
|
||||
// remains for v1.13.1 fallback compatibility (dropped in v1.13.2).
|
||||
// v1.13.20: message_parts is the sole source of truth for tool_calls.
|
||||
// Legacy messages.tool_calls column was dropped; reads route through the
|
||||
// messages_with_parts view.
|
||||
// v1.13.1-C: include result.reasoning so models with separate reasoning
|
||||
// channels (qwen3.6) get a kind='reasoning' part at sequence 0.
|
||||
// TODO(v1.13.1): wrap the UPDATE above and this insertParts in a single
|
||||
// sql.begin before flipping read authority to message_parts. Without the
|
||||
// transaction, a crash between the two leaves an orphan message that
|
||||
// becomes invisible in the parts-authoritative read path.
|
||||
await insertParts(
|
||||
ctx.sql,
|
||||
partsFromAssistantMessage({
|
||||
@@ -192,16 +187,9 @@ export async function executeToolPhase(
|
||||
if (tc.name === 'ask_user_input') {
|
||||
pausingForUserInput = true;
|
||||
const sentinel = { tool_call_id: tc.id, output: null, truncated: false };
|
||||
await ctx.sql`
|
||||
UPDATE messages
|
||||
SET tool_results = ${ctx.sql.json(sentinel as never)}
|
||||
WHERE id = ${toolMessageId}
|
||||
`;
|
||||
// v1.13.0: mirror the pending sentinel into message_parts. The
|
||||
// answer-endpoint UPDATE later (messages.ts:576) will delete and
|
||||
// re-insert this part when the user submits their answer.
|
||||
// TODO(v1.13.1): wrap the INSERT + UPDATE + insertParts triple in
|
||||
// a per-iteration sql.begin before flipping read authority.
|
||||
// v1.13.20: parts-only. The answer-endpoint UPDATE later
|
||||
// (messages.ts) will delete and re-insert this part when the user
|
||||
// submits their answer.
|
||||
await insertParts(
|
||||
ctx.sql,
|
||||
partsFromToolMessage({ tool_results: sentinel }).map((p) => ({
|
||||
@@ -234,11 +222,7 @@ export async function executeToolPhase(
|
||||
output: `denied: ${resolution.reason}`,
|
||||
truncated: false,
|
||||
};
|
||||
await ctx.sql`
|
||||
UPDATE messages
|
||||
SET tool_results = ${ctx.sql.json(stored as never)}
|
||||
WHERE id = ${toolMessageId}
|
||||
`;
|
||||
// v1.13.20: parts-only write.
|
||||
await insertParts(
|
||||
ctx.sql,
|
||||
partsFromToolMessage({ tool_results: stored }).map((p) => ({
|
||||
@@ -261,11 +245,7 @@ export async function executeToolPhase(
|
||||
// (state may have changed in the meantime) so we don't stash it here.
|
||||
pausingForUserInput = true;
|
||||
const sentinel = { tool_call_id: tc.id, output: null, truncated: false };
|
||||
await ctx.sql`
|
||||
UPDATE messages
|
||||
SET tool_results = ${ctx.sql.json(sentinel as never)}
|
||||
WHERE id = ${toolMessageId}
|
||||
`;
|
||||
// v1.13.20: parts-only write.
|
||||
await insertParts(
|
||||
ctx.sql,
|
||||
partsFromToolMessage({ tool_results: sentinel }).map((p) => ({
|
||||
@@ -285,14 +265,7 @@ export async function executeToolPhase(
|
||||
truncated: tres.truncated,
|
||||
...(tres.error ? { error: tres.error } : {}),
|
||||
};
|
||||
await ctx.sql`
|
||||
UPDATE messages
|
||||
SET tool_results = ${ctx.sql.json(stored as never)}
|
||||
WHERE id = ${toolMessageId}
|
||||
`;
|
||||
// v1.13.0: dual-write the tool_result part.
|
||||
// TODO(v1.13.1): wrap the INSERT + UPDATE + insertParts triple in a
|
||||
// per-iteration sql.begin before flipping read authority.
|
||||
// v1.13.20: parts-only write. Reads route through messages_with_parts.
|
||||
await insertParts(
|
||||
ctx.sql,
|
||||
partsFromToolMessage({ tool_results: stored }).map((p) => ({
|
||||
|
||||
Reference in New Issue
Block a user