fix(n8n): make replacement replay idempotent
This commit is contained in:
parent
bc23c550db
commit
358b259aac
|
|
@ -13,22 +13,22 @@ const engineRoot = resolve(platformRoot, "../NODEDC_ENGINE_INFRA");
|
||||||
const artifactRoot = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || join(here, "../deploy-artifacts"));
|
const artifactRoot = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || join(here, "../deploy-artifacts"));
|
||||||
const stageArtifact = resolve(
|
const stageArtifact = resolve(
|
||||||
process.env.NODEDC_N8N_EXTENSION_STAGE_ARTIFACT
|
process.env.NODEDC_N8N_EXTENSION_STAGE_ARTIFACT
|
||||||
|| join(artifactRoot, "nodedc-n8n-private-extension-n8n-nodes-ndc-geozone-replace-20260720-017.tgz"),
|
|| join(artifactRoot, "nodedc-n8n-private-extension-n8n-nodes-ndc-replace-replay-v1-20260721-009.tgz"),
|
||||||
);
|
);
|
||||||
const predecessorArtifact = resolve(
|
const predecessorArtifact = resolve(
|
||||||
process.env.NODEDC_N8N_EXTENSION_PREDECESSOR_ARTIFACT
|
process.env.NODEDC_N8N_EXTENSION_PREDECESSOR_ARTIFACT
|
||||||
|| join(artifactRoot, "nodedc-engine-n8n-private-extension-20260718-008.tgz"),
|
|| join(artifactRoot, "nodedc-engine-n8n-private-extension-20260721-005.tgz"),
|
||||||
);
|
);
|
||||||
|
|
||||||
const stageArtifactSha256 = "0c680d2436917eb6f2416c5c855f2e01dd59ed50bedb1e36456a7405f3f595af";
|
const stageArtifactSha256 = "4984d4937e2c8de1ecbf5b836cf7fafac131cc0dc64d49c06034507d0cb94577";
|
||||||
const predecessorArtifactSha256 = "5ef46404dfd7753b4ffd14c5e9b9666f96d2c9875c8fe470714cdea1c6decdce";
|
const predecessorArtifactSha256 = "037d4790b03c0b245b543fe2a841d28f3cfc7822b9a12f2bff9324bf1328b395";
|
||||||
const releaseId = "0.1.5-3c8ae53f010d7c88";
|
const releaseId = "0.1.6-25cc2d7a52a0d0ee";
|
||||||
const packageVersion = "0.1.5";
|
const packageVersion = "0.1.6";
|
||||||
const packageSha256 = "3c8ae53f010d7c88b6e90cb8fc4929e7d0880089ad8c3a9edb876ce034786743";
|
const packageSha256 = "25cc2d7a52a0d0ee288b7a4b3de9f436489c010f234498a94bcccd114d7355af";
|
||||||
const n8nVersion = "2.3.2";
|
const n8nVersion = "2.3.2";
|
||||||
const baseImage = "docker.n8n.io/n8nio/n8n:2.3.2";
|
const baseImage = "docker.n8n.io/n8nio/n8n:2.3.2";
|
||||||
const architecture = "amd64";
|
const architecture = "amd64";
|
||||||
const generatedAt = "2026-07-21T10:30:00.000Z";
|
const generatedAt = "2026-07-21T12:00:00.000Z";
|
||||||
const previouslyIssuedTransitionIds = new Set([
|
const previouslyIssuedTransitionIds = new Set([
|
||||||
"20260715-002",
|
"20260715-002",
|
||||||
"20260716-003",
|
"20260716-003",
|
||||||
|
|
@ -37,6 +37,8 @@ const previouslyIssuedTransitionIds = new Set([
|
||||||
"20260718-006",
|
"20260718-006",
|
||||||
"20260718-007",
|
"20260718-007",
|
||||||
"20260718-008",
|
"20260718-008",
|
||||||
|
"20260721-005",
|
||||||
|
"20260721-008",
|
||||||
]);
|
]);
|
||||||
const transitionId = readTransitionId(process.argv.slice(2), process.env.NODEDC_N8N_TRANSITION_ID);
|
const transitionId = readTransitionId(process.argv.slice(2), process.env.NODEDC_N8N_TRANSITION_ID);
|
||||||
const activationId = `engine-n8n-private-extension-${transitionId}`;
|
const activationId = `engine-n8n-private-extension-${transitionId}`;
|
||||||
|
|
@ -354,15 +356,15 @@ function assertPackage(value) {
|
||||||
|
|
||||||
function assertPredecessorCatalogs(descriptorValue, nodes, credentials, meta) {
|
function assertPredecessorCatalogs(descriptorValue, nodes, credentials, meta) {
|
||||||
if (descriptorValue?.action !== "activate"
|
if (descriptorValue?.action !== "activate"
|
||||||
|| descriptorValue?.releaseId !== "0.1.4-59dc9f7882721d6a"
|
|| descriptorValue?.releaseId !== "0.1.5-3c8ae53f010d7c88"
|
||||||
|| descriptorValue?.packageVersion !== "0.1.4"
|
|| descriptorValue?.packageVersion !== "0.1.5"
|
||||||
|| descriptorValue?.packageSha256 !== "59dc9f7882721d6a5e2ae84ffa6aa8cd9bc5432f5904d24e70e363573ed2757f") {
|
|| descriptorValue?.packageSha256 !== "3c8ae53f010d7c88b6e90cb8fc4929e7d0880089ad8c3a9edb876ce034786743") {
|
||||||
throw new Error("predecessor_descriptor_mismatch");
|
throw new Error("predecessor_descriptor_mismatch");
|
||||||
}
|
}
|
||||||
if (!Array.isArray(nodes) || nodes.length !== 437
|
if (!Array.isArray(nodes) || nodes.length !== 437
|
||||||
|| !Array.isArray(credentials) || credentials.length !== 389
|
|| !Array.isArray(credentials) || credentials.length !== 389
|
||||||
|| meta?.n8nVersion !== n8nVersion
|
|| meta?.n8nVersion !== n8nVersion
|
||||||
|| meta?.source !== "n8n-core+n8n-nodes-ndc@0.1.4"
|
|| meta?.source !== "n8n-core+n8n-nodes-ndc@0.1.5"
|
||||||
|| meta?.nodeCount !== 437
|
|| meta?.nodeCount !== 437
|
||||||
|| meta?.credentialCount !== 389) {
|
|| meta?.credentialCount !== 389) {
|
||||||
throw new Error("predecessor_catalog_mismatch");
|
throw new Error("predecessor_catalog_mismatch");
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,8 @@ const platformRoot = resolve(scriptDir, "../..");
|
||||||
const packageRoot = resolve(platformRoot, "packages/n8n-nodes-ndc");
|
const packageRoot = resolve(platformRoot, "packages/n8n-nodes-ndc");
|
||||||
const artifactDir = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts"));
|
const artifactDir = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts"));
|
||||||
const requireModule = createRequire(import.meta.url);
|
const requireModule = createRequire(import.meta.url);
|
||||||
const expectedPackageVersion = "0.1.5";
|
const expectedPackageVersion = "0.1.6";
|
||||||
const [patchId = "n8n-nodes-ndc-geozone-replace-20260720-017", ...extra] = process.argv.slice(2);
|
const [patchId = "n8n-nodes-ndc-replace-replay-v1-20260721-009", ...extra] = process.argv.slice(2);
|
||||||
const expectedRuntimeNodes = [
|
const expectedRuntimeNodes = [
|
||||||
{
|
{
|
||||||
file: "dist/nodes/NdcDataProductPublish/NdcDataProductPublish.node.js",
|
file: "dist/nodes/NdcDataProductPublish/NdcDataProductPublish.node.js",
|
||||||
|
|
|
||||||
|
|
@ -291,6 +291,11 @@ ENGINE_N8N_RELEASE_CATALOG_JSON_SHA256 = {
|
||||||
"credentials": "af8ada839070a4c24b9b10981a751c2b2db651dced1392333d7a4ef13de4564e",
|
"credentials": "af8ada839070a4c24b9b10981a751c2b2db651dced1392333d7a4ef13de4564e",
|
||||||
"meta": "b324592c38d518af8320d8540c7061e4684056ae88a676cf5e50c6eef03c0dd7",
|
"meta": "b324592c38d518af8320d8540c7061e4684056ae88a676cf5e50c6eef03c0dd7",
|
||||||
},
|
},
|
||||||
|
"0.1.6-25cc2d7a52a0d0ee": {
|
||||||
|
"nodes": "fb2ef4d38b9c071fae117a27381714658a1a19334b82fc79d64d13f27555fe5a",
|
||||||
|
"credentials": "af8ada839070a4c24b9b10981a751c2b2db651dced1392333d7a4ef13de4564e",
|
||||||
|
"meta": "58adcb182e5f231a4798636a99887a1f214e3c6e1eb4c0dc2d9b2f3aa80a99ef",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
ENGINE_N8N_INACTIVE_NODES_CATALOG_JSON_SHA256 = "b70d9d8130d498c55de46a5d0758c844f1242b70803457b2291ab3a9de8056f2"
|
ENGINE_N8N_INACTIVE_NODES_CATALOG_JSON_SHA256 = "b70d9d8130d498c55de46a5d0758c844f1242b70803457b2291ab3a9de8056f2"
|
||||||
ENGINE_N8N_INACTIVE_CREDENTIALS_CATALOG_JSON_SHA256 = "680e9f52aac791efbd38e3bd99bd51ef5cded9756d867897c6c2755850e87b50"
|
ENGINE_N8N_INACTIVE_CREDENTIALS_CATALOG_JSON_SHA256 = "680e9f52aac791efbd38e3bd99bd51ef5cded9756d867897c6c2755850e87b50"
|
||||||
|
|
@ -337,6 +342,7 @@ ENGINE_N8N_CREDENTIAL_TYPES_BY_RELEASE = {
|
||||||
"0.1.3-3354149b5245e39a": ENGINE_N8N_CREDENTIAL_TYPES[:3],
|
"0.1.3-3354149b5245e39a": ENGINE_N8N_CREDENTIAL_TYPES[:3],
|
||||||
"0.1.4-59dc9f7882721d6a": ENGINE_N8N_CREDENTIAL_TYPES,
|
"0.1.4-59dc9f7882721d6a": ENGINE_N8N_CREDENTIAL_TYPES,
|
||||||
"0.1.5-3c8ae53f010d7c88": ENGINE_N8N_CREDENTIAL_TYPES,
|
"0.1.5-3c8ae53f010d7c88": ENGINE_N8N_CREDENTIAL_TYPES,
|
||||||
|
"0.1.6-25cc2d7a52a0d0ee": ENGINE_N8N_CREDENTIAL_TYPES,
|
||||||
}
|
}
|
||||||
ENGINE_CREDENTIAL_SINK_ARTIFACT_ENTRIES = (
|
ENGINE_CREDENTIAL_SINK_ARTIFACT_ENTRIES = (
|
||||||
"nodedc-source/server/credentialPolicies/ndcPrivateNode.js",
|
"nodedc-source/server/credentialPolicies/ndcPrivateNode.js",
|
||||||
|
|
|
||||||
|
|
@ -23,17 +23,17 @@ BUILDER_PATH = SCRIPT_DIR / "build-engine-n8n-private-extension-artifact.mjs"
|
||||||
STAGE_ARTIFACT = (
|
STAGE_ARTIFACT = (
|
||||||
PLATFORM_ROOT
|
PLATFORM_ROOT
|
||||||
/ "infra/deploy-artifacts"
|
/ "infra/deploy-artifacts"
|
||||||
/ "nodedc-n8n-private-extension-n8n-nodes-ndc-geozone-replace-20260720-017.tgz"
|
/ "nodedc-n8n-private-extension-n8n-nodes-ndc-replace-replay-v1-20260721-009.tgz"
|
||||||
)
|
)
|
||||||
PREDECESSOR_ARTIFACT = (
|
PREDECESSOR_ARTIFACT = (
|
||||||
PLATFORM_ROOT
|
PLATFORM_ROOT
|
||||||
/ "infra/deploy-artifacts"
|
/ "infra/deploy-artifacts"
|
||||||
/ "nodedc-engine-n8n-private-extension-20260718-008.tgz"
|
/ "nodedc-engine-n8n-private-extension-20260721-005.tgz"
|
||||||
)
|
)
|
||||||
TRANSITION_ID = "20260721-005"
|
TRANSITION_ID = "20260721-010"
|
||||||
SEALED_RELEASE_ID = "0.1.5-3c8ae53f010d7c88"
|
SEALED_RELEASE_ID = "0.1.6-25cc2d7a52a0d0ee"
|
||||||
SEALED_PACKAGE_SHA256 = "3c8ae53f010d7c88b6e90cb8fc4929e7d0880089ad8c3a9edb876ce034786743"
|
SEALED_PACKAGE_SHA256 = "25cc2d7a52a0d0ee288b7a4b3de9f436489c010f234498a94bcccd114d7355af"
|
||||||
PREDECESSOR_RELEASE_ID = "0.1.4-59dc9f7882721d6a"
|
PREDECESSOR_RELEASE_ID = "0.1.5-3c8ae53f010d7c88"
|
||||||
BUILDER_ENGINE_PATHS = (
|
BUILDER_ENGINE_PATHS = (
|
||||||
"nodedc-source/server/assets/n8n/schema/v2.3.2/nodes.catalog.json",
|
"nodedc-source/server/assets/n8n/schema/v2.3.2/nodes.catalog.json",
|
||||||
"nodedc-source/server/assets/n8n/schema/v2.3.2/credentials.catalog.json",
|
"nodedc-source/server/assets/n8n/schema/v2.3.2/credentials.catalog.json",
|
||||||
|
|
@ -177,7 +177,9 @@ class EngineN8nPrivateExtensionTest(unittest.TestCase):
|
||||||
([], "transition_id_required"),
|
([], "transition_id_required"),
|
||||||
(["20260718-007"], "transition_id_already_issued"),
|
(["20260718-007"], "transition_id_already_issued"),
|
||||||
(["20260718-008"], "transition_id_already_issued"),
|
(["20260718-008"], "transition_id_already_issued"),
|
||||||
(["engine-n8n-private-extension-20260721-005"], "transition_id_invalid"),
|
(["20260721-005"], "transition_id_already_issued"),
|
||||||
|
(["20260721-008"], "transition_id_already_issued"),
|
||||||
|
(["engine-n8n-private-extension-20260721-010"], "transition_id_invalid"),
|
||||||
(["20260230-004"], "transition_id_invalid"),
|
(["20260230-004"], "transition_id_invalid"),
|
||||||
(["20260717-000"], "transition_id_invalid"),
|
(["20260717-000"], "transition_id_invalid"),
|
||||||
([TRANSITION_ID, "unexpected-second-id"], "transition_id_argument_count_invalid"),
|
([TRANSITION_ID, "unexpected-second-id"], "transition_id_argument_count_invalid"),
|
||||||
|
|
@ -288,7 +290,7 @@ class EngineN8nPrivateExtensionTest(unittest.TestCase):
|
||||||
self.assertEqual(override, RUNNER.expected_engine_n8n_compose_override(descriptor))
|
self.assertEqual(override, RUNNER.expected_engine_n8n_compose_override(descriptor))
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
hashlib.sha256(override.encode("utf-8")).hexdigest(),
|
hashlib.sha256(override.encode("utf-8")).hexdigest(),
|
||||||
"304377edf7d0e184fa4156475df74b7a807a47e4472c8d8e13102639eb9e9d0b",
|
"7fc118de152b7f5b76a7a502aea0dfc8d7dd191f6bfd304d6de9a4bf06f8fe1e",
|
||||||
)
|
)
|
||||||
self.assertIn("pull_policy: never", override)
|
self.assertIn("pull_policy: never", override)
|
||||||
self.assertIn("N8N_USER_FOLDER: /home/node", override)
|
self.assertIn("N8N_USER_FOLDER: /home/node", override)
|
||||||
|
|
@ -580,7 +582,7 @@ class EngineN8nPrivateExtensionTest(unittest.TestCase):
|
||||||
|
|
||||||
def test_sealed_release_exact_set_includes_implicit_directories(self):
|
def test_sealed_release_exact_set_includes_implicit_directories(self):
|
||||||
release_relative = Path(
|
release_relative = Path(
|
||||||
"payload/releases/n8n-nodes-ndc/0.1.5-3c8ae53f010d7c88"
|
"payload/releases/n8n-nodes-ndc/0.1.6-25cc2d7a52a0d0ee"
|
||||||
)
|
)
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
work = Path(directory)
|
work = Path(directory)
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ RUNNER_PATH = Path(
|
||||||
os.environ.get("NODEDC_DEPLOY_RUNNER_UNDER_TEST", SCRIPT_DIR / "nodedc-deploy")
|
os.environ.get("NODEDC_DEPLOY_RUNNER_UNDER_TEST", SCRIPT_DIR / "nodedc-deploy")
|
||||||
).resolve()
|
).resolve()
|
||||||
BUILDER_PATH = SCRIPT_DIR / "build-n8n-private-extension-artifact.mjs"
|
BUILDER_PATH = SCRIPT_DIR / "build-n8n-private-extension-artifact.mjs"
|
||||||
PATCH_ID = "n8n-nodes-ndc-geozone-replace-20260720-017"
|
PATCH_ID = "n8n-nodes-ndc-replace-replay-v1-20260721-009"
|
||||||
EXPECTED_NODES = [
|
EXPECTED_NODES = [
|
||||||
"dist/nodes/NdcDataProductPublish/NdcDataProductPublish.node.js",
|
"dist/nodes/NdcDataProductPublish/NdcDataProductPublish.node.js",
|
||||||
"dist/nodes/NdcDataProductRead/NdcDataProductRead.node.js",
|
"dist/nodes/NdcDataProductRead/NdcDataProductRead.node.js",
|
||||||
|
|
@ -110,7 +110,7 @@ class N8nPrivateExtensionPolicyTest(unittest.TestCase):
|
||||||
manifest, entries, _payload = RUNNER.load_artifact(self.artifacts[0], Path(directory))
|
manifest, entries, _payload = RUNNER.load_artifact(self.artifacts[0], Path(directory))
|
||||||
self.assertEqual(manifest["component"], "n8n-private-extension")
|
self.assertEqual(manifest["component"], "n8n-private-extension")
|
||||||
self.assertEqual(len(entries), 1)
|
self.assertEqual(len(entries), 1)
|
||||||
self.assertRegex(entries[0], r"^releases/n8n-nodes-ndc/0\.1\.5-[a-f0-9]{16}$")
|
self.assertRegex(entries[0], r"^releases/n8n-nodes-ndc/0\.1\.6-[a-f0-9]{16}$")
|
||||||
|
|
||||||
with tarfile.open(self.artifacts[0], "r:gz") as archive:
|
with tarfile.open(self.artifacts[0], "r:gz") as archive:
|
||||||
names = archive.getnames()
|
names = archive.getnames()
|
||||||
|
|
@ -122,7 +122,7 @@ class N8nPrivateExtensionPolicyTest(unittest.TestCase):
|
||||||
with tarfile.open(fileobj=io.BytesIO(package), mode="r:gz") as archive:
|
with tarfile.open(fileobj=io.BytesIO(package), mode="r:gz") as archive:
|
||||||
package_json_member = archive.getmember("package/package.json")
|
package_json_member = archive.getmember("package/package.json")
|
||||||
package_json = json.loads(archive.extractfile(package_json_member).read())
|
package_json = json.loads(archive.extractfile(package_json_member).read())
|
||||||
self.assertEqual(package_json["version"], "0.1.5")
|
self.assertEqual(package_json["version"], "0.1.6")
|
||||||
self.assertEqual(package_json["n8n"]["nodes"], EXPECTED_NODES)
|
self.assertEqual(package_json["n8n"]["nodes"], EXPECTED_NODES)
|
||||||
self.assertEqual(package_json["n8n"]["credentials"], EXPECTED_CREDENTIALS)
|
self.assertEqual(package_json["n8n"]["credentials"], EXPECTED_CREDENTIALS)
|
||||||
for node_path in EXPECTED_NODES:
|
for node_path in EXPECTED_NODES:
|
||||||
|
|
@ -158,7 +158,7 @@ class N8nPrivateExtensionPolicyTest(unittest.TestCase):
|
||||||
"requiresPreActivationVerification": True,
|
"requiresPreActivationVerification": True,
|
||||||
}
|
}
|
||||||
self.assertEqual(release["schemaVersion"], "nodedc.n8n-private-extension-release/v2")
|
self.assertEqual(release["schemaVersion"], "nodedc.n8n-private-extension-release/v2")
|
||||||
self.assertEqual(release["package"]["version"], "0.1.5")
|
self.assertEqual(release["package"]["version"], "0.1.6")
|
||||||
self.assertEqual(release["activation"]["rollbackBaselinePolicy"], expected_policy)
|
self.assertEqual(release["activation"]["rollbackBaselinePolicy"], expected_policy)
|
||||||
self.assertEqual(rollback["schemaVersion"], "nodedc.n8n-private-extension-rollback/v2")
|
self.assertEqual(rollback["schemaVersion"], "nodedc.n8n-private-extension-rollback/v2")
|
||||||
self.assertEqual(rollback["baselinePolicy"], expected_policy)
|
self.assertEqual(rollback["baselinePolicy"], expected_policy)
|
||||||
|
|
|
||||||
|
|
@ -98,7 +98,11 @@ Each incoming item must be either a canonical fact or `{ "fact": <fact> }`:
|
||||||
|
|
||||||
The node emits `nodedc.data-product.publish/v1`, enforces the 5000-fact and
|
The node emits `nodedc.data-product.publish/v1`, enforces the 5000-fact and
|
||||||
64-KiB-per-attributes limits, rejects secret-like material in attributes, and
|
64-KiB-per-attributes limits, rejects secret-like material in attributes, and
|
||||||
derives stable run/idempotency identifiers from workflow execution context.
|
derives upsert run/idempotency identifiers from workflow execution context.
|
||||||
|
Complete-snapshot replacement instead derives one canonical identity from the
|
||||||
|
product, generation timestamp, sequence and normalized fact set. Replaying the
|
||||||
|
same immutable generation therefore reaches the Data Plane as the same request,
|
||||||
|
while changed content under an already-used generation remains a conflict.
|
||||||
|
|
||||||
## Build and verification
|
## Build and verification
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -109,10 +109,6 @@ export function buildPublishPayload(
|
||||||
): NdcPublishPayload {
|
): NdcPublishPayload {
|
||||||
if (!Number.isInteger(sequence) || sequence < 0 || sequence > 2_147_483_647) throw new Error('batch_sequence_invalid');
|
if (!Number.isInteger(sequence) || sequence < 0 || sequence > 2_147_483_647) throw new Error('batch_sequence_invalid');
|
||||||
requireIdentifier(dataProductId, 'dataProductId');
|
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 facts = factsFromItems(items, publishMode === 'replace');
|
||||||
const explicitGenerationAt = items.length === 1 && Array.isArray(items[0]?.json?.facts)
|
const explicitGenerationAt = items.length === 1 && Array.isArray(items[0]?.json?.facts)
|
||||||
? items[0].json.generationAt
|
? items[0].json.generationAt
|
||||||
|
|
@ -120,6 +116,15 @@ export function buildPublishPayload(
|
||||||
const generationAt = publishMode === 'replace'
|
const generationAt = publishMode === 'replace'
|
||||||
? replacementGenerationAt(facts, explicitGenerationAt)
|
? replacementGenerationAt(facts, explicitGenerationAt)
|
||||||
: undefined;
|
: 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 {
|
return {
|
||||||
schemaVersion: DATA_PRODUCT_PUBLISH_SCHEMA_VERSION,
|
schemaVersion: DATA_PRODUCT_PUBLISH_SCHEMA_VERSION,
|
||||||
batch: {
|
batch: {
|
||||||
|
|
@ -332,6 +337,35 @@ function replacementGenerationAt(facts: NdcFact[], explicit: unknown): string {
|
||||||
return timestamps[0];
|
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 {
|
function requireIsoTimestamp(value: unknown, field: string): string {
|
||||||
const normalized = String(value ?? '').trim();
|
const normalized = String(value ?? '').trim();
|
||||||
if (!normalized || Number.isNaN(Date.parse(normalized))) throw new Error(`${field}_invalid`);
|
if (!normalized || Number.isNaN(Date.parse(normalized))) throw new Error(`${field}_invalid`);
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "n8n-nodes-ndc",
|
"name": "n8n-nodes-ndc",
|
||||||
"version": "0.1.5",
|
"version": "0.1.6",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "n8n-nodes-ndc",
|
"name": "n8n-nodes-ndc",
|
||||||
"version": "0.1.5",
|
"version": "0.1.6",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@n8n/node-cli": "0.39.3",
|
"@n8n/node-cli": "0.39.3",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "n8n-nodes-ndc",
|
"name": "n8n-nodes-ndc",
|
||||||
"version": "0.1.5",
|
"version": "0.1.6",
|
||||||
"description": "Private NODE.DC nodes for scoped data products and Foundry bindings.",
|
"description": "Private NODE.DC nodes for scoped data products and Foundry bindings.",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
|
|
|
||||||
|
|
@ -170,6 +170,39 @@ async function main() {
|
||||||
assert.equal(externalContract.validateDataProductPublish(replacement).ok, true);
|
assert.equal(externalContract.validateDataProductPublish(replacement).ok, true);
|
||||||
assert.equal(replacement.batch.mode, 'replace');
|
assert.equal(replacement.batch.mode, 'replace');
|
||||||
assert.equal(replacement.batch.generationAt, zoneItems[0].json.observedAt);
|
assert.equal(replacement.batch.generationAt, zoneItems[0].json.observedAt);
|
||||||
|
const replacementReplay = contracts.buildPublishPayload(
|
||||||
|
zoneItems,
|
||||||
|
'execution-replay',
|
||||||
|
'workflow-other',
|
||||||
|
'node-other',
|
||||||
|
'map.zones.current.v1',
|
||||||
|
0,
|
||||||
|
'replace',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
replacementReplay.batch.runId,
|
||||||
|
replacement.batch.runId,
|
||||||
|
'the same complete generation must keep one run identity across executions',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
replacementReplay.batch.idempotencyKey,
|
||||||
|
replacement.batch.idempotencyKey,
|
||||||
|
'the same complete generation must replay through the data plane idempotently',
|
||||||
|
);
|
||||||
|
const changedReplacement = contracts.buildPublishPayload(
|
||||||
|
[{ json: { ...zoneItems[0].json, attributes: { display_name: 'Zone 42 changed' } } }],
|
||||||
|
'execution-replay',
|
||||||
|
'workflow-other',
|
||||||
|
'node-other',
|
||||||
|
'map.zones.current.v1',
|
||||||
|
0,
|
||||||
|
'replace',
|
||||||
|
);
|
||||||
|
assert.notEqual(
|
||||||
|
changedReplacement.batch.idempotencyKey,
|
||||||
|
replacement.batch.idempotencyKey,
|
||||||
|
'different content at one declared generation must not alias an accepted replay',
|
||||||
|
);
|
||||||
const replacementBatch = contracts.buildPublishPayload(
|
const replacementBatch = contracts.buildPublishPayload(
|
||||||
[{ json: { generationAt: zoneItems[0].json.observedAt, facts: zoneItems.map((item) => item.json) } }],
|
[{ json: { generationAt: zoneItems[0].json.observedAt, facts: zoneItems.map((item) => item.json) } }],
|
||||||
'execution-44',
|
'execution-44',
|
||||||
|
|
@ -181,6 +214,7 @@ async function main() {
|
||||||
);
|
);
|
||||||
assert.deepEqual(replacementBatch.facts, replacement.facts);
|
assert.deepEqual(replacementBatch.facts, replacement.facts);
|
||||||
assert.equal(replacementBatch.batch.generationAt, zoneItems[0].json.observedAt);
|
assert.equal(replacementBatch.batch.generationAt, zoneItems[0].json.observedAt);
|
||||||
|
assert.equal(replacementBatch.batch.idempotencyKey, replacement.batch.idempotencyKey);
|
||||||
const emptyReplacement = contracts.buildPublishPayload(
|
const emptyReplacement = contracts.buildPublishPayload(
|
||||||
[{ json: { generationAt: '2026-07-15T13:00:00.000Z', facts: [] } }],
|
[{ json: { generationAt: '2026-07-15T13:00:00.000Z', facts: [] } }],
|
||||||
'execution-45',
|
'execution-45',
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue