feat(platform): add replaceable geozone data layer

This commit is contained in:
Codex
2026-07-20 21:48:27 +03:00
parent 8a7465cf0e
commit 3446f3edc2
31 changed files with 1342 additions and 128 deletions
@@ -25,7 +25,7 @@ export class NdcDataProductPublish implements INodeType {
name: 'ndcDataProductPublish',
icon: NDC_NODE_ICON,
group: ['output'],
version: 1,
version: [1, 2],
subtitle: '={{$parameter["dataProductId"]}}',
description: 'Publish canonical facts through a scoped NDC Data Product grant',
defaults: { name: 'NDC Data Product Publish' },
@@ -41,6 +41,19 @@ export class NdcDataProductPublish implements INodeType {
default: '',
required: true,
},
{
displayName: 'Publish Mode',
name: 'publishMode',
type: 'options',
options: [
{ name: 'Create or Update', value: 'upsert' },
{ name: 'Replace Complete Snapshot', value: 'replace' },
],
default: 'upsert',
required: true,
displayOptions: { show: { '@version': [2] } },
description: 'Replace commits one complete generation atomically and removes keys absent from it',
},
{
displayName: 'Batch Sequence',
name: 'sequence',
@@ -71,6 +84,9 @@ export class NdcDataProductPublish implements INodeType {
const inputItems = this.getInputData();
const dataProductId = this.getNodeParameter('dataProductId', 0) as string;
const sequence = this.getNodeParameter('sequence', 0, 0) as number;
const publishMode = this.getNode().typeVersion >= 2
? this.getNodeParameter('publishMode', 0, 'upsert') as 'upsert' | 'replace'
: 'upsert';
let body;
let encodedProductId;
try {
@@ -81,6 +97,7 @@ export class NdcDataProductPublish implements INodeType {
this.getNode().id,
dataProductId,
sequence,
publishMode,
);
encodedProductId = encodeIdentifierPath(dataProductId, 'dataProductId');
} catch (error) {
@@ -18,18 +18,23 @@ export interface NdcFact extends IDataObject {
semanticType: string;
observedAt: string;
attributes?: IDataObject;
geometry?: {
type: 'Point';
coordinates: [number, number];
};
geometry?: NdcGeometry;
}
export type NdcGeometry =
| { type: 'Point'; coordinates: [number, number] }
| { type: 'LineString'; coordinates: [number, number][] }
| { type: 'Polygon'; coordinates: [number, number][][] }
| { type: 'MultiPolygon'; coordinates: [number, number][][][] };
export interface NdcPublishPayload extends IDataObject {
schemaVersion: typeof DATA_PRODUCT_PUBLISH_SCHEMA_VERSION;
batch: {
runId: string;
sequence: number;
idempotencyKey: string;
mode?: 'replace';
generationAt?: string;
};
facts: NdcFact[];
}
@@ -62,12 +67,16 @@ export function encodeIdentifierPath(value: unknown, field: string): string {
return encodeURIComponent(requireIdentifier(value, field));
}
export function factsFromItems(items: INodeExecutionData[]): NdcFact[] {
if (!items.length) throw new Error('facts_required');
if (items.length > MAX_FACTS) throw new Error('facts_limit_exceeded');
export function factsFromItems(items: INodeExecutionData[], allowEmpty = false): NdcFact[] {
const batchFacts = items.length === 1 && Array.isArray(items[0]?.json?.facts)
? items[0].json.facts
: null;
const sources = batchFacts ?? items.map((item) => (isObject(item.json.fact) ? item.json.fact : item.json));
if (!sources.length && !allowEmpty) throw new Error('facts_required');
if (sources.length > MAX_FACTS) throw new Error('facts_limit_exceeded');
const entityKeys = new Set<string>();
return items.map((item, index) => {
const source = isObject(item.json.fact) ? item.json.fact : item.json;
return sources.map((source, index) => {
if (!isObject(source)) throw new Error(`facts_${index}_invalid`);
const fact: NdcFact = {
sourceId: requireIdentifier(source.sourceId, `facts_${index}_sourceId`),
semanticType: requireIdentifier(source.semanticType, `facts_${index}_semanticType`),
@@ -84,7 +93,7 @@ export function factsFromItems(items: INodeExecutionData[]): NdcFact[] {
if (containsSecretLikeMaterial(source.attributes)) throw new Error(`facts_${index}_attributes_secret_material_forbidden`);
fact.attributes = source.attributes;
}
if (source.geometry !== undefined) fact.geometry = normalizePoint(source.geometry, index);
if (source.geometry !== undefined) fact.geometry = normalizeGeometry(source.geometry, index);
return fact;
});
}
@@ -96,6 +105,7 @@ export function buildPublishPayload(
nodeId: string,
dataProductId: string,
sequence: number,
publishMode: 'upsert' | 'replace' = 'upsert',
): NdcPublishPayload {
if (!Number.isInteger(sequence) || sequence < 0 || sequence > 2_147_483_647) throw new Error('batch_sequence_invalid');
requireIdentifier(dataProductId, 'dataProductId');
@@ -103,10 +113,22 @@ export function buildPublishPayload(
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
: undefined;
const generationAt = publishMode === 'replace'
? replacementGenerationAt(facts, explicitGenerationAt)
: undefined;
return {
schemaVersion: DATA_PRODUCT_PUBLISH_SCHEMA_VERSION,
batch: { runId, sequence, idempotencyKey },
facts: factsFromItems(items),
batch: {
runId,
sequence,
idempotencyKey,
...(generationAt ? { mode: 'replace' as const, generationAt } : {}),
},
facts,
};
}
@@ -254,18 +276,60 @@ function requireFoundrySlotId(value: unknown): string {
return normalized;
}
function normalizePoint(value: unknown, index: number): NdcFact['geometry'] {
if (!isObject(value) || value.type !== 'Point' || !Array.isArray(value.coordinates) || value.coordinates.length !== 2) {
function normalizeGeometry(value: unknown, index: number): NdcGeometry {
if (!isObject(value) || !['Point', 'LineString', 'Polygon', 'MultiPolygon'].includes(String(value.type))
|| !Array.isArray(value.coordinates)) {
throw new Error(`facts_${index}_geometry_invalid`);
}
const [longitude, latitude] = value.coordinates;
if (![longitude, latitude].every((coordinate) => typeof coordinate === 'number' && Number.isFinite(coordinate))) {
throw new Error(`facts_${index}_geometry_coordinates_invalid`);
if (Buffer.byteLength(JSON.stringify(value)) > 192 * 1024) throw new Error(`facts_${index}_geometry_size_exceeded`);
let vertices = 0;
const position = (candidate: unknown, path: string): [number, number] => {
if (!Array.isArray(candidate) || candidate.length !== 2 || !candidate.every(Number.isFinite)) {
throw new Error(`${path}_position_invalid`);
}
const [longitude, latitude] = candidate as [number, number];
if (longitude < -180 || longitude > 180 || latitude < -90 || latitude > 90) {
throw new Error(`${path}_position_out_of_range`);
}
vertices += 1;
if (vertices > 10_000) throw new Error(`facts_${index}_geometry_vertex_limit_exceeded`);
return [longitude, latitude];
};
const line = (candidate: unknown, path: string, minimum: number): [number, number][] => {
if (!Array.isArray(candidate) || candidate.length < minimum) throw new Error(`${path}_line_invalid`);
return candidate.map((entry, positionIndex) => position(entry, `${path}_${positionIndex}`));
};
const ring = (candidate: unknown, path: string): [number, number][] => {
const result = line(candidate, path, 4);
const first = result[0];
const last = result.at(-1);
if (!last || first[0] !== last[0] || first[1] !== last[1]) throw new Error(`${path}_ring_not_closed`);
return result;
};
const polygon = (candidate: unknown, path: string): [number, number][][] => {
if (!Array.isArray(candidate) || !candidate.length) throw new Error(`${path}_polygon_invalid`);
return candidate.map((entry, ringIndex) => ring(entry, `${path}_${ringIndex}`));
};
if (value.type === 'Point') return { type: 'Point', coordinates: position(value.coordinates, `facts_${index}_geometry`) };
if (value.type === 'LineString') return { type: 'LineString', coordinates: line(value.coordinates, `facts_${index}_geometry`, 2) };
if (value.type === 'Polygon') return { type: 'Polygon', coordinates: polygon(value.coordinates, `facts_${index}_geometry`) };
const coordinates = value.coordinates.map((entry, polygonIndex) => polygon(entry, `facts_${index}_geometry_${polygonIndex}`));
if (!coordinates.length) throw new Error(`facts_${index}_geometry_multipolygon_invalid`);
return { type: 'MultiPolygon', coordinates };
}
function replacementGenerationAt(facts: NdcFact[], explicit: unknown): string {
const declared = explicit === undefined ? undefined : requireIsoTimestamp(explicit, 'replace_generationAt');
const timestamps = [...new Set(facts.map((fact) => new Date(fact.observedAt).toISOString()))];
if (!timestamps.length) {
if (!declared) throw new Error('replace_generationAt_required_for_empty_generation');
return new Date(declared).toISOString();
}
if ((longitude as number) < -180 || (longitude as number) > 180 || (latitude as number) < -90 || (latitude as number) > 90) {
throw new Error(`facts_${index}_geometry_coordinates_out_of_range`);
if (timestamps.length !== 1) throw new Error('replace_generation_requires_one_observedAt');
if (declared && new Date(declared).toISOString() !== timestamps[0]) {
throw new Error('replace_generationAt_observedAt_mismatch');
}
return { type: 'Point', coordinates: [longitude as number, latitude as number] };
return timestamps[0];
}
function requireIsoTimestamp(value: unknown, field: string): string {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "n8n-nodes-ndc",
"version": "0.1.4",
"version": "0.1.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "n8n-nodes-ndc",
"version": "0.1.4",
"version": "0.1.5",
"license": "UNLICENSED",
"devDependencies": {
"@n8n/node-cli": "0.39.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "n8n-nodes-ndc",
"version": "0.1.4",
"version": "0.1.5",
"description": "Private NODE.DC nodes for scoped data products and Foundry bindings.",
"private": true,
"license": "UNLICENSED",
@@ -146,6 +146,64 @@ async function main() {
publish.batch.idempotencyKey,
contracts.buildPublishPayload(items, 'execution-42', 'workflow-7', 'node-3', 'fleet.positions.current.v2', 0).batch.idempotencyKey,
);
const zoneItems = [{
json: {
sourceId: 'gelios-zone-42',
semanticType: 'map.zone',
observedAt: '2026-07-15T12:10:00.000Z',
attributes: { display_name: 'Zone 42' },
geometry: {
type: 'Polygon',
coordinates: [[[37.60, 55.74], [37.62, 55.74], [37.62, 55.76], [37.60, 55.74]]],
},
},
}];
const replacement = contracts.buildPublishPayload(
zoneItems,
'execution-43',
'workflow-7',
'node-zones',
'map.zones.current.v1',
0,
'replace',
);
assert.equal(externalContract.validateDataProductPublish(replacement).ok, true);
assert.equal(replacement.batch.mode, 'replace');
assert.equal(replacement.batch.generationAt, zoneItems[0].json.observedAt);
const replacementBatch = contracts.buildPublishPayload(
[{ json: { generationAt: zoneItems[0].json.observedAt, facts: zoneItems.map((item) => item.json) } }],
'execution-44',
'workflow-7',
'node-zones',
'map.zones.current.v1',
0,
'replace',
);
assert.deepEqual(replacementBatch.facts, replacement.facts);
assert.equal(replacementBatch.batch.generationAt, zoneItems[0].json.observedAt);
const emptyReplacement = contracts.buildPublishPayload(
[{ json: { generationAt: '2026-07-15T13:00:00.000Z', facts: [] } }],
'execution-45',
'workflow-7',
'node-zones',
'map.zones.current.v1',
0,
'replace',
);
assert.deepEqual(emptyReplacement.facts, []);
assert.equal(externalContract.validateDataProductPublish(emptyReplacement).ok, true);
assert.throws(
() => contracts.buildPublishPayload(
[{ json: { generationAt: '2026-07-15T13:00:00.000Z', facts: zoneItems.map((item) => item.json) } }],
'execution-46',
'workflow-7',
'node-zones',
'map.zones.current.v1',
0,
'replace',
),
/replace_generationAt_observedAt_mismatch/,
);
assert.throws(
() => contracts.buildPublishPayload(
[{ json: { ...items[0].json, attributes: { accessToken: 'forbidden' } } }],
@@ -303,6 +361,29 @@ async function assertNodeHttpContracts(nodes, externalContract, constants) {
assert.equal(externalContract.validateDataProductPublish(publishCalls[0].options.body).ok, true);
assert.equal(publishResult[0][0].json.publishedFactCount, 1);
const replacementCalls = [];
const replacementContext = executionContext({
typeVersion: 2,
parameters: { dataProductId: 'map.zones.current.v1', publishMode: 'replace', sequence: 0 },
input: [{
json: {
generationAt: '2026-07-15T12:10:00.000Z',
facts: [{
sourceId: 'gelios-zone-42',
semanticType: 'map.zone',
observedAt: '2026-07-15T12:10:00.000Z',
attributes: { display_name: 'Zone 42' },
geometry: { type: 'Polygon', coordinates: [[[37.60, 55.74], [37.62, 55.74], [37.62, 55.76], [37.60, 55.74]]] },
}],
},
}],
response: { ok: true, publishedFactCount: 1, currentRemovedCount: 0 },
calls: replacementCalls,
});
await nodes.get('NdcDataProductPublish').execute.call(replacementContext);
assert.equal(replacementCalls[0].options.body.batch.mode, 'replace');
assert.equal(externalContract.validateDataProductPublish(replacementCalls[0].options.body).ok, true);
const readCalls = [];
const readContext = executionContext({
parameters: { dataProductId: 'fleet.positions.current.v1', pageSize: 1000 },
@@ -411,7 +492,7 @@ async function assertNodeHttpContracts(nodes, externalContract, constants) {
}
}
function executionContext({ parameters, input = [{ json: {} }], response, calls }) {
function executionContext({ parameters, input = [{ json: {} }], response, calls, typeVersion = 1 }) {
return {
getNodeParameter(name, _index, defaultValue) {
return Object.prototype.hasOwnProperty.call(parameters, name) ? parameters[name] : defaultValue;
@@ -419,7 +500,7 @@ function executionContext({ parameters, input = [{ json: {} }], response, calls
getInputData() { return input; },
getExecutionId() { return 'execution-42'; },
getWorkflow() { return { id: 'workflow-7' }; },
getNode() { return { id: 'node-3' }; },
getNode() { return { id: 'node-3', typeVersion }; },
helpers: {
async httpRequestWithAuthentication(credentialName, options) {
calls.push({ credentialName, options });