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