`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.
247 lines
8.3 KiB
JavaScript
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
|
|
}
|