feat(platform): add replaceable geozone data layer
This commit is contained in:
@@ -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 {
|
||||
|
||||
Generated
+2
-2
@@ -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,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 });
|
||||
|
||||
Reference in New Issue
Block a user