stammtisch-hersbruck/frontend/scripts/migrate-schema.ts
Daniel Michelberger 1061a8b9ad chore: Repo-Struktur mit frontend/ und backend/ aufsetzen
Die SvelteKit-App liegt unter frontend/, das Backend unter backend/ als
eigenständige PocketBase-Instanz mit Dockerfile, docker-compose und
versioniertem Schema.

- PocketBase-URL über PUBLIC_PB_URL konfigurierbar, Default bleibt die
  produktive Instanz https://api.stammtisch-hersbruck.de
- Schema als Snapshot-Migration der sechs fachlichen Collections
  (users, teams, events, runs, riders, times), abgezogen von der
  produktiven Instanz. Verifiziert: ein Erststart gegen leere pb_data
  legt alle sechs an, Felder und API-Rules stimmen überein.
- pb_data und pb_migrations als Bind-Mounts, damit Daten persistieren und
  im Admin-UI erzeugte Migrationen im Repo landen
- Veraltete Dokumentation entfernt: pocketbase_schema.json nannte
  Collections (stages, results, organizers), die es nicht gibt

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 13:16:43 +02:00

245 lines
7 KiB
TypeScript

/**
* Schema-Migration für PocketBase
*
* Erweitert das vorhandene Schema und migriert bestehende Daten:
* - events: + name (Text), + status (Select draft/active/finished)
* Daten: description → name kopieren, status = 'draft'
* - riders: + number (Text), + firstname (Text), + lastname (Text)
* Daten: bestehender name-Wert → number kopieren
* - times: + correction (Number, Sekunden)
* status erweitern: + dnf, dns, dsq
*
* Aufruf: npx tsx scripts/migrate-schema.ts
*/
import { readFileSync } from 'node:fs'
// .env minimal selbst parsen
for (const line of readFileSync('.env', 'utf8').split('\n')) {
const m = line.match(/^([A-Z_]+)=(.*)$/)
if (m && !process.env[m[1]]) process.env[m[1]] = m[2]
}
const API = process.env.PB_TYPEGEN_URL?.replace(/\/$/, '') ?? 'https://api.stammtisch-hersbruck.de'
const TOKEN = process.env.PB_SUPERUSER_TOKEN
if (!TOKEN) {
console.error('PB_SUPERUSER_TOKEN fehlt in .env')
process.exit(1)
}
const headers = {
'Authorization': TOKEN,
'Content-Type': 'application/json',
}
async function req(method: string, path: string, body?: unknown) {
const res = await fetch(`${API}${path}`, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
})
if (!res.ok) {
const text = await res.text()
throw new Error(`${method} ${path}${res.status}: ${text}`)
}
return res.status === 204 ? null : await res.json()
}
async function getCollection(name: string) {
return req('GET', `/api/collections/${name}`)
}
async function updateCollection(name: string, body: any) {
return req('PATCH', `/api/collections/${name}`, body)
}
async function listRecords(collection: string) {
const all: any[] = []
let page = 1
while (true) {
const res: any = await req('GET', `/api/collections/${collection}/records?page=${page}&perPage=200`)
all.push(...res.items)
if (page >= res.totalPages || res.totalPages === 0) break
page++
}
return all
}
async function updateRecord(collection: string, id: string, data: any) {
return req('PATCH', `/api/collections/${collection}/records/${id}`, data)
}
function hasField(coll: any, name: string) {
return coll.fields.some((f: any) => f.name === name)
}
// ---- Migration ----
async function migrateEvents() {
console.log('\n== events ==')
const coll = await getCollection('events')
const fields = [...coll.fields]
if (!hasField(coll, 'name')) {
fields.push({
name: 'name',
type: 'text',
required: false,
presentable: true,
max: 0,
min: 0,
pattern: '',
autogeneratePattern: '',
})
console.log(' + Feld "name" (text)')
} else {
console.log(' = Feld "name" existiert bereits')
}
if (!hasField(coll, 'status')) {
fields.push({
name: 'status',
type: 'select',
required: false,
presentable: false,
maxSelect: 1,
values: ['draft', 'active', 'finished'],
})
console.log(' + Feld "status" (select draft/active/finished)')
} else {
console.log(' = Feld "status" existiert bereits')
}
await updateCollection('events', { fields })
// Daten migrieren
const records = await listRecords('events')
for (const r of records) {
const patch: any = {}
if (!r.name && r.description) patch.name = r.description
if (!r.status) patch.status = 'draft'
if (Object.keys(patch).length) {
await updateRecord('events', r.id, patch)
console.log(`${r.id}: ${JSON.stringify(patch)}`)
}
}
}
async function migrateRiders() {
console.log('\n== riders ==')
const coll = await getCollection('riders')
const fields = [...coll.fields]
if (!hasField(coll, 'number')) {
fields.push({
name: 'number',
type: 'text',
required: false,
presentable: true,
max: 0,
min: 0,
pattern: '',
autogeneratePattern: '',
})
console.log(' + Feld "number" (text)')
} else {
console.log(' = Feld "number" existiert bereits')
}
if (!hasField(coll, 'firstname')) {
fields.push({
name: 'firstname',
type: 'text',
required: false,
presentable: true,
max: 0,
min: 0,
pattern: '',
autogeneratePattern: '',
})
console.log(' + Feld "firstname" (text)')
} else {
console.log(' = Feld "firstname" existiert bereits')
}
if (!hasField(coll, 'lastname')) {
fields.push({
name: 'lastname',
type: 'text',
required: false,
presentable: true,
max: 0,
min: 0,
pattern: '',
autogeneratePattern: '',
})
console.log(' + Feld "lastname" (text)')
} else {
console.log(' = Feld "lastname" existiert bereits')
}
await updateCollection('riders', { fields })
// Daten migrieren: name → number, falls number leer
const records = await listRecords('riders')
for (const r of records) {
if (!r.number && r.name) {
await updateRecord('riders', r.id, { number: r.name })
console.log(`${r.id}: number = "${r.name}"`)
}
}
}
async function migrateTimes() {
console.log('\n== times ==')
const coll = await getCollection('times')
const fields = [...coll.fields]
let changed = false
if (!hasField(coll, 'correction')) {
fields.push({
name: 'correction',
type: 'number',
required: false,
presentable: false,
min: null,
max: null,
onlyInt: false,
})
console.log(' + Feld "correction" (number)')
changed = true
} else {
console.log(' = Feld "correction" existiert bereits')
}
// status um dnf, dns, dsq erweitern
const statusField = fields.find((f: any) => f.name === 'status')
if (statusField) {
const current: string[] = statusField.values || []
const target = ['active', 'finished', 'dnf', 'dns', 'dsq']
const missing = target.filter((v) => !current.includes(v))
if (missing.length) {
statusField.values = [...current, ...missing]
console.log(` ~ Feld "status" erweitert um: ${missing.join(', ')}`)
changed = true
} else {
console.log(' = Feld "status" hat bereits alle Werte')
}
}
if (changed) await updateCollection('times', { fields })
}
async function main() {
console.log(`Migration gegen ${API}`)
await migrateEvents()
await migrateRiders()
await migrateTimes()
console.log('\n✓ Migration fertig')
}
main().catch((e) => {
console.error('\n✗ Fehler:', e.message)
process.exit(1)
})