fix(sales-orders): pin the sales_order_items embed FK and teach the embed guard composite keys (#2207)

* fix(sales-orders): pin the sales_order_items embed FK and teach the embed guard composite keys

Migration 20260902180000_sales_orders_hardening added a composite
(sales_order_id, company_id) foreign key from sales_order_items to
sales_orders next to the original single-column one. PostgREST then saw
two relationships and answered every `items:sales_order_items(*)` embed
with HTTP 300 / PGRST201, so kundorder list, detail, create and the MCP
list tool all failed on prod and staging with "Oväntat serverfel".

- Hint the three embeds with `!sales_order_items_sales_order_id_fkey`
  (route, load service, MCP list tool).
- scripts/checks/ambiguous-embed.mjs only parsed single-column
  `FOREIGN KEY (col)`, which is why the ratchet reported 0 for this pair.
  It now reads composite column lists (named or default constraint
  name) in both CREATE TABLE and ALTER TABLE, derives the same 17
  ambiguous pairs prod's pg_constraint reports, and flags all three
  shipped sites on main.
- Unit tests for the composite shapes: alongside a single-column key,
  replacing one, and inline in CREATE TABLE.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7xJQL2aZo6iRHxCZNntUq

* fix(checks): drop composite embed edges when DROP COLUMN removes a member column

Postgres drops every foreign key a column takes part in, so the
ambiguous-embed parser must release a composite edge (and its constraint
name) when one of its columns is dropped, not only the single-column key.
Otherwise a later migration would keep a pair armed for a relationship
that no longer exists and reject valid embeds. Regression case added.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7xJQL2aZo6iRHxCZNntUq

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-09-03 12:18:47 +02:00
committed by GitHub
co-authored by Claude Fable 5.1
parent 4c6feea64d
commit fefef038c5
6 changed files with 141 additions and 14 deletions
+31 -9
View File
@@ -47,7 +47,12 @@
* where c.contype = 'f'
* group by 1, 2 having count(*) > 1 order by 1, 2;
*
* On 2026-09-01 that returned the same 15 pairs the parser derives.
* On 2026-09-03 that returned the same 17 pairs the parser derives. Composite
* foreign keys count: until 2026-09-03 the parser only read single-column
* `FOREIGN KEY (col)`, so the composite (sales_order_id, company_id) guard in
* 20260902180000_sales_orders_hardening.sql was invisible to it and the
* un-hinted `items:sales_order_items(*)` embeds shipped, taking every
* kundorder list and detail load down with PGRST201 (fixed 2026-09-03).
*
* No baseline: the count is 0, any new un-hinted ambiguous embed is a hard
* failure.
@@ -92,10 +97,16 @@ export function deriveForeignKeys(migrationsDir) {
// Unnamed foreign keys get Postgres's default `<table>_<column>_fkey`.
const byConstraint = new Map()
// `column` is one column or a composite list ("sales_order_id, company_id").
// A composite foreign key is its own edge, distinct from a single-column one
// on its leading column: PostgREST counts both, which is what made
// sales_order_items -> sales_orders ambiguous (migration 20260902180000)
// while this parser, then single-column only, still derived one edge.
const addEdge = (table, column, target, constraintName) => {
const key = `${table}.${column}`
const columns = column.split(',').map((c) => normIdent(c.trim())).filter(Boolean)
const key = `${table}.${columns.join(',')}`
edges.set(key, target)
byConstraint.set(constraintName ?? `${table}_${column}_fkey`, key)
byConstraint.set(constraintName ?? `${table}_${columns.join('_')}_fkey`, key)
}
let files
@@ -121,11 +132,12 @@ export function deriveForeignKeys(migrationsDir) {
for (const m of stmt.matchAll(/(?:^|,)\s*([\w"]+)\s+[^,()]*?references\s+([\w".]+)/gis)) {
addEdge(table, normIdent(m[1]), normIdent(m[2]))
}
// `foreign key (col) references public.other(id)` as a table constraint.
// `foreign key (col[, col]) references public.other(id[, id])` as a
// table constraint, named or not.
for (const m of stmt.matchAll(
/foreign\s+key\s*\(\s*([\w"]+)\s*\)\s*references\s+([\w".]+)/gi,
/(?:constraint\s+([\w"]+)\s+)?foreign\s+key\s*\(\s*([\w",\s]+?)\s*\)\s*references\s+([\w".]+)/gi,
)) {
addEdge(table, normIdent(m[1]), normIdent(m[2]))
addEdge(table, m[2], normIdent(m[3]), m[1] && normIdent(m[1]))
}
continue
}
@@ -133,9 +145,9 @@ export function deriveForeignKeys(migrationsDir) {
if (altered) {
const table = normIdent(altered[1])
for (const m of stmt.matchAll(
/(?:add\s+constraint\s+([\w"]+)\s+)?foreign\s+key\s*\(\s*([\w"]+)\s*\)\s*references\s+([\w".]+)/gi,
/(?:add\s+constraint\s+([\w"]+)\s+)?foreign\s+key\s*\(\s*([\w",\s]+?)\s*\)\s*references\s+([\w".]+)/gi,
)) {
addEdge(table, normIdent(m[2]), normIdent(m[3]), m[1] && normIdent(m[1]))
addEdge(table, m[2], normIdent(m[3]), m[1] && normIdent(m[1]))
}
for (const m of stmt.matchAll(
/add\s+column\s+(?:if\s+not\s+exists\s+)?([\w"]+)\s+[^,;]*?references\s+([\w".]+)/gi,
@@ -143,7 +155,17 @@ export function deriveForeignKeys(migrationsDir) {
addEdge(table, normIdent(m[1]), normIdent(m[2]))
}
for (const m of stmt.matchAll(/drop\s+column\s+(?:if\s+exists\s+)?([\w"]+)/gi)) {
edges.delete(`${table}.${normIdent(m[1])}`)
// Postgres drops every foreign key the column takes part in, so a
// composite edge listing it goes too, not only the single-column key.
const column = normIdent(m[1])
for (const key of [...edges.keys()]) {
if (!key.startsWith(`${table}.`)) continue
if (!key.slice(table.length + 1).split(',').includes(column)) continue
edges.delete(key)
for (const [name, edge] of [...byConstraint]) {
if (edge === key) byConstraint.delete(name)
}
}
}
for (const m of stmt.matchAll(/drop\s+constraint\s+(?:if\s+exists\s+)?([\w"]+)/gi)) {
const edge = byConstraint.get(normIdent(m[1]))