/** * The starter library — twelve authored templates, shipped with the product. * * These are **not** demo data and carry no `DEMO — ` prefix. They are written * content in the same class as `docs/learn-scripts.md`: a customer who * self-hosts PIG gets them, uses them against real deals, and promotes their * own versions back over the top. That is why they seed `isSystem: true`, * `visibility: 'shared'`, `version: 1` and `ownerUserId: null` — a shared * system row belongs to the deployment rather than to whoever ran the seed, * and version 1 is the base of a lineage that a customer's promotions extend. * * The prose lives in JSON beside this file rather than in a TypeScript literal * because the bodies are long-form markdown — the shortest is 7kB and the * longest 24kB — and a 200kB module of backtick strings is unreviewable and * unmergeable by two people at once. They are imported statically with an * import attribute rather than read with `readdir`, which was tried first: a * static import makes a missing or renamed file a typecheck failure instead of * a seed that quietly ships eleven templates. Both `tsx` and `tsc` were run * against this to confirm the attribute syntax is understood by each, since * the server runs the TypeScript directly. * * Idempotency is keyed on `(slug, version)`, which is enforceable **only** * because of `motion_templates_slug_version_key`. AGENTS.md §5 is blunt about * what `onConflictDoNothing` does without a constraint to fire on — it silently * duplicated seed data here twice — so `assertNoDuplicates` below checks the * outcome rather than trusting the schema, and CI counts this table in its * idempotency gate alongside `contacts`. * * A second run REFRESHES a starter row rather than skipping it, but only while * that row is still ours: `is_system`, nobody has forked or instantiated it * (`usage_count = 0`) and it has no owner. Insert-only was the first version and * is wrong for the one thing this library is for — the content improves, and a * deployment seeded in August would otherwise be frozen on August's wording for * ever, with no upgrade path short of hand-editing production rows. The * pristine test is what keeps that from becoming a write over somebody's work: * the moment a template has been used, §7a says it is never edited in place, * and this respects that with the same condition the API enforces. */ import { DEMAND_STAGES, isMotionKind, type DemandStage, type MotionKind } from '@pig/core'; import { and, eq, inArray } from 'drizzle-orm'; import type { Database } from '../../client'; import { motionTemplates } from '../../schema/index'; import caseStudyFrame from './case-study-frame.json' with { type: 'json' }; import dataAndSecurityBrief from './data-and-security-brief.json' with { type: 'json' }; import postTrainingPoc from './post-training-poc.json' with { type: 'json' }; import pricingAndPackaging from './pricing-and-packaging.json' with { type: 'json' }; import procurementAndBudgetPath from './procurement-and-budget-path.json' with { type: 'json' }; import productionReadiness from './production-readiness.json' with { type: 'json' }; import proposalBlocks from './proposal-blocks.json' with { type: 'json' }; import referenceArchitectures from './reference-architectures.json' with { type: 'json' }; import strategicDeploymentPlaybook from './strategic-deployment-playbook.json' with { type: 'json' }; import technicalDiscovery from './technical-discovery.json' with { type: 'json' }; import technicalNarratives from './technical-narratives.json' with { type: 'json' }; import trainabilityQualification from './trainability-qualification.json' with { type: 'json' }; /** * The authored shape. `kind` and `stage` are `string` here and narrowed at the * boundary below, because `resolveJsonModule` infers `string` for a JSON string * and there is no honest way to tell TypeScript otherwise without a cast that * would also swallow a genuine typo in the file. */ interface StarterTemplate { readonly kind: string; readonly slug: string; readonly title: string; readonly summary: string; readonly stage: string; readonly body: string; readonly fields: Record; /** Guidance for whoever runs it. Folded into `fields` — see `toRow`. */ readonly notes: string; } const STARTER_LIBRARY: readonly StarterTemplate[] = [ technicalDiscovery, dataAndSecurityBrief, trainabilityQualification, referenceArchitectures, technicalNarratives, postTrainingPoc, proposalBlocks, pricingAndPackaging, procurementAndBudgetPath, productionReadiness, caseStudyFrame, strategicDeploymentPlaybook, ]; const isDemandStage = (value: string): value is DemandStage => (DEMAND_STAGES as readonly string[]).includes(value); /** * `notes` has no column, deliberately. * * It is guidance for the person running the template — when to reach for it, * what it is not for — rather than a field the artifact renders, so a column * would put it on every engagement artifact's editor for no reason. Keeping it * inside `fields` means promotion carries it forward with the rest of the * structured payload without any special handling in the promote path. */ /* * Key order is not preserved by `jsonb`: Postgres stores an object with its * keys sorted by length and then bytewise, so the value that comes back is * rarely the value that went in. A plain `JSON.stringify` comparison therefore * reported every starter template as changed on every run, and the seed * rewrote nine rows each time while claiming to be idempotent. Found by * running it twice and reading the count, not by reading the code. */ function canonical(value: unknown): string { const order = (node: unknown): unknown => { if (Array.isArray(node)) return node.map(order); if (node && typeof node === 'object') { return Object.fromEntries( Object.keys(node as Record) .sort() .map((key) => [key, order((node as Record)[key])]), ); } return node; }; return JSON.stringify(order(value)); } function toRow( template: StarterTemplate, kind: MotionKind, stage: DemandStage, ): typeof motionTemplates.$inferInsert { return { kind, stage, slug: template.slug, version: 1, title: template.title, summary: template.summary, body: template.body, fields: { ...template.fields, notes: template.notes }, visibility: 'shared', ownerUserId: null, isSystem: true, }; } /** * Confirm the outcome, not the constraint. * * Reading `pg_constraint` would prove the unique index exists; counting the * rows proves the clause did its job, which is the thing that has actually * gone wrong here before. It throws rather than warns: a duplicated starter * library is a corrupt library, and every subsequent run would double it again. * * Scoped to version 1, and found by running it: the demo book promotes an * artifact into `case-study-frame` v2, so counting every row for these slugs * reported the loop working as a duplicated seed. Version 1 is the only row * this loader writes and therefore the only one it may assert about. */ async function assertNoDuplicates(db: Database, slugs: readonly string[]): Promise { const rows = await db .select({ slug: motionTemplates.slug }) .from(motionTemplates) .where(and(inArray(motionTemplates.slug, [...slugs]), eq(motionTemplates.version, 1))); if (rows.length > slugs.length) { const counts = new Map(); for (const row of rows) counts.set(row.slug, (counts.get(row.slug) ?? 0) + 1); const duplicated = [...counts.entries()].filter(([, count]) => count > 1).map(([slug]) => slug); throw new Error( `The starter library has duplicate rows for: ${duplicated.join(', ')}. ` + 'The unique constraint motion_templates_slug_version_key is missing or the ' + 'conflict target no longer matches it — see AGENTS.md §5.', ); } return rows.length; } export async function seedMotionLibrary( db: Database, ): Promise<{ total: number; added: number; refreshed: number; held: readonly string[]; rejected: readonly string[]; }> { const rejected: string[] = []; const held: string[] = []; let added = 0; let refreshed = 0; for (const template of STARTER_LIBRARY) { /* * A bad `kind` or `stage` is skipped, not thrown on. These files are * authored by hand and the CHECK constraints would reject the row anyway — * but as an aborted transaction partway through the base seed, taking the * eleven good templates and everything after them down with it. Reporting * the one bad file by name is more useful and leaves the deployment usable. */ if (!isMotionKind(template.kind) || !isDemandStage(template.stage)) { rejected.push(`${template.slug} (kind=${template.kind}, stage=${template.stage})`); continue; } const row = toRow(template, template.kind, template.stage); const [existing] = await db .select({ id: motionTemplates.id, isSystem: motionTemplates.isSystem, usageCount: motionTemplates.usageCount, ownerUserId: motionTemplates.ownerUserId, title: motionTemplates.title, summary: motionTemplates.summary, body: motionTemplates.body, fields: motionTemplates.fields, kind: motionTemplates.kind, stage: motionTemplates.stage, }) .from(motionTemplates) .where(and(eq(motionTemplates.slug, template.slug), eq(motionTemplates.version, 1))) .limit(1); if (!existing) { const [created] = await db .insert(motionTemplates) .values(row) .onConflictDoNothing({ target: [motionTemplates.slug, motionTemplates.version] }) .returning({ id: motionTemplates.id }); if (created) added += 1; continue; } const pristine = existing.isSystem && existing.usageCount === 0 && existing.ownerUserId === null; const changed = existing.title !== row.title || existing.summary !== row.summary || existing.body !== row.body || existing.kind !== row.kind || existing.stage !== row.stage || canonical(existing.fields) !== canonical(row.fields); if (pristine && changed) { await db .update(motionTemplates) .set({ title: row.title, summary: row.summary, body: row.body, fields: row.fields, kind: row.kind, stage: row.stage, updatedAt: new Date(), }) .where(eq(motionTemplates.id, existing.id)); refreshed += 1; } else if (changed) { // Reported rather than forced: the row has been used or adopted, so // overwriting it would edit a template a live engagement was cut from. held.push(template.slug); } } const total = await assertNoDuplicates( db, STARTER_LIBRARY.map((template) => template.slug), ); return { total, added, refreshed, held, rejected }; }