Commit 4b2a7b52 by luoqi

fix(sync): synthesizer occurredAt 缺失时确定性回退 updatedAt→createdAt,墙钟仅最终兜底

原 occurredAt ?? new Date() 在 occurredAtField 缺失/不可解析时每次摄入产生新墙钟值 —
时间锚纳入 fact 变更检测后会被放大成伪 supersede 版本抖动。改为确定性回退链
(occurredAtField → updatedAt → createdAt),三者皆缺才落墙钟并 warn。含回退顺序测试。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
parent e1697a0d
......@@ -98,13 +98,21 @@ export class TransactionSynthesizer {
? input.patientIdResolver(patientExternalId)
: undefined;
// occurredAt — 从 yaml emits.occurredAtField 指定的字段拿
const occurredAt = this.pickOccurredAt(c, emits.occurredAtField);
// occurredAt — 从 yaml emits.occurredAtField 指定的字段拿;
// 缺失/不可解析时确定性回退 updatedAt → createdAt(与 sourceEventId 的 updatedAtMark 同序)。
// 墙钟只在三者皆缺时兜底:否则同一源行每次摄入 occurredAt 抖动,
// 下游 fact 时间锚变更检测会产生伪 supersede 版本。
let occurredAt = this.pickOccurredAt(c, emits.occurredAtField);
if (!occurredAt) {
occurredAt =
this.pickOccurredAt(c, 'updatedAt') ?? this.pickOccurredAt(c, 'createdAt');
}
if (!occurredAt) {
this.logger.warn(
`Cannot determine occurredAt from field="${emits.occurredAtField}" ` +
`for ${input.resource} externalId=${subjectId};using now()`,
`(updatedAt/createdAt 亦缺)for ${input.resource} externalId=${subjectId};using now()`,
);
occurredAt = new Date();
}
// sourceEventId 幂等键
......@@ -128,7 +136,7 @@ export class TransactionSynthesizer {
rawPayload: input.rawRow as Prisma.InputJsonValue,
canonicalPayload: input.canonicalRow as Prisma.InputJsonValue,
payloadHash,
occurredAt: occurredAt ?? new Date(),
occurredAt,
};
}
......
/**
* TransactionSynthesizer occurredAt 三级确定性回退单元测试。
*
* 坐实:emits.occurredAtField 缺失/不可解析时,回退 updatedAt → createdAt,
* 仅三者皆缺才落墙钟。否则同一源行(新 sourceEventId、时间字段为空)每次摄入
* 得到不同 occurredAt,下游 fact 时间锚变更检测会产生伪 supersede 版本。
*/
import { TransactionSynthesizer } from '../src/modules/sync/pipeline/transaction-synthesizer';
import type { EmitsConfig } from '../src/modules/sync/assembler/assembler.schema';
describe('synthesizer | occurredAt 确定性回退', () => {
const synth = new TransactionSynthesizer();
const emits: EmitsConfig = {
action: 'appointment_created',
subjectType: 'appointment',
occurredAtField: 'occurredAt',
};
const make = (canonicalRow: Record<string, unknown>) => ({
rawRow: {},
emits,
resource: 'appointments',
hostId: 'h1',
tenantId: 'grp_001',
patientIdResolver: () => undefined,
sourceContext: 'test:occurred-at',
sourceUnit: '',
canonicalRow: { externalId: 'apt-1', clinicId: 'C001', ...canonicalRow },
});
test('occurredAtField 有值 → 直接用,不看 updatedAt/createdAt', () => {
const tx = synth.synthesize(
make({
occurredAt: '2026-01-01T10:00:00+08:00',
updatedAt: '2026-02-01T00:00:00+08:00',
createdAt: '2026-03-01T00:00:00+08:00',
}),
);
expect(tx!.occurredAt).toEqual(new Date('2026-01-01T10:00:00+08:00'));
});
test('occurredAtField 缺失 → 回退 updatedAt', () => {
const tx = synth.synthesize(
make({
updatedAt: '2026-02-01T00:00:00+08:00',
createdAt: '2026-03-01T00:00:00+08:00',
}),
);
expect(tx!.occurredAt).toEqual(new Date('2026-02-01T00:00:00+08:00'));
});
test('occurredAtField 不可解析 → 回退 updatedAt', () => {
const tx = synth.synthesize(
make({
occurredAt: 'not-a-date',
updatedAt: '2026-02-01T00:00:00+08:00',
}),
);
expect(tx!.occurredAt).toEqual(new Date('2026-02-01T00:00:00+08:00'));
});
test('occurredAtField/updatedAt 皆缺 → 回退 createdAt', () => {
const tx = synth.synthesize(make({ createdAt: '2026-03-01T00:00:00+08:00' }));
expect(tx!.occurredAt).toEqual(new Date('2026-03-01T00:00:00+08:00'));
});
test('时间字段有值 → 重复摄入 occurredAt 逐字节一致(确定性,不受墙钟影响)', () => {
const row = { updatedAt: '2026-02-01T00:00:00+08:00' };
const a = synth.synthesize(make(row));
const b = synth.synthesize(make(row));
expect(a!.occurredAt).toEqual(b!.occurredAt);
});
test('三者皆缺 → 落墙钟兜底(列非空,不能是 null)', () => {
const before = Date.now();
const tx = synth.synthesize(make({}));
const after = Date.now();
expect(tx!.occurredAt).toBeInstanceOf(Date);
const t = (tx!.occurredAt as Date).getTime();
expect(t).toBeGreaterThanOrEqual(before);
expect(t).toBeLessThanOrEqual(after);
});
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment