Add Motion: the go-to-market operating system on top of the ledger
The ledger answers which contracted capacity is sold, to whom, at what margin. It says nothing about the motion — the repeatable practice that turns a customer conversation into a scoped deployment, and turns that deployment into something the next one reuses. Motion is deliberately not a parallel entity tree. DEMAND_STAGES already is the motion, so Motion binds reusable artefacts to the stages of a demand deal that already exists: an engagement hangs off one deal, cascade deleted, one per deal by unique constraint. Nine closed kinds, each declaring which stages it serves, and a starter library of twelve templates covering all eight open stages. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* 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 `onConflictDoNothing({ target: [slug, version] })`, which
|
||||
* works **only** because of `motion_templates_slug_version_key`. AGENTS.md §5
|
||||
* is blunt about what that clause 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`.
|
||||
*/
|
||||
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<string, unknown>;
|
||||
/** 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.
|
||||
*/
|
||||
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<number> {
|
||||
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<string, number>();
|
||||
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; rejected: readonly string[] }> {
|
||||
const rejected: string[] = [];
|
||||
let added = 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 [created] = await db
|
||||
.insert(motionTemplates)
|
||||
.values(toRow(template, template.kind, template.stage))
|
||||
.onConflictDoNothing({ target: [motionTemplates.slug, motionTemplates.version] })
|
||||
.returning({ id: motionTemplates.id });
|
||||
if (created) added += 1;
|
||||
}
|
||||
|
||||
const total = await assertNoDuplicates(
|
||||
db,
|
||||
STARTER_LIBRARY.map((template) => template.slug),
|
||||
);
|
||||
|
||||
return { total, added, rejected };
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user