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>
245 lines
7 KiB
TypeScript
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)
|
|
})
|