lib: idea-code generator + plan_md yaml-frontmatter parser (M12 T-494)

- lib/idea-code.ts: pure formatIdeaCode helper (client-safe, no prisma)
- lib/idea-code-server.ts: atomic nextIdeaCode via Prisma row-lock,
  accepts optional TransactionClient for combining with idea.create
- lib/idea-plan-parser.ts: parsePlanMd extracts ---yaml---/body, runs
  yaml.parse + ideaPlanMdFrontmatterSchema, returns line-info on failure;
  CRLF-tolerant
- adds yaml@^2.8.4 dependency
- 8 unit tests (parser happy/missing/yaml-error/zod-error/empty-stories/CRLF;
  formatIdeaCode pad-3 + overflow)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-05-04 19:40:39 +02:00
parent bba3f11269
commit dfee518996
7 changed files with 236 additions and 4 deletions

26
lib/idea-code-server.ts Normal file
View file

@ -0,0 +1,26 @@
// Atomic per-user idea-code generator (DB-side).
// Schema: User.idea_code_counter Int @default(0) — increment-and-return via
// Prisma `update` (which acquires a row-lock for the duration of the
// transaction; concurrent calls serialize). Format: "IDEA-001", "IDEA-002", …
//
// Concurrency: vertrouwt op Postgres row-locking binnen Prisma `update`.
// Geen aparte $transaction nodig voor enkelvoudige update — de update is
// atomisch op één rij. Voor combineren met een idea.create wordt
// nextIdeaCode aangeroepen binnen de bredere $transaction van de caller.
import { prisma } from '@/lib/prisma'
import { formatIdeaCode } from '@/lib/idea-code'
import type { Prisma } from '@prisma/client'
export async function nextIdeaCode(
userId: string,
client: Prisma.TransactionClient | typeof prisma = prisma,
): Promise<string> {
const u = await client.user.update({
where: { id: userId },
data: { idea_code_counter: { increment: 1 } },
select: { idea_code_counter: true },
})
return formatIdeaCode(u.idea_code_counter)
}

8
lib/idea-code.ts Normal file
View file

@ -0,0 +1,8 @@
// Pure helpers voor IDEA-codes. Geen DB-imports — daarom client-safe.
// De DB-mutating nextIdeaCode staat in lib/idea-code-server.ts.
const PAD = 3 // "IDEA-001". Bumps to 4 digits at counter 1000 organically.
export function formatIdeaCode(n: number): string {
return `IDEA-${String(n).padStart(PAD, '0')}`
}

73
lib/idea-plan-parser.ts Normal file
View file

@ -0,0 +1,73 @@
// Parser voor de plan_md die make-plan-job produceert.
// Format: yaml-frontmatter (structuur, parseerbaar) + markdown-body (vrije
// reasoning). Frontmatter wordt gevalideerd via ideaPlanMdFrontmatterSchema.
//
// Wordt zowel door de server-action materializeIdeaPlanAction als door de
// MCP-tool update_idea_plan_md gebruikt. Synchroon — geen LLM-call.
//
// Zie docs/plans/M12-ideas.md "Plan-md formaat A" voor het format-voorbeeld.
import { parse as parseYaml, YAMLParseError } from 'yaml'
import {
ideaPlanMdFrontmatterSchema,
type IdeaPlanFrontmatter,
} from '@/lib/schemas/idea'
export type PlanParseError = { line?: number; message: string }
export type PlanParseResult =
| { ok: true; plan: IdeaPlanFrontmatter; body: string }
| { ok: false; errors: PlanParseError[] }
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/
export function parsePlanMd(md: string): PlanParseResult {
const match = md.match(FRONTMATTER_RE)
if (!match) {
return {
ok: false,
errors: [
{
line: 1,
message:
'Plan ontbreekt yaml-frontmatter. Verwacht eerste regel: ---',
},
],
}
}
const [, frontmatterRaw, body] = match
let parsed: unknown
try {
parsed = parseYaml(frontmatterRaw)
} catch (err) {
if (err instanceof YAMLParseError) {
return {
ok: false,
errors: [
{
line: err.linePos?.[0]?.line,
message: err.message,
},
],
}
}
return {
ok: false,
errors: [{ message: err instanceof Error ? err.message : String(err) }],
}
}
const validation = ideaPlanMdFrontmatterSchema.safeParse(parsed)
if (!validation.success) {
return {
ok: false,
errors: validation.error.issues.map((iss) => ({
message: `${iss.path.join('.') || '<root>'}: ${iss.message}`,
})),
}
}
return { ok: true, plan: validation.data, body: body.trimStart() }
}