Files
dc/sas/mocks/sasjs/services/editors/restore.js
T
dc 1a60c689f4 feat(mocks): restore a load, and record the diffs it needs
`editors/restore` had no mock, and the reason it could not have had one is that
`postdata` wrote no audit rows: an approval applied the load and left nothing
to rewind.  Both halves are here.

postdata (final approval): writes MPE_AUDIT the way %mp_storediffs does - one
row per column, keyed by the md5 of the business-key values joined by '|', with
move_type A/D/M, is_pk on the key columns, is_diff on a changed one, and the
old and new value on their own typed columns.  An append carries every column
on the new side, a delete every column on the old side (which is what lets a
restore put the row back), and a modification the keys plus only the columns
that actually changed.  A failure writing the audit is logged and does not undo
an approval that has already landed.

restore: mpe_checkrestore's two decidable checks (the table has an audit table;
the load has entries in it) with their messages, then mp_stripdiffs' window -
the load's timestamp and every change to that table from then on, because a
restore reverts this load *and all subsequent ones*.  Per business key the
pre-window state is the earliest event: an append means the row is staged for
deletion (`_____DELETE__THIS__RECORD_____=YES`), a delete means it is staged
with the values the delete recorded, and a modification means each column takes
its earliest old value while untouched columns keep the current one.  The
result goes through the same stageSubmission the editors use, so it arrives as
a new submission with the reason `Restoring <loadref>` and is approved like any
other - restore.sas never writes the base table either.

The access and group/RLS checks are not emulated, consistent with the rest of
the mocks: nothing enforces MPE_SECURITY there.

Tests: 52 pass across the seven mock suites, 7 of them new.  They drive a real
approve and then read the staged rows back: append staged for deletion,
modification staged with the pre-load value, delete staged with the row's
values, and restoring the first of two loads reverting the second as well.
With the base postdata and no restore service, all 7 fail.
2026-11-05 10:44:40 +00:00

247 lines
8.3 KiB
JavaScript

const nodePath = require('path')
let appLoc = nodePath.join(..._program.split('services')[0].split('/'))
const sasjsRoot = nodePath.resolve(weboutPath, '..', '..', '..')
const driveRoot = nodePath.resolve(sasjsRoot, 'drive')
// Load shared DC mock utilities
eval(
fs.readFileSync(
nodePath.resolve(driveRoot, 'files', appLoc, 'services', 'dcMockUtils.js'),
'utf8'
)
)
const dcConf = loadDcSettings(driveRoot, appLoc)
const controlTable = makeControlTableLoader(dcConf.dataDir)
const stagingDir = nodePath.resolve(dcConf.dataDir, 'staging')
const DELETE_FLAG = '_____DELETE__THIS__RECORD_____'
/** LIBREF.TABLE split into its two parts, upper-cased like the SAS compares. */
const splitLibds = (libds) => {
const parts = String(libds || '').toUpperCase().split('.')
return { libref: parts[0] || '', dsn: parts[1] || '' }
}
/** The audit table named by MPE_TABLES, read from whichever library holds it. */
const auditRows = (auditLibds) => {
const parts = splitLibds(auditLibds)
const loader = makeTableLoader(libDataDir(parts.libref))
const table = loader(parts.dsn)
return table && table.rows ? table.rows : []
}
/**
* The state a key had before the window: the old value of each column at its
* earliest change, which is what "revert this load and all subsequent" means.
*/
const earliestByColumn = (rows, column) => {
const matching = rows
.filter((row) => upper(colVal(row, 'TGTVAR_NM')) === upper(column))
.sort(
(a, b) =>
Number(colVal(a, 'PROCESSED_DTTM')) - Number(colVal(b, 'PROCESSED_DTTM'))
)
return matching[0]
}
/** The value a diff row carries for its side: char and numeric are separate. */
const valueOf = (row, side) => {
const isChar = upper(colVal(row, 'TGTVAR_TYPE')) === 'C'
const value = colVal(row, side === 'old' ? 'OLDVAL_CHAR' : 'NEWVAL_CHAR')
const num = colVal(row, side === 'old' ? 'OLDVAL_NUM' : 'NEWVAL_NUM')
if (isChar) return value === undefined || value === null ? '' : String(value)
return num === undefined || num === null ? null : Number(num)
}
const upper = (value) => String(value === undefined || value === null ? '' : value).toUpperCase()
try {
// ─── Input ──────────────────────────────────────────────────────────────────
const request = fetchTable('restore_in')[0] || {}
const loadRef = String(colVal(request, 'LOAD_REF') || '').trim()
// ─── mpe_checkrestore ───────────────────────────────────────────────────────
// The access and group checks are not emulated (no mock service enforces
// MPE_SECURITY); the two checks that decide whether a restore is possible at
// all are, because their messages are what the screen shows.
const submitRow = controlTable('MPE_SUBMIT').find(
(row) => String(colVal(row, 'TABLE_ID')) === loadRef
)
const base = submitRow
? splitLibds(
String(colVal(submitRow, 'BASE_LIB')) +
'.' +
String(colVal(submitRow, 'BASE_DS'))
)
: { libref: '', dsn: '' }
const tableReg = controlTable('MPE_TABLES')
.filter(isCurrentRow)
.find(
(row) =>
upper(colVal(row, 'LIBREF')) === base.libref &&
upper(colVal(row, 'DSN')) === base.dsn
)
const auditLibds =
(tableReg && colVal(tableReg, 'AUDIT_LIBDS')) || dcConf.dcLibref + '.MPE_AUDIT'
if (!tableReg) {
mpAbort(
'Cannot restore because: ' +
base.libref +
'.' +
base.dsn +
' has no audit table configured',
'restore'
)
}
const all = auditRows(auditLibds)
const mine = all.filter((row) => String(colVal(row, 'LOAD_REF')) === loadRef)
if (!mine.length) {
mpAbort(
'Cannot restore because: No entry for ' + loadRef + ' in ' + auditLibds,
'restore'
)
}
// ─── mp_stripdiffs ──────────────────────────────────────────────────────────
// The load's timestamp, then every change to that table from then on: the
// change to revert plus all subsequent ones.
const stamp = Math.min(
...mine.map((row) => Number(colVal(row, 'PROCESSED_DTTM')))
)
const window = all.filter(
(row) =>
upper(colVal(row, 'LIBREF')) === base.libref &&
upper(colVal(row, 'DSN')) === base.dsn &&
Number(colVal(row, 'PROCESSED_DTTM')) >= stamp
)
if (!window.length) {
mpAbort('THERE ARE NO DIFFERENCES TO APPLY', 'restore')
}
// ─── Rebuild the rows to stage ──────────────────────────────────────────────
const baseTable =
makeTableLoader(libDataDir(base.libref))(base.dsn) || { columns: [], rows: [] }
const columns = (baseTable.columns || []).map((column) => column.name)
const byHash = {}
for (const row of window) {
const hash = String(colVal(row, 'KEY_HASH'))
if (!byHash[hash]) byHash[hash] = []
byHash[hash].push(row)
}
const stagedRows = []
for (const hash of Object.keys(byHash)) {
const rows = byHash[hash].slice().sort(
(a, b) =>
Number(colVal(a, 'PROCESSED_DTTM')) - Number(colVal(b, 'PROCESSED_DTTM'))
)
const earliest = rows[0]
const moveType = upper(colVal(earliest, 'MOVE_TYPE'))
// the key values travel on the is_pk rows of the earliest event
const pkRows = rows.filter((row) => Number(colVal(row, 'IS_PK')) === 1)
const keyValues = {}
for (const row of pkRows) {
// coalesce(old, new): an append carries its key on the new side, a delete
// on the old one - mp_stripdiffs coalesces them for the same reason
const value = valueOf(row, 'old')
keyValues[upper(colVal(row, 'TGTVAR_NM'))] =
value === null || value === '' ? valueOf(row, 'new') : value
}
const current = baseTable.rows.find((row) => {
// match on the key values that the audit recorded
return Object.keys(keyValues).every(
(name) => String(colVal(row, name)) === String(keyValues[name])
)
})
if (moveType === 'A') {
// the row did not exist before this append, so restoring removes it
const out = { [DELETE_FLAG]: 'YES' }
for (const name of columns) {
out[name] = current ? colVal(current, name) : keyValues[upper(name)]
}
stagedRows.push(out)
continue
}
const out = { [DELETE_FLAG]: 'No' }
for (const name of columns) {
if (moveType === 'D') {
// a deleted row comes back with the values the delete recorded
const del = rows.find(
(row) =>
upper(colVal(row, 'MOVE_TYPE')) === 'D' &&
upper(colVal(row, 'TGTVAR_NM')) === upper(name)
)
out[name] = del
? valueOf(del, 'old')
: current
? colVal(current, name)
: null
continue
}
// a modification: the column's earliest old value is the pre-window one
const change = earliestByColumn(rows, name)
const changed =
change &&
upper(colVal(change, 'MOVE_TYPE')) === 'M' &&
Number(colVal(change, 'IS_PK')) === 0 &&
Number(colVal(change, 'IS_DIFF')) !== 0
out[name] = changed
? valueOf(change, 'old')
: current
? colVal(current, name)
: keyValues[upper(name)]
}
stagedRows.push(out)
}
if (!stagedRows.length) {
mpAbort('THERE ARE NO DIFFERENCES TO APPLY', 'restore')
}
// ─── mpe_loader: stage it as a new submission ───────────────────────────────
const newRef = makeTableId()
const headers = [DELETE_FLAG].concat(columns)
stageSubmission({
dataDir: dcConf.dataDir,
stagingDir,
tableId: newRef,
libds: base.libref + '.' + base.dsn,
libref: base.libref,
dsn: base.dsn,
message: 'Restoring ' + loadRef,
headers,
rows: stagedRows,
auditFiles: [
{
name: base.libref + '.' + base.dsn + '.csv',
content: toCsv(headers, stagedRows)
}
]
})
webout('OPEN')
webout('OBJ', 'restore_out', [{ loadref: newRef }])
webout('CLOSE')
} catch (err) {
if (!(err && err.mockAbort)) throw err
}