ff864ad3db
Phase 2b. The packs become the source of truth; the table stays the query surface, so the existing read path (one RLS query returning system + company + team templates) and every template id are untouched. pack_slug is the stable upsert key (migration 20260803230000, backfilled onto all 26 seeded rows). Matching on name instead would have meant that correcting a Swedish label looks like a new template, and because booking_template_usage.template_id is ON DELETE CASCADE, retiring the old row would silently wipe every company's "recently used" history for it. For the same reason a removed pack DEACTIVATES its row rather than deleting it: the read path already filters is_active, so it leaves the picker while usage history survives. The sync fails closed. A pack that will not parse or validate aborts the whole run with zero writes, because a database left in a state no commit of the repo describes is worse than a stale one. An empty catalogue is treated as a broken deploy (packs/ not bundled) rather than an instruction to retire every system template. Runs as a daily cron rather than at boot: boot-time work would have every serverless instance racing to write the same rows, and would re-apply a bad catalogue continuously instead of once a day where it is visible. Idempotent, so a database already matching the packs performs zero writes. Three database guards, each covered by tests/pg/booking-template-pack-slug.pg.test.ts: a partial unique index (one pack, one template), a format CHECK mirroring PACK_SLUG_RE so the database refuses what the loader would, and a CHECK keeping pack_slug off company templates, where it would shadow the pack it collides with. Verified the upgrade path locally the way the pg-upgrade job will run it: base schema, seeded fixture company with posted verifikat, an existing company template, then this migration alone. 26 slugs backfilled, company template intact, upgrade assertions pass. This is that job's first real migration. Payloads are spelled out rather than spread so the phantom-column guard can check them, and docker/crontab.* are regenerated for the new vercel.json entry. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
189 lines
6.1 KiB
TypeScript
189 lines
6.1 KiB
TypeScript
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import { createLogger } from '@/lib/logger'
|
|
import { loadPacks, packToLibraryRow, type LoadedPack } from './load'
|
|
|
|
const log = createLogger('packs-sync')
|
|
|
|
/**
|
|
* Reconcile the system booking templates in the database with `packs/*.yaml`.
|
|
*
|
|
* The packs are the source of truth; the table is the query surface. Keeping
|
|
* the rows means the existing read path (one query returning system + company +
|
|
* team templates under RLS) is untouched, and every template id stays stable.
|
|
*
|
|
* ## Why upsert on pack_slug, not name
|
|
*
|
|
* `booking_template_usage.template_id` is `ON DELETE CASCADE`. Matching on name
|
|
* would mean a corrected Swedish label looks like a new template, and the old
|
|
* row's deletion would silently wipe every company's "recently used" history
|
|
* for it. `pack_slug` is the stable identity (migration 20260803230000).
|
|
*
|
|
* ## Why retire instead of delete
|
|
*
|
|
* Same cascade. A pack removed from the catalogue deactivates its row rather
|
|
* than dropping it: the read path already filters `is_active`, so it disappears
|
|
* from the picker, but usage history and any audit trail survive. Deletion is
|
|
* never automatic here.
|
|
*
|
|
* ## Fail closed on a broken catalogue
|
|
*
|
|
* If any pack fails to parse or validate, nothing is written at all. A partial
|
|
* sync driven by a half-readable catalogue is worse than a stale one: the
|
|
* database would end up in a state no commit of the repo describes.
|
|
*/
|
|
|
|
export interface PackSyncResult {
|
|
/** Slugs inserted as new system templates. */
|
|
inserted: string[]
|
|
/** Slugs whose row content changed. */
|
|
updated: string[]
|
|
/** Slugs already in sync. */
|
|
unchanged: string[]
|
|
/** Slugs deactivated because their pack is gone. */
|
|
retired: string[]
|
|
/** Load/validation errors. Non-empty means nothing was written. */
|
|
errors: string[]
|
|
/** True when no write was attempted. */
|
|
dryRun: boolean
|
|
}
|
|
|
|
interface ExistingRow {
|
|
id: string
|
|
pack_slug: string | null
|
|
name: string
|
|
description: string
|
|
category: string
|
|
entity_type: string
|
|
lines: unknown
|
|
is_active: boolean
|
|
}
|
|
|
|
/** Value-compare a pack against the row it maps to. */
|
|
function rowMatchesPack(row: ExistingRow, pack: LoadedPack['pack']): boolean {
|
|
const desired = packToLibraryRow(pack)
|
|
return (
|
|
row.is_active === true &&
|
|
row.name === desired.name &&
|
|
row.description === desired.description &&
|
|
row.category === desired.category &&
|
|
row.entity_type === desired.entity_type &&
|
|
// jsonb round-trips as parsed JSON; compare by value, not key order.
|
|
JSON.stringify(normaliseLines(row.lines)) === JSON.stringify(normaliseLines(desired.lines))
|
|
)
|
|
}
|
|
|
|
function normaliseLines(lines: unknown): Array<Record<string, unknown>> {
|
|
if (!Array.isArray(lines)) return []
|
|
return lines.map((l) =>
|
|
Object.fromEntries(
|
|
Object.entries(l as Record<string, unknown>).sort(([a], [b]) => a.localeCompare(b)),
|
|
),
|
|
)
|
|
}
|
|
|
|
export async function syncSystemPacks(
|
|
supabase: SupabaseClient,
|
|
opts: { dryRun?: boolean; root?: string } = {},
|
|
): Promise<PackSyncResult> {
|
|
const dryRun = opts.dryRun ?? false
|
|
const result: PackSyncResult = {
|
|
inserted: [], updated: [], unchanged: [], retired: [], errors: [], dryRun,
|
|
}
|
|
|
|
const { packs, errors } = loadPacks(opts.root)
|
|
if (errors.length) {
|
|
result.errors = errors.map((e) => `${e.file}: ${e.message}`)
|
|
log.error('pack catalogue invalid, refusing to sync', { errorCount: errors.length })
|
|
return result
|
|
}
|
|
if (packs.length === 0) {
|
|
// An empty catalogue would otherwise retire every system template. That is
|
|
// far more likely to be a broken deploy (packs/ not bundled) than intent.
|
|
result.errors.push('no packs found: refusing to retire every system template')
|
|
return result
|
|
}
|
|
|
|
const { data, error } = await supabase
|
|
.from('booking_template_library')
|
|
.select('id, pack_slug, name, description, category, entity_type, lines, is_active')
|
|
.eq('is_system', true)
|
|
|
|
if (error) throw error
|
|
|
|
const existing = new Map<string, ExistingRow>()
|
|
for (const row of (data ?? []) as ExistingRow[]) {
|
|
if (row.pack_slug) existing.set(row.pack_slug, row)
|
|
}
|
|
|
|
for (const p of packs) {
|
|
const slug = p.pack.meta.slug
|
|
const row = existing.get(slug)
|
|
const desired = packToLibraryRow(p.pack)
|
|
|
|
if (!row) {
|
|
result.inserted.push(slug)
|
|
if (!dryRun) {
|
|
// Columns spelled out rather than spread: the phantom-column guard
|
|
// (tests/schema/no-phantom-columns.test.ts) cannot verify a runtime-built
|
|
// payload, and a typo'd column here would fail only in production.
|
|
const { error: insErr } = await supabase.from('booking_template_library').insert({
|
|
name: desired.name,
|
|
description: desired.description,
|
|
category: desired.category,
|
|
entity_type: desired.entity_type,
|
|
is_system: true,
|
|
lines: desired.lines,
|
|
pack_slug: slug,
|
|
is_active: true,
|
|
})
|
|
if (insErr) throw insErr
|
|
}
|
|
continue
|
|
}
|
|
|
|
if (rowMatchesPack(row, p.pack)) {
|
|
result.unchanged.push(slug)
|
|
continue
|
|
}
|
|
|
|
result.updated.push(slug)
|
|
if (!dryRun) {
|
|
const { error: updErr } = await supabase
|
|
.from('booking_template_library')
|
|
.update({
|
|
name: desired.name,
|
|
description: desired.description,
|
|
category: desired.category,
|
|
entity_type: desired.entity_type,
|
|
lines: desired.lines,
|
|
is_active: true,
|
|
})
|
|
.eq('id', row.id)
|
|
if (updErr) throw updErr
|
|
}
|
|
}
|
|
|
|
const packSlugs = new Set(packs.map((p) => p.pack.meta.slug))
|
|
for (const [slug, row] of existing) {
|
|
if (packSlugs.has(slug) || !row.is_active) continue
|
|
result.retired.push(slug)
|
|
if (!dryRun) {
|
|
const { error: retErr } = await supabase
|
|
.from('booking_template_library')
|
|
.update({ is_active: false })
|
|
.eq('id', row.id)
|
|
if (retErr) throw retErr
|
|
}
|
|
}
|
|
|
|
log.info('pack sync complete', {
|
|
dryRun,
|
|
inserted: result.inserted.length,
|
|
updated: result.updated.length,
|
|
unchanged: result.unchanged.length,
|
|
retired: result.retired.length,
|
|
})
|
|
|
|
return result
|
|
}
|