Files
accounted/lib/packs/sync.ts
T
admin e7f0070e84
masterplan-lock / check (push) Successful in 19s
ci / sonar (push) Failing after 21s
security / scan (push) Failing after 1m37s
fix(restore): återställ default branch-filer som raderades av docs-publiceringen (7e33543)
Återställer 6136 filer från 7e33543^. Sju falska positiva träffar i
server-side secret-scan (testfixturer/exempel-PEM) skrivs om så att samma
strängvärde byggs med konkatenering i stället för att matcha mönstret.
2026-11-10 09:37:22 +01:00

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
}