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
@@ -245,15 +245,119 @@ describe('ambiguous-embed: pair derivation from the migration history', () => {
).toBe(0)
})
it('counts a composite foreign key next to a single-column one', () => {
// 20260902130000_sales_orders.sql + 20260902180000_sales_orders_hardening.sql:
// the composite (sales_order_id, company_id) guard is a second relationship
// in PostgREST's eyes. A single-column-only parser derived one edge here and
// let `items:sales_order_items(*)` ship un-hinted; prod answered PGRST201
// on every kundorder load (2026-09-03).
const dir = migrationsRoot({
'20260902130000_sales_orders.sql': `
CREATE TABLE public.sales_order_items (
id uuid PRIMARY KEY,
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
sales_order_id uuid NOT NULL REFERENCES public.sales_orders(id) ON DELETE CASCADE
);
`,
'20260902180000_sales_orders_hardening.sql': `
ALTER TABLE public.sales_order_items
DROP CONSTRAINT IF EXISTS sales_order_items_order_company_fkey;
ALTER TABLE public.sales_order_items
ADD CONSTRAINT sales_order_items_order_company_fkey
FOREIGN KEY (sales_order_id, company_id)
REFERENCES public.sales_orders (id, company_id)
ON DELETE CASCADE;
`,
})
expect([...deriveAmbiguousPairs(dir)]).toEqual([pairKey('sales_order_items', 'sales_orders')])
})
it('does not count a composite foreign key that REPLACED the single-column one', () => {
// 20260721101500_harden_tax_assessment_notices.sql shape: the old
// single-column constraint is dropped and the composite added, leaving one
// relationship. Prod agrees: tax_assessment_notices|fiscal_periods is not
// in the pg_constraint list.
const dir = migrationsRoot({
'1_base.sql': `
CREATE TABLE public.tax_assessment_notices (
id uuid PRIMARY KEY,
company_id uuid NOT NULL REFERENCES public.companies(id),
fiscal_period_id uuid REFERENCES public.fiscal_periods(id)
);
`,
'2_harden.sql': `
ALTER TABLE public.tax_assessment_notices
DROP CONSTRAINT IF EXISTS tax_assessment_notices_fiscal_period_id_fkey;
ALTER TABLE public.tax_assessment_notices
ADD CONSTRAINT tax_assessment_notices_fiscal_period_company_fkey
FOREIGN KEY (fiscal_period_id, company_id)
REFERENCES public.fiscal_periods (id, company_id);
`,
})
expect(deriveAmbiguousPairs(dir).size).toBe(0)
})
it('drops a composite edge when DROP COLUMN removes one of its columns', () => {
// Postgres drops every foreign key a column takes part in. Keeping the
// composite edge would arm a pair that no longer exists and reject valid
// embeds (CodeRabbit on PR #2207).
const base = `
CREATE TABLE public.sales_order_items (
id uuid PRIMARY KEY,
company_id uuid NOT NULL,
sales_order_id uuid NOT NULL REFERENCES public.sales_orders(id),
CONSTRAINT sales_order_items_order_company_fkey
FOREIGN KEY (sales_order_id, company_id) REFERENCES public.sales_orders(id, company_id)
);
`
expect([...deriveAmbiguousPairs(migrationsRoot({ '1_a.sql': base }))]).toEqual([
pairKey('sales_order_items', 'sales_orders'),
])
expect(
deriveAmbiguousPairs(
migrationsRoot({
'1_a.sql': base,
'2_b.sql': `ALTER TABLE public.sales_order_items DROP COLUMN company_id;`,
}),
).size,
).toBe(0)
// The dropped constraint's name is released too: a later DROP CONSTRAINT
// by that name must not delete an unrelated edge.
expect(
deriveAmbiguousPairs(
migrationsRoot({
'1_a.sql': base,
'2_b.sql': `ALTER TABLE public.sales_order_items DROP COLUMN company_id;`,
'3_c.sql': `ALTER TABLE public.sales_order_items DROP CONSTRAINT IF EXISTS sales_order_items_order_company_fkey;`,
}),
).size,
).toBe(0)
})
it('reads a composite table-level constraint inside CREATE TABLE', () => {
const dir = migrationsRoot({
'1_a.sql': `
CREATE TABLE public.a (
id uuid PRIMARY KEY,
company_id uuid NOT NULL,
b_id uuid REFERENCES public.b(id),
CONSTRAINT a_b_company_fkey FOREIGN KEY (b_id, company_id) REFERENCES public.b(id, company_id)
);
`,
})
expect([...deriveAmbiguousPairs(dir)]).toEqual([pairKey('a', 'b')])
})
it('matches the live schema on the real migration history', () => {
// Verified against prod (pwxtzglxptnnvjrpixpg) on 2026-09-01 with the
// pg_constraint query in ambiguous-embed.mjs: the same 15 pairs.
// Verified against prod (pwxtzglxptnnvjrpixpg) on 2026-09-03 with the
// pg_constraint query in ambiguous-embed.mjs: the same 17 pairs.
const pairs = deriveAmbiguousPairs(
path.join(__dirname, '..', '..', '..', 'supabase', 'migrations'),
)
expect(pairs.has(pairKey('journal_entries', 'fiscal_periods'))).toBe(true)
expect(pairs.has(pairKey('journal_entries', 'salary_runs'))).toBe(true)
expect(pairs.has(pairKey('supplier_invoices', 'transactions'))).toBe(true)
expect(pairs.has(pairKey('sales_order_items', 'sales_orders'))).toBe(true)
// Single-foreign-key pairs that legitimate code embeds without a hint.
expect(pairs.has(pairKey('journal_entries', 'journal_entry_lines'))).toBe(false)
expect(pairs.has(pairKey('supplier_invoice_payments', 'supplier_invoices'))).toBe(false)