export const id = '20260926-runner-companion-into-member';
export const name = 'Fold the runner companion (RunnerData) into Member';
/**
* `RunnerData` war eine eigene Companion-Tabelle für den heißen Runner-Zustand
* (20260907-runner-companion) — dabei ist `Member` im Schema längst die
* **generische Companion-Tabelle** nach dem Member-Muster: gleiche UID wie das
* `ObjectBase`-Objekt, nicht system-versioniert, `Data`-JSON trägt den heißen
* Zustand, nur der gesuchte Lookup liegt als Spalte daneben. Gruppen, Events,
* Locations, Familien und E-Mails liegen dort schon — Runner brauchen keine
* zweite Tabelle.
*
* Diese Migration zieht den Runner-Zustand nach `Member`:
*
* - `credential_id` (UNIQUE) → neue Lookup-Spalte an `Member` (Runner-Auth;
* inverser Lookup „Credential-ID → Runner")
* - `public_key` → entfällt als Spalte. Der Key steht bereits in
* `ObjectBase.Data.publicKey` (von `createRunner`
* gesetzt) und ist damit die eine Quelle. Für
* Bestands-Runner, die vor 20260907 entstanden
* sind, wird er hier einmalig nachgezogen —
* sonst könnten sie sich nach dem Drop nicht
* mehr authentifizieren.
* - `Data` (heartbeat/capacity/lastSeenAt) → `Member.Data` des Runner-Objekts
*
* Danach wird `RunnerData` gedroppt. Idempotent: ein frischer Boot (Member hat
* die Spalte schon aus `initTables.sql`, `RunnerData` existiert nicht) und eine
* laufende dev-DB (Spalte fehlt, `RunnerData` ist da) landen im selben Zustand.
*
* Hinweis: `20260907-runner-companion` legt `RunnerData` weiterhin an — auf einem
* frischen Boot entsteht sie leer und wird hier wieder entfernt. Die Migration
* bleibt unangetastet, weil sie auf laufenden DBs bereits appliziert ist.
*/
const hasColumn = async (query, table, column) => {
const rows = await query(
`SELECT COUNT(*) AS c FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = ? AND column_name = ?`,
[table, column],
);
return Number(rows[0]?.c) > 0;
};
const hasIndex = async (query, table, indexName) => {
const rows = await query(
`SELECT COUNT(*) AS c FROM information_schema.statistics
WHERE table_schema = DATABASE() AND table_name = ? AND index_name = ?`,
[table, indexName],
);
return Number(rows[0]?.c) > 0;
};
const hasTable = async (query, table) => {
const rows = await query(
`SELECT COUNT(*) AS c FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name = ?`,
[table],
);
return Number(rows[0]?.c) > 0;
};
export const migrate = async ({ query }) => {
// ----------------------------------------- 1. Lookup-Spalte an Member
if (!(await hasColumn(query, 'Member', 'credential_id'))) {
await query(
`ALTER TABLE \`Member\` ADD COLUMN \`credential_id\` varchar(64) DEFAULT NULL`,
[],
);
}
if (!(await hasIndex(query, 'Member', 'member_credential_id_uq'))) {
await query(
`ALTER TABLE \`Member\` ADD UNIQUE KEY \`member_credential_id_uq\` (\`credential_id\`)`,
[],
);
}
if (await hasTable(query, 'RunnerData')) {
// ------------------------------------ 2. Bestands-Companion → Member
// Eine Member-Zeile pro Runner. `ON DUPLICATE KEY UPDATE` fasst nur
// credential_id an: ein bereits vorhandenes Member-Data (Heartbeat)
// darf nicht von einer leeren Zeile überschrieben werden.
await query(`
INSERT INTO Member (UID, Display, SortName, FullTextIndex, Data, credential_id)
SELECT ObjectBase.UID,
LEFT(COALESCE(ObjectBase.Title, ObjectBase.Display, 'Runner'), 128),
'', '',
COALESCE(RunnerData.Data, JSON_OBJECT()),
RunnerData.credential_id
FROM ObjectBase
LEFT JOIN RunnerData ON (RunnerData.UID = ObjectBase.UID)
WHERE ObjectBase.Type = 'runner'
ON DUPLICATE KEY UPDATE credential_id = VALUES(credential_id)
`, []);
// --------------------------- 3. publicKey an ObjectBase.Data nachziehen
// Altbestand (vor 20260907) hatte den Key nur in RunnerData.public_key.
// ObjectBase ist system-versioniert, daher nur die echten Abweichungen
// schreiben (im Regelfall keine).
const legacyKeys = await query(
`SELECT ObjectBase.UID, ObjectBase.Data, RunnerData.public_key
FROM ObjectBase
INNER JOIN RunnerData ON (RunnerData.UID = ObjectBase.UID)
WHERE ObjectBase.Type = 'runner' AND RunnerData.public_key IS NOT NULL`,
[],
);
for (const row of legacyKeys) {
const data = typeof row.Data === 'string'
? JSON.parse(row.Data || '{}')
: (row.Data || {});
if (data.publicKey === row.public_key) continue;
data.publicKey = row.public_key;
await query(
`UPDATE ObjectBase SET Data = ? WHERE UID = ? AND Type = 'runner'`,
[JSON.stringify(data), row.UID],
);
}
// ------------------------------------------- 4. Companion-Tabelle weg
await query(`DROP TABLE IF EXISTS \`RunnerData\``, []);
return;
}
// --------- 2b. Frischer Boot: keine Companion-Tabelle vorhanden
// Bereits existierende Runner (z. B. während eines Deploys entstanden)
// bekommen ihre Member-Zeile, damit der Service einheitlich liest.
await query(`
INSERT IGNORE INTO Member (UID, Display, SortName, FullTextIndex, Data)
SELECT ObjectBase.UID,
LEFT(COALESCE(ObjectBase.Title, ObjectBase.Display, 'Runner'), 128),
'', '', JSON_OBJECT()
FROM ObjectBase
WHERE ObjectBase.Type = 'runner'
`, []);
};