feat(W4): daily full reconciliation — user/role drift repair + ReconcileLogs audit
- w4-body.js: ONE-DIRECTIONAL sync (NocoDB status = SOT, never reverse);
drift matrix: missing user/role (provision w/ username lookup to avoid
duplicates), dangling refs (adopt/recreate), disabled<->status, canonical
base_path '/' + permission 16384, scopes vs calculateEffectiveGrants,
username drift detect+log_only; verify-after-repair on every change;
password never pushed on repair (方案 C); dry-run via ?dryRun=true
- gen_w4.js: daily 03:00 cron + webhook alist-w4-reconcile; API-import-safe
(UUID node ids, no pinData, webhookId)
- ReconcileLogs table mktsb7le6fqr2u1 (add_reconcilelogs_table.py, idempotent,
v2 API); FK column nc_jitu___Customers_id for bt-link writes (v2 ignores
link column itself)
- test_w4_reconcile.js: 34 assertions / 12 mock worlds, ALL PASSED
- live-verified against production: dry-run (3 drifts + 1 error found),
real repair (3 repaired, verify all passed, 4 log rows), idempotent rerun
- NocoDB 2026.08.x pitfalls documented (v1 col endpoints gone, row delete
via body [{Id}]); n8n API import/activation pitfalls (activeVersionId)
- docs: W4-DESIGN.md new; DATABASE_SCHEMA 8 tables; N8N_WORKFLOWS, SETUP,
PROJECT_STATE updated
This commit is contained in:
@@ -43,6 +43,8 @@ NOCODB_TABLE_PRODUCTS=molyb3pxv81l75g
|
||||
NOCODB_TABLE_RESOURCES=m6cm7tkxzd8m18c
|
||||
NOCODB_TABLE_CUSTOMERPRODUCTS=mvyqbv8ao7u6bct
|
||||
NOCODB_TABLE_ACCESSGRANTS=mxdx3yhfcyej3ao
|
||||
NOCODB_TABLE_RECONCILELOGS=mktsb7le6fqr2u1
|
||||
NOCODB_FK_RECONCILELOG_CUSTOMER=nc_jitu___Customers_id
|
||||
NOCODB_LINK_PROD_RESOURCES=c4ko4x8kwi16swg
|
||||
ALIST_URL=http://alist-serve-resource:5244
|
||||
ALIST_ADMIN_USERNAME=hoelee
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,62 @@
|
||||
// Builds n8n/W4-daily-reconcile.json — the daily full reconciliation workflow.
|
||||
// Single source of truth: shared/effective-grants.js (inlined) + w4-body.js.
|
||||
// Run: node n8n/gen_w4.js
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { sharedSource } = require('./inline_shared');
|
||||
|
||||
let body = fs.readFileSync(path.join(__dirname, 'w4-body.js'), 'utf8');
|
||||
body = body.replace('// __SHARED_INLINE__ (replaced by shared/effective-grants.js source at build)', sharedSource());
|
||||
|
||||
// n8n API import (POST /api/v1/workflows) strictly validates: node ids must be
|
||||
// UUIDs and top-level must NOT carry pinData (class-validator whitelist).
|
||||
const uuid = () => require('crypto').randomUUID();
|
||||
const node = (o) => Object.assign({ id: uuid() }, o);
|
||||
|
||||
const workflow = {
|
||||
name: 'W4 - Daily Full Reconciliation (AList Drift Repair)',
|
||||
nodes: [
|
||||
node({
|
||||
parameters: {
|
||||
rule: {
|
||||
interval: [{ field: 'cronExpression', expression: '0 3 * * *' }]
|
||||
}
|
||||
},
|
||||
name: 'Daily 03:00',
|
||||
type: 'n8n-nodes-base.scheduleTrigger',
|
||||
typeVersion: 1.2,
|
||||
position: [-420, 0]
|
||||
}),
|
||||
node({
|
||||
parameters: {
|
||||
httpMethod: 'POST',
|
||||
path: 'alist-w4-reconcile',
|
||||
options: {}
|
||||
},
|
||||
name: 'Manual / Webhook',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
typeVersion: 2,
|
||||
webhookId: 'alist-w4-reconcile', // required: without it the production URL never registers
|
||||
position: [-420, 240]
|
||||
}),
|
||||
node({
|
||||
parameters: { jsCode: body },
|
||||
name: 'Reconcile & Repair (Daily Full)',
|
||||
type: 'n8n-nodes-base.code',
|
||||
typeVersion: 2,
|
||||
position: [0, 120]
|
||||
})
|
||||
],
|
||||
connections: {
|
||||
'Daily 03:00': {
|
||||
main: [[{ node: 'Reconcile & Repair (Daily Full)', type: 'main', index: 0 }]]
|
||||
},
|
||||
'Manual / Webhook': {
|
||||
main: [[{ node: 'Reconcile & Repair (Daily Full)', type: 'main', index: 0 }]]
|
||||
}
|
||||
},
|
||||
settings: { executionOrder: 'v1' }
|
||||
};
|
||||
|
||||
fs.writeFileSync(path.join(__dirname, 'W4-daily-reconcile.json'), JSON.stringify(workflow, null, 2) + '\n');
|
||||
console.log('wrote W4-daily-reconcile.json (' + body.length + ' chars body)');
|
||||
@@ -0,0 +1,369 @@
|
||||
// Verify the W4 daily reconciliation body against a mock AList + NocoDB.
|
||||
// Runs the ACTUAL inlined code from W4-daily-reconcile.json in a vm sandbox
|
||||
// with realistic state mutations (create/update actually change the mock so
|
||||
// verify-after-repair reads back real state).
|
||||
// Run: node n8n/test_w4_reconcile.js
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
|
||||
const workflow = JSON.parse(fs.readFileSync(path.join(__dirname, 'W4-daily-reconcile.json'), 'utf8'));
|
||||
const code = workflow.nodes.find(n => n.name === 'Reconcile & Repair (Daily Full)').parameters.jsCode;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// mock world
|
||||
// ---------------------------------------------------------------------------
|
||||
function freshWorld(w) {
|
||||
return {
|
||||
customers: JSON.parse(JSON.stringify(w.customers || [])),
|
||||
users: JSON.parse(JSON.stringify(w.users || [])),
|
||||
roles: JSON.parse(JSON.stringify(w.roles || [])),
|
||||
cps: JSON.parse(JSON.stringify(w.cps || {})),
|
||||
grants: JSON.parse(JSON.stringify(w.grants || {})),
|
||||
productRes: JSON.parse(JSON.stringify(w.productRes || {})),
|
||||
resources: JSON.parse(JSON.stringify(w.resources || [
|
||||
{ Id: 1, alist_path: '/Class/A' }, { Id: 2, alist_path: '/Class/B' },
|
||||
{ Id: 3, alist_path: '/Class/D' }, { Id: 4, alist_path: '/Template/Music' },
|
||||
])),
|
||||
};
|
||||
}
|
||||
|
||||
let nextUserId = 1000, nextRoleId = 900;
|
||||
|
||||
async function runWorld(world, extraEnv = {}) {
|
||||
const S = freshWorld(world);
|
||||
const logRows = [];
|
||||
const userUpdates = [], roleUpdates = [], patches = [], creates = { users: [], roles: [] };
|
||||
const throwOn = {}; // url-substring -> Error
|
||||
|
||||
const httpRequest = async (opts) => {
|
||||
const u = opts.url, m = (opts.method || 'GET').toUpperCase();
|
||||
for (const k of Object.keys(throwOn)) if (u.includes(k)) throw new Error('mock throw: ' + k);
|
||||
const body = opts.body;
|
||||
|
||||
// ---- NocoDB ----
|
||||
if (m === 'GET' && u.includes(`/tc/records?limit=10000`)) return { list: S.customers };
|
||||
if (m === 'GET' && u.includes(`/tr/records?limit=10000`)) return { list: S.resources };
|
||||
const cm = u.match(/\/tc\/records\/(\d+)$/);
|
||||
if (m === 'GET' && cm) return S.customers.find(c => c.Id === Number(cm[1])) || null;
|
||||
if (m === 'PATCH' && u.includes('/tc/records')) {
|
||||
for (const row of body) {
|
||||
const c = S.customers.find(x => x.Id === row.Id);
|
||||
if (c) Object.assign(c, row);
|
||||
patches.push(row);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
const cpm = u.match(/\/tcp\/records\?where=\(customer\.Id,eq,(\d+)\)/);
|
||||
if (m === 'GET' && cpm) return { list: S.cps[Number(cpm[1])] || [] };
|
||||
const gm = u.match(/\/tag\/records\?where=\(customer\.Id,eq,(\d+)\)/);
|
||||
if (m === 'GET' && gm) return { list: S.grants[Number(gm[1])] || [] };
|
||||
const pm = u.match(/\/tp\/links\/lnk\/records\/(\d+)/);
|
||||
if (m === 'GET' && pm) return { list: S.productRes[Number(pm[1])] || [] };
|
||||
if (m === 'GET' && u.includes('/tlog/records')) return { list: logRows };
|
||||
if (m === 'POST' && u.includes('/tlog/records')) { (Array.isArray(body) ? body : [body]).forEach(r => logRows.push(r)); return {}; }
|
||||
|
||||
// ---- AList auth ----
|
||||
if (u.endsWith('/api/auth/login')) return { data: { token: 'ADMIN_TOKEN' } };
|
||||
|
||||
// ---- AList users ----
|
||||
if (m === 'GET' && u.includes('/api/admin/user/list')) return { data: { content: S.users } };
|
||||
const ug = u.match(/\/api\/admin\/user\/get\?id=(\d+)/);
|
||||
if (m === 'GET' && ug) {
|
||||
const x = S.users.find(uu => uu.id === Number(ug[1]));
|
||||
return x ? { data: x } : { code: 404, data: null };
|
||||
}
|
||||
if (m === 'POST' && u.endsWith('/api/admin/user/create')) {
|
||||
const nu = Object.assign({ id: nextUserId++, disabled: false }, body); // real AList user/get always carries disabled
|
||||
S.users.push(nu); creates.users.push(body);
|
||||
return { code: 200, data: null };
|
||||
}
|
||||
if (m === 'POST' && u.endsWith('/api/admin/user/update')) {
|
||||
const x = S.users.find(uu => uu.id === body.id);
|
||||
if (x) {
|
||||
userUpdates.push(body);
|
||||
if (!(body.id === 400)) Object.assign(x, body); // 400 simulates AList refusing
|
||||
}
|
||||
return { code: 200, data: null };
|
||||
}
|
||||
|
||||
// ---- AList roles ----
|
||||
if (m === 'GET' && u.includes('/api/admin/role/list')) return { data: { content: S.roles } };
|
||||
const rg = u.match(/\/api\/admin\/role\/get\?id=(\d+)/);
|
||||
if (m === 'GET' && rg) {
|
||||
const x = S.roles.find(r => r.id === Number(rg[1]));
|
||||
return x ? { data: x } : { code: 404, data: null };
|
||||
}
|
||||
if (m === 'POST' && u.endsWith('/api/admin/role/create')) {
|
||||
const nr = Object.assign({ id: nextRoleId++, permission_scopes: [] }, body);
|
||||
S.roles.push(nr); creates.roles.push(body);
|
||||
return { code: 200, data: null };
|
||||
}
|
||||
if (m === 'POST' && u.endsWith('/api/admin/role/update')) {
|
||||
const x = S.roles.find(r => r.id === body.id);
|
||||
roleUpdates.push(body);
|
||||
if (x) Object.assign(x, body);
|
||||
return { code: 200, data: null };
|
||||
}
|
||||
throw new Error('unexpected request: ' + m + ' ' + u);
|
||||
};
|
||||
|
||||
const sandbox = {
|
||||
$env: Object.assign({
|
||||
NOCODB_URL: 'https://nc', NOCODB_TOKEN: 't',
|
||||
NOCODB_TABLE_CUSTOMERS: 'tc', NOCODB_TABLE_CUSTOMERPRODUCTS: 'tcp',
|
||||
NOCODB_TABLE_PRODUCTS: 'tp', NOCODB_TABLE_RESOURCES: 'tr',
|
||||
NOCODB_TABLE_ACCESSGRANTS: 'tag', NOCODB_TABLE_RECONCILELOGS: 'tlog',
|
||||
NOCODB_LINK_PROD_RESOURCES: 'lnk',
|
||||
NOCODB_FK_RECONCILELOG_CUSTOMER: 'nc_jitu___Customers_id',
|
||||
ALIST_URL: 'https://al', ALIST_ADMIN_USERNAME: 'admin', ALIST_ADMIN_PASSWORD: 'p',
|
||||
W4_DRY_RUN: 'false',
|
||||
}, extraEnv),
|
||||
$json: {},
|
||||
console,
|
||||
};
|
||||
sandbox.helpers = { httpRequest };
|
||||
|
||||
const wrapped = `(async function(){\n${code}\n})`;
|
||||
const fn = vm.runInNewContext(wrapped, sandbox, { filename: 'w4-body.js' });
|
||||
const result = await fn();
|
||||
return {
|
||||
s: result.json, users: S.users, roles: S.roles, customers: S.customers,
|
||||
logRows, userUpdates, roleUpdates, patches, creates,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// worlds
|
||||
// ---------------------------------------------------------------------------
|
||||
const W = {
|
||||
healthy: {
|
||||
customers: [{ Id: 1, username: 'alice', status: 'active', alist_user_id: 100, alist_role_id: 50 }],
|
||||
users: [{ id: 100, username: 'alice', base_path: '/', permission: 16384, disabled: false, role: [50] }],
|
||||
roles: [{ id: 50, name: 'customer-alice', description: 'x', permission_scopes: [{ path: '/Class/A', permission: 0 }] }],
|
||||
cps: { 1: [{ Id: 11, customer: 1, product: 2, status: 'active', expires_at: '2099-01-01' }] },
|
||||
productRes: { 2: [{ Id: 1 }] },
|
||||
grants: { 1: [] },
|
||||
},
|
||||
adoptUser: { // alist_user_id lost in NocoDB but user exists by username
|
||||
customers: [{ Id: 2, username: 'alice', status: 'active', alist_user_id: null, alist_role_id: 50 }],
|
||||
users: [{ id: 100, username: 'alice', base_path: '/', permission: 16384, disabled: false, role: [50] }],
|
||||
roles: [{ id: 50, name: 'customer-alice', description: 'x', permission_scopes: [{ path: '/Class/A', permission: 0 }] }],
|
||||
cps: { 2: [{ Id: 12, customer: 2, product: 2, status: 'active', expires_at: '2099-01-01' }] },
|
||||
productRes: { 2: [{ Id: 1 }] },
|
||||
grants: { 2: [] },
|
||||
},
|
||||
danglingUser: { // alist_user_id points at deleted user
|
||||
customers: [{ Id: 3, username: 'alice', status: 'active', alist_user_id: 999, alist_role_id: 50 }],
|
||||
users: [{ id: 100, username: 'alice', base_path: '/', permission: 16384, disabled: false, role: [50] }],
|
||||
roles: [{ id: 50, name: 'customer-alice', description: 'x', permission_scopes: [{ path: '/Class/A', permission: 0 }] }],
|
||||
cps: { 3: [{ Id: 13, customer: 3, product: 2, status: 'active', expires_at: '2099-01-01' }] },
|
||||
productRes: { 2: [{ Id: 1 }] },
|
||||
grants: { 3: [] },
|
||||
},
|
||||
provision: { // nothing in AList, active customer with entitlement
|
||||
customers: [{ Id: 4, username: 'bob', status: 'active', alist_user_id: null, alist_role_id: null, password: 'pwBob1' }],
|
||||
users: [],
|
||||
roles: [],
|
||||
cps: { 4: [{ Id: 14, customer: 4, product: 2, status: 'active', expires_at: '2099-01-01' }] },
|
||||
productRes: { 2: [{ Id: 2 }] },
|
||||
grants: { 4: [] },
|
||||
},
|
||||
reEnable: { // disabled + wrong base_path/permission
|
||||
customers: [{ Id: 5, username: 'carol', status: 'active', alist_user_id: 101, alist_role_id: 51 }],
|
||||
users: [{ id: 101, username: 'carol', base_path: '/Class', permission: 0, disabled: true, role: [51] }],
|
||||
roles: [{ id: 51, name: 'customer-carol', description: 'x', permission_scopes: [{ path: '/Class/A', permission: 0 }] }],
|
||||
cps: { 5: [{ Id: 15, customer: 5, product: 2, status: 'active', expires_at: '2099-01-01' }] },
|
||||
productRes: { 2: [{ Id: 1 }] },
|
||||
grants: { 5: [] },
|
||||
},
|
||||
scopesDrift: { // stale scope in AList
|
||||
customers: [{ Id: 6, username: 'dave', status: 'active', alist_user_id: 102, alist_role_id: 52 }],
|
||||
users: [{ id: 102, username: 'dave', base_path: '/', permission: 16384, disabled: false, role: [52] }],
|
||||
roles: [{ id: 52, name: 'customer-dave', description: 'x', permission_scopes: [{ path: '/Old/Path', permission: 0 }] }],
|
||||
cps: { 6: [{ Id: 16, customer: 6, product: 2, status: 'active', expires_at: '2099-01-01' }] },
|
||||
productRes: { 2: [{ Id: 3 }] },
|
||||
grants: { 6: [] },
|
||||
},
|
||||
expired: { // non-active customer: disable + clear scopes
|
||||
customers: [{ Id: 7, username: 'eve', status: 'expired', alist_user_id: 103, alist_role_id: 53 }],
|
||||
users: [{ id: 103, username: 'eve', base_path: '/', permission: 16384, disabled: false, role: [53] }],
|
||||
roles: [{ id: 53, name: 'customer-eve', description: 'x', permission_scopes: [{ path: '/Class/A', permission: 0 }] }],
|
||||
cps: { 7: [] },
|
||||
grants: { 7: [] },
|
||||
},
|
||||
pendingNoUser: {
|
||||
customers: [{ Id: 8, username: 'frank', status: 'pending', alist_user_id: null, alist_role_id: null }],
|
||||
users: [], roles: [],
|
||||
cps: { 8: [] }, grants: { 8: [] },
|
||||
},
|
||||
usernameDrift: {
|
||||
customers: [{ Id: 9, username: 'grace', status: 'active', alist_user_id: 104, alist_role_id: 54 }],
|
||||
users: [{ id: 104, username: 'grace_old', base_path: '/', permission: 16384, disabled: false, role: [54] }],
|
||||
roles: [{ id: 54, name: 'customer-grace', description: 'x', permission_scopes: [{ path: '/Class/A', permission: 0 }] }],
|
||||
cps: { 9: [{ Id: 17, customer: 9, product: 2, status: 'active', expires_at: '2099-01-01' }] },
|
||||
productRes: { 2: [{ Id: 1 }] },
|
||||
grants: { 9: [] },
|
||||
},
|
||||
verifyFail: { // AList refuses the user update -> verify_failed
|
||||
customers: [{ Id: 10, username: 'ivy', status: 'active', alist_user_id: 400, alist_role_id: 56 }],
|
||||
users: [{ id: 400, username: 'ivy', base_path: '/', permission: 16384, disabled: true, role: [56] }],
|
||||
roles: [{ id: 56, name: 'customer-ivy', description: 'x', permission_scopes: [{ path: '/Class/A', permission: 0 }] }],
|
||||
cps: { 10: [{ Id: 18, customer: 10, product: 2, status: 'active', expires_at: '2099-01-01' }] },
|
||||
productRes: { 2: [{ Id: 1 }] },
|
||||
grants: { 10: [] },
|
||||
},
|
||||
error: { // grants fetch explodes for this customer
|
||||
customers: [{ Id: 11, username: 'hank', status: 'active', alist_user_id: 105, alist_role_id: 57 }],
|
||||
users: [{ id: 105, username: 'hank', base_path: '/', permission: 16384, disabled: false, role: [57] }],
|
||||
roles: [{ id: 57, name: 'customer-hank', description: 'x', permission_scopes: [{ path: '/Class/A', permission: 0 }] }],
|
||||
cps: { 11: [] },
|
||||
grants: { 11: [] },
|
||||
},
|
||||
};
|
||||
|
||||
async function main() {
|
||||
let fail = 0;
|
||||
const A = (n, c, d) => { if (c) console.log('PASS ' + n); else { fail++; console.log('FAIL ' + n + ' -> ' + JSON.stringify(d)); } };
|
||||
const E = { W4_DRY_RUN: 'false' };
|
||||
|
||||
// -- healthy: nothing to do
|
||||
{
|
||||
const r = await runWorld(W.healthy, E);
|
||||
A('healthy skipped=1', r.s.skipped === 1, r.s);
|
||||
A('healthy no log rows', r.logRows.length === 0, r.logRows);
|
||||
A('healthy no writes', r.userUpdates.length === 0 && r.roleUpdates.length === 0, r);
|
||||
A('healthy 0 drift', r.s.driftsDetected === 0, r.s);
|
||||
}
|
||||
|
||||
// -- adopt user by username (id lost)
|
||||
{
|
||||
const r = await runWorld(W.adoptUser, E);
|
||||
A('adopt: 1 drift / 1 repaired', r.s.driftsDetected === 1 && r.s.repaired === 1, r.s);
|
||||
A('adopt: dangling_user_ref log', r.logRows[0] && r.logRows[0].drift_type === 'dangling_user_ref' && r.logRows[0].result === 'repaired', r.logRows);
|
||||
A('adopt: customer FK written', r.logRows[0] && r.logRows[0].nc_jitu___Customers_id === 2, r.logRows[0]);
|
||||
A('adopt: no duplicate user', r.users.length === 1, r.users);
|
||||
A('adopt: writeBack alist_user_id', r.patches.some(p => p.alist_user_id === 100), r.patches);
|
||||
A('adopt: next run healthy (id filled)', r.customers[0].alist_user_id === 100, r.customers);
|
||||
}
|
||||
|
||||
// -- dangling user ref (404 -> lookup by username -> adopt)
|
||||
{
|
||||
const r = await runWorld(W.danglingUser, E);
|
||||
A('dangling: adopted 100', r.s.driftsDetected === 1 && r.s.repaired === 1, r.s);
|
||||
A('dangling: no user created', r.users.length === 1 && r.creates.users.length === 0, r);
|
||||
A('dangling: customer id repaired', r.customers[0].alist_user_id === 100, r.customers);
|
||||
}
|
||||
|
||||
// -- full provision
|
||||
{
|
||||
const r = await runWorld(W.provision, E);
|
||||
A('provision: role+user created', r.creates.roles.length === 1 && r.creates.users.length === 1, r.creates);
|
||||
A('provision: user role bound', r.users[0].role[0] === r.roles[0].id, r.users);
|
||||
A('provision: canonical fields', r.users[0].base_path === '/' && r.users[0].permission === 16384 && r.users[0].disabled === false, r.users);
|
||||
A('provision: scopes applied', JSON.stringify(r.roles[0].permission_scopes.map(s => s.path)) === JSON.stringify(['/Class/B']), r.roles);
|
||||
A('provision: ids written back', r.patches.some(p => p.alist_user_id) && r.patches.some(p => p.alist_role_id), r.patches);
|
||||
A('provision: user_missing logged', r.logRows.some(l => l.drift_type === 'user_missing'), r.logRows);
|
||||
A('provision: verify passed (no verify_failed)', !r.logRows.some(l => l.drift_type === 'verify_failed'), r.logRows);
|
||||
}
|
||||
|
||||
// -- re-enable + canonical fix
|
||||
{
|
||||
const r = await runWorld(W.reEnable, E);
|
||||
A('re-enable: user updated', r.userUpdates.some(u => u.disabled === false && u.base_path === '/' && u.permission === 16384), r.userUpdates);
|
||||
A('re-enable: 3 drifts logged', ['user_disabled_drift','base_path_drift','permission_drift'].every(t => r.logRows.some(l => l.drift_type === t)), r.logRows);
|
||||
A('re-enable: repaired=3', r.s.repaired === 3, r.s);
|
||||
}
|
||||
|
||||
// -- scopes drift
|
||||
{
|
||||
const r = await runWorld(W.scopesDrift, E);
|
||||
A('scopes: role rewritten', r.roleUpdates.length === 1 && r.roles[0].permission_scopes.map(s => s.path).join() === '/Class/D', r.roleUpdates);
|
||||
A('scopes: drift logged', r.logRows.some(l => l.drift_type === 'scopes_drift' && l.result === 'repaired'), r.logRows);
|
||||
}
|
||||
|
||||
// -- expired cleanup
|
||||
{
|
||||
const r = await runWorld(W.expired, E);
|
||||
A('expired: user disabled', r.userUpdates.some(u => u.disabled === true), r.userUpdates);
|
||||
A('expired: scopes cleared', r.roles[0].permission_scopes.length === 0, r.roles);
|
||||
A('expired: repaired=2', r.s.repaired === 2, r.s);
|
||||
A('expired: verify ok', !r.logRows.some(l => l.drift_type === 'verify_failed'), r.logRows);
|
||||
}
|
||||
|
||||
// -- pending no user: untouched
|
||||
{
|
||||
const r = await runWorld(W.pendingNoUser, E);
|
||||
A('pending: skipped=1', r.s.skipped === 1, r.s);
|
||||
A('pending: no rows / no writes', r.logRows.length === 0 && r.creates.users.length === 0 && r.creates.roles.length === 0, r);
|
||||
}
|
||||
|
||||
// -- username drift: detect + log only
|
||||
{
|
||||
const r = await runWorld(W.usernameDrift, E);
|
||||
A('drift: username_drift logged', r.logRows.some(l => l.drift_type === 'username_drift' && l.result === 'log_only' && l.action === 'log_only'), r.logRows);
|
||||
A('drift: no rename (username untouched)', r.users[0].username === 'grace_old', r.users);
|
||||
A('drift: no new user', r.creates.users.length === 0, r.creates);
|
||||
A('drift: no user patch', r.userUpdates.length === 0, r.userUpdates);
|
||||
}
|
||||
|
||||
// -- verify failed (AList refuses update)
|
||||
{
|
||||
const r = await runWorld(W.verifyFail, E);
|
||||
A('verifyfail: verify_failed logged', r.logRows.some(l => l.drift_type === 'verify_failed' && l.result === 'failed'), r.logRows);
|
||||
A('verifyfail: not counted as repaired', r.logRows.filter(l => l.result === 'repaired').length === 1, r.logRows);
|
||||
}
|
||||
|
||||
// -- error path (grants fetch explodes)
|
||||
{
|
||||
const r3 = await runWorldWithThrow(W.error, 'grant');
|
||||
A('error: errors[] populated', r3.s.errors.length === 1 && r3.s.errors[0].customerId === 11, r3.s.errors);
|
||||
A('error: reconcile_error row', r3.logRows.some(l => l.drift_type === 'reconcile_error' && l.result === 'failed'), r3.logRows);
|
||||
}
|
||||
|
||||
// -- dry run: zero writes
|
||||
{
|
||||
const r = await runWorld(W.provision, { W4_DRY_RUN: 'true' });
|
||||
A('dryrun: no creates', r.creates.users.length === 0 && r.creates.roles.length === 0, r.creates);
|
||||
A('dryrun: no patches', r.patches.length === 0, r.patches);
|
||||
A('dryrun: no log POST', r.logRows.length === 0, r.logRows);
|
||||
A('dryrun: planned rows reported', r.s.plannedLogRows > 0 && r.s.logRowsWritten === 0, r.s);
|
||||
A('dryrun: dryRun flag', r.s.dryRun === true, r.s);
|
||||
}
|
||||
|
||||
console.log(fail === 0 ? '\nALL PASSED' : `\n${fail} FAILED`);
|
||||
process.exit(fail === 0 ? 0 : 1);
|
||||
}
|
||||
|
||||
// helper: run with grants fetch throwing for customer 11
|
||||
async function runWorldWithThrow(world, marker) {
|
||||
const S = freshWorld(world);
|
||||
const logRows = [];
|
||||
const httpRequest = async (opts) => {
|
||||
const u = opts.url, m = (opts.method || 'GET').toUpperCase();
|
||||
if (u.includes('/tag/records')) throw new Error('grants fetch boom');
|
||||
if (m === 'GET' && u.includes('/tc/records?limit=10000')) return { list: S.customers };
|
||||
if (m === 'GET' && u.includes(`/tr/records?limit=10000`)) return { list: S.resources };
|
||||
if (m === 'GET' && u.includes('/api/admin/user/list')) return { data: { content: S.users } };
|
||||
if (m === 'GET' && u.includes('/api/admin/role/list')) return { data: { content: S.roles } };
|
||||
const ug = u.match(/\/api\/admin\/user\/get\?id=(\d+)/);
|
||||
if (m === 'GET' && ug) { const x = S.users.find(uu => uu.id === Number(ug[1])); return x ? { data: x } : { code: 404, data: null }; }
|
||||
const rg = u.match(/\/api\/admin\/role\/get\?id=(\d+)/);
|
||||
if (m === 'GET' && rg) { const x = S.roles.find(r => r.id === Number(rg[1])); return x ? { data: x } : { code: 404, data: null }; }
|
||||
if (u.endsWith('/api/auth/login')) return { data: { token: 'ADMIN_TOKEN' } };
|
||||
if (m === 'POST' && u.includes('/tlog/records')) { (Array.isArray(opts.body) ? opts.body : [opts.body]).forEach(r => logRows.push(r)); return {}; }
|
||||
if (m === 'POST' && u.endsWith('/api/admin/user/update')) return { code: 200 };
|
||||
if (m === 'POST' && u.endsWith('/api/admin/role/update')) return { code: 200 };
|
||||
throw new Error('unexpected: ' + m + ' ' + u);
|
||||
};
|
||||
const sandbox = {
|
||||
$env: { NOCODB_URL: 'https://nc', NOCODB_TOKEN: 't', NOCODB_TABLE_CUSTOMERS: 'tc', NOCODB_TABLE_CUSTOMERPRODUCTS: 'tcp', NOCODB_TABLE_PRODUCTS: 'tp', NOCODB_TABLE_RESOURCES: 'tr', NOCODB_TABLE_ACCESSGRANTS: 'tag', NOCODB_TABLE_RECONCILELOGS: 'tlog', NOCODB_LINK_PROD_RESOURCES: 'lnk', ALIST_URL: 'https://al', ALIST_ADMIN_USERNAME: 'admin', ALIST_ADMIN_PASSWORD: 'p', W4_DRY_RUN: 'false' },
|
||||
$json: {}, console,
|
||||
};
|
||||
sandbox.helpers = { httpRequest };
|
||||
const fn = vm.runInNewContext(`(async function(){\n${code}\n})`, sandbox, { filename: 'w4-body.js' });
|
||||
const result = await fn();
|
||||
return { s: result.json, logRows };
|
||||
}
|
||||
|
||||
main().then(() => {}).catch(e => { console.error(e); process.exit(1); });
|
||||
+377
@@ -0,0 +1,377 @@
|
||||
// ============================================================================
|
||||
// W4 runtime body — Daily full reconciliation + drift repair + reconcile log.
|
||||
// Rebuilt by `node n8n/gen_w4.js` (single source of truth = shared/effective-grants.js).
|
||||
//
|
||||
// ONE-DIRECTIONAL sync: NocoDB `Customers.status` is the BUSINESS source of
|
||||
// truth; AList user enabled/disabled is only ENFORCEMENT state. W4 NEVER
|
||||
// writes Customers.status. A manual `disabled` flip in the AList admin UI is
|
||||
// repaired back to match NocoDB — never the other way round.
|
||||
//
|
||||
// Drift matrix (auto-repair):
|
||||
// status active -> user must exist (provision if missing; lookup by
|
||||
// username FIRST to avoid duplicate users when the
|
||||
// NocoDB id was lost), be ENABLED, carry the desired
|
||||
// role scopes (calculateEffectiveGrants), base_path
|
||||
// '/', permission 16384.
|
||||
// status non-active -> if a user exists: must be DISABLED with empty role
|
||||
// scopes (soft state kept). pending/never-provisioned
|
||||
// customers stay WITHOUT an AList user (no auto-create).
|
||||
// dangling refs -> alist_user_id / alist_role_id point at deleted AList
|
||||
// records: lookup by username / role name, adopt if
|
||||
// found, recreate if not, then re-sync.
|
||||
// username drift -> NocoDB username differs from AList username: DETECT
|
||||
// + log only (no safe official rename API assumed; no
|
||||
// destructive migration).
|
||||
//
|
||||
// Every repair is VERIFIED by re-reading AList state afterwards; a failed
|
||||
// verify is logged as verify_failed. ReconcileLogs receives ONLY
|
||||
// drift/repair/error rows — healthy customers produce nothing.
|
||||
//
|
||||
// Triggers: daily cron (03:00) + manual webhook. Dry run: webhook
|
||||
// `?dryRun=true` or env W4_DRY_RUN=true -> plan only, ZERO writes.
|
||||
// ============================================================================
|
||||
// __SHARED_INLINE__ (replaced by shared/effective-grants.js source at build)
|
||||
// ============================================================================
|
||||
|
||||
const ctx = { $env, helpers: this.helpers };
|
||||
|
||||
const query = ($json && $json.query) || {};
|
||||
const DRY_RUN = query.dryRun === 'true' || query.dryRun === true ||
|
||||
String($env.W4_DRY_RUN || '').toLowerCase() === 'true';
|
||||
const T_CUST = $env.NOCODB_TABLE_CUSTOMERS;
|
||||
const T_LOG = $env.NOCODB_TABLE_RECONCILELOGS;
|
||||
// NocoDB v2 (2026.08.x): bt links are NOT created by writing the link column
|
||||
// (scalar/array/object all ignored). Write the FK column directly:
|
||||
// `nc_jitu___Customers_id` (verified live 2026-08-23).
|
||||
const FK_CUST = $env.NOCODB_FK_RECONCILELOG_CUSTOMER || 'nc_jitu___Customers_id';
|
||||
const runId = 'recon-' + new Date().toISOString().replace(/[:.]/g, '-');
|
||||
|
||||
// canonical AList user configuration — must stay identical to W1's (verified
|
||||
// working: base_path '/' + absolute role scopes + permission 16384)
|
||||
const BASE_PATH = '/';
|
||||
const PERMISSION = 16384;
|
||||
|
||||
const logRows = []; // ReconcileLogs payload — drift/repair/error rows ONLY
|
||||
|
||||
const summary = {
|
||||
runId, dryRun: DRY_RUN,
|
||||
customersScanned: 0, driftsDetected: 0, repaired: 0, skipped: 0,
|
||||
errors: [], details: [],
|
||||
};
|
||||
|
||||
function pushLog(cid, username, driftType, details, action, result) {
|
||||
const row = {
|
||||
run_id: runId,
|
||||
drift_type: driftType,
|
||||
details: details == null ? '' : String(details).slice(0, 2000),
|
||||
action,
|
||||
result,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
if (cid != null) row[FK_CUST] = cid; // FK column (link writes are ignored)
|
||||
logRows.push(row);
|
||||
}
|
||||
|
||||
// ---- AList helpers (read) ----
|
||||
async function listUsers(token) {
|
||||
const r = await alistReq(ctx, 'GET', '/api/admin/user/list?page=1&per_page=1000', null, token);
|
||||
return (r && (r.data?.content || r.data?.list)) || [];
|
||||
}
|
||||
async function listRoles(token) {
|
||||
const r = await alistReq(ctx, 'GET', '/api/admin/role/list?page=1&per_page=1000', null, token);
|
||||
return (r && (r.data?.content || r.data?.list)) || [];
|
||||
}
|
||||
async function getUser(token, id) {
|
||||
const r = await alistReq(ctx, 'GET', `/api/admin/user/get?id=${id}`, null, token);
|
||||
return (r && r.data) ? r.data : null;
|
||||
}
|
||||
async function getRole(token, id) {
|
||||
const r = await alistReq(ctx, 'GET', `/api/admin/role/get?id=${id}`, null, token);
|
||||
return (r && r.data) ? r.data : null;
|
||||
}
|
||||
|
||||
// ---- AList helpers (write) — all no-op under DRY_RUN ----
|
||||
async function createRole(token, name, description) {
|
||||
if (DRY_RUN) return { id: null, name, permission_scopes: [] };
|
||||
await alistReq(ctx, 'POST', '/api/admin/role/create', {
|
||||
name, description, default: false, permission_scopes: [],
|
||||
}, token);
|
||||
const roles = await listRoles(token);
|
||||
const r = roles.find(x => x.name === name);
|
||||
if (!r) throw new Error('Created role not found: ' + name);
|
||||
return r;
|
||||
}
|
||||
async function createUser(token, body) {
|
||||
if (DRY_RUN) return { id: null, username: body.username };
|
||||
await alistReq(ctx, 'POST', '/api/admin/user/create', body, token);
|
||||
const users = await listUsers(token);
|
||||
const u = users.find(x => x.username === body.username);
|
||||
if (!u) throw new Error('Created user not found: ' + body.username);
|
||||
return u;
|
||||
}
|
||||
async function updateUser(token, fields) {
|
||||
if (DRY_RUN) return;
|
||||
await alistReq(ctx, 'POST', '/api/admin/user/update', fields, token);
|
||||
}
|
||||
async function updateRoleScopes(token, roleId, name, description, scopes) {
|
||||
if (DRY_RUN) return;
|
||||
await alistReq(ctx, 'POST', '/api/admin/role/update', {
|
||||
id: roleId, name, description, permission_scopes: scopes,
|
||||
}, token);
|
||||
}
|
||||
|
||||
// ---- NocoDB bookkeeping write-back (adopted user/role ids). Echo-safe: only
|
||||
// alist_user_id / alist_role_id are written; W1's old/new-field comparison
|
||||
// treats these as bookkeeping -> echo guard short-circuits. ----
|
||||
async function writeBack(cid, fields) {
|
||||
if (DRY_RUN || !Object.keys(fields).length) return;
|
||||
const live = await ncGet(ctx, `/api/v2/tables/${T_CUST}/records/${cid}`);
|
||||
const changes = {};
|
||||
for (const k of Object.keys(fields)) {
|
||||
if ((live[k] ?? null) !== (fields[k] ?? null)) changes[k] = fields[k];
|
||||
}
|
||||
if (!Object.keys(changes).length) return; // value unchanged -> no PATCH -> no webhook
|
||||
await ctx.helpers.httpRequest({
|
||||
method: 'PATCH',
|
||||
url: `${$env.NOCODB_URL}/api/v2/tables/${T_CUST}/records`,
|
||||
headers: { 'xc-token': $env.NOCODB_TOKEN },
|
||||
body: [Object.assign({ Id: cid }, changes)],
|
||||
json: true,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- verification (post-repair read-back) ----
|
||||
async function verifyUserState(token, userId, expect) {
|
||||
const u = await getUser(token, userId);
|
||||
if (!u) return { ok: false, actual: null };
|
||||
for (const k of Object.keys(expect)) {
|
||||
const want = expect[k];
|
||||
const have = u[k];
|
||||
const wantS = Array.isArray(want) ? JSON.stringify(want) : want;
|
||||
const haveS = Array.isArray(have) ? JSON.stringify(have) : have;
|
||||
if (String(haveS ?? '') !== String(wantS ?? '')) return { ok: false, actual: u };
|
||||
}
|
||||
return { ok: true, actual: u };
|
||||
}
|
||||
async function verifyRoleScopes(token, roleId, expectScopes) {
|
||||
const r = await getRole(token, roleId);
|
||||
if (!r) return { ok: false, actual: null };
|
||||
const cur = (r.permission_scopes || []).map(s => s.path).sort();
|
||||
const exp = expectScopes.map(s => s.path).sort();
|
||||
const ok = cur.length === exp.length && cur.every((p, i) => p === exp[i]);
|
||||
return { ok, actual: r };
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// per-customer reconciliation
|
||||
// ============================================================================
|
||||
async function reconcileOne(cust, token) {
|
||||
const cid = cust.Id;
|
||||
const username = cust.username;
|
||||
const status = cust.status || 'active'; // W1 convention: missing = active
|
||||
const isActive = status === 'active';
|
||||
const drifts = []; // {type, action, result}
|
||||
|
||||
const note = (type, details, action, result) => {
|
||||
drifts.push({ type, action, result });
|
||||
pushLog(cid, username, type, details, action, result);
|
||||
};
|
||||
|
||||
// ---------- 1. resolve the AList user ----------
|
||||
let user = null;
|
||||
if (cust.alist_user_id) {
|
||||
user = await getUser(token, cust.alist_user_id);
|
||||
if (user && user.username !== username) {
|
||||
note('username_drift',
|
||||
JSON.stringify({ noco: username, alist: user.username, user_id: user.id }),
|
||||
'log_only', 'log_only');
|
||||
}
|
||||
}
|
||||
if (!user) {
|
||||
// id missing or dangling: lookup by username to avoid duplicate users
|
||||
const byName = (await listUsers(token)).find(u => u.username === username);
|
||||
if (byName) {
|
||||
user = byName;
|
||||
if (cust.alist_user_id !== byName.id) {
|
||||
note('dangling_user_ref',
|
||||
JSON.stringify({ ref: cust.alist_user_id, adopted: byName.id, found_by: 'username' }),
|
||||
'adopted', 'repaired');
|
||||
await writeBack(cid, { alist_user_id: byName.id });
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!user && isActive) {
|
||||
// genuinely missing: provision role first, then user (empty role -> 400)
|
||||
let role = null;
|
||||
const roleName = 'customer-' + username;
|
||||
const byName = (await listRoles(token)).find(r => r.name === roleName);
|
||||
if (byName) role = byName;
|
||||
else if (!DRY_RUN) role = await createRole(token, roleName, 'Auto role for ' + username);
|
||||
else role = { id: null, name: roleName, permission_scopes: [] };
|
||||
user = await createUser(token, {
|
||||
username,
|
||||
password: cust.password || 'change_me',
|
||||
base_path: BASE_PATH,
|
||||
role: role.id ? [role.id] : [],
|
||||
permission: PERMISSION,
|
||||
});
|
||||
note('user_missing',
|
||||
JSON.stringify({ alist_user_id: cust.alist_user_id ?? null, alist_role_id: cust.alist_role_id ?? null }),
|
||||
DRY_RUN ? 'provision_planned' : 'provisioned', 'repaired');
|
||||
if (role.id) await writeBack(cid, { alist_role_id: role.id });
|
||||
}
|
||||
if (!user) {
|
||||
// non-active and never provisioned -> pure label, keep NO AList user.
|
||||
// (Healthy state: no drift, no log row.)
|
||||
return drifts;
|
||||
}
|
||||
if (user.id) await writeBack(cid, { alist_user_id: user.id });
|
||||
|
||||
// ---------- 2. resolve the role (the user's role binding) ----------
|
||||
const roleFor = async (wantName) => {
|
||||
const roleName = wantName || ('customer-' + username);
|
||||
const bound = Array.isArray(user.role) ? user.role[0] : (user.role || null);
|
||||
if (bound) {
|
||||
const r = await getRole(token, bound);
|
||||
if (r) return r;
|
||||
const byName = (await listRoles(token)).find(x => x.name === roleName);
|
||||
note('dangling_role_ref',
|
||||
JSON.stringify({ ref: bound, adopted: byName ? byName.id : null, want: roleName }),
|
||||
byName ? 'adopted' : 'recreate_required', 'repaired');
|
||||
if (byName) return byName;
|
||||
}
|
||||
const byName = (await listRoles(token)).find(x => x.name === roleName);
|
||||
if (byName) return byName;
|
||||
if (isActive) {
|
||||
const created = await createRole(token, roleName, 'Auto role for ' + username);
|
||||
note('role_missing', JSON.stringify({ roleName }), 'created', 'repaired');
|
||||
return created;
|
||||
}
|
||||
return null; // non-active with no role: nothing to clear
|
||||
};
|
||||
let role = await roleFor();
|
||||
|
||||
// ---------- 3. desired scopes (shared algorithm; also reads manual grants) ----------
|
||||
const g = await calculateEffectiveGrants(ctx, cid);
|
||||
const desiredScopes = isActive ? g.newScopes : [];
|
||||
|
||||
// ---------- 4. apply fixes (canonical state = W1's verified config) ----------
|
||||
const userPatches = [];
|
||||
const expectUser = {};
|
||||
if (!isActive) {
|
||||
if (user.disabled !== true) { userPatches.push('disabled'); note('user_disabled_drift', JSON.stringify({ have: user.disabled, want: true }), 'disabled', 'repaired'); }
|
||||
expectUser.disabled = true;
|
||||
} else {
|
||||
if (user.disabled === true) { userPatches.push('re-enabled'); note('user_disabled_drift', JSON.stringify({ have: true, want: false }), 'enabled', 'repaired'); }
|
||||
expectUser.disabled = false;
|
||||
}
|
||||
if (user.base_path !== BASE_PATH) {
|
||||
userPatches.push('base_path'); note('base_path_drift', JSON.stringify({ have: user.base_path, want: BASE_PATH }), 'fixed', 'repaired');
|
||||
}
|
||||
expectUser.base_path = BASE_PATH;
|
||||
if (user.permission !== PERMISSION) {
|
||||
userPatches.push('permission'); note('permission_drift', JSON.stringify({ have: user.permission, want: PERMISSION }), 'fixed', 'repaired');
|
||||
}
|
||||
expectUser.permission = PERMISSION;
|
||||
const boundRoleId = role && role.id;
|
||||
if (boundRoleId && !(Array.isArray(user.role) && user.role.includes(boundRoleId))) {
|
||||
userPatches.push('role_binding'); note('role_binding_drift', JSON.stringify({ have: user.role, want: [boundRoleId] }), 'rebound', 'repaired');
|
||||
}
|
||||
expectUser.role = boundRoleId ? [boundRoleId] : user.role || [];
|
||||
|
||||
if (userPatches.length) {
|
||||
// NOTE: password intentionally NOT included in repairs — NocoDB plaintext
|
||||
// may be stale (方案 C: customer may have changed their own AList password;
|
||||
// pushing it here would clobber it). Password only used at provision.
|
||||
await updateUser(token, Object.assign({ id: user.id }, {
|
||||
username, base_path: BASE_PATH, permission: PERMISSION, disabled: !isActive,
|
||||
role: boundRoleId ? [boundRoleId] : [],
|
||||
}));
|
||||
}
|
||||
|
||||
// scopes drift (role may be null for non-active with no role -> nothing to clear)
|
||||
if (role && role.id) {
|
||||
const cur = (role.permission_scopes || []).map(s => s.path).sort();
|
||||
const want = desiredScopes.map(s => s.path).sort();
|
||||
if (cur.length !== want.length || cur.some((p, i) => p !== want[i])) {
|
||||
note('scopes_drift',
|
||||
JSON.stringify({ have: cur, want }),
|
||||
DRY_RUN ? 'rewrite_planned' : 'rewritten', 'repaired');
|
||||
await updateRoleScopes(token, role.id, role.name, role.description, desiredScopes);
|
||||
}
|
||||
} else if (isActive && !DRY_RUN) {
|
||||
// active but could not bind a role (should not happen; roleFor creates one)
|
||||
throw new Error('Active customer has no role to write scopes into');
|
||||
}
|
||||
|
||||
// ---------- 5. verify actual AList state after every repair ----------
|
||||
if (!DRY_RUN && user.id) {
|
||||
const uu = await verifyUserState(token, user.id, expectUser);
|
||||
if (!uu.ok) {
|
||||
note('verify_failed', JSON.stringify({ expect: expectUser, actual: uu.actual }), 'verify', 'failed');
|
||||
}
|
||||
if (role && role.id) {
|
||||
const rr = await verifyRoleScopes(token, role.id, desiredScopes);
|
||||
if (!rr.ok) {
|
||||
note('verify_failed', JSON.stringify({ expect_scopes: desiredScopes, actual: rr.actual }), 'verify', 'failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return drifts;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// main sweep
|
||||
// ============================================================================
|
||||
const login = await alistReq(ctx, 'POST', '/api/auth/login', {
|
||||
username: $env.ALIST_ADMIN_USERNAME,
|
||||
password: $env.ALIST_ADMIN_PASSWORD,
|
||||
}, null);
|
||||
const token = login?.data?.token;
|
||||
if (!token) throw new Error('AList login failed');
|
||||
|
||||
const customers = (await ncGet(ctx, `/api/v2/tables/${T_CUST}/records?limit=10000`)).list || [];
|
||||
|
||||
for (const cust of customers) {
|
||||
if (!cust.username) {
|
||||
summary.errors.push({ customerId: cust.Id, username: null, error: 'missing username' });
|
||||
pushLog(cust.Id, null, 'reconcile_error', JSON.stringify({ row: cust.Id }), 'log_only', 'failed');
|
||||
summary.customersScanned++;
|
||||
continue;
|
||||
}
|
||||
summary.customersScanned++;
|
||||
const entry = { customerId: cust.Id, username: cust.username, status: cust.status || 'active', drifts: [] };
|
||||
try {
|
||||
entry.drifts = await reconcileOne(cust, token);
|
||||
if (entry.drifts.length === 0) summary.skipped++;
|
||||
else summary.details.push(entry);
|
||||
} catch (e) {
|
||||
summary.errors.push({ customerId: cust.Id, username: cust.username, error: e.message });
|
||||
pushLog(cust.Id, cust.username, 'reconcile_error', JSON.stringify({ error: e.message }), 'log_only', 'failed');
|
||||
}
|
||||
}
|
||||
|
||||
summary.driftsDetected = logRows.filter(r => r.result !== 'failed').length;
|
||||
summary.repaired = logRows.filter(r => r.result === 'repaired').length;
|
||||
|
||||
// ---- write ReconcileLogs (drift/repair/error rows only) ----
|
||||
if (logRows.length && !DRY_RUN) {
|
||||
if (!T_LOG) {
|
||||
summary.logWriteSkipped = 'NOCODB_TABLE_RECONCILELOGS env missing — rows not persisted';
|
||||
} else {
|
||||
await ctx.helpers.httpRequest({
|
||||
method: 'POST',
|
||||
url: `${$env.NOCODB_URL}/api/v2/tables/${T_LOG}/records`,
|
||||
headers: { 'xc-token': $env.NOCODB_TOKEN },
|
||||
body: logRows,
|
||||
json: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { json: Object.assign({}, summary, {
|
||||
at: new Date().toISOString(),
|
||||
logRowsWritten: DRY_RUN ? 0 : logRows.length,
|
||||
plannedLogRows: DRY_RUN ? logRows.length : 0,
|
||||
}) };
|
||||
Reference in New Issue
Block a user