236 lines
19 KiB
JavaScript
236 lines
19 KiB
JavaScript
const assert = require('node:assert/strict')
|
|||
|
|
const fs = require('node:fs')
|
||
|
|
const path = require('node:path')
|
||
|
|
const { execFileSync } = require('node:child_process')
|
||
|
|
const { assertPublicVerification, publicVerificationReports, fixtureReconciliationCounts } = require('./publish_public_runtime.cjs')
|
||
|
|
|
||
|
|
const root = path.resolve(__dirname, '../..')
|
||
|
|
const tenantID = 10059
|
||
|
|
const reportNames = publicVerificationReports
|
||
|
|
const memoryTables = ['memory_item_embeddings', 'memory_items', 'memory_tombstones', 'memory_topic_stats', 'memory_doc_affinity', 'memory_subjects']
|
||
|
|
const fixtureIDFields = ['users', 'roles', 'agents', 'skills', 'humans', 'kbs', 'workbench', 'docs', 'tags', 'sessions', 'messages', 'resources']
|
||
|
|
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||
|
|
const literal = value => `'${String(value).replaceAll("'", "''")}'`
|
||
|
|
const idsSQL = values => values.length ? values.map(literal).join(',') : 'NULL'
|
||
|
|
const completed = report => report.checked_at || report.time
|
||
|
|
const runWindow = (alias, report) => `${alias}.created_at >= ${literal(report.started_at)}::timestamptz AND ${alias}.created_at <= ${literal(completed(report))}::timestamptz`
|
||
|
|
const unique = values => [...new Set(values)]
|
||
|
|
|
||
|
|
function validateIDs(value, name, minimum = 0) {
|
||
|
|
assert.ok(Array.isArray(value) && value.length >= minimum, `Missing fixture IDs: ${name}`)
|
||
|
|
value.forEach(id => assert.match(id, uuid, `Invalid fixture ID: ${name}`))
|
||
|
|
assert.equal(new Set(value).size, value.length, `Duplicate fixture IDs: ${name}`)
|
||
|
|
return value
|
||
|
|
}
|
||
|
|
|
||
|
|
function validateReports(manifest, reports, now = Date.now()) {
|
||
|
|
assert.match(manifest.release, /^public-\d{8}-\d{6}$/)
|
||
|
|
assert.equal(manifest.url, 'https://agent.pducv.cn')
|
||
|
|
for (const name of reportNames) {
|
||
|
|
assert.ok(reports[name], `Missing verification report: ${name}`)
|
||
|
|
assertPublicVerification(manifest, reports[name], name, now)
|
||
|
|
for (const field of fixtureIDFields) {
|
||
|
|
if (reports[name].fixtures?.[field] !== undefined) validateIDs(reports[name].fixtures[field], `${name}.${field}`)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
const specifications = [
|
||
|
|
['public-resource-isolation', 'resource-isolation', {users: 2, roles: 1, agents: 2, skills: 1, kbs: 1}],
|
||
|
|
['public-resource-files', 'file-isolation', {users: 2, roles: 1, kbs: 2, docs: 1, sessions: 1, messages: 2, resources: 3}],
|
||
|
|
['public-indexed-retrieval-isolation', 'indexed-isolation', {users: 2, roles: 1, kbs: 1, docs: 1, tags: 2}],
|
||
|
|
]
|
||
|
|
for (const [name, prefix, fields] of specifications) {
|
||
|
|
const report = reports[name]
|
||
|
|
assert.match(report.prefix, new RegExp(`^${prefix}-[0-9]+$`))
|
||
|
|
for (const [field, minimum] of Object.entries(fields)) validateIDs(report.fixtures?.[field], `${name}.${field}`, minimum)
|
||
|
|
}
|
||
|
|
const resource = reports['public-resource-isolation']
|
||
|
|
assert.deepEqual(resource.fixtures.memory_subjects, resource.fixtures.users.map(id => `web_user:${id}`))
|
||
|
|
const file = reports['public-resource-files']
|
||
|
|
for (const [field, count] of Object.entries({users: 2, sessions: 1, messages: 2, resources: 3})) assert.equal(file.fixtures[field].length, count, `Unexpected file fixture count: ${field}`)
|
||
|
|
assert.equal(file.fixtures.message_scopes?.length, 1)
|
||
|
|
const scope = file.fixtures.message_scopes[0]
|
||
|
|
assert.equal(scope.tenant_id, tenantID)
|
||
|
|
assert.equal(scope.session_id, file.fixtures.sessions[0])
|
||
|
|
assert.equal(scope.user_id, file.fixtures.users[0])
|
||
|
|
assert.equal(scope.title, file.prefix)
|
||
|
|
assert.equal(scope.username, `${file.prefix}-alice`)
|
||
|
|
assert.equal(scope.email, `${scope.username}@example.test`)
|
||
|
|
assert.ok(Array.isArray(scope.messages), 'Missing file fixture messages')
|
||
|
|
assert.deepEqual(scope.messages.map(message => message.id), file.fixtures.messages)
|
||
|
|
assert.deepEqual(scope.messages.map(message => message.content), ['Disposable attachment fixture.', 'Unrelated message.'])
|
||
|
|
const tenant = reports['public-tenant-isolation']
|
||
|
|
assert.match(tenant.prefix, /^tenant-isolation-[0-9]+$/)
|
||
|
|
assert.ok(Number.isSafeInteger(tenant.second_tenant) && tenant.second_tenant > tenantID)
|
||
|
|
const access = reports['public-access-control']
|
||
|
|
if (access.prefix !== undefined) assert.match(access.prefix, /^access-check-[0-9]+$/)
|
||
|
|
}
|
||
|
|
|
||
|
|
function readOnlySQL(query) {
|
||
|
|
assert.match(query.trimStart(), /^(SELECT|WITH)\b/)
|
||
|
|
return `BEGIN READ ONLY;\nSET LOCAL statement_timeout='15s';\n${query}\nROLLBACK;`
|
||
|
|
}
|
||
|
|
|
||
|
|
function executeReadOnly(query, run = execFileSync) {
|
||
|
|
const output = run('docker', ['--context', 'colima-yawan', 'exec', '-i', 'WeKnora-postgres',
|
||
|
|
'psql', '-X', '-q', '-U', 'postgres', '-d', 'yawan_native_acceptance_20260915', '-At', '-v', 'ON_ERROR_STOP=1'], {
|
||
|
|
input: readOnlySQL(query), encoding: 'utf8', timeout: 45000, maxBuffer: 4 * 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'],
|
||
|
|
}).trim()
|
||
|
|
return JSON.parse(output)
|
||
|
|
}
|
||
|
|
|
||
|
|
function identityQuery(reports) {
|
||
|
|
const clauses = []
|
||
|
|
for (const name of ['public-resource-isolation', 'public-resource-files', 'public-indexed-retrieval-isolation']) {
|
||
|
|
clauses.push(`(u.id IN (${idsSQL(reports[name].fixtures.users)}))`)
|
||
|
|
}
|
||
|
|
const tenant = reports['public-tenant-isolation']
|
||
|
|
clauses.push(`(${runWindow('u', tenant)} AND u.username IN (${literal(tenant.prefix + '-alice')},${literal(tenant.prefix + '-bob')}) AND u.email=u.username || '@example.test')`)
|
||
|
|
const access = reports['public-access-control']
|
||
|
|
const username = access.prefix ? `u.username=${literal(access.prefix)}` : "u.username ~ '^access-check-[0-9]+$'"
|
||
|
|
clauses.push(`(${runWindow('u', access)} AND u.tenant_id=${tenantID} AND ${username} AND u.email IN (u.username || '@example.test','updated-' || u.username || '@example.test'))`)
|
||
|
|
return `SELECT COALESCE(json_agg(json_build_object('id',u.id,'username',u.username,'email',u.email,'tenant_id',u.tenant_id,'created_at',u.created_at)), '[]'::json) FROM users u WHERE ${clauses.join(' OR ')};`
|
||
|
|
}
|
||
|
|
|
||
|
|
function resolveIdentities(reports, rows) {
|
||
|
|
assert.ok(Array.isArray(rows), 'User discovery did not return an array')
|
||
|
|
const groups = [], used = new Set()
|
||
|
|
const matchUser = (report, username, id, tenants, updatedEmail = false) => {
|
||
|
|
const found = rows.filter(row => row.username === username && (!id || row.id === id))
|
||
|
|
assert.equal(found.length, 1, `Expected one retained fixture identity: ${username}`)
|
||
|
|
const row = found[0]
|
||
|
|
assert.match(row.id, uuid)
|
||
|
|
assert.ok(tenants.includes(Number(row.tenant_id)), `Unexpected fixture tenant: ${username}`)
|
||
|
|
assert.ok([`${username}@example.test`, ...(updatedEmail ? [`updated-${username}@example.test`] : [])].includes(row.email), `Unexpected fixture email: ${username}`)
|
||
|
|
assert.ok(Date.parse(row.created_at) >= Date.parse(report.started_at) && Date.parse(row.created_at) <= Date.parse(completed(report)), `Fixture identity outside report window: ${username}`)
|
||
|
|
assert.ok(!used.has(row.id), `Fixture ID reused across reports: ${row.id}`)
|
||
|
|
used.add(row.id)
|
||
|
|
return row.id
|
||
|
|
}
|
||
|
|
for (const [name, labels] of [
|
||
|
|
['public-resource-isolation', ['alice', 'bob']], ['public-resource-files', ['alice', 'bob']],
|
||
|
|
['public-indexed-retrieval-isolation', ['owner', 'reader']],
|
||
|
|
]) {
|
||
|
|
const report = reports[name]
|
||
|
|
assert.equal(report.fixtures.users.length, labels.length)
|
||
|
|
groups.push({name, report, prefix: report.prefix, users: labels.map((label, index) => matchUser(report, `${report.prefix}-${label}`, report.fixtures.users[index], [tenantID]))})
|
||
|
|
}
|
||
|
|
const tenant = reports['public-tenant-isolation']
|
||
|
|
groups.push({name: 'public-tenant-isolation', report: tenant, prefix: tenant.prefix, users: ['alice', 'bob'].map(label => matchUser(tenant, `${tenant.prefix}-${label}`, undefined, [tenantID, tenant.second_tenant]))})
|
||
|
|
const access = reports['public-access-control']
|
||
|
|
const candidates = rows.filter(row => /^access-check-[0-9]+$/.test(row.username) && (!access.prefix || access.prefix === row.username))
|
||
|
|
assert.equal(candidates.length, 1, 'Expected one access-control fixture account in its exact report window')
|
||
|
|
const accessPrefix = candidates[0].username
|
||
|
|
groups.push({name: 'public-access-control', report: access, prefix: accessPrefix, users: [matchUser(access, accessPrefix, undefined, [tenantID], true)]})
|
||
|
|
assert.equal(used.size, rows.length, 'Unexpected identity returned by fixture discovery')
|
||
|
|
return groups
|
||
|
|
}
|
||
|
|
|
||
|
|
function reconciliationQuery(reports, groups) {
|
||
|
|
const allUsers = unique(groups.flatMap(group => group.users))
|
||
|
|
const reported = key => unique(reportNames.flatMap(name => reports[name].fixtures?.[key] || []))
|
||
|
|
const targetName = (alias, field, group) => `${alias}.${field} ~ ${literal(`^${group.prefix}($|-(invalid|agent|admin-agent|member-agent|human|knowledge|skill|flow|admin-edited|member-flow|shared-flow-task|blocked|viewer-blocked|forbidden|document|alice|bob)$)`)}`
|
||
|
|
const discover = (alias, owner, field = 'name') => groups.map(group => `(${runWindow(alias, group.report)} AND (${owner ? `${alias}.${owner} IN (${idsSQL(group.users)}) OR ` : ''}${targetName(alias, field, group)}))`).join(' OR ')
|
||
|
|
const scoped = (table, key, owner, field = 'name') => `SELECT t.id FROM ${table} t WHERE t.tenant_id=${tenantID} AND (t.id IN (${idsSQL(reported(key))}) OR ${discover('t', owner, field)})`
|
||
|
|
const eventScope = groups.map(group => `(${runWindow('e', group.report)} AND ${targetName('e', 'message', {...group, prefix: '创建' + group.prefix})})`).join(' OR ')
|
||
|
|
const ctes = [
|
||
|
|
`fixture_roles AS (${scoped('workbench_access_roles', 'roles', null)})`,
|
||
|
|
`fixture_agents AS (${scoped('custom_agents', 'agents', 'created_by')})`,
|
||
|
|
`fixture_skills AS (${scoped('tenant_skill_catalog', 'skills', 'created_by')})`,
|
||
|
|
`fixture_humans AS (${scoped('digital_humans', 'humans', 'owner_id')})`,
|
||
|
|
`fixture_kbs AS (${scoped('knowledge_bases', 'kbs', 'creator_id')})`,
|
||
|
|
`fixture_workbench AS (${scoped('workbench_resources', 'workbench', 'owner_id')} UNION SELECT e.resource_id FROM workbench_events e WHERE e.tenant_id=${tenantID} AND e.action='create' AND (${eventScope}))`,
|
||
|
|
`fixture_sessions AS (${scoped('sessions', 'sessions', 'user_id', 'title')})`,
|
||
|
|
`fixture_docs AS (SELECT id FROM knowledges WHERE tenant_id=${tenantID} AND (id IN (${idsSQL(reported('docs'))}) OR knowledge_base_id IN (SELECT id FROM fixture_kbs)))`,
|
||
|
|
`fixture_tags AS (SELECT id FROM knowledge_tags WHERE tenant_id=${tenantID} AND (id IN (${idsSQL(reported('tags'))}) OR knowledge_base_id IN (SELECT id FROM fixture_kbs)))`,
|
||
|
|
`fixture_resource_ids AS (SELECT id FROM fixture_agents UNION SELECT id FROM fixture_skills UNION SELECT id FROM fixture_humans UNION SELECT id FROM fixture_kbs UNION SELECT id FROM fixture_workbench)`,
|
||
|
|
]
|
||
|
|
// Include recorded IDs even when their resource row was physically removed.
|
||
|
|
for (const [cte, key] of [['fixture_roles', 'roles'], ['fixture_agents', 'agents'], ['fixture_skills', 'skills'], ['fixture_humans', 'humans'], ['fixture_kbs', 'kbs'], ['fixture_workbench', 'workbench'], ['fixture_docs', 'docs'], ['fixture_tags', 'tags'], ['fixture_sessions', 'sessions']]) {
|
||
|
|
const index = ctes.findIndex(value => value.startsWith(`${cte} AS (`))
|
||
|
|
const ids = reported(key)
|
||
|
|
if (ids.length) ctes[index] = ctes[index].slice(0, -1) + ` UNION SELECT unnest(ARRAY[${idsSQL(ids)}]::text[]))`
|
||
|
|
}
|
||
|
|
const inUsers = `IN (${idsSQL(allUsers)})`
|
||
|
|
const fileIDs = idsSQL(reported('resources')), messageIDs = idsSQL(reported('messages'))
|
||
|
|
const membershipTenants = `${tenantID},${reports['public-tenant-isolation'].second_tenant}`
|
||
|
|
const counts = {
|
||
|
|
active_users: `SELECT count(*) FROM users WHERE id ${inUsers} AND deleted_at IS NULL`,
|
||
|
|
active_memberships: `SELECT count(*) FROM tenant_members WHERE user_id ${inUsers} AND deleted_at IS NULL`,
|
||
|
|
roles: 'SELECT count(*) FROM workbench_access_roles WHERE id IN (SELECT id FROM fixture_roles)',
|
||
|
|
assignments: `SELECT count(*) FROM workbench_access_assignments WHERE user_id ${inUsers} OR role_id IN (SELECT id FROM fixture_roles)`,
|
||
|
|
grants: `SELECT count(*) FROM workbench_access_grants WHERE user_id ${inUsers} OR (tenant_id=${tenantID} AND resource_id IN (SELECT id FROM fixture_resource_ids))`,
|
||
|
|
unrevoked_tokens: `SELECT count(*) FROM auth_tokens WHERE user_id ${inUsers} AND is_revoked=false`,
|
||
|
|
active_agents: 'SELECT count(*) FROM custom_agents WHERE id IN (SELECT id FROM fixture_agents) AND deleted_at IS NULL',
|
||
|
|
active_skills: 'SELECT count(*) FROM tenant_skill_catalog WHERE id IN (SELECT id FROM fixture_skills) AND deleted_at IS NULL',
|
||
|
|
active_digital_humans: 'SELECT count(*) FROM digital_humans WHERE id IN (SELECT id FROM fixture_humans) AND deleted_at IS NULL',
|
||
|
|
workbench_resources: 'SELECT count(*) FROM workbench_resources WHERE id IN (SELECT id FROM fixture_workbench)',
|
||
|
|
active_sessions: 'SELECT count(*) FROM sessions WHERE id IN (SELECT id FROM fixture_sessions) AND deleted_at IS NULL',
|
||
|
|
synthetic_messages: `SELECT count(*) FROM messages WHERE id IN (${messageIDs})`,
|
||
|
|
file_resources: `SELECT count(*) FROM resources WHERE id IN (${fileIDs})`,
|
||
|
|
file_bindings: `SELECT count(*) FROM resource_bindings WHERE resource_id IN (${fileIDs})`,
|
||
|
|
file_access_grants: `SELECT count(*) FROM resource_access_grants WHERE resource_id IN (${fileIDs}) AND revoked_at IS NULL`,
|
||
|
|
active_knowledge_bases: 'SELECT count(*) FROM knowledge_bases WHERE id IN (SELECT id FROM fixture_kbs) AND deleted_at IS NULL',
|
||
|
|
active_documents: 'SELECT count(*) FROM knowledges WHERE id IN (SELECT id FROM fixture_docs) AND deleted_at IS NULL',
|
||
|
|
active_chunks: `SELECT count(*) FROM chunks WHERE tenant_id=${tenantID} AND (knowledge_base_id IN (SELECT id FROM fixture_kbs) OR knowledge_id IN (SELECT id FROM fixture_docs)) AND deleted_at IS NULL`,
|
||
|
|
tags: 'SELECT count(*) FROM knowledge_tags WHERE id IN (SELECT id FROM fixture_tags)',
|
||
|
|
tag_relations: 'SELECT count(*) FROM knowledge_tag_relations WHERE tag_id IN (SELECT id FROM fixture_tags) OR knowledge_id IN (SELECT id FROM fixture_docs)',
|
||
|
|
publications: `SELECT count(*) FROM workbench_access_publications WHERE tenant_id=${tenantID} AND resource_id IN (SELECT id FROM fixture_resource_ids)`,
|
||
|
|
secondary_tenant: `SELECT count(*) FROM tenants WHERE id=${reports['public-tenant-isolation'].second_tenant}`,
|
||
|
|
secondary_memberships: `SELECT count(*) FROM tenant_members WHERE tenant_id=${reports['public-tenant-isolation'].second_tenant} AND deleted_at IS NULL`,
|
||
|
|
}
|
||
|
|
for (const table of memoryTables) counts[table] = `SELECT count(*) FROM ${table} WHERE tenant_id IN (${membershipTenants}) AND subject_id IN (${idsSQL(allUsers.map(id => `web_user:${id}`))})`
|
||
|
|
assert.deepEqual(Object.keys(counts), fixtureReconciliationCounts, 'Reconciliation queries must cover the publication contract')
|
||
|
|
const retained = {
|
||
|
|
soft_deleted_users: `SELECT count(*) FROM users WHERE id ${inUsers} AND deleted_at IS NOT NULL`,
|
||
|
|
revoked_tokens: `SELECT count(*) FROM auth_tokens WHERE user_id ${inUsers} AND is_revoked=true`,
|
||
|
|
soft_deleted_sessions: 'SELECT count(*) FROM sessions WHERE id IN (SELECT id FROM fixture_sessions) AND deleted_at IS NOT NULL',
|
||
|
|
soft_deleted_documents: 'SELECT count(*) FROM knowledges WHERE id IN (SELECT id FROM fixture_docs) AND deleted_at IS NOT NULL',
|
||
|
|
soft_deleted_chunks: `SELECT count(*) FROM chunks WHERE tenant_id=${tenantID} AND knowledge_base_id IN (SELECT id FROM fixture_kbs) AND deleted_at IS NOT NULL`,
|
||
|
|
workbench_revisions: `SELECT count(*) FROM workbench_revisions WHERE tenant_id=${tenantID} AND resource_id IN (SELECT id FROM fixture_workbench)`,
|
||
|
|
}
|
||
|
|
const objectSQL = values => `json_build_object(${Object.entries(values).map(([name, query]) => `${literal(name)},(${query})`).join(',')})`
|
||
|
|
const discovered = Object.fromEntries(['roles', 'agents', 'skills', 'humans', 'kbs', 'workbench', 'sessions', 'docs', 'tags'].map(key => [key, `SELECT COALESCE(json_agg(id), '[]'::json) FROM fixture_${key}`]))
|
||
|
|
return {query: `WITH ${ctes.join(',\n')}\nSELECT json_build_object('counts',${objectSQL(counts)},'retained',${objectSQL(retained)},'discovered',${objectSQL(discovered)});`, countNames: Object.keys(counts)}
|
||
|
|
}
|
||
|
|
|
||
|
|
function reconcile({manifest, reports, executeSQL = executeReadOnly, exists = fs.existsSync, directory = root, now = Date.now()}) {
|
||
|
|
validateReports(manifest, reports, now)
|
||
|
|
const identities = executeSQL(identityQuery(reports))
|
||
|
|
const groups = resolveIdentities(reports, identities)
|
||
|
|
const {query, countNames} = reconciliationQuery(reports, groups)
|
||
|
|
const database = executeSQL(query)
|
||
|
|
const checks = countNames.map(name => {
|
||
|
|
const count = database.counts?.[name]
|
||
|
|
assert.ok(Number.isSafeInteger(count) && count >= 0, `Invalid reconciliation count: ${name}`)
|
||
|
|
return {name, count, passed: count === 0}
|
||
|
|
})
|
||
|
|
const fileDirectory = path.join(directory, 'acceptance', 'files', String(tenantID), 'exports', reports['public-resource-files'].prefix)
|
||
|
|
checks.push({name: 'file_fixture_directory_removed', path: fileDirectory, passed: !exists(fileDirectory)})
|
||
|
|
return {release: manifest.release, origin: manifest.url, checked_at: new Date(now).toISOString(), read_only: true,
|
||
|
|
reports: Object.fromEntries(reportNames.map(name => [name, {started_at: reports[name].started_at, completed_at: completed(reports[name])}])),
|
||
|
|
identities, discovered: database.discovered, retained: database.retained, checks,
|
||
|
|
failures: checks.filter(check => !check.passed).map(check => check.name), passed: checks.every(check => check.passed),
|
||
|
|
scope: 'PostgreSQL fixture records and exact file-test export directory. Soft-deleted history, revoked tokens and audits are retained. Milvus zero-vector cleanup is attested by the fresh indexed-retrieval report, not queried again here.'}
|
||
|
|
}
|
||
|
|
|
||
|
|
function main() {
|
||
|
|
const release = process.argv[2]
|
||
|
|
assert.match(release || '', /^public-\d{8}-\d{6}$/, 'Usage: node scripts/verify_public_fixture_reconciliation.cjs public-YYYYMMDD-HHMMSS')
|
||
|
|
const reportPath = path.join(root, 'acceptance', 'public-fixture-reconciliation.json')
|
||
|
|
// Invalidate a previous success before reading reports or contacting the database.
|
||
|
|
fs.writeFileSync(reportPath, JSON.stringify({release, started_at: new Date().toISOString(), read_only: true,
|
||
|
|
passed: false, checks: [], failures: ['Reconciliation has not completed.']}, null, 2) + '\n', {mode: 0o600})
|
||
|
|
const manifest = JSON.parse(fs.readFileSync(path.join(root, 'releases', release, 'manifest.json'), 'utf8'))
|
||
|
|
assert.equal(manifest.release, release)
|
||
|
|
const reports = Object.fromEntries(reportNames.map(name => [name, JSON.parse(fs.readFileSync(path.join(root, 'acceptance', `${name}.json`), 'utf8'))]))
|
||
|
|
const report = reconcile({manifest, reports})
|
||
|
|
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2) + '\n', {mode: 0o600})
|
||
|
|
console.log(JSON.stringify({release, passed: report.passed, checks: report.checks.length, failures: report.failures, retained: report.retained}))
|
||
|
|
if (!report.passed) process.exitCode = 1
|
||
|
|
}
|
||
|
|
|
||
|
|
module.exports = {reportNames, memoryTables, validateReports, readOnlySQL, executeReadOnly, identityQuery, resolveIdentities, reconciliationQuery, reconcile}
|
||
|
|
if (require.main === module) {
|
||
|
|
try { main() } catch (error) { console.error(error.message); process.exitCode = 1 }
|
||
|
|
}
|