fix(mocks): scope the derived hook's graph to the submission
Build / Build-and-ng-test (pull_request) Successful in 4m52s
Lighthouse Checks / lighthouse (pull_request) Successful in 19m50s
Build / Build-and-test-development (pull_request) Successful in 31m26s

Two divergences from the server hook, both of which reject a valid submission:

- VAR_DEP_UNION joins on libref, dsn and name, so a derived variable called
  STATE on one table does not make STATE a derived name on another. The mock
  kept a flat set of names, so a physical column named STATE was reported as
  an undeclared reference whenever any other table derived a variable of that
  name.

- The control-table arm filters staging_ds for the tables being submitted, so
  another table's configuration cannot block this one. The mock read the whole
  MPE_DERIVED table, so a cycle declared on an unrelated table blocked every
  submission.

Reproduced both against the previous head - a physical STATE reference and an
unrelated CYC_A/CYC_B cycle were each rejected - and both pass after, while the
cycle, undeclared-reference and stale-dependency cases on the submitted table
still fire.
This commit is contained in:
dc committed 2026-10-25 23:44:17 +00:00
1 parent 5fc0ff1ee3
commit 6cad23fce9
1 file changed
+22 -6
@@ -154,6 +154,15 @@
const stagedKeys = new Set(rows.map(keyOf))
const stagedLive = rows.filter(isLive)
// the control-table arm of the SAS hook is limited to the tables this
// submission touches, so another table's configuration cannot block it
const stagedTables = new Set(
rows.map((row) => [field(row, 'LIBREF'), field(row, 'DSN')].join('.'))
)
/** libref.dsn.name - the key every graph lookup uses. */
const nodeKey = (n) => [n.libref, n.dsn, n.name].join('.')
const graph = new Map()
for (const row of stagedLive) {
graph.set(keyOf(row), {
@@ -170,6 +179,11 @@
(r) =>
isCurrent(r) &&
Number(colVal(r, 'ACTIVE')) === 1 &&
stagedTables.has(
[up(colVal(r, 'LIBREF')), up(colVal(r, 'DSN'))].join(
'.'
)
) &&
!stagedKeys.has(
[
up(colVal(r, 'LIBREF')),
@@ -242,7 +256,9 @@
.split(/\s+/)
.filter(Boolean)
const graphNames = new Set([...graph.values()].map((n) => n.name))
// scoped per table, as the server does: a name that is a derived variable
// on another table says nothing about this one
const graphKeys = new Set([...graph.values()].map((n) => nodeKey(n)))
// one check at a time, in the order the server runs them, so the message a
// row gets is the one it would get there
@@ -257,7 +273,7 @@
for (const node of graph.values()) {
for (const token of identifiers(node.logic)) {
if (
graphNames.has(token) &&
graphKeys.has([node.libref, node.dsn, token].join('.')) &&
token !== node.name &&
!node.deps.includes(token)
) {
@@ -303,7 +319,7 @@
// Repeatedly place the variables whose derived inputs are already placed; a
// round that places nothing leaves a cycle. The path is walked so the
// message names it, as the SAS hook does.
const nodeKey = new Map(
const nodeKeys = new Map(
[...graph.values()].map((n) => [n, [n.libref, n.dsn, n.name].join('.')])
)
const placed = new Set()
@@ -320,7 +336,7 @@
// walk the unplaced edges so the message names the cycle
const start = remaining[0]
const path = [start.name]
const visited = new Set([nodeKey.get(start)])
const visited = new Set([nodeKeys.get(start)])
let cursor = start
for (let i = 0; i < remaining.length; i++) {
const parentKey = cursor.deps
@@ -336,7 +352,7 @@
`MPE_DERIVED: VAR_DEPS contains a cycle: ${path.join(' -> ')}`
)
}
for (const node of ready) placed.add(nodeKey.get(node))
remaining = remaining.filter((n) => !placed.has(nodeKey.get(n)))
for (const node of ready) placed.add(nodeKeys.get(node))
remaining = remaining.filter((n) => !placed.has(nodeKeys.get(n)))
}
})()