借款出借人增加员工与公司组合数据库唯一约束(09-17-company-loan-principal-repayment)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s

- 新迁移 20261011010000_loan_lender_employee_unique:(legal_entity_id, employee_id) 唯一,归档计入、无员工出借人不冲突;生产只读排查重复 0 组
- 触到约束时返回原有 loan.employee_lender_conflict,不报 500 或误报请求重复;员工行锁与代码检查保留
- 测试改为断言数据库拒绝模糊旧数据,补空员工共存与归档占用;变异 12 项含去索引与数据库兜底两种运行
- PRD 补本迁移回退说明;独立只读复核无阻断,发布窗口需重跑重复计数(AC59)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
这个提交包含在:
2026-10-11 13:21:08 +08:00
共同撰写人 Claude Opus 5.5
父节点 5469db585c
当前提交 36b949ecf6
共修改 8 个文件,包含 91 行新增和 10 行删除
+23 -3
查看文件
@@ -16,7 +16,10 @@ module.exports = async function employeeMutations(h, runFixture) {
// Restores the pre-fix fingerprint (updated_at selected and hashed).
['source_version_updated_at', [['status: true, archived_at: true,\n', 'status: true, archived_at: true, updated_at: true,\n'],
['"loan-employee-source-v2", legalEntityId, employee.id,', '"loan-employee-source-v2", legalEntityId, employee.id, employee.updated_at,']]],
['legacy_duplicate', 'if (links.some((lender) => lender.id !== lenderId))', 'if (false)'],
// The code check is proven with the database constraint removed; with it in
// place the same mutant must be fully absorbed, reporting the same conflict.
['legacy_duplicate', 'if (links.some((lender) => lender.id !== lenderId))', 'if (false)', { withoutConstraint: true }],
['legacy_duplicate_db_backstop', 'if (links.some((lender) => lender.id !== lenderId))', 'if (false)', { expectAbsorbed: true }],
['employee_lock', 'if (unique.length) await tx.$queryRaw', 'if (false) await tx.$queryRaw'],
['employee_lock_fk_compatibility', 'ORDER BY id FOR NO KEY UPDATE', 'ORDER BY id FOR UPDATE'],
['in_flight', 'await assertLenderNotInFlight(tx, id);', ''],
@@ -25,7 +28,8 @@ module.exports = async function employeeMutations(h, runFixture) {
['required_audit', 'await auditLoan(tx, request, "LOAN_LENDER_EMPLOYEE_CREATED", created.id, {\n ...requestBinding, employeeSourceRequestKey: input.requestKey,\n });', ''],
];
const results = [];
for (const [name, from, to] of mutations) {
const INDEX = 'loan_lenders_legal_entity_id_employee_id_key';
for (const [name, from, to, options = {}] of mutations) {
const edits = Array.isArray(from) ? from : [[from, to]];
let mutated = source;
for (const [target, replacement] of edits) {
@@ -37,8 +41,24 @@ module.exports = async function employeeMutations(h, runFixture) {
mutant._compile(mutated, filename);
let failure;
Object.assign(live, mutant.exports);
if (options.withoutConstraint) await h.prisma.$executeRawUnsafe(`DROP INDEX "${INDEX}"`);
try { await runFixture(h); } catch (error) { failure = error; }
finally { Object.assign(live, original); }
finally {
Object.assign(live, original);
if (options.withoutConstraint) {
// Detach the duplicates this mutant let through, then restore the index.
await h.prisma.$executeRawUnsafe(`UPDATE loan_lenders SET employee_id = NULL WHERE id IN (SELECT id FROM (
SELECT id, row_number() OVER (PARTITION BY legal_entity_id, employee_id ORDER BY created_at, id) AS n
FROM loan_lenders WHERE employee_id IS NOT NULL) ranked WHERE n > 1)`);
await h.prisma.$executeRawUnsafe(`CREATE UNIQUE INDEX "${INDEX}" ON loan_lenders (legal_entity_id, employee_id)`);
}
}
if (options.expectAbsorbed) {
assert.ok(!failure, `mutation ${name} was not absorbed by the database constraint: ${failure?.message?.split('\n')[0]}`);
results.push({ name, absorbedByDatabase: true, witness: 'database_constraint_backstop' });
console.log(JSON.stringify({ stage: 'loan_employee_mutation', ...results.at(-1) }));
continue;
}
assert.ok(failure, `mutation ${name} survived`);
// A generic environment, SQL or parsing failure is not a killed guard.
const wrongAcceptance = failure.code === 'ERR_ASSERTION' && failure.actual === 200 && [403, 409, 500].includes(failure.expected);