fix(n8n): make replacement replay idempotent

This commit is contained in:
Codex
2026-07-21 15:06:59 +03:00
parent bc23c550db
commit 358b259aac
10 changed files with 117 additions and 35 deletions
@@ -109,10 +109,6 @@ export function buildPublishPayload(
): NdcPublishPayload {
if (!Number.isInteger(sequence) || sequence < 0 || sequence > 2_147_483_647) throw new Error('batch_sequence_invalid');
requireIdentifier(dataProductId, 'dataProductId');
const runId = `run-${sha256(`${workflowId}|${executionId}`).slice(0, 48)}`;
const idempotencyKey = `publish-${sha256(
`${workflowId}|${executionId}|${nodeId}|${dataProductId}|${sequence}`,
)}`;
const facts = factsFromItems(items, publishMode === 'replace');
const explicitGenerationAt = items.length === 1 && Array.isArray(items[0]?.json?.facts)
? items[0].json.generationAt
@@ -120,6 +116,15 @@ export function buildPublishPayload(
const generationAt = publishMode === 'replace'
? replacementGenerationAt(facts, explicitGenerationAt)
: undefined;
const replacementDigest = generationAt
? replacementReplayDigest(dataProductId, sequence, generationAt, facts)
: undefined;
const runId = replacementDigest
? `run-${replacementDigest.slice(0, 48)}`
: `run-${sha256(`${workflowId}|${executionId}`).slice(0, 48)}`;
const idempotencyKey = replacementDigest
? `publish-${replacementDigest}`
: `publish-${sha256(`${workflowId}|${executionId}|${nodeId}|${dataProductId}|${sequence}`)}`;
return {
schemaVersion: DATA_PRODUCT_PUBLISH_SCHEMA_VERSION,
batch: {
@@ -332,6 +337,35 @@ function replacementGenerationAt(facts: NdcFact[], explicit: unknown): string {
return timestamps[0];
}
function replacementReplayDigest(
dataProductId: string,
sequence: number,
generationAt: string,
facts: NdcFact[],
): string {
const canonicalFacts = [...facts].sort((left, right) => {
const leftKey = `${left.sourceId}\u0000${left.semanticType}`;
const rightKey = `${right.sourceId}\u0000${right.semanticType}`;
return leftKey.localeCompare(rightKey);
});
return sha256(stableJson({
schemaVersion: DATA_PRODUCT_PUBLISH_SCHEMA_VERSION,
dataProductId,
mode: 'replace',
generationAt,
sequence,
facts: canonicalFacts,
}));
}
function stableJson(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`;
if (isObject(value)) {
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(',')}}`;
}
return JSON.stringify(value);
}
function requireIsoTimestamp(value: unknown, field: string): string {
const normalized = String(value ?? '').trim();
if (!normalized || Number.isNaN(Date.parse(normalized))) throw new Error(`${field}_invalid`);