Skip to content

Commit eecc713

Browse files
committed
chore: canary->live copy
1 parent cf5bdd8 commit eecc713

1 file changed

Lines changed: 294 additions & 0 deletions

File tree

scripts/copy-ama-session.mjs

Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,294 @@
1+
// One-off script: copy a single `ama_sessions` row -- with its full triage state -- from one
2+
// deployment's database into another's (canary -> live, for the AMA originally brought over from
3+
// legacy `ChatSift/AMA` by `import-legacy-ama.mjs`).
4+
//
5+
// This is NOT `import-legacy-ama.mjs`. That one reads the *legacy* schema and deliberately flattens
6+
// every question to 'PENDING_REVIEW', because legacy rows can't reconstruct question state. This one
7+
// reads the *current* schema on both ends, so it carries state faithfully instead: question states,
8+
// prepared answers, tags and their assignments, and merged-duplicate asker records all survive the
9+
// copy. Use this when the work done on the source deployment is the thing worth keeping.
10+
//
11+
// Like its sibling it writes SQL to stdout rather than connecting to a database -- node builtins
12+
// only, so it runs on a deploy host that can't resolve workspace packages (see the canary
13+
// `ERR_MODULE_NOT_FOUND` note in docs), and the write is reviewable before it happens.
14+
//
15+
// Usage:
16+
// # 0. Find the session id on the SOURCE host if you don't know it:
17+
// # ./compose exec -T postgres psql -U chatsift -d chatsift -c \
18+
// # "SELECT id, guild_id, title, ended, created_at FROM ama_sessions ORDER BY id"
19+
// #
20+
// # 1. On the SOURCE host, extract the rows (read-only):
21+
// ./compose exec -T postgres psql -U chatsift -d chatsift -At -c "
22+
// SELECT json_build_object(
23+
// 'session', row_to_json(s),
24+
// 'prompt', (SELECT row_to_json(p) FROM ama_prompt_data p WHERE p.ama_id = s.id),
25+
// 'questions', COALESCE((SELECT json_agg(q ORDER BY q.id)
26+
// FROM ama_questions q WHERE q.ama_id = s.id), '[]'::json),
27+
// 'askers', COALESCE((SELECT json_agg(k ORDER BY k.id)
28+
// FROM ama_question_askers k JOIN ama_questions q ON q.id = k.question_id
29+
// WHERE q.ama_id = s.id), '[]'::json),
30+
// 'tags', COALESCE((SELECT json_agg(t ORDER BY t.id)
31+
// FROM ama_question_tags t WHERE t.ama_id = s.id), '[]'::json),
32+
// 'assignments', COALESCE((SELECT json_agg(a)
33+
// FROM ama_question_tag_assignments a WHERE a.ama_id = s.id), '[]'::json)
34+
// ) FROM ama_sessions s WHERE s.id = <ID>" > ama-session-<ID>.json
35+
// #
36+
// # 2. Generate the SQL (no network calls, no database connection):
37+
// node scripts/copy-ama-session.mjs --file ./ama-session-<ID>.json > copy.sql
38+
// #
39+
// # 3. Read copy.sql, then apply it on the TARGET host:
40+
// ./compose exec -T postgres psql -U chatsift -d chatsift -v ON_ERROR_STOP=1 < copy.sql
41+
//
42+
// Flags:
43+
// --file <path> Required. The JSON produced by step 1.
44+
// --guild-id <id> Override the source guild_id (rehearse against a test guild first).
45+
// --open Import with `ended = false`. Default is `ended = true` (submissions closed),
46+
// regardless of the source row -- see the note on the Submit button below.
47+
// --keep-message-ids Carry `queue_message_id` / `answers_message_id` across verbatim instead of
48+
// nulling them. See the note below before using this.
49+
//
50+
// Timestamps are passed through as the source rendered them rather than round-tripped through a JS
51+
// `Date`, which would silently truncate microseconds.
52+
//
53+
// `share_token` is carried over rather than regenerated, so a `/ama-answers/:shareToken` link handed
54+
// out from the source deployment keeps working against the target's own frontend.
55+
//
56+
// Two things deliberately do NOT survive the copy:
57+
//
58+
// * **Discord message pointers.** `queue_message_id` and `answers_message_id` name messages posted
59+
// by the *source* deployment's bot -- a different Discord application. The target's bot can read
60+
// those messages but can never edit them, so carrying them over turns every path that edits a
61+
// question's live message (merging a duplicate into an already-sent question, refreshing an
62+
// embed) from "no message to update" into a hard 403. Nulling them degrades instead: attachment
63+
// recovery returns `[]` and the edit paths no-op, which is what `resolveCurrentQueueMessage`
64+
// already handles for a dashboard-only question. `--keep-message-ids` opts back in if both
65+
// deployments genuinely run the same bot application.
66+
// * **Submission state.** The session lands `ended = true` by default. The prompt message this AMA
67+
// was created from carries a Submit button owned by whichever bot posted it; the target's bot
68+
// won't answer it. If you want live submissions, pass `--open` AND repost the prompt from the
69+
// target's dashboard so the button belongs to the target's bot.
70+
71+
import { readFile } from 'node:fs/promises';
72+
import process from 'node:process';
73+
74+
function flagValue(name) {
75+
const index = process.argv.indexOf(name);
76+
return index === -1 ? null : (process.argv[index + 1] ?? null);
77+
}
78+
79+
const filePath = flagValue('--file');
80+
if (!filePath) {
81+
console.error('--file <path to the extracted JSON> is required');
82+
process.exit(1);
83+
}
84+
85+
const guildIdOverride = flagValue('--guild-id');
86+
const ended = !process.argv.includes('--open');
87+
const keepMessageIds = process.argv.includes('--keep-message-ids');
88+
89+
// Dollar-quote tag for the DO block. Asserted below to not appear in any emitted literal, since a
90+
// collision would terminate the block early and produce syntactically broken SQL.
91+
const DOLLAR_TAG = '$ama_copy$';
92+
93+
// Mirrors the `ama_question_state` enum. Validated rather than trusted so a value from a
94+
// hand-edited JSON file fails here, with a readable message, instead of inside psql.
95+
const QUESTION_STATES = new Set(['PENDING_REVIEW', 'APPROVED', 'DENIED', 'ASKED']);
96+
97+
const raw = JSON.parse(await readFile(filePath, 'utf8'));
98+
const { session, prompt, questions = [], askers = [], tags = [], assignments = [] } = raw;
99+
100+
if (!session?.id) {
101+
console.error(`${filePath} doesn't look like the expected { session, prompt, questions, ... } shape`);
102+
process.exit(1);
103+
}
104+
105+
if (!prompt?.prompt_message_id) {
106+
// `ama_prompt_data` is 1:1 and NOT NULL on both columns; a session without it can't be rebuilt.
107+
console.error(`${filePath} has no ama_prompt_data row -- refusing to emit an incomplete session`);
108+
process.exit(1);
109+
}
110+
111+
const guildId = guildIdOverride ?? session.guild_id;
112+
113+
/**
114+
* Renders a JS value as a SQL literal. Single quotes are doubled, and NUL bytes are stripped
115+
* (Postgres rejects them outright in text values). Assumes `standard_conforming_strings = on`, the
116+
* default since Postgres 9.1, so backslashes in question text stay literal rather than becoming
117+
* escape sequences.
118+
*/
119+
function lit(value) {
120+
if (value === null || value === undefined) {
121+
return 'NULL';
122+
}
123+
124+
return `'${String(value).replaceAll('\0', '').replaceAll("'", "''")}'`;
125+
}
126+
127+
/** Same, for a `TIMESTAMPTZ` column -- `NULL` stays unquoted and uncast. */
128+
function ts(value) {
129+
return value === null || value === undefined ? 'NULL' : `${lit(value)}::timestamptz`;
130+
}
131+
132+
/** Renders a JSON array of strings as a `TEXT[]` literal. */
133+
function textArray(values) {
134+
if (!values?.length) {
135+
return `'{}'::TEXT[]`;
136+
}
137+
138+
return `ARRAY[${values.map((value) => lit(value)).join(', ')}]::TEXT[]`;
139+
}
140+
141+
for (const question of questions) {
142+
if (!QUESTION_STATES.has(question.state)) {
143+
console.error(`Aborting: question #${question.id} has unknown state ${JSON.stringify(question.state)}`);
144+
process.exit(1);
145+
}
146+
}
147+
148+
// Reported at the end so an operator can tell, before applying, whether nulling the pointers is
149+
// actually throwing anything away in this particular copy.
150+
const droppedMessageIds = keepMessageIds
151+
? 0
152+
: questions.filter((question) => question.queue_message_id ?? question.answers_message_id).length;
153+
154+
const askersByQuestion = new Map();
155+
for (const asker of askers) {
156+
const list = askersByQuestion.get(asker.question_id) ?? [];
157+
list.push(asker);
158+
askersByQuestion.set(asker.question_id, list);
159+
}
160+
161+
const tagIdsByQuestion = new Map();
162+
for (const assignment of assignments) {
163+
const list = tagIdsByQuestion.get(assignment.question_id) ?? [];
164+
list.push(assignment.tag_id);
165+
tagIdsByQuestion.set(assignment.question_id, list);
166+
}
167+
168+
const lines = [];
169+
lines.push(
170+
`-- Generated by scripts/copy-ama-session.mjs on ${new Date().toISOString()}`,
171+
`-- Source: ama_sessions.id = ${session.id} ${JSON.stringify(session.title)} (guild ${session.guild_id})`,
172+
`-- Target guild: ${guildId}${guildIdOverride ? ' (overridden)' : ''}`,
173+
`-- ${questions.length} question(s), ${tags.length} tag(s), ${assignments.length} tag assignment(s), ` +
174+
`${askers.length} merged-asker row(s)`,
175+
`-- ended = ${ended}; message ids ${keepMessageIds ? 'carried over' : 'nulled'}`,
176+
'',
177+
'BEGIN;',
178+
'',
179+
`DO ${DOLLAR_TAG}`,
180+
'DECLARE',
181+
' v_ama_id INTEGER;',
182+
' v_tag_id INTEGER;',
183+
' v_qid INTEGER;',
184+
'BEGIN',
185+
// Maps source ids to the identity values the target assigns, so tag assignments can be rebuilt
186+
// without assuming the two databases hand out the same numbers. Dropped at COMMIT.
187+
' CREATE TEMP TABLE _ama_copy_tag_map (old_id INTEGER PRIMARY KEY, new_id INTEGER NOT NULL)',
188+
' ON COMMIT DROP;',
189+
'',
190+
' INSERT INTO ama_sessions (',
191+
' guild_id, queue_id, title, answers_channel_id, prompt_channel_id,',
192+
' allowed_question_uploads, ended, scheduled_close_at, prepared_answers_enabled,',
193+
' review_enabled, share_token, guest_ids, created_at',
194+
' ) VALUES (',
195+
` ${lit(guildId)}, ${lit(session.queue_id)}, ${lit(session.title)}, ${lit(session.answers_channel_id)}, ${lit(session.prompt_channel_id)},`,
196+
` ${session.allowed_question_uploads}, ${ended}, ${ts(session.scheduled_close_at)}, ${session.prepared_answers_enabled},`,
197+
` ${session.review_enabled}, ${lit(session.share_token)}, ${textArray(session.guest_ids)}, ${ts(session.created_at)}`,
198+
' ) RETURNING id INTO v_ama_id;',
199+
'',
200+
' INSERT INTO ama_prompt_data (ama_id, prompt_message_id, prompt_json_data)',
201+
` VALUES (v_ama_id, ${lit(prompt.prompt_message_id)}, ${lit(prompt.prompt_json_data)});`,
202+
'',
203+
);
204+
205+
for (const tag of tags) {
206+
lines.push(
207+
` -- source tag #${tag.id} ${JSON.stringify(tag.name)}`,
208+
' INSERT INTO ama_question_tags (ama_id, name, created_at)',
209+
` VALUES (v_ama_id, ${lit(tag.name)}, ${ts(tag.created_at)}) RETURNING id INTO v_tag_id;`,
210+
` INSERT INTO _ama_copy_tag_map (old_id, new_id) VALUES (${tag.id}, v_tag_id);`,
211+
'',
212+
);
213+
}
214+
215+
for (const question of questions) {
216+
const queueMessageId = keepMessageIds ? lit(question.queue_message_id) : 'NULL';
217+
const answersMessageId = keepMessageIds ? lit(question.answers_message_id) : 'NULL';
218+
219+
lines.push(
220+
` -- source question #${question.id} (${question.state})`,
221+
' INSERT INTO ama_questions (',
222+
' ama_id, author_id, state, content, queue_message_id, answers_message_id,',
223+
' answer_content, answer_image_url, answered_by_id, answered_at, created_at, updated_at',
224+
' ) VALUES (',
225+
` v_ama_id, ${lit(question.author_id)}, ${lit(question.state)}, ${lit(question.content)}, ${queueMessageId}, ${answersMessageId},`,
226+
` ${lit(question.answer_content)}, ${lit(question.answer_image_url)}, ${lit(question.answered_by_id)}, ${ts(question.answered_at)}, ${ts(question.created_at)}, ${ts(question.updated_at)}`,
227+
' ) RETURNING id INTO v_qid;',
228+
);
229+
230+
for (const asker of askersByQuestion.get(question.id) ?? []) {
231+
lines.push(
232+
' INSERT INTO ama_question_askers (question_id, author_id, content, merged_at)',
233+
` VALUES (v_qid, ${lit(asker.author_id)}, ${lit(asker.content)}, ${ts(asker.merged_at)});`,
234+
);
235+
}
236+
237+
for (const tagId of tagIdsByQuestion.get(question.id) ?? []) {
238+
// `ama_id` is denormalized here on purpose -- the composite FKs pin a tag assignment to the same
239+
// AMA as both its question and its tag, so it has to be passed explicitly. Selecting through the
240+
// map (rather than a bare VALUES) also means a dangling tag_id inserts zero rows instead of
241+
// violating the FK -- which can't happen given the extraction query scopes both to one session,
242+
// but keeps a hand-edited JSON file from producing a confusing constraint error.
243+
lines.push(
244+
' INSERT INTO ama_question_tag_assignments (question_id, tag_id, ama_id)',
245+
` SELECT v_qid, new_id, v_ama_id FROM _ama_copy_tag_map WHERE old_id = ${tagId};`,
246+
);
247+
}
248+
249+
lines.push('');
250+
}
251+
252+
lines.push(
253+
" RAISE NOTICE 'Copied in as ama_sessions.id = %', v_ama_id;",
254+
" RAISE NOTICE 'To undo: DELETE FROM ama_sessions WHERE id = %;', v_ama_id;",
255+
`END ${DOLLAR_TAG};`,
256+
'',
257+
'COMMIT;',
258+
'',
259+
);
260+
261+
const sql = lines.join('\n');
262+
263+
// A literal containing the dollar-quote tag would close the DO block early and produce syntactically
264+
// broken SQL, so refuse to emit rather than hand over something that half-parses.
265+
const bodyBetweenTags = sql.slice(sql.indexOf(DOLLAR_TAG) + DOLLAR_TAG.length, sql.lastIndexOf(DOLLAR_TAG));
266+
if (bodyBetweenTags.includes(DOLLAR_TAG)) {
267+
console.error(`Aborting: the data contains ${DOLLAR_TAG}, which would terminate the DO block early`);
268+
process.exit(1);
269+
}
270+
271+
process.stdout.write(sql);
272+
273+
const stateCounts = {};
274+
for (const question of questions) {
275+
stateCounts[question.state] = (stateCounts[question.state] ?? 0) + 1;
276+
}
277+
278+
console.error(
279+
`Generated SQL for ama_sessions #${session.id} -> guild ${guildId}: ${questions.length} question(s) ` +
280+
`(${Object.entries(stateCounts)
281+
.map(([state, count]) => `${count} ${state}`)
282+
.join(', ')}), ${tags.length} tag(s), ${assignments.length} assignment(s), ${askers.length} merged asker(s).`,
283+
);
284+
285+
if (session.ended !== ended) {
286+
console.error(`note: source had ended = ${session.ended}, emitting ended = ${ended}`);
287+
}
288+
289+
if (droppedMessageIds) {
290+
console.error(
291+
`note: nulled Discord message ids on ${droppedMessageIds} question(s) -- they pointed at messages ` +
292+
`posted by the source deployment's bot. Pass --keep-message-ids only if both run the same bot application.`,
293+
);
294+
}

0 commit comments

Comments
 (0)