4ec2ff4b4d
Prod has three foreign keys between journal_entries and fiscal_periods, so
PostgREST answers PGRST201 to any embed of that pair that does not name the
relationship. pickAnchorEntry() destructured only data, so the error was
dropped and the helper returned null on every call since it shipped on
2026-07-27: supplier-invoice underlag has never once anchored in production.
Users see "Underlag saknas" on a verifikat that plainly shows the invoice PDF.
Names the constraint, matching the already-merged sibling fix in
lib/transactions/inbox-underlag.ts (6a40b3c0e), and handles the error instead
of dropping it.
Adds scripts/checks/ambiguous-embed.mjs to the ratchet guard, because neither
test layer can see this class: a mocked Supabase client never resolves a
relationship, and pg-real bypasses PostgREST entirely. The check derives the
ambiguous table pairs by parsing supabase/migrations, so a migration adding a
second foreign key between two tables arms the guard on the same commit; the
derived list reproduces prod's pg_constraint output exactly. It accepts both
PostgREST hint forms (constraint name and FK column name, both in use here) and
parses aliased embeds, which is the shape the real bug took.
Claude-Session: https://claude.ai/code/session_016ifKg6Ec67A39oxfGPU1yc
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
328 lines
12 KiB
JavaScript
328 lines
12 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Guard: an un-hinted PostgREST embed between two tables that share more than
|
|
* one foreign key.
|
|
*
|
|
* PostgREST resolves `.from('a').select('b(...)')` by looking for THE foreign
|
|
* key between a and b, in either direction. When several exist it refuses the
|
|
* request with PGRST201 ("Could not embed because more than one relationship
|
|
* was found") instead of picking one. The embed must then name the
|
|
* relationship, and PostgREST accepts two spellings for that:
|
|
*
|
|
* b!a_b_id_fkey(...) the constraint name
|
|
* b!b_column_id(...) the foreign key COLUMN name
|
|
*
|
|
* Both are in use here (`fiscal_periods!journal_entries_fiscal_period_id_fkey`
|
|
* in lib/transactions/inbox-underlag.ts, `journal_entries!opening_balance_entry_id`
|
|
* in lib/import/opening-balance/cascade.ts), so either counts as hinted.
|
|
* `!inner` / `!left` are join modifiers, not hints, and do not disambiguate.
|
|
*
|
|
* Why a static guard is the only thing that can catch this: the failure is a
|
|
* runtime PostgREST decision. Unit tests mock the Supabase client and hand back
|
|
* an already-joined row shape, so they never resolve a relationship; pg-real
|
|
* tests talk to Postgres directly and never go through PostgREST at all. The
|
|
* class has now been reasoned about three times and shipped broken twice, both
|
|
* on journal_entries -> fiscal_periods, ambiguous since migration
|
|
* 20240101000019 added fiscal_periods.closing_entry_id and
|
|
* fiscal_periods.opening_balance_entry_id:
|
|
* - lib/transactions/inbox-underlag.ts (fixed 2026-08-31, PR #2022): the
|
|
* nightly underlag cron read an empty locked-set and then tried to link
|
|
* documents into locked periods, ~60 trigger rejections a night.
|
|
* - lib/core/documents/supplier-invoice-underlag.ts (fixed 2026-09-01): the
|
|
* error was dropped on the floor, so supplier-invoice underlag never once
|
|
* anchored in production and users kept seeing "Underlag saknas" on a
|
|
* verifikat that plainly showed the invoice PDF.
|
|
*
|
|
* The ambiguous pairs are DERIVED from supabase/migrations/*.sql rather than
|
|
* hardcoded, so a migration that adds a second foreign key between two tables
|
|
* arms the guard for that pair on the same commit. To check the derivation
|
|
* against the live schema:
|
|
*
|
|
* select least(s.relname, t.relname) a, greatest(s.relname, t.relname) b, count(*)
|
|
* from pg_constraint c
|
|
* join pg_class s on s.oid = c.conrelid
|
|
* join pg_class t on t.oid = c.confrelid
|
|
* join pg_namespace ns on ns.oid = s.relnamespace and ns.nspname = 'public'
|
|
* join pg_namespace nt on nt.oid = t.relnamespace and nt.nspname = 'public'
|
|
* 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.
|
|
*
|
|
* No baseline: the count is 0, any new un-hinted ambiguous embed is a hard
|
|
* failure.
|
|
*/
|
|
|
|
import fs from 'node:fs'
|
|
import path from 'node:path'
|
|
import ts from 'typescript'
|
|
|
|
const SCAN_DIRS = ['app', 'components', 'contexts', 'extensions', 'lib', 'scripts']
|
|
const IGNORE_DIRS = new Set([
|
|
'node_modules',
|
|
'.next',
|
|
'.git',
|
|
'dist',
|
|
'build',
|
|
'coverage',
|
|
'__tests__',
|
|
])
|
|
|
|
/** Undirected pair key: PostgREST considers foreign keys in both directions. */
|
|
export const pairKey = (a, b) => (a < b ? `${a}|${b}` : `${b}|${a}`)
|
|
|
|
const stripSql = (sql) => sql.replace(/--[^\n]*/g, '').replace(/\/\*[\s\S]*?\*\//g, '')
|
|
const normIdent = (t) => t.replace(/"/g, '').replace(/^public\./i, '').toLowerCase()
|
|
|
|
/**
|
|
* Foreign keys declared across the migration history, as a map of
|
|
* `<table>.<column>` -> target table. Keying on the column (rather than
|
|
* counting statements) makes the repeated idempotent re-adds this repo is full
|
|
* of collapse onto one edge, which is what Postgres ends up with.
|
|
*
|
|
* Only the shapes that actually appear in supabase/migrations are parsed:
|
|
* inline `REFERENCES` in a CREATE TABLE column, table-level
|
|
* `FOREIGN KEY (col) REFERENCES`, `ADD COLUMN ... REFERENCES`, and
|
|
* `ADD CONSTRAINT ... FOREIGN KEY (col) REFERENCES`. Drops are honoured so a
|
|
* relationship that was removed again does not keep a pair armed forever.
|
|
*/
|
|
export function deriveForeignKeys(migrationsDir) {
|
|
const edges = new Map()
|
|
// Constraint name -> edge key, so DROP CONSTRAINT can find what it removes.
|
|
// Unnamed foreign keys get Postgres's default `<table>_<column>_fkey`.
|
|
const byConstraint = new Map()
|
|
|
|
const addEdge = (table, column, target, constraintName) => {
|
|
const key = `${table}.${column}`
|
|
edges.set(key, target)
|
|
byConstraint.set(constraintName ?? `${table}_${column}_fkey`, key)
|
|
}
|
|
|
|
let files
|
|
try {
|
|
files = fs.readdirSync(migrationsDir).filter((f) => f.endsWith('.sql')).sort()
|
|
} catch {
|
|
return edges
|
|
}
|
|
|
|
for (const file of files) {
|
|
const sql = stripSql(fs.readFileSync(path.join(migrationsDir, file), 'utf8'))
|
|
for (const raw of sql.split(';')) {
|
|
const stmt = raw.trim()
|
|
if (!stmt) continue
|
|
|
|
const created = stmt.match(/create\s+table\s+(?:if\s+not\s+exists\s+)?([\w".]+)\s*\(/i)
|
|
const altered = stmt.match(/alter\s+table\s+(?:if\s+exists\s+)?(?:only\s+)?([\w".]+)/i)
|
|
const dropped = stmt.match(/drop\s+table\s+(?:if\s+exists\s+)?([\w".]+)/i)
|
|
|
|
if (created) {
|
|
const table = normIdent(created[1])
|
|
// `col uuid references public.other(id)` inside the column list.
|
|
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.
|
|
for (const m of stmt.matchAll(
|
|
/foreign\s+key\s*\(\s*([\w"]+)\s*\)\s*references\s+([\w".]+)/gi,
|
|
)) {
|
|
addEdge(table, normIdent(m[1]), normIdent(m[2]))
|
|
}
|
|
continue
|
|
}
|
|
|
|
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,
|
|
)) {
|
|
addEdge(table, normIdent(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,
|
|
)) {
|
|
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])}`)
|
|
}
|
|
for (const m of stmt.matchAll(/drop\s+constraint\s+(?:if\s+exists\s+)?([\w"]+)/gi)) {
|
|
const edge = byConstraint.get(normIdent(m[1]))
|
|
if (edge) edges.delete(edge)
|
|
}
|
|
continue
|
|
}
|
|
|
|
if (dropped) {
|
|
const table = normIdent(dropped[1])
|
|
for (const [key, target] of [...edges]) {
|
|
if (key.startsWith(`${table}.`) || target === table) edges.delete(key)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return edges
|
|
}
|
|
|
|
/** Table pairs joined by more than one foreign key, as `pairKey` strings. */
|
|
export function deriveAmbiguousPairs(migrationsDir) {
|
|
const counts = new Map()
|
|
for (const [key, target] of deriveForeignKeys(migrationsDir)) {
|
|
const source = key.slice(0, key.indexOf('.'))
|
|
// auth.users and other non-public tables are never embedded through PostgREST here.
|
|
if (source.includes('.') || target.includes('.')) continue
|
|
const pair = pairKey(source, target)
|
|
counts.set(pair, (counts.get(pair) ?? 0) + 1)
|
|
}
|
|
return new Set([...counts].filter(([, n]) => n > 1).map(([pair]) => pair))
|
|
}
|
|
|
|
const HEAD_CHARS = /[\w!]/
|
|
// An embed head is preceded by the start of the select string, a separator, or
|
|
// the `:` of an alias. `fiscal_period:fiscal_periods(...)` is the shape both
|
|
// production bugs were written in, so `:` MUST be a delimiter here.
|
|
const HEAD_DELIMITERS = new Set([',', ':', '(', ' ', '\t', '\n', '\r'])
|
|
const JOIN_MODIFIERS = new Set(['inner', 'left'])
|
|
|
|
/**
|
|
* The embed head immediately before `index` (which points at its `(`), or null
|
|
* when the paren does not open an embed.
|
|
*/
|
|
function headBefore(select, index) {
|
|
let end = index
|
|
while (end > 0 && /\s/.test(select[end - 1])) end--
|
|
let start = end
|
|
while (start > 0 && HEAD_CHARS.test(select[start - 1])) start--
|
|
if (start === end) return null
|
|
if (start > 0 && !HEAD_DELIMITERS.has(select[start - 1])) return null
|
|
|
|
const [target, ...modifiers] = select.slice(start, end).split('!')
|
|
if (!target) return null
|
|
return { target: target.toLowerCase(), hinted: modifiers.some((m) => !JOIN_MODIFIERS.has(m)) }
|
|
}
|
|
|
|
/**
|
|
* Every embed in one select string, paired with the table it is embedded FROM.
|
|
* Nested embeds resolve against their enclosing embed, not against the root.
|
|
*/
|
|
export function parseEmbeds(select, rootTable) {
|
|
const embeds = []
|
|
const stack = [rootTable]
|
|
for (let i = 0; i < select.length; i++) {
|
|
if (select[i] === '(') {
|
|
const head = headBefore(select, i)
|
|
const from = stack[stack.length - 1]
|
|
if (head && from) embeds.push({ from, target: head.target, hinted: head.hinted })
|
|
stack.push(head ? head.target : null)
|
|
} else if (select[i] === ')') {
|
|
if (stack.length > 1) stack.pop()
|
|
}
|
|
}
|
|
return embeds
|
|
}
|
|
|
|
/**
|
|
* The table a `.select()` call reads from, resolved by walking ITS OWN method
|
|
* chain back to `.from()`. Never the nearest preceding `.from()` in the file:
|
|
* `.from('journal_entry_lines').select('... journal_entries!inner(...)')` sits
|
|
* a few lines below an unrelated `.from('journal_entries')` in
|
|
* scripts/seed-demo-account.ts, and pairing by proximity flags it wrongly.
|
|
*/
|
|
function fromTableOfChain(selectCall) {
|
|
let node = selectCall.expression.expression
|
|
while (node) {
|
|
if (ts.isCallExpression(node)) {
|
|
const callee = node.expression
|
|
if (ts.isPropertyAccessExpression(callee) && callee.name.text === 'from') {
|
|
const arg = node.arguments[0]
|
|
return arg && ts.isStringLiteralLike(arg) ? arg.text.toLowerCase() : null
|
|
}
|
|
node = ts.isPropertyAccessExpression(callee) ? callee.expression : callee
|
|
continue
|
|
}
|
|
if (ts.isPropertyAccessExpression(node)) {
|
|
node = node.expression
|
|
continue
|
|
}
|
|
return null
|
|
}
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Un-hinted embeds of an ambiguous pair in one file's source, as
|
|
* `{ line, from, target }`. Exported for the unit test.
|
|
*/
|
|
export function findAmbiguousEmbedsInSource(sourceText, fileName, ambiguousPairs) {
|
|
const source = ts.createSourceFile(
|
|
fileName,
|
|
sourceText,
|
|
ts.ScriptTarget.Latest,
|
|
true,
|
|
fileName.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
|
|
)
|
|
const findings = []
|
|
|
|
const visit = (node) => {
|
|
if (
|
|
ts.isCallExpression(node) &&
|
|
ts.isPropertyAccessExpression(node.expression) &&
|
|
node.expression.name.text === 'select' &&
|
|
node.arguments.length > 0 &&
|
|
ts.isStringLiteralLike(node.arguments[0])
|
|
) {
|
|
const from = fromTableOfChain(node)
|
|
if (from) {
|
|
for (const embed of parseEmbeds(node.arguments[0].text, from)) {
|
|
if (embed.hinted) continue
|
|
if (!ambiguousPairs.has(pairKey(embed.from, embed.target))) continue
|
|
const pos = source.getLineAndCharacterOfPosition(node.getStart(source))
|
|
findings.push({ line: pos.line + 1, from: embed.from, target: embed.target })
|
|
}
|
|
}
|
|
}
|
|
ts.forEachChild(node, visit)
|
|
}
|
|
visit(source)
|
|
return findings
|
|
}
|
|
|
|
function walk(dir, out) {
|
|
let entries
|
|
try {
|
|
entries = fs.readdirSync(dir, { withFileTypes: true })
|
|
} catch {
|
|
return out
|
|
}
|
|
for (const entry of entries) {
|
|
if (entry.name.startsWith('.') || IGNORE_DIRS.has(entry.name)) continue
|
|
const full = path.join(dir, entry.name)
|
|
if (entry.isDirectory()) walk(full, out)
|
|
else if (/\.tsx?$/.test(entry.name) && !/\.test\.tsx?$/.test(entry.name)) out.push(full)
|
|
}
|
|
return out
|
|
}
|
|
|
|
/** Findings across the repo, as `{ where, from, target }`, sorted. */
|
|
export function findAmbiguousEmbeds(root) {
|
|
const ambiguousPairs = deriveAmbiguousPairs(path.join(root, 'supabase', 'migrations'))
|
|
if (ambiguousPairs.size === 0) return []
|
|
|
|
const findings = []
|
|
for (const dir of SCAN_DIRS) {
|
|
for (const file of walk(path.join(root, dir), [])) {
|
|
const sourceText = fs.readFileSync(file, 'utf8')
|
|
if (!sourceText.includes('.select(')) continue
|
|
const relPath = path.relative(root, file).split(path.sep).join('/')
|
|
for (const finding of findAmbiguousEmbedsInSource(sourceText, file, ambiguousPairs)) {
|
|
findings.push({
|
|
where: `${relPath}:${finding.line}`,
|
|
from: finding.from,
|
|
target: finding.target,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
return findings.sort((a, b) => a.where.localeCompare(b.where))
|
|
}
|