Files
alist-serve-resource/n8n/W4-daily-reconcile.json
hoelee 4693c9d684 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
2026-08-23 09:34:11 +08:00

82 lines
23 KiB
JSON

{
"name": "W4 - Daily Full Reconciliation (AList Drift Repair)",
"nodes": [
{
"id": "ea636132-2235-4935-a575-84ee51197c10",
"parameters": {
"rule": {
"interval": [
{
"field": "cronExpression",
"expression": "0 3 * * *"
}
]
}
},
"name": "Daily 03:00",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.2,
"position": [
-420,
0
]
},
{
"id": "23153525-50ae-48e5-b28a-27973c23c7f1",
"parameters": {
"httpMethod": "POST",
"path": "alist-w4-reconcile",
"options": {}
},
"name": "Manual / Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"webhookId": "alist-w4-reconcile",
"position": [
-420,
240
]
},
{
"id": "987e71ca-e1ce-4bd0-af88-dbc5d211c6b0",
"parameters": {
"jsCode": "// ============================================================================\n// W4 runtime body — Daily full reconciliation + drift repair + reconcile log.\n// Rebuilt by `node n8n/gen_w4.js` (single source of truth = shared/effective-grants.js).\n//\n// ONE-DIRECTIONAL sync: NocoDB `Customers.status` is the BUSINESS source of\n// truth; AList user enabled/disabled is only ENFORCEMENT state. W4 NEVER\n// writes Customers.status. A manual `disabled` flip in the AList admin UI is\n// repaired back to match NocoDB — never the other way round.\n//\n// Drift matrix (auto-repair):\n// status active -> user must exist (provision if missing; lookup by\n// username FIRST to avoid duplicate users when the\n// NocoDB id was lost), be ENABLED, carry the desired\n// role scopes (calculateEffectiveGrants), base_path\n// '/', permission 16384.\n// status non-active -> if a user exists: must be DISABLED with empty role\n// scopes (soft state kept). pending/never-provisioned\n// customers stay WITHOUT an AList user (no auto-create).\n// dangling refs -> alist_user_id / alist_role_id point at deleted AList\n// records: lookup by username / role name, adopt if\n// found, recreate if not, then re-sync.\n// username drift -> NocoDB username differs from AList username: DETECT\n// + log only (no safe official rename API assumed; no\n// destructive migration).\n//\n// Every repair is VERIFIED by re-reading AList state afterwards; a failed\n// verify is logged as verify_failed. ReconcileLogs receives ONLY\n// drift/repair/error rows — healthy customers produce nothing.\n//\n// Triggers: daily cron (03:00) + manual webhook. Dry run: webhook\n// `?dryRun=true` or env W4_DRY_RUN=true -> plan only, ZERO writes.\n// ============================================================================\n// ============================================================================\n// SHARED: calculateEffectiveGrants — single source of truth for \"what scopes\n// should customer X have right now\", reused by W1 (active resume), W2\n// (CustomerProduct sync) and W3 (expiry sweep).\n//\n// This file is INLINED into each workflow's Code node at build time (see\n// gen_w1.js / gen_w2.js / gen_w3.js). It must therefore be self-contained:\n// no require/import, no process.exit, no top-level return outside functions.\n//\n// Config is read via $env (the ONLY reliable channel in n8n Code nodes for\n// custom env vars — process.env is unreliable there). The caller passes a\n// `ctx` with { $env, helpers } so this file is also unit-testable outside n8n.\n//\n// ============================================================================\n\n// --- helpers (depend on ctx, so they are functions not top-level consts) ---\n\nfunction ncUrl(ctx, path) {\n return ctx.$env.NOCODB_URL + path;\n}\n\nasync function ncGet(ctx, path) {\n return await ctx.helpers.httpRequest({\n method: 'GET',\n url: ncUrl(ctx, path),\n headers: { 'xc-token': ctx.$env.NOCODB_TOKEN },\n json: true,\n });\n}\n\nasync function alistReq(ctx, method, path, body, token) {\n // NOTE: AList expects the RAW token in Authorization (no \"Bearer \" prefix).\n return await ctx.helpers.httpRequest({\n method,\n url: ctx.$env.ALIST_URL + path,\n headers: token ? { Authorization: token } : {},\n body,\n json: true,\n });\n}\n\n// NocoDB LinkToAnotherRecord values are objects/arrays/numbers depending on\n// whether the API was asked to expand them. Normalise to a single id or null.\nfunction linkId(v) {\n if (v == null) return null;\n if (typeof v === 'object' && !Array.isArray(v)) return v.Id ?? v.id ?? null;\n if (Array.isArray(v)) return v[0]?.Id ?? v[0]?.id ?? null;\n return v;\n}\n\n// --- core algorithm --------------------------------------------------------\n//\n// desired scopes for a customer = union of:\n// (a) every RESOURCE reachable from an ACTIVE, NOT-YET-EXPIRED CustomerProduct\n// (b) every RESOURCE granted directly via an ACTIVE, NOT-YET-EXPIRED AccessGrant\n//\n// For (a) the SAME resource coming from MULTIPLE products keeps the MAX\n// expires_at, exactly matching the \"two products share a resource\" semantics\n// the user tested. Expiry boundary is `<= today` (expiring today is expired).\n\nasync function calculateEffectiveGrants(ctx, customerId) {\n const T_CUST = ctx.$env.NOCODB_TABLE_CUSTOMERS;\n const T_CUSTPROD = ctx.$env.NOCODB_TABLE_CUSTOMERPRODUCTS;\n const T_PROD = ctx.$env.NOCODB_TABLE_PRODUCTS;\n const T_RES = ctx.$env.NOCODB_TABLE_RESOURCES;\n const T_GRANT = ctx.$env.NOCODB_TABLE_ACCESSGRANTS;\n const LNK_PROD_RES = ctx.$env.NOCODB_LINK_PROD_RESOURCES; // c4ko4x8kwi16swg\n const today = new Date().toISOString().slice(0, 10); // 'YYYY-MM-DD'\n\n // index all Resources by Id -> alist_path (cheap, one query)\n const allRes = (await ncGet(ctx, `/api/v2/tables/${T_RES}/records?limit=10000`)).list || [];\n const resPath = {};\n allRes.forEach(r => { resPath[r.Id] = r.alist_path; });\n\n const desired = {}; // path -> { expires, permission }\n\n // (a) CustomerProducts -> Resources (via product M2M link), MAX expiry on\n // the same path.\n // NOTE: `(customer,eq,N)` matches the link field's DISPLAY NAME and always\n // returns 0 rows. Must use the `customer.Id` sub-field syntax (or the\n // foreign-key column `nc_jitu___Customers_id`).\n const cps = (await ncGet(ctx, `/api/v2/tables/${T_CUSTPROD}/records?where=(customer.Id,eq,${customerId})&limit=1000`)).list || [];\n for (const c of cps) {\n if (c.status && c.status !== 'active') continue;\n if (c.expires_at && String(c.expires_at) <= today) continue; // expired (incl. today)\n const pid = linkId(c.product);\n if (!pid) continue;\n const res = (await ncGet(ctx, `/api/v2/tables/${T_PROD}/links/${LNK_PROD_RES}/records/${pid}`)).list || [];\n const exp = c.expires_at; // 'YYYY-MM-DD' (may be null = no expiry)\n for (const r of res) {\n const p = resPath[r.Id];\n if (!p) continue;\n const cur = desired[p];\n if (!cur || !cur.expires || (exp && exp > cur.expires)) {\n desired[p] = { expires: exp || null, permission: cur?.permission ?? 0 };\n }\n }\n }\n\n // (b) AccessGrants -> Resources (manual / materialised direct grants)\n const grants = (await ncGet(ctx, `/api/v2/tables/${T_GRANT}/records?where=(customer.Id,eq,${customerId})&limit=1000`)).list || [];\n for (const g of grants) {\n if (g.status && g.status !== 'active') continue;\n if (g.expires_at && String(g.expires_at) <= today) continue;\n const p = resPath[linkId(g.resource)];\n if (!p) continue;\n const exp = g.expires_at;\n const cur = desired[p];\n if (!cur || !cur.expires || (exp && exp > cur.expires)) {\n desired[p] = { expires: exp || null, permission: g.permission === 'readwrite' ? 1 : 0 };\n }\n }\n\n const desiredPaths = Object.keys(desired).sort();\n return {\n customerId,\n desired, // { path: {expires, permission} }\n desiredPaths, // sorted array of paths\n newScopes: desiredPaths.map(p => ({ path: p, permission: desired[p].permission })),\n };\n}\n\n// --- export shim: kept only when unit-tested, stripped when inlined -------\n// ============================================================================\n\nconst ctx = { $env, helpers: this.helpers };\n\nconst query = ($json && $json.query) || {};\nconst DRY_RUN = query.dryRun === 'true' || query.dryRun === true ||\n String($env.W4_DRY_RUN || '').toLowerCase() === 'true';\nconst T_CUST = $env.NOCODB_TABLE_CUSTOMERS;\nconst T_LOG = $env.NOCODB_TABLE_RECONCILELOGS;\n// NocoDB v2 (2026.08.x): bt links are NOT created by writing the link column\n// (scalar/array/object all ignored). Write the FK column directly:\n// `nc_jitu___Customers_id` (verified live 2026-08-23).\nconst FK_CUST = $env.NOCODB_FK_RECONCILELOG_CUSTOMER || 'nc_jitu___Customers_id';\nconst runId = 'recon-' + new Date().toISOString().replace(/[:.]/g, '-');\n\n// canonical AList user configuration — must stay identical to W1's (verified\n// working: base_path '/' + absolute role scopes + permission 16384)\nconst BASE_PATH = '/';\nconst PERMISSION = 16384;\n\nconst logRows = []; // ReconcileLogs payload — drift/repair/error rows ONLY\n\nconst summary = {\n runId, dryRun: DRY_RUN,\n customersScanned: 0, driftsDetected: 0, repaired: 0, skipped: 0,\n errors: [], details: [],\n};\n\nfunction pushLog(cid, username, driftType, details, action, result) {\n const row = {\n run_id: runId,\n drift_type: driftType,\n details: details == null ? '' : String(details).slice(0, 2000),\n action,\n result,\n timestamp: new Date().toISOString(),\n };\n if (cid != null) row[FK_CUST] = cid; // FK column (link writes are ignored)\n logRows.push(row);\n}\n\n// ---- AList helpers (read) ----\nasync function listUsers(token) {\n const r = await alistReq(ctx, 'GET', '/api/admin/user/list?page=1&per_page=1000', null, token);\n return (r && (r.data?.content || r.data?.list)) || [];\n}\nasync function listRoles(token) {\n const r = await alistReq(ctx, 'GET', '/api/admin/role/list?page=1&per_page=1000', null, token);\n return (r && (r.data?.content || r.data?.list)) || [];\n}\nasync function getUser(token, id) {\n const r = await alistReq(ctx, 'GET', `/api/admin/user/get?id=${id}`, null, token);\n return (r && r.data) ? r.data : null;\n}\nasync function getRole(token, id) {\n const r = await alistReq(ctx, 'GET', `/api/admin/role/get?id=${id}`, null, token);\n return (r && r.data) ? r.data : null;\n}\n\n// ---- AList helpers (write) — all no-op under DRY_RUN ----\nasync function createRole(token, name, description) {\n if (DRY_RUN) return { id: null, name, permission_scopes: [] };\n await alistReq(ctx, 'POST', '/api/admin/role/create', {\n name, description, default: false, permission_scopes: [],\n }, token);\n const roles = await listRoles(token);\n const r = roles.find(x => x.name === name);\n if (!r) throw new Error('Created role not found: ' + name);\n return r;\n}\nasync function createUser(token, body) {\n if (DRY_RUN) return { id: null, username: body.username };\n await alistReq(ctx, 'POST', '/api/admin/user/create', body, token);\n const users = await listUsers(token);\n const u = users.find(x => x.username === body.username);\n if (!u) throw new Error('Created user not found: ' + body.username);\n return u;\n}\nasync function updateUser(token, fields) {\n if (DRY_RUN) return;\n await alistReq(ctx, 'POST', '/api/admin/user/update', fields, token);\n}\nasync function updateRoleScopes(token, roleId, name, description, scopes) {\n if (DRY_RUN) return;\n await alistReq(ctx, 'POST', '/api/admin/role/update', {\n id: roleId, name, description, permission_scopes: scopes,\n }, token);\n}\n\n// ---- NocoDB bookkeeping write-back (adopted user/role ids). Echo-safe: only\n// alist_user_id / alist_role_id are written; W1's old/new-field comparison\n// treats these as bookkeeping -> echo guard short-circuits. ----\nasync function writeBack(cid, fields) {\n if (DRY_RUN || !Object.keys(fields).length) return;\n const live = await ncGet(ctx, `/api/v2/tables/${T_CUST}/records/${cid}`);\n const changes = {};\n for (const k of Object.keys(fields)) {\n if ((live[k] ?? null) !== (fields[k] ?? null)) changes[k] = fields[k];\n }\n if (!Object.keys(changes).length) return; // value unchanged -> no PATCH -> no webhook\n await ctx.helpers.httpRequest({\n method: 'PATCH',\n url: `${$env.NOCODB_URL}/api/v2/tables/${T_CUST}/records`,\n headers: { 'xc-token': $env.NOCODB_TOKEN },\n body: [Object.assign({ Id: cid }, changes)],\n json: true,\n });\n}\n\n// ---- verification (post-repair read-back) ----\nasync function verifyUserState(token, userId, expect) {\n const u = await getUser(token, userId);\n if (!u) return { ok: false, actual: null };\n for (const k of Object.keys(expect)) {\n const want = expect[k];\n const have = u[k];\n const wantS = Array.isArray(want) ? JSON.stringify(want) : want;\n const haveS = Array.isArray(have) ? JSON.stringify(have) : have;\n if (String(haveS ?? '') !== String(wantS ?? '')) return { ok: false, actual: u };\n }\n return { ok: true, actual: u };\n}\nasync function verifyRoleScopes(token, roleId, expectScopes) {\n const r = await getRole(token, roleId);\n if (!r) return { ok: false, actual: null };\n const cur = (r.permission_scopes || []).map(s => s.path).sort();\n const exp = expectScopes.map(s => s.path).sort();\n const ok = cur.length === exp.length && cur.every((p, i) => p === exp[i]);\n return { ok, actual: r };\n}\n\n// ============================================================================\n// per-customer reconciliation\n// ============================================================================\nasync function reconcileOne(cust, token) {\n const cid = cust.Id;\n const username = cust.username;\n const status = cust.status || 'active'; // W1 convention: missing = active\n const isActive = status === 'active';\n const drifts = []; // {type, action, result}\n\n const note = (type, details, action, result) => {\n drifts.push({ type, action, result });\n pushLog(cid, username, type, details, action, result);\n };\n\n // ---------- 1. resolve the AList user ----------\n let user = null;\n if (cust.alist_user_id) {\n user = await getUser(token, cust.alist_user_id);\n if (user && user.username !== username) {\n note('username_drift',\n JSON.stringify({ noco: username, alist: user.username, user_id: user.id }),\n 'log_only', 'log_only');\n }\n }\n if (!user) {\n // id missing or dangling: lookup by username to avoid duplicate users\n const byName = (await listUsers(token)).find(u => u.username === username);\n if (byName) {\n user = byName;\n if (cust.alist_user_id !== byName.id) {\n note('dangling_user_ref',\n JSON.stringify({ ref: cust.alist_user_id, adopted: byName.id, found_by: 'username' }),\n 'adopted', 'repaired');\n await writeBack(cid, { alist_user_id: byName.id });\n }\n }\n }\n if (!user && isActive) {\n // genuinely missing: provision role first, then user (empty role -> 400)\n let role = null;\n const roleName = 'customer-' + username;\n const byName = (await listRoles(token)).find(r => r.name === roleName);\n if (byName) role = byName;\n else if (!DRY_RUN) role = await createRole(token, roleName, 'Auto role for ' + username);\n else role = { id: null, name: roleName, permission_scopes: [] };\n user = await createUser(token, {\n username,\n password: cust.password || 'change_me',\n base_path: BASE_PATH,\n role: role.id ? [role.id] : [],\n permission: PERMISSION,\n });\n note('user_missing',\n JSON.stringify({ alist_user_id: cust.alist_user_id ?? null, alist_role_id: cust.alist_role_id ?? null }),\n DRY_RUN ? 'provision_planned' : 'provisioned', 'repaired');\n if (role.id) await writeBack(cid, { alist_role_id: role.id });\n }\n if (!user) {\n // non-active and never provisioned -> pure label, keep NO AList user.\n // (Healthy state: no drift, no log row.)\n return drifts;\n }\n if (user.id) await writeBack(cid, { alist_user_id: user.id });\n\n // ---------- 2. resolve the role (the user's role binding) ----------\n const roleFor = async (wantName) => {\n const roleName = wantName || ('customer-' + username);\n const bound = Array.isArray(user.role) ? user.role[0] : (user.role || null);\n if (bound) {\n const r = await getRole(token, bound);\n if (r) return r;\n const byName = (await listRoles(token)).find(x => x.name === roleName);\n note('dangling_role_ref',\n JSON.stringify({ ref: bound, adopted: byName ? byName.id : null, want: roleName }),\n byName ? 'adopted' : 'recreate_required', 'repaired');\n if (byName) return byName;\n }\n const byName = (await listRoles(token)).find(x => x.name === roleName);\n if (byName) return byName;\n if (isActive) {\n const created = await createRole(token, roleName, 'Auto role for ' + username);\n note('role_missing', JSON.stringify({ roleName }), 'created', 'repaired');\n return created;\n }\n return null; // non-active with no role: nothing to clear\n };\n let role = await roleFor();\n\n // ---------- 3. desired scopes (shared algorithm; also reads manual grants) ----------\n const g = await calculateEffectiveGrants(ctx, cid);\n const desiredScopes = isActive ? g.newScopes : [];\n\n // ---------- 4. apply fixes (canonical state = W1's verified config) ----------\n const userPatches = [];\n const expectUser = {};\n if (!isActive) {\n if (user.disabled !== true) { userPatches.push('disabled'); note('user_disabled_drift', JSON.stringify({ have: user.disabled, want: true }), 'disabled', 'repaired'); }\n expectUser.disabled = true;\n } else {\n if (user.disabled === true) { userPatches.push('re-enabled'); note('user_disabled_drift', JSON.stringify({ have: true, want: false }), 'enabled', 'repaired'); }\n expectUser.disabled = false;\n }\n if (user.base_path !== BASE_PATH) {\n userPatches.push('base_path'); note('base_path_drift', JSON.stringify({ have: user.base_path, want: BASE_PATH }), 'fixed', 'repaired');\n }\n expectUser.base_path = BASE_PATH;\n if (user.permission !== PERMISSION) {\n userPatches.push('permission'); note('permission_drift', JSON.stringify({ have: user.permission, want: PERMISSION }), 'fixed', 'repaired');\n }\n expectUser.permission = PERMISSION;\n const boundRoleId = role && role.id;\n if (boundRoleId && !(Array.isArray(user.role) && user.role.includes(boundRoleId))) {\n userPatches.push('role_binding'); note('role_binding_drift', JSON.stringify({ have: user.role, want: [boundRoleId] }), 'rebound', 'repaired');\n }\n expectUser.role = boundRoleId ? [boundRoleId] : user.role || [];\n\n if (userPatches.length) {\n // NOTE: password intentionally NOT included in repairs — NocoDB plaintext\n // may be stale (方案 C: customer may have changed their own AList password;\n // pushing it here would clobber it). Password only used at provision.\n await updateUser(token, Object.assign({ id: user.id }, {\n username, base_path: BASE_PATH, permission: PERMISSION, disabled: !isActive,\n role: boundRoleId ? [boundRoleId] : [],\n }));\n }\n\n // scopes drift (role may be null for non-active with no role -> nothing to clear)\n if (role && role.id) {\n const cur = (role.permission_scopes || []).map(s => s.path).sort();\n const want = desiredScopes.map(s => s.path).sort();\n if (cur.length !== want.length || cur.some((p, i) => p !== want[i])) {\n note('scopes_drift',\n JSON.stringify({ have: cur, want }),\n DRY_RUN ? 'rewrite_planned' : 'rewritten', 'repaired');\n await updateRoleScopes(token, role.id, role.name, role.description, desiredScopes);\n }\n } else if (isActive && !DRY_RUN) {\n // active but could not bind a role (should not happen; roleFor creates one)\n throw new Error('Active customer has no role to write scopes into');\n }\n\n // ---------- 5. verify actual AList state after every repair ----------\n if (!DRY_RUN && user.id) {\n const uu = await verifyUserState(token, user.id, expectUser);\n if (!uu.ok) {\n note('verify_failed', JSON.stringify({ expect: expectUser, actual: uu.actual }), 'verify', 'failed');\n }\n if (role && role.id) {\n const rr = await verifyRoleScopes(token, role.id, desiredScopes);\n if (!rr.ok) {\n note('verify_failed', JSON.stringify({ expect_scopes: desiredScopes, actual: rr.actual }), 'verify', 'failed');\n }\n }\n }\n\n return drifts;\n}\n\n// ============================================================================\n// main sweep\n// ============================================================================\nconst login = await alistReq(ctx, 'POST', '/api/auth/login', {\n username: $env.ALIST_ADMIN_USERNAME,\n password: $env.ALIST_ADMIN_PASSWORD,\n}, null);\nconst token = login?.data?.token;\nif (!token) throw new Error('AList login failed');\n\nconst customers = (await ncGet(ctx, `/api/v2/tables/${T_CUST}/records?limit=10000`)).list || [];\n\nfor (const cust of customers) {\n if (!cust.username) {\n summary.errors.push({ customerId: cust.Id, username: null, error: 'missing username' });\n pushLog(cust.Id, null, 'reconcile_error', JSON.stringify({ row: cust.Id }), 'log_only', 'failed');\n summary.customersScanned++;\n continue;\n }\n summary.customersScanned++;\n const entry = { customerId: cust.Id, username: cust.username, status: cust.status || 'active', drifts: [] };\n try {\n entry.drifts = await reconcileOne(cust, token);\n if (entry.drifts.length === 0) summary.skipped++;\n else summary.details.push(entry);\n } catch (e) {\n summary.errors.push({ customerId: cust.Id, username: cust.username, error: e.message });\n pushLog(cust.Id, cust.username, 'reconcile_error', JSON.stringify({ error: e.message }), 'log_only', 'failed');\n }\n}\n\nsummary.driftsDetected = logRows.filter(r => r.result !== 'failed').length;\nsummary.repaired = logRows.filter(r => r.result === 'repaired').length;\n\n// ---- write ReconcileLogs (drift/repair/error rows only) ----\nif (logRows.length && !DRY_RUN) {\n if (!T_LOG) {\n summary.logWriteSkipped = 'NOCODB_TABLE_RECONCILELOGS env missing — rows not persisted';\n } else {\n await ctx.helpers.httpRequest({\n method: 'POST',\n url: `${$env.NOCODB_URL}/api/v2/tables/${T_LOG}/records`,\n headers: { 'xc-token': $env.NOCODB_TOKEN },\n body: logRows,\n json: true,\n });\n }\n}\n\nreturn { json: Object.assign({}, summary, {\n at: new Date().toISOString(),\n logRowsWritten: DRY_RUN ? 0 : logRows.length,\n plannedLogRows: DRY_RUN ? logRows.length : 0,\n}) };"
},
"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"
}
}