import { createHash, randomUUID } from "node:crypto"; import { DATA_PRODUCT_HISTORY_SCHEMA_VERSION, DATA_PRODUCT_PATCH_SCHEMA_VERSION, DATA_PRODUCT_SNAPSHOT_SCHEMA_VERSION, isBoundedGeoJsonGeometry, } from "@nodedc/external-provider-contract/data-plane"; const IDENTIFIER = /^[a-z][a-z0-9._:-]{2,127}$/; const MAX_HISTORY_RESOLUTION_MS = 24 * 60 * 60 * 1000; const MAX_HISTORY_SOURCE_IDS = 1000; export async function loadDataProductDefinition(db, dataProductId, { activeOnly = true } = {}) { const result = await db.query( `select id, version, ontology_revision as "ontologyRevision", delivery_mode as "deliveryMode", semantic_types as "semanticTypes", fields, field_contracts as "fieldContracts", history_policy as "historyPolicy", active, created_at as "createdAt", updated_at as "updatedAt" from external_data_plane_products where id = $1 ${activeOnly ? "and active = true" : ""}`, [dataProductId], ); return result.rows[0] || null; } export async function persistDataProductDefinition(db, definition) { const result = await db.query( `insert into external_data_plane_products ( id, version, ontology_revision, delivery_mode, semantic_types, fields, field_contracts, history_policy ) values ($1, $2, $3, $4, $5::jsonb, $6::jsonb, $7::jsonb, $8::jsonb) on conflict (id) do update set active = true, updated_at = now() where external_data_plane_products.version = excluded.version and external_data_plane_products.ontology_revision = excluded.ontology_revision and external_data_plane_products.delivery_mode = excluded.delivery_mode and external_data_plane_products.semantic_types = excluded.semantic_types and external_data_plane_products.fields = excluded.fields and external_data_plane_products.field_contracts = excluded.field_contracts and external_data_plane_products.history_policy = excluded.history_policy returning id, version, ontology_revision as "ontologyRevision", delivery_mode as "deliveryMode", semantic_types as "semanticTypes", fields, field_contracts as "fieldContracts", history_policy as "historyPolicy", active, created_at as "createdAt", updated_at as "updatedAt"`, [ definition.id, definition.version, definition.ontologyRevision, definition.deliveryMode, JSON.stringify(definition.semanticTypes), JSON.stringify(definition.fields), JSON.stringify(definition.fieldContracts || {}), JSON.stringify(definition.history), ], ); if (!result.rowCount) throw deliveryError("data_product_version_is_immutable", 409); return result.rows[0]; } export async function persistDataProductPublish(pool, batch, definition, { maxPatchOperations = 500, maxPatchBytes = 256 * 1024, } = {}) { assertPublishMatchesDefinition(batch, definition); const requestFingerprint = publishFingerprint(batch); const client = await pool.connect(); try { await client.query("begin"); const batchId = randomUUID(); const insertedBatch = await client.query( `insert into external_data_plane_batches ( id, tenant_id, connection_id, provider_id, data_product_id, contract_version, ontology_revision, run_id, sequence, idempotency_key, received_at, fact_count, request_fingerprint ) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) on conflict (tenant_id, connection_id, provider_id, data_product_id, idempotency_key) do nothing returning id`, [ batchId, batch.source.tenantId, batch.source.connectionId, batch.source.providerId, batch.contract.dataProductId, batch.contract.version, batch.contract.ontologyRevision, batch.batch.runId, batch.batch.sequence, batch.batch.idempotencyKey, batch.batch.receivedAt, batch.facts.length, requestFingerprint, ], ); if (!insertedBatch.rowCount) { const existing = await client.query( `select id as "batchId", fact_count as "publishedFactCount", current_updated_count as "currentUpdatedCount", current_removed_count as "currentRemovedCount", history_inserted_count as "historyInsertedCount", patch_operation_count as "patchOperationCount", delivery_cursor::text as cursor, received_at as "acceptedAt", request_fingerprint as "requestFingerprint" from external_data_plane_batches where tenant_id = $1 and connection_id = $2 and provider_id = $3 and data_product_id = $4 and idempotency_key = $5`, scopeValues(batch, batch.batch.idempotencyKey), ); if (!existing.rowCount || existing.rows[0].requestFingerprint !== requestFingerprint) { throw deliveryError("idempotency_key_reused", 409); } await client.query("commit"); return { ...normalizeReceipt(existing.rows[0]), idempotent: true }; } if (batch.batch.mode === "replace") await lockReplacementGeneration(client, batch); const records = batch.facts.map((fact) => ({ source_id: fact.sourceId, semantic_type: fact.semanticType, observed_at: fact.observedAt, received_at: batch.batch.receivedAt, attributes: fact.attributes || {}, geometry: fact.geometry || null, fingerprint: fingerprint(fact), })); const updated = await client.query( `insert into external_data_plane_current ( tenant_id, connection_id, provider_id, data_product_id, source_id, semantic_type, observed_at, received_at, attributes, geometry, fingerprint, updated_at ) select $1, $2, $3, $4, item.source_id, item.semantic_type, item.observed_at, item.received_at, coalesce(item.attributes, '{}'::jsonb), case when item.geometry is null then null else ST_SetSRID(ST_GeomFromGeoJSON(item.geometry), 4326)::geography end, item.fingerprint, now() from jsonb_to_recordset($5::jsonb) as item( source_id text, semantic_type text, observed_at timestamptz, received_at timestamptz, attributes jsonb, geometry jsonb, fingerprint text ) on conflict (tenant_id, connection_id, provider_id, data_product_id, source_id, semantic_type) do update set observed_at = excluded.observed_at, received_at = excluded.received_at, attributes = excluded.attributes, geometry = excluded.geometry, fingerprint = excluded.fingerprint, updated_at = now() where excluded.observed_at > external_data_plane_current.observed_at or (excluded.observed_at = external_data_plane_current.observed_at and excluded.fingerprint <> external_data_plane_current.fingerprint) returning source_id as "sourceId", semantic_type as "semanticType", observed_at as "observedAt", received_at as "receivedAt", attributes, case when geometry is null then null else ST_AsGeoJSON(geometry::geometry)::jsonb end as geometry, fingerprint`, [ batch.source.tenantId, batch.source.connectionId, batch.source.providerId, batch.contract.dataProductId, JSON.stringify(records), ], ); const removed = batch.batch.mode === "replace" ? await removeMissingCurrent(client, batch, records) : []; const historyInsertedCount = await persistHistory(client, batch, definition.historyPolicy, records); const operations = [ ...updated.rows.map((fact) => ({ op: "upsert", fact: publicFact(fact) })), ...removed.map((fact) => ({ op: "remove", sourceId: fact.sourceId, semanticType: fact.semanticType })), ]; const chunks = definition.deliveryMode === "snapshot+patch" ? chunkOperations(operations, maxPatchOperations, maxPatchBytes) : []; const cursor = chunks.length ? await persistPatchChunks(client, batch, definition, batchId, chunks) : await currentCursor(client, batch); if (batch.batch.mode === "replace") await persistReplacementGeneration(client, batch); await client.query( `update external_data_plane_batches set current_updated_count = $2, current_removed_count = $3, history_inserted_count = $4, patch_operation_count = $5, delivery_cursor = $6 where id = $1`, [batchId, updated.rowCount, removed.length, historyInsertedCount, operations.length, cursor], ); await client.query("commit"); return normalizeReceipt({ batchId, idempotent: false, publishedFactCount: batch.facts.length, currentUpdatedCount: updated.rowCount, currentRemovedCount: removed.length, historyInsertedCount, patchOperationCount: operations.length, cursor: String(cursor), acceptedAt: batch.batch.receivedAt, }); } catch (error) { await client.query("rollback"); throw error; } finally { client.release(); } } export async function readDataProductSnapshot(pool, binding, definition, { limit = 5000 } = {}) { const sourceConnectionId = readerSourceConnectionId(binding); const client = await pool.connect(); try { await client.query("begin isolation level repeatable read read only"); const cursor = await currentCursor(client, binding, definition.id); const rows = await client.query( `select source_id as "sourceId", semantic_type as "semanticType", observed_at as "observedAt", received_at as "receivedAt", attributes, case when geometry is null then null else ST_AsGeoJSON(geometry::geometry)::jsonb end as geometry from external_data_plane_current where tenant_id = $1 and connection_id = $2 and provider_id = $3 and data_product_id = $4 order by source_id asc, semantic_type asc limit $5`, [binding.tenantId, sourceConnectionId, binding.providerId, definition.id, limit + 1], ); if (rows.rowCount > limit) throw deliveryError("data_product_snapshot_limit_exceeded", 413); await client.query("commit"); return { schemaVersion: DATA_PRODUCT_SNAPSHOT_SCHEMA_VERSION, dataProduct: { id: definition.id, version: definition.version }, generatedAt: new Date().toISOString(), cursor: String(cursor), facts: rows.rows.map(publicFact), }; } catch (error) { await client.query("rollback"); throw error; } finally { client.release(); } } export function normalizeHistoryReadOptions(value, definition) { const policy = definition?.historyPolicy || {}; if (policy.mode === "none") throw deliveryError("data_product_history_not_supported", 409); const from = new Date(String(value?.from || "")); const to = new Date(String(value?.to || "")); if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime()) || from >= to) { throw deliveryError("data_product_history_range_invalid", 400); } const retentionDays = Math.max(1, Number(policy.retentionDays) || 1); if (to.getTime() - from.getTime() > retentionDays * 24 * 60 * 60 * 1000) { throw deliveryError("data_product_history_range_exceeds_retention", 400); } const nativeResolutionMs = policy.mode === "sampled" ? Number(policy.intervalMs) : 1000; const resolutionMs = value?.resolutionMs === undefined || value?.resolutionMs === "" ? nativeResolutionMs : Number(value.resolutionMs); if ( !Number.isInteger(resolutionMs) || resolutionMs < nativeResolutionMs || resolutionMs > MAX_HISTORY_RESOLUTION_MS || resolutionMs % nativeResolutionMs !== 0 ) throw deliveryError("data_product_history_resolution_invalid", 400); const sourceIds = [...new Set((Array.isArray(value?.sourceIds) ? value.sourceIds : []) .map((item) => String(item || "").trim()) .filter(Boolean))]; if (sourceIds.length > MAX_HISTORY_SOURCE_IDS || sourceIds.some((item) => !IDENTIFIER.test(item))) { throw deliveryError("data_product_history_source_ids_invalid", 400); } const limit = Number(value?.limit ?? 1000); if (!Number.isInteger(limit) || limit < 1 || limit > 5000) { throw deliveryError("data_product_history_limit_invalid", 400); } const query = { from: from.toISOString(), to: to.toISOString(), resolutionMs, sourceIds: sourceIds.sort(), order: "asc", }; const queryHash = createHash("sha256").update(JSON.stringify(query)).digest("hex"); const cursor = decodeHistoryCursor(value?.cursor, queryHash); return Object.freeze({ ...query, limit, queryHash, cursor }); } export async function readDataProductHistory(pool, binding, definition, options) { const sourceConnectionId = readerSourceConnectionId(binding); const query = normalizeHistoryReadOptions(options, definition); const cursor = query.cursor || {}; const rows = await pool.query( `with sampled as ( select to_timestamp( floor(extract(epoch from bucket_start) * 1000 / $8::double precision) * $8::double precision / 1000 ) as query_bucket, source_id, semantic_type, observed_at, received_at, attributes, geometry, fingerprint from external_data_plane_history where tenant_id = $1 and connection_id = $2 and provider_id = $3 and data_product_id = $4 and bucket_start >= $5 and bucket_start < $6 and (cardinality($7::text[]) = 0 or source_id = any($7::text[])) ), ranked as ( select *, row_number() over ( partition by query_bucket, source_id, semantic_type order by observed_at desc, received_at desc, fingerprint desc ) as sample_rank from sampled ) select query_bucket as "bucketStart", source_id as "sourceId", semantic_type as "semanticType", observed_at as "observedAt", received_at as "receivedAt", attributes, case when geometry is null then null else ST_AsGeoJSON(geometry::geometry)::jsonb end as geometry from ranked where sample_rank = 1 and ( $9::boolean = false or (query_bucket, source_id, semantic_type) > ($10::timestamptz, $11::text, $12::text) ) order by query_bucket asc, source_id asc, semantic_type asc limit $13`, [ binding.tenantId, sourceConnectionId, binding.providerId, definition.id, query.from, query.to, query.sourceIds, query.resolutionMs, Boolean(query.cursor), cursor.bucketStart || query.from, cursor.sourceId || "", cursor.semanticType || "", query.limit + 1, ], ); const page = rows.rows.slice(0, query.limit); const last = page.at(-1); return { schemaVersion: DATA_PRODUCT_HISTORY_SCHEMA_VERSION, dataProduct: { id: definition.id, version: definition.version }, generatedAt: new Date().toISOString(), query: { from: query.from, to: query.to, resolutionMs: query.resolutionMs, sourceIds: query.sourceIds, order: query.order, }, facts: page.map((fact) => ({ ...publicFact(fact), bucketStart: new Date(fact.bucketStart).toISOString(), })), ...(rows.rowCount > query.limit && last ? { nextCursor: encodeHistoryCursor({ queryHash: query.queryHash, bucketStart: new Date(last.bucketStart).toISOString(), sourceId: last.sourceId, semanticType: last.semanticType, }), } : {}), }; } export async function readPatchEvents(pool, binding, definition, after, { limit = 100 } = {}) { const sourceConnectionId = readerSourceConnectionId(binding); const client = await pool.connect(); try { await client.query("begin isolation level repeatable read read only"); const current = await currentCursor(client, binding, definition.id); if (after > current) throw deliveryError("resync_required", 409); const floor = await patchFloor(client, binding, definition.id); if (after < floor - 1n) throw deliveryError("resync_required", 409); const rows = await client.query( `select cursor::text, previous_cursor::text as "previousCursor", operations, emitted_at as "emittedAt" from external_data_plane_patch_outbox where tenant_id = $1 and connection_id = $2 and provider_id = $3 and data_product_id = $4 and cursor > $5 order by cursor asc limit $6`, [binding.tenantId, sourceConnectionId, binding.providerId, definition.id, after.toString(), limit], ); await client.query("commit"); return rows.rows.map((row) => ({ schemaVersion: DATA_PRODUCT_PATCH_SCHEMA_VERSION, dataProduct: { id: definition.id, version: definition.version }, previousCursor: row.previousCursor, cursor: row.cursor, emittedAt: new Date(row.emittedAt).toISOString(), operations: row.operations, })); } catch (error) { await client.query("rollback"); throw error; } finally { client.release(); } } export async function prunePatchOutbox(db, { retentionMs, limit = 10_000 }) { const cutoff = new Date(Date.now() - retentionMs); const result = await db.query( `with expired as ( select tenant_id, connection_id, provider_id, data_product_id, cursor from external_data_plane_patch_outbox where emitted_at < $1 order by emitted_at asc limit $2 ) delete from external_data_plane_patch_outbox as target using expired where target.tenant_id = expired.tenant_id and target.connection_id = expired.connection_id and target.provider_id = expired.provider_id and target.data_product_id = expired.data_product_id and target.cursor = expired.cursor`, [cutoff, limit], ); return result.rowCount; } export async function pruneDataProductHistory(db, { limit = 10_000 } = {}) { const result = await db.query( `with expired as ( select history.tenant_id, history.connection_id, history.provider_id, history.data_product_id, history.source_id, history.semantic_type, history.bucket_start from external_data_plane_history as history join external_data_plane_products as product on product.id = history.data_product_id where history.bucket_start < now() - ( greatest(1, coalesce((product.history_policy->>'retentionDays')::integer, 1)) * interval '1 day' ) order by history.bucket_start asc limit $1 ) delete from external_data_plane_history as target using expired where target.tenant_id = expired.tenant_id and target.connection_id = expired.connection_id and target.provider_id = expired.provider_id and target.data_product_id = expired.data_product_id and target.source_id = expired.source_id and target.semantic_type = expired.semantic_type and target.bucket_start = expired.bucket_start`, [limit], ); return result.rowCount; } export async function pruneBatchReceipts(db, { retentionMs, limit = 10_000 }) { const cutoff = new Date(Date.now() - retentionMs); const result = await db.query( `with expired as ( select batch.id from external_data_plane_batches as batch where batch.created_at < $1 and not exists (select 1 from external_data_plane_patch_outbox as patch where patch.batch_id = batch.id) and not exists (select 1 from external_data_plane_raw_envelopes as raw where raw.batch_id = batch.id) order by batch.created_at asc limit $2 ) delete from external_data_plane_batches as target using expired where target.id = expired.id`, [cutoff, limit], ); return result.rowCount; } async function lockReplacementGeneration(client, batch) { const scope = [batch.source.tenantId, batch.source.connectionId, batch.source.providerId, batch.contract.dataProductId]; await client.query( `insert into external_data_plane_delivery_state ( tenant_id, connection_id, provider_id, data_product_id, current_cursor ) values ($1, $2, $3, $4, 0) on conflict (tenant_id, connection_id, provider_id, data_product_id) do nothing`, scope, ); const state = await client.query( `select current_generation_at as "currentGenerationAt" from external_data_plane_delivery_state where tenant_id = $1 and connection_id = $2 and provider_id = $3 and data_product_id = $4 for update`, scope, ); const generationAt = new Date(batch.batch.generationAt); const currentGenerationAt = state.rows[0]?.currentGenerationAt ? new Date(state.rows[0].currentGenerationAt) : null; if (currentGenerationAt && generationAt <= currentGenerationAt) { throw deliveryError("data_product_generation_not_newer", 409); } } async function removeMissingCurrent(client, batch, records) { const result = await client.query( `delete from external_data_plane_current as target where target.tenant_id = $1 and target.connection_id = $2 and target.provider_id = $3 and target.data_product_id = $4 and not exists ( select 1 from jsonb_to_recordset($5::jsonb) as incoming(source_id text, semantic_type text) where incoming.source_id = target.source_id and incoming.semantic_type = target.semantic_type ) returning source_id as "sourceId", semantic_type as "semanticType"`, [ batch.source.tenantId, batch.source.connectionId, batch.source.providerId, batch.contract.dataProductId, JSON.stringify(records), ], ); return result.rows; } async function persistReplacementGeneration(client, batch) { await client.query( `update external_data_plane_delivery_state set current_generation_at = $5, current_generation_id = $6, updated_at = now() where tenant_id = $1 and connection_id = $2 and provider_id = $3 and data_product_id = $4`, [ batch.source.tenantId, batch.source.connectionId, batch.source.providerId, batch.contract.dataProductId, batch.batch.generationAt, batch.batch.runId, ], ); } async function persistHistory(client, batch, policy, facts) { if (!facts.length || policy?.mode === "none") return 0; const intervalMs = policy?.mode === "sampled" ? Number(policy.intervalMs) : 1; const records = facts.map((fact) => ({ ...fact, bucket_start: new Date(Math.floor(new Date(fact.observed_at).getTime() / intervalMs) * intervalMs).toISOString(), })); const result = await client.query( `insert into external_data_plane_history ( tenant_id, connection_id, provider_id, data_product_id, source_id, semantic_type, bucket_start, observed_at, received_at, attributes, geometry, fingerprint, updated_at ) select $1, $2, $3, $4, item.source_id, item.semantic_type, item.bucket_start, item.observed_at, item.received_at, coalesce(item.attributes, '{}'::jsonb), case when item.geometry is null then null else ST_SetSRID(ST_GeomFromGeoJSON(item.geometry), 4326)::geography end, item.fingerprint, now() from jsonb_to_recordset($5::jsonb) as item( source_id text, semantic_type text, bucket_start timestamptz, observed_at timestamptz, received_at timestamptz, attributes jsonb, geometry jsonb, fingerprint text ) on conflict (tenant_id, connection_id, provider_id, data_product_id, source_id, semantic_type, bucket_start) do update set observed_at = excluded.observed_at, received_at = excluded.received_at, attributes = excluded.attributes, geometry = excluded.geometry, fingerprint = excluded.fingerprint, updated_at = now() where excluded.observed_at >= external_data_plane_history.observed_at`, [ batch.source.tenantId, batch.source.connectionId, batch.source.providerId, batch.contract.dataProductId, JSON.stringify(records), ], ); return result.rowCount; } async function persistPatchChunks(client, batch, definition, batchId, chunks) { const endCursorResult = await client.query( `insert into external_data_plane_delivery_state ( tenant_id, connection_id, provider_id, data_product_id, current_cursor ) values ($1, $2, $3, $4, $5) on conflict (tenant_id, connection_id, provider_id, data_product_id) do update set current_cursor = external_data_plane_delivery_state.current_cursor + excluded.current_cursor, updated_at = now() returning current_cursor`, [ batch.source.tenantId, batch.source.connectionId, batch.source.providerId, definition.id, chunks.length, ], ); const end = BigInt(endCursorResult.rows[0].current_cursor); const start = end - BigInt(chunks.length) + 1n; for (let index = 0; index < chunks.length; index += 1) { const cursor = start + BigInt(index); await client.query( `insert into external_data_plane_patch_outbox ( tenant_id, connection_id, provider_id, data_product_id, cursor, previous_cursor, batch_id, operations ) values ($1, $2, $3, $4, $5, $6, $7, $8::jsonb)`, [ batch.source.tenantId, batch.source.connectionId, batch.source.providerId, definition.id, cursor.toString(), (cursor - 1n).toString(), batchId, JSON.stringify(chunks[index]), ], ); } return end; } async function currentCursor(db, scope, explicitProductId) { const dataProductId = explicitProductId || scope.contract.dataProductId; const connectionId = scope.source ? scope.source.connectionId : readerSourceConnectionId(scope); const result = await db.query( `select current_cursor from external_data_plane_delivery_state where tenant_id = $1 and connection_id = $2 and provider_id = $3 and data_product_id = $4`, [scope.tenantId || scope.source.tenantId, connectionId, scope.providerId || scope.source.providerId, dataProductId], ); return result.rowCount ? BigInt(result.rows[0].current_cursor) : 0n; } async function patchFloor(db, binding, dataProductId) { const sourceConnectionId = readerSourceConnectionId(binding); const result = await db.query( `select min(cursor) as floor from external_data_plane_patch_outbox where tenant_id = $1 and connection_id = $2 and provider_id = $3 and data_product_id = $4`, [binding.tenantId, sourceConnectionId, binding.providerId, dataProductId], ); if (result.rows[0]?.floor !== null && result.rows[0]?.floor !== undefined) return BigInt(result.rows[0].floor); const current = await currentCursor(db, binding, dataProductId); return current + 1n; } function readerSourceConnectionId(binding) { const sourceConnectionId = String(binding?.sourceConnectionId || "").trim(); if (sourceConnectionId) return sourceConnectionId; if (!binding?.bindingKey) { const legacyConnectionId = String(binding?.connectionId || "").trim(); if (legacyConnectionId) return legacyConnectionId; } throw deliveryError("reader_source_scope_unresolved", 409); } function scopeValues(batch, tail) { return [ batch.source.tenantId, batch.source.connectionId, batch.source.providerId, batch.contract.dataProductId, tail, ]; } function normalizeReceipt(value) { return { batchId: value.batchId, idempotent: value.idempotent === true, publishedFactCount: Number(value.publishedFactCount || 0), currentUpdatedCount: Number(value.currentUpdatedCount || 0), currentRemovedCount: Number(value.currentRemovedCount || 0), historyInsertedCount: Number(value.historyInsertedCount || 0), patchOperationCount: Number(value.patchOperationCount || 0), cursor: String(value.cursor || "0"), acceptedAt: new Date(value.acceptedAt).toISOString(), }; } function publicFact(fact) { const value = { sourceId: fact.sourceId, semanticType: fact.semanticType, observedAt: new Date(fact.observedAt).toISOString(), receivedAt: new Date(fact.receivedAt).toISOString(), attributes: fact.attributes || {}, }; if (fact.geometry) value.geometry = fact.geometry; return value; } function encodeHistoryCursor(value) { return Buffer.from(JSON.stringify(value), "utf8").toString("base64url"); } function decodeHistoryCursor(value, queryHash) { const raw = String(value || "").trim(); if (!raw) return null; if (raw.length > 1024 || !/^[A-Za-z0-9_-]+$/.test(raw)) { throw deliveryError("data_product_history_cursor_invalid", 400); } let cursor; try { cursor = JSON.parse(Buffer.from(raw, "base64url").toString("utf8")); } catch { throw deliveryError("data_product_history_cursor_invalid", 400); } if ( !cursor || typeof cursor !== "object" || Array.isArray(cursor) || cursor.queryHash !== queryHash || Number.isNaN(Date.parse(cursor.bucketStart)) || !IDENTIFIER.test(String(cursor.sourceId || "")) || !IDENTIFIER.test(String(cursor.semanticType || "")) ) throw deliveryError("data_product_history_cursor_invalid", 400); return Object.freeze({ bucketStart: new Date(cursor.bucketStart).toISOString(), sourceId: cursor.sourceId, semanticType: cursor.semanticType, }); } function chunkOperations(values, maxOperations, maxBytes) { const result = []; let current = []; let currentBytes = 2; for (const value of values) { const serializedBytes = Buffer.byteLength(JSON.stringify(value)); if (serializedBytes + 2 > maxBytes) throw deliveryError("data_product_patch_operation_size_exceeded", 413); const separatorBytes = current.length ? 1 : 0; if (current.length && (current.length >= maxOperations || currentBytes + separatorBytes + serializedBytes > maxBytes)) { result.push(current); current = []; currentBytes = 2; } current.push(value); currentBytes += (current.length > 1 ? 1 : 0) + serializedBytes; } if (current.length) result.push(current); return result; } function fingerprint(value) { return createHash("sha256").update(stableJson(value)).digest("hex"); } function publishFingerprint(batch) { return fingerprint({ schemaVersion: batch.schemaVersion, source: batch.source, contract: batch.contract, batch: { runId: batch.batch.runId, sequence: batch.batch.sequence, idempotencyKey: batch.batch.idempotencyKey, mode: batch.batch.mode || "upsert", ...(batch.batch.generationAt ? { generationAt: batch.batch.generationAt } : {}), }, facts: batch.facts, }); } export function assertPublishMatchesDefinition(batch, definition) { const semanticTypes = new Set(Array.isArray(definition?.semanticTypes) ? definition.semanticTypes : []); const fields = new Set(Array.isArray(definition?.fields) ? definition.fields : []); const fieldContracts = definition?.fieldContracts && typeof definition.fieldContracts === "object" && !Array.isArray(definition.fieldContracts) ? definition.fieldContracts : {}; const entityKeys = new Set(); for (const fact of batch?.facts || []) { if (!semanticTypes.has(fact.semanticType)) throw deliveryError("data_product_semantic_type_forbidden", 422); const entityKey = `${fact.sourceId}\u0000${fact.semanticType}`; if (entityKeys.has(entityKey)) throw deliveryError("data_product_duplicate_entity_key", 422); entityKeys.add(entityKey); for (const attribute of Object.keys(fact.attributes || {})) { if (!fields.has(attribute) && !fields.has(`attributes.${attribute}`)) { throw deliveryError("data_product_field_forbidden", 422); } } if (fact.geometry !== undefined && !geometryDeclared(fields)) { throw deliveryError("data_product_geometry_forbidden", 422); } for (const [field, contract] of Object.entries(fieldContracts)) { const resolved = resolveContractField(fact, field); if (!resolved.present) { if (contract.required === true) throw deliveryError("data_product_field_required", 422); continue; } assertFieldContractValue(resolved.value, contract); } } } function resolveContractField(fact, field) { if (field === "geometry") return { present: fact.geometry !== undefined, value: fact.geometry }; if (field === "source_id") return { present: fact.sourceId !== undefined, value: fact.sourceId }; if (field === "semantic_type") return { present: fact.semanticType !== undefined, value: fact.semanticType }; if (field === "observed_at") return { present: fact.observedAt !== undefined, value: fact.observedAt }; const attribute = field.startsWith("attributes.") ? field.slice("attributes.".length) : field; const attributes = fact.attributes || {}; return { present: Object.hasOwn(attributes, attribute), value: attributes[attribute] }; } function assertFieldContractValue(value, contract) { if (!fieldContractValueMatchesType(value, contract.type)) { throw deliveryError("data_product_field_type_invalid", 422); } if (Array.isArray(contract.enum) && !contract.enum.some((allowed) => Object.is(allowed, value))) { throw deliveryError("data_product_field_value_forbidden", 422); } if (typeof value === "number" && ( (Number.isFinite(contract.minimum) && value < contract.minimum) || (Number.isFinite(contract.maximum) && value > contract.maximum) )) throw deliveryError("data_product_field_value_out_of_range", 422); } function fieldContractValueMatchesType(value, type) { if (type === "string_array") return Array.isArray(value) && value.every((item) => typeof item === "string"); if (type === "point") return isBoundedGeoJsonGeometry(value, new Set(["Point"])); if (type === "geometry") return isBoundedGeoJsonGeometry(value); return typeof value === type && (type !== "number" || Number.isFinite(value)); } function geometryDeclared(fields) { return fields.has("geometry") || fields.has("coordinates") || (fields.has("longitude") && fields.has("latitude")); } function stableJson(value) { if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`; if (value && typeof value === "object") { return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(",")}}`; } return JSON.stringify(value); } function deliveryError(code, status) { return Object.assign(new Error(code), { status, code }); }