ERP-Unlocked Data Backend Architecture Proposal
Lakehouse + Semantic Layer for AI-Powered Order Processing
Section titled βLakehouse + Semantic Layer for AI-Powered Order ProcessingβVersion: 1.0
Date: November 9, 2025
Status: Proposal for Evaluation
Author: Technical Architecture Review
Executive Summary
Section titled βExecutive SummaryβThis document proposes migrating ERP-Unlockedβs current PostgreSQL-based ERP replica system to a modern data backend architecture consisting of:
- Apache Hudi Lakehouse - Analytical storage for ERP data (products, customers, inventory, pricing)
- Cube Semantic Layer - Unified API for AI agents and analytics with built-in RLS
- dlt + Spark ETL - Modern data ingestion replacing current sync services
- Dual Database Strategy - PostgreSQL for OLTP (orders, documents), Hudi for OLAP (ERP data)
The Core Question
Section titled βThe Core QuestionβShould we replace IERPSyncService and erp_replica PostgreSQL tables with a lakehouse-based data backend to power AI-driven features?
TL;DR Recommendation
Section titled βTL;DR RecommendationβYes, but phased approach starting with a pilot. The data backend architecture is a strong fit for ERP-Unlockedβs future, particularly for:
- Multi-ERP federation (query across Prophet21 + NetSuite simultaneously)
- AI-powered product matching and order validation
- Historical analytics (pricing trends, inventory patterns)
- Scalability (100+ organizations with millions of SKUs)
However, proceed incrementally:
- Phase 1 (Pilot): Implement for ONE entity type (products) alongside current system
- Phase 2: Migrate remaining entities if pilot succeeds
- Phase 3: Deprecate old replica system
Table of Contents
Section titled βTable of Contentsβ- Current Architecture Analysis
- Proposed Data Backend Architecture
- Detailed Component Design
- Migration Strategy
- Cost-Benefit Analysis
- Risk Assessment
- Implementation Roadmap
- Recommendation
Current Architecture Analysis
Section titled βCurrent Architecture AnalysisβHow ERP Sync Works Today
Section titled βHow ERP Sync Works Todayββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ CURRENT ARCHITECTURE βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€β ββ ββββββββββββββββββββββββββββββββββββββββββββββββββββββ ββ β TRIGGER.DEV SCHEDULED TASKS β ββ β β’ scheduledERPSync (daily 2 AM UTC) β ββ β β’ scheduledCustomerPricingSync (daily 1 AM UTC) β ββ ββββββββββββββββββ¬ββββββββββββββββββββββββββββββββββββ ββ β ββ βΌ ββ ββββββββββββββββββββββββββββββββββββββββββββββββββββββ ββ β IERPSyncService IMPLEMENTATIONS β ββ β ββ Prophet21SyncService β ββ β ββ DemoSyncService β ββ β ββ (Future: NetSuiteSyncService, etc.) β ββ β β ββ β Methods: β ββ β β’ syncProducts() - batch fetch & upsert β ββ β β’ syncCustomers() - batch fetch & upsert β ββ β β’ syncCrossReferences() - customer SKU mappings β ββ β β’ syncShippingAddresses() - delivery locations β ββ ββββββββββββββββββ¬ββββββββββββββββββββββββββββββββββββ ββ β Direct SQL upserts ββ βΌ ββ ββββββββββββββββββββββββββββββββββββββββββββββββββββββ ββ β POSTGRESQL (OLTP + OLAP Mixed) β ββ β β ββ β Operational Tables (OLTP): β ββ β β’ orders β ββ β β’ pdf_documents β ββ β β’ part_number_mappings β ββ β β’ erp_connections β ββ β β ββ β Replica Tables (OLAP - THIS IS THE PROBLEM): β ββ β β’ erp_products (~2.3M records per org) β ββ β β’ erp_customers (~10K records per org) β ββ β β’ erp_cross_references (~500K per org) β ββ β β’ erp_shipping_addresses (~50K per org) β ββ β β’ erp_inventory_locations (~5M per org) β ββ β β’ erp_sync_logs (audit trail) β ββ β β ββ β Full-text search via tsvector β ββ βββββββββββββββββββββββββββββββββββββββββββββββββββββββ ββ βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββCurrent System Characteristics
Section titled βCurrent System Characteristicsβ| Aspect | Current Implementation |
|---|---|
| Storage | PostgreSQL for both OLTP and OLAP |
| Sync Mechanism | Prophet21SyncService with batch upserts |
| Search | PostgreSQL tsvector (full-text search) |
| Orchestration | Trigger.dev scheduled tasks |
| Multi-tenancy | clerk_organization_id + connection_id |
| Concurrency | Queue with concurrencyLimit: 1 per connection |
| Retry Logic | 3 attempts, exponential backoff |
| Machine Resources | Medium-2x (2 vCPU, 4 GB RAM) |
Problems with Current Approach
Section titled βProblems with Current Approachβ1. OLTP/OLAP Mixing Anti-Pattern
Section titled β1. OLTP/OLAP Mixing Anti-PatternβPostgreSQL is designed for transactional workloads (orders, documents), not analytical queries (product search, inventory analysis).
// Current: Analytical query on transactional DBconst products = await db.query.erpProducts.findMany({ where: and( eq(erpProducts.connectionId, connectionId), sql`product_search_vector @@ plainto_tsquery('industrial pump valve')` ), limit: 1000, // Scanning millions of rows});Issues:
- Full-text search on 2.3M products per org = slow
- Index bloat (tsvector GIN indexes are large)
- Postgres optimized for row-level locking, not bulk scans
- Vacuum/autovacuum overhead on large replica tables
2. No Time-Travel or Historical Analytics
Section titled β2. No Time-Travel or Historical Analyticsβ// IMPOSSIBLE with current architecture:// "What was the price of product X on July 15, 2024?"// "Show inventory trend for product Y over last 6 months"ERP replica tables only maintain current snapshot. No historical data retention.
3. Single-ERP Limitation
Section titled β3. Single-ERP Limitationβ// IMPOSSIBLE: Federated queries across multiple ERPs// "Find all products matching 'pump' across Prophet21 AND NetSuite"Current architecture requires ONE sync service per connection. No unified query layer.
4. Rigid Schema
Section titled β4. Rigid Schemaβ// Every new ERP field requires schema migrationexport const erpProducts = pgTable('erp_products', { // ... 15 columns // What if NetSuite has 30 different fields? // What if customer wants custom metadata?});Schema changes require migrations, downtime, application restarts.
5. Scalability Concerns
Section titled β5. Scalability ConcernsβCurrent data volume (per organization):β’ Products: 2.3M rows Γ 5 KB avg = ~11.5 GBβ’ Cross-references: 500K rows Γ 2 KB = ~1 GBβ’ Inventory: 5M rows Γ 3 KB = ~15 GBβ’ Total: ~30 GB per large org
With 100 organizations:β’ Total replica data: ~3 TB in PostgreSQLβ’ Index overhead: ~1.5 TB additionalβ’ Total PostgreSQL size: ~4.5 TBPostgreSQL can handle this, but itβs expensive and not its optimal use case.
6. AI/LLM Integration Friction
Section titled β6. AI/LLM Integration Frictionβ// Current: AI must query raw replica tablesconst context = await db.query.erpProducts.findMany({ where: /* complex SQL */});
// No semantic abstraction layer// No pre-computed aggregations// No business logic in query layerLLMs need semantic APIs, not raw SQL tables.
Proposed Data Backend Architecture
Section titled βProposed Data Backend ArchitectureβHigh-Level Design
Section titled βHigh-Level Designβββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ PROPOSED ARCHITECTURE ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€β ββ ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ ββ β WEBAPP + PDF PROCESSING (No Changes) β ββ β β’ Astro + React frontend β ββ β β’ FastAPI PDF processing β ββ β β’ Trigger.dev orchestration β ββ ββββββββββββββββββ¬ββββββββββββββββββββββββββββββββββββββββββ ββ β ββ β JWT (clerk_organization_id in claims) ββ β ββ ββββββββββββββββββΌββββββββββββββββββββββββββββββββββββββββββ ββ β POSTGRES (OLTP - Transactional Data) β ββ β β’ orders β ββ β β’ pdf_documents β ββ β β’ part_number_mappings (stays here!) β ββ β β’ erp_connections β ββ β β’ organizations, users, subscriptions β ββ β β’ OPERATIONAL DATA ONLY β ββ ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ ββ ββ ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ ββ β DATA BACKEND (NEW - Analytical Data) β ββ β β ββ β βββββββββββββββββββββββββββββββββββββββββββββββ β ββ β β CUBE SEMANTIC LAYER (Unified API) β β ββ β β β β ββ β β Semantic Models: β β ββ β β ββ ERPProducts β β ββ β β β measures: count, avg_price β β ββ β β β dimensions: product_id, name, uom β β ββ β β β segments: active_products, has_image β β ββ β β β β β ββ β β ββ ERPCustomers β β ββ β β β measures: count, total_revenue β β ββ β β β dimensions: customer_id, name, pricing β β ββ β β β β β ββ β β ββ ERPInventory β β ββ β β β measures: total_on_hand, available β β ββ β β β dimensions: location_id, product_id β β ββ β β β β β ββ β β ββ ERPPricingHistory (NEW!) β β ββ β β time-series pricing analytics β β ββ β β β β ββ β β RLS: WHERE org_id = user.clerk_org_id β β ββ β β APIs: GraphQL, REST β β ββ β ββββββββββββ¬ββββββββββββββββββββ¬ββββββββββββββββ β ββ β β β β ββ β ββββββββββΌβββββββββ ββββββββΌβββββββββ β ββ β β TRINO β β Postgres β β ββ β β (Query Engine) β β (Read Replicaβ β ββ β β for Hudi β β for join) β β ββ β ββββββββββ¬βββββββββ βββββββββββββββββ β ββ β β β ββ β βΌ β ββ β βββββββββββββββββββββββββββββββββββββββ β ββ β β APACHE HUDI LAKEHOUSE β β ββ β β β β ββ β β Tables: β β ββ β β β’ erp_products β β ββ β β β’ partition: org_id, erp_type β β ββ β β β’ format: Parquet (compressed) β β ββ β β β’ time-travel enabled β β ββ β β β’ full-text search: Hudi index β β ββ β β β’ β β ββ β β β’ erp_customers β β ββ β β β’ erp_cross_references β β ββ β β β’ erp_inventory_locations β β ββ β β β’ erp_pricing_snapshots (NEW!) β β ββ β β β’ erp_sync_events (audit) β β ββ β β β β ββ β β Storage: Cloudflare R2 / AWS S3 β β ββ β β Cost: ~$0.015/GB/mo (vs PG $0.20) β β ββ β βββββββββββββββββββββββββββββββββββββββ β ββ β β ββ β βββββββββββββββββββββββββββββββββββββββββββββββ β ββ β β ETL ORCHESTRATION β β ββ β β β β ββ β β ββββββββββββββββββββββββββββββββββ β β ββ β β β dlt PIPELINES (Extraction) β β β ββ β β β β’ prophet21_products.py β β β ββ β β β β’ prophet21_customers.py β β β ββ β β β β’ netsuite_products.py (future)β β β ββ β β β β β β ββ β β β Replaces: IERPSyncService β β β ββ β β ββββββββββββββββββββββββββββββββββ β β ββ β β β β ββ β β ββββββββββββββββββββββββββββββββββ β β ββ β β β SPARK (Loading to Hudi) β β β ββ β β β β’ Batch writes to Hudi tables β β β ββ β β β β’ Partition management β β β ββ β β β β’ Compaction & cleanup β β β ββ β β ββββββββββββββββββββββββββββββββββ β β ββ β β β β ββ β β ββββββββββββββββββββββββββββββββββ β β ββ β β β dbt (Business Logic) β β β ββ β β β β’ fct_inventory_snapshot.sql β β β ββ β β β β’ dim_product_enriched.sql β β β ββ β β β β’ fct_pricing_history.sql β β β ββ β β ββββββββββββββββββββββββββββββββββ β β ββ β β β β ββ β β Orchestration: Trigger.dev (existing!) β β ββ β βββββββββββββββββββββββββββββββββββββββββββββββ β ββ ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ ββ ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββWhat Changes vs What Stays
Section titled βWhat Changes vs What Staysβ| Component | Current | Proposed |
|---|---|---|
| Orders, PDFs, Mappings | PostgreSQL | PostgreSQL (no change) |
| ERP Products | PostgreSQL replica | Hudi lakehouse |
| ERP Customers | PostgreSQL replica | Hudi lakehouse |
| ERP Inventory | PostgreSQL replica | Hudi lakehouse |
| Sync Logic | IERPSyncService (TypeScript) | dlt pipelines (Python) |
| Orchestration | Trigger.dev | Trigger.dev (no change) |
| Query API | Direct Drizzle queries | Cube semantic layer |
| Auth | Clerk | Clerk (no change) |
| Frontend | Astro + React | No change |
Detailed Component Design
Section titled βDetailed Component Designβ1. Apache Hudi Lakehouse
Section titled β1. Apache Hudi LakehouseβWhy Hudi?
Section titled βWhy Hudi?βFeatures we need: β ACID transactions (upsert support for ERP syncs) β Time-travel (historical pricing, inventory) β Incremental processing (sync only changed records) β Partition pruning (org_id partitions for multi-tenancy) β Schema evolution (add NetSuite fields without downtime) β Compaction (optimize storage over time)
Alternatives considered: β’ Delta Lake: AWS-centric, less active community β’ Iceberg: No upsert support (append-only) β’ PostgreSQL: Current solution (OLTP/OLAP mixing)Hudi Table Configuration
Section titled βHudi Table Configurationβtable: name: erp_productstype: COPY_ON_WRITE # Optimized for read-heavy workloads partition: fields: [clerk_organization_id, erp_type] # Physical layout: # s3://erp-data/erp_products/ # org_id=550e8400.../erp_type=prophet21/*.parquet # org_id=550e8400.../erp_type=netsuite/*.parquetprimary_key: fields: [connection_id, erp_product_id]
precombine: field: last_synced_at # Use for conflict resolution
indexes: - type: BLOOM fields: [erp_product_id, name]
- type: LUCENE # Full-text search replacement for tsvector fields: [name, description] compaction: strategy: NUM_COMMITS max_commits: 10 target_file_size: 128MBcleaner: retain_commits: 30 # 30-day time-travel windowSchema Design
Section titled βSchema Designβ-- Hudi: erp_products (schema-on-read, flexible JSONB-like)CREATE TABLE erp_products ( -- Core fields (strongly typed) id STRING, connection_id STRING, clerk_organization_id STRING, -- Partition key erp_type STRING, -- Partition key (prophet21, netsuite, etc.) erp_product_id STRING, name STRING, description STRING, unit_of_measure STRING, delete_flag BOOLEAN, last_synced_at TIMESTAMP,
-- Flexible metadata (varies by ERP) metadata STRUCT< prophet21 STRUCT< company_id STRING, price_code STRING, weight DECIMAL, ... >, netsuite STRUCT< internal_id STRING, subsidiary_id STRING, ... > >,
-- Image metadata images STRUCT< erp_url STRING, r2_key STRING, has_image BOOLEAN >,
-- Pricing (time-series, append-only) current_pricing STRUCT< base_price DECIMAL, currency STRING, effective_date TIMESTAMP >,-- Full-text search (Lucene index) search_text STRING -- Concatenated name + description)USING HUDIPARTITIONED BY (clerk_organization_id, erp_type);2. Cube Semantic Layer
Section titled β2. Cube Semantic LayerβWhy Cube?
Section titled βWhy Cube?βRequirements: β Multi-tenant RLS (filter by clerk_organization_id) β GraphQL + REST APIs (for AI agents) β Pre-aggregated metrics (dashboards) β Caching layer (reduce Trino queries) β SQL abstraction (business logic in models)
Cube Core (open-source) is perfect for this.Cube Model Example
Section titled βCube Model Exampleβcubes: - name: ERPProductssql_table: erp_products # Trino table backed by Hudi
# Multi-tenant security data_source: default # Row-level security sql: > SELECT * FROM erp_products WHERE clerk_organization_id = '${SECURITY_CONTEXT.clerk_org_id}' AND delete_flag = false
# Joins (federated query with PostgreSQL) joins: - name: PartNumberMappings sql: > {ERPProducts.erp_product_id} = {PartNumberMappings.source_item_id} AND {ERPProducts.connection_id} = {PartNumberMappings.connection_id} relationship: one_to_many
# Measures (aggregations) measures: - name: count type: count- name: products_with_images type: count filters: - sql: "{CUBE}.has_product_image = true"
- name: avg_weight sql: "metadata.prophet21.weight" type: avg # Dimensions dimensions: - name: clerk_organization_id sql: clerk_organization_id type: stringshown: false # Hide from API - name: product_id sql: erp_product_id type: string primary_key: true- name: name sql: name type: string
- name: description sql: description type: string
- name: unit_of_measure sql: unit_of_measure type: string
- name: has_image sql: images.has_image type: boolean
- name: erp_type sql: erp_type type: string
- name: last_synced_at sql: last_synced_at type: time
# Segments (pre-defined filters) segments: - name: active_products sql: "{CUBE}.delete_flag = false"
- name: products_with_images sql: "{CUBE}.images.has_image = true" - name: prophet21_products sql: "{CUBE}.erp_type = 'prophet21'"Cube API Usage (from AI Agents)
Section titled βCube API Usage (from AI Agents)β// AI Tool: Search products across all ERPsimport { CubeApi } from '@cubejs-client/core';
const cubeApi = new CubeApi({ apiUrl: process.env.CUBE_API_URL, headers: { // Clerk JWT with clerk_organization_id claim Authorization: `Bearer ${clerkToken}`, },});
// GraphQL query (semantic, not SQL)const result = await cubeApi.load({ measures: ['ERPProducts.count'], dimensions: [ 'ERPProducts.product_id', 'ERPProducts.name', 'ERPProducts.description', 'ERPProducts.erp_type', ], filters: [ { member: 'ERPProducts.name', operator: 'contains', values: ['industrial pump'], }, ], segments: ['ERPProducts.active_products'], limit: 100,});
// Returns:// [// { product_id: 'P-1234', name: 'Industrial Pump A', erp_type: 'prophet21' },// { product_id: 'N-5678', name: 'Industrial Pump B', erp_type: 'netsuite' }// ]
// β¨ Federated across multiple ERPs automatically!// β¨ RLS enforced (only user's org data)// β¨ Cached by Cube (fast repeated queries)3. dlt ETL Pipelines
Section titled β3. dlt ETL PipelinesβReplacing IERPSyncService
Section titled βReplacing IERPSyncServiceβimport dltfrom dlt.sources import incrementalfrom typing import Iterator, Dict, Anyimport requests
@dlt.resource( name="erp_products", primary_key=["connection_id", "erp_product_id"], write_disposition="merge", # Upsert behavior columns={ "clerk_organization_id": {"data_type": "text", "partition": True}, "erp_type": {"data_type": "text", "partition": True}, "last_synced_at": {"data_type": "timestamp"}, })def fetch_prophet21_products( connection_id: str, clerk_organization_id: str, api_url: str, username: str, password: str, last_sync: str = None, # Incremental sync) -> Iterator[Dict[str, Any]]: """ Extract products from Prophet21 OData API.Replaces: Prophet21SyncService.syncProducts() """
# Incremental state (only fetch changed records) incremental_cursor = incremental("last_modified_date", initial_value=last_sync)
# Paginate through Prophet21 API skip = 0 page_size = 100 while True: # Build OData query filter_clause = "" if last_sync: filter_clause = f"&$filter=modified_date gt datetime'{last_sync}'"url = f"{api_url}/items?$skip={skip}&$top={page_size}{filter_clause}"
response = requests.get(url, auth=(username, password)) response.raise_for_status()
data = response.json() items = data.get("value", [])
if not items: break # Transform and yield records for item in items: yield { "id": f"{connection_id}_{item['item_id']}", # Unique across all connections "connection_id": connection_id, "clerk_organization_id": clerk_organization_id, "erp_type": "prophet21", "erp_product_id": item["item_id"], "name": item["item_desc"], "description": item.get("extended_desc"), "unit_of_measure": item.get("unit_of_measure"), "delete_flag": item.get("delete_flag") == "Y", "last_synced_at": dlt.common.time.now_timestamp(),
# Flexible metadata (no schema changes needed) "metadata": { "prophet21": { "company_id": item.get("company_id"), "price_code": item.get("price_code"), "weight": item.get("weight"), "hazmat_flag": item.get("hazmat_flag"), # ... all Prophet21-specific fields } },
# Image metadata "images": { "erp_url": item.get("image_url"), "r2_key": None, # Populated by separate image sync task "has_image": bool(item.get("image_url")) },
# Pricing snapshot "current_pricing": { "base_price": item.get("base_price"), "currency": "USD", "effective_date": item.get("price_effective_date") },# Full-text search field "search_text": f"{item['item_id']} {item['item_desc']} {item.get('extended_desc', '')}" } skip += page_size
# Pipeline configurationpipeline = dlt.pipeline( pipeline_name="prophet21_sync", destination="hudi", # Write to Hudi lakehouse dataset_name="erp_data")
# Run sync (orchestrated by Trigger.dev)def sync_prophet21_products(connection_config: dict): pipeline.run( fetch_prophet21_products(**connection_config), table_name="erp_products", write_disposition="merge", loader_file_format="parquet" )Trigger.dev Orchestration (No Major Changes)
Section titled βTrigger.dev Orchestration (No Major Changes)βimport { schemaTask } from '@trigger.dev/sdk';import { z } from 'zod';import { exec } from 'child_process';import { promisify } from 'util';
const execAsync = promisify(exec);
export const syncERPProducts = schemaTask({ id: 'sync-erp-products-v2', // New version for dlt-based sync schema: z.object({ connectionId: z.string().uuid(), clerkOrganizationId: z.string(), erpType: z.enum(['prophet21', 'netsuite', 'demo']), fullSync: z.boolean().default(false), }), retry: { maxAttempts: 3, factor: 1.5, }, machine: { preset: 'medium-2x', // Same as current }, run: async payload => { // Get ERP connection credentials from PostgreSQL const connection = await db.query.erpConnections.findFirst({ where: eq(erpConnections.id, payload.connectionId), });
if (!connection) { throw new Error(`Connection ${payload.connectionId} not found`); }
// Determine last sync timestamp (for incremental sync) const lastSync = payload.fullSync ? null : await getLastSyncTimestamp(payload.connectionId, 'products');
// Invoke dlt pipeline (Python process) const { stdout, stderr } = await execAsync(`python3 /etl/pipelines/prophet21_products.py`, { env: { CONNECTION_ID: payload.connectionId, CLERK_ORG_ID: payload.clerkOrganizationId, API_URL: connection.apiUrl, USERNAME: connection.username, PASSWORD: connection.password, // TODO: Use secret manager LAST_SYNC: lastSync?.toISOString(), HUDI_TABLE: 'erp_products', S3_BUCKET: process.env.R2_BUCKET_NAME, S3_ACCESS_KEY: process.env.R2_ACCESS_KEY_ID, S3_SECRET_KEY: process.env.R2_SECRET_ACCESS_KEY, }, });
logger.info('Product sync completed', { connectionId: payload.connectionId, stdout, });
return { success: true, recordsProcessed: parseRecordCount(stdout), }; },});
// Scheduled daily sync (same as current)export const scheduledERPSyncV2 = schedules.task({ id: 'scheduled-erp-sync-v2', cron: '0 2 * * *', // 2 AM UTC daily run: async payload => { // Get all active ERP connections const connections = await db.query.erpConnections.findMany({ where: eq(erpConnections.active, true), }); // Trigger sync for each connection for (const conn of connections) { await syncERPProducts.trigger({ connectionId: conn.id, clerkOrganizationId: conn.clerkOrganizationId, erpType: conn.type, fullSync: false, }); } },});4. dbt Business Logic Layer
Section titled β4. dbt Business Logic Layerβ-- dbt/models/marts/fct_product_enriched.sql{{ config( materialized='incremental', partition_by={ "field": "clerk_organization_id", "data_type": "string" }, unique_key=['connection_id', 'erp_product_id'] )}}
WITH base_products AS ( SELECT *, -- Extract nested fields metadata.prophet21.company_id AS p21_company_id, metadata.prophet21.price_code AS p21_price_code, images.has_image, current_pricing.base_price FROM {{ source('hudi', 'erp_products') }} WHERE delete_flag = false),
product_mappings AS ( SELECT connection_id, source_item_id AS erp_product_id, COUNT(*) AS mapping_count, ARRAY_AGG(target_item_number) AS mapped_item_numbers FROM {{ source('postgres', 'part_number_mappings') }} GROUP BY connection_id, source_item_id)
SELECT p.*, m.mapping_count, m.mapped_item_numbers,
-- Business logic: Product health score CASE WHEN p.has_image AND p.base_price > 0 AND m.mapping_count > 0 THEN 'excellent' WHEN p.base_price > 0 AND m.mapping_count > 0 THEN 'good' WHEN p.base_price > 0 THEN 'fair' ELSE 'poor' END AS data_quality_score
FROM base_products pLEFT JOIN product_mappings m ON p.connection_id = m.connection_id AND p.erp_product_id = m.erp_product_id
{% if is_incremental() %} WHERE p.last_synced_at > (SELECT MAX(last_synced_at) FROM {{ this }}){% endif %}Migration Strategy
Section titled βMigration StrategyβPhase 1: Pilot (Products Only, 4-6 Weeks)
Section titled βPhase 1: Pilot (Products Only, 4-6 Weeks)βGoal: Validate architecture with ONE entity type alongside current system.
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ PHASE 1: DUAL SYSTEM (PILOT) ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€β ββ Current System (Production): ββ β’ Prophet21SyncService.syncProducts() βββΊ PostgreSQL ββ β’ Product search uses tsvector ββ ββ New System (Pilot): ββ β’ dlt pipeline βββΊ Hudi (R2 storage) ββ β’ Cube semantic layer (read-only) ββ β’ Compare results with current system ββ ββ Metrics to Compare: ββ β’ Sync duration ββ β’ Search query performance ββ β’ Storage costs ββ β’ Data accuracy ββ ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββWeek 1-2: Infrastructure Setup
Section titled βWeek 1-2: Infrastructure Setupβ# 1. Spin up Hudi + Trino + Cube locally (Docker Compose)docker-compose -f docker-compose.data-backend.yml up
# Services:# - Trino coordinator (port 8080)# - Cube API (port 4000)# - MinIO (local S3, port 9000)# - Hudi metadata service
# 2. Configure Hudi table for productsspark-submit create_products_table.py
# 3. Deploy Cube modelcube deployWeek 3-4: dlt Pipeline Development
Section titled βWeek 3-4: dlt Pipeline Developmentβ# Develop prophet21_products.py pipeline# Run parallel to existing sync# Compare record counts, data qualityWeek 5-6: Validation & Decision
Section titled βWeek 5-6: Validation & DecisionβSuccess Criteria: β dlt sync completes in < current sync duration β Hudi storage cost < 50% of PostgreSQL β Cube queries perform within 200ms p95 β 100% data parity with current system β Multi-tenant isolation verified
Decision Gate: β Proceed to Phase 2 (migrate remaining entities) β Abandon data backend approachPhase 2: Full Migration (8-12 Weeks)
Section titled βPhase 2: Full Migration (8-12 Weeks)βGoal: Migrate all ERP entities to Hudi lakehouse.
Entities to Migrate:1. Products (already done in Phase 1)2. Customers3. Cross-references4. Shipping addresses5. Inventory locations6. Sync logs (for analytics)Migration Sequence
Section titled βMigration Sequenceβ// Week 1-2: CustomerssyncERPCustomersV2; // dlt pipeline// Parallel run for 2 weeks, then cut over
// Week 3-4: Cross-referencessyncERPCrossReferencesV2;
// Week 5-6: Shipping addressessyncERPShippingAddressesV2;
// Week 7-8: Inventory locationssyncERPInventoryV2;
// Week 9-10: Testing & validation// Week 11-12: Cutover & deprecationPhase 3: Deprecation (2-4 Weeks)
Section titled βPhase 3: Deprecation (2-4 Weeks)βGoal: Remove old PostgreSQL replica tables and IERPSyncService.
-- Mark tables for deletionALTER TABLE erp_products RENAME TO erp_products_deprecated;ALTER TABLE erp_customers RENAME TO erp_customers_deprecated;-- ... etc.
-- Monitor for 30 days (confirm no queries)
-- Drop tablesDROP TABLE erp_products_deprecated CASCADE;// Remove deprecated code// β DELETE: Prophet21SyncService// β DELETE: IERPSyncService interface// β DELETE: erp-replica.ts schema
// Keep only:// β dlt pipelines// β Cube models// β Trigger.dev orchestration tasksCost-Benefit Analysis
Section titled βCost-Benefit AnalysisβCurrent System Costs (Estimated)
Section titled βCurrent System Costs (Estimated)βPostgreSQL (OLTP + OLAP): Instance: db.r6g.2xlarge (8 vCPU, 64 GB RAM) Cost: ~$800/monthStorage (4.5 TB SSD): Cost: ~$450/month (100 IOPS/GB)
Backup & Replication: Cost: ~$200/month
Total: ~$1,450/month for databaseCompute (Trigger.dev tasks): Medium-2x machines (2 vCPU, 4 GB) Sync duration: ~30 min/org/day Cost: ~$0.50/org/month100 orgs: ~$50/monthTotal Current Monthly Cost: ~$1,500Proposed System Costs (Estimated)
Section titled βProposed System Costs (Estimated)βPostgreSQL (OLTP only, downsized): Instance: db.r6g.xlarge (4 vCPU, 32 GB RAM) Cost: ~$400/monthStorage (500 GB SSD - 90% reduction): Cost: ~$50/month
Total PostgreSQL: ~$450/monthHudi Lakehouse: Storage (Cloudflare R2): 4.5 TB Parquet (compressed ~60%): 2.7 TB Cost: $0.015/GB/month = ~$40/monthEgress (read queries): ~100 GB/month: Free (R2 has no egress fees)
Total Storage: ~$40/month
Trino Query Engine: Instance: c6g.2xlarge (8 vCPU, 16 GB) Cost: ~$250/month
Cube Semantic Layer: Instance: t3.medium (2 vCPU, 4 GB) Cost: ~$35/month
Spark for dlt: On-demand (runs 1-2 hours/day) Cost: ~$50/month
Compute (Trigger.dev - unchanged): Cost: ~$50/monthTotal Proposed Monthly Cost: ~$875/monthCost Savings
Section titled βCost SavingsβCurrent: $1,500/monthProposed: $875/monthSavings: $625/month (~42% reduction)
Annual Savings: $7,5003-Year Savings: $22,500Non-Cost Benefits
Section titled βNon-Cost BenefitsβScalability: Current: PostgreSQL struggles > 5 TB Proposed: Hudi scales to petabytes on object storageHistorical Analytics: Current: Impossible (snapshot-only) Proposed: Time-travel queries for pricing trends, inventory patterns
Multi-ERP Federation: Current: Requires complex unions across connection_id Proposed: Native support via Cube models
AI/LLM Integration: Current: Direct SQL queries (brittle, security risk) Proposed: Semantic layer with RLS, pre-aggregations, cachingDeveloper Experience: Current: Schema migrations for every new ERP field Proposed: Schema-on-read (flexible metadata JSONB)Risk Assessment
Section titled βRisk AssessmentβTechnical Risks
Section titled βTechnical Risksβ| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| Hudi performance < PostgreSQL | Low | High | Run Phase 1 pilot with benchmarks |
| Cube adds latency | Medium | Medium | Enable aggressive caching, pre-aggregations |
| dlt pipeline bugs | Medium | Medium | Dual-run with current system during pilot |
| Spark complexity | Medium | Medium | Use managed Spark (Databricks or EMR) |
| Migration data loss | Low | Critical | Checksums, row counts, reconciliation |
| Learning curve | High | Low | Invest in training, documentation |
Operational Risks
Section titled βOperational Risksβ| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| Increased operational complexity | High | Medium | Phased rollout, comprehensive monitoring |
| Vendor lock-in (Hudi/Trino) | Low | Medium | Open-source tools (no vendor lock-in) |
| R2 outage | Low | High | Dual-region replication, fallback to S3 |
| Team unfamiliarity | High | Low | Pilot phase builds expertise |
Business Risks
Section titled βBusiness Risksβ| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| Customer-facing downtime | Low | Critical | Blue-green deployment, rollback plan |
| Feature development slowdown | Medium | Medium | Pilot in parallel, no disruption to app layer |
| Cost overruns | Low | Medium | Monitor actual costs vs estimates in pilot |
Risk Score: MEDIUM (Acceptable with phased approach)
Section titled βRisk Score: MEDIUM (Acceptable with phased approach)βImplementation Roadmap
Section titled βImplementation RoadmapβTimeline Overview
Section titled βTimeline OverviewβPhase 0: Planning & Approval (2 weeks) Week 1: Architecture review, stakeholder alignment Week 2: Resource allocation, budgeting
Phase 1: Pilot (Products Only) (6 weeks) Week 1-2: Infrastructure setup (Hudi, Trino, Cube) Week 3-4: dlt pipeline development Week 5-6: Validation & decision gate
Phase 2: Full Migration (12 weeks) Week 1-2: Customers Week 3-4: Cross-references Week 5-6: Shipping addresses Week 7-8: Inventory locations Week 9-10: Testing & validation Week 11-12: Cutover
Phase 3: Deprecation & Cleanup (4 weeks) Week 1-2: Mark old tables for deletion Week 3-4: Code cleanup, documentation
Total Duration: ~24 weeks (6 months)Resource Requirements
Section titled βResource RequirementsβTeam: β’ 1 Data Engineer (full-time) - Hudi, Spark, dlt expertise - Build ETL pipelines
β’ 1 Backend Engineer (50% time) - Integrate Cube API - Update Trigger.dev tasks
β’ 1 DevOps Engineer (25% time) - Infrastructure setup - Monitoring, alerting
β’ 1 QA Engineer (25% time) - Validation testing - Performance benchmarks
Budget: β’ Cloud infrastructure: ~$875/month (ongoing) β’ Pilot phase (6 weeks): ~$1,500 one-time β’ Engineering time: ~$60K (salary allocation)
Total Phase 1 Investment: ~$65KRecommendation
Section titled βRecommendationβPrimary Recommendation: PROCEED WITH PHASED APPROACH
Section titled βPrimary Recommendation: PROCEED WITH PHASED APPROACHβRationale:
-
β Strong Technical Fit: Hudi lakehouse + Cube semantic layer aligns perfectly with ERP-Unlockedβs analytical workload (product search, inventory queries, pricing analytics).
-
β Future-Proofs Architecture: Enables multi-ERP federation, historical analytics, AI-powered features that are impossible with current architecture.
-
β Cost Savings: 42% reduction in infrastructure costs ($7,500/year) while improving performance and capabilities.
-
β Phased Risk Mitigation: Pilot with products only validates architecture before full commitment.
-
β No Customer Disruption: Can run parallel to current system, gradual cutover.
Conditions for Success
Section titled βConditions for SuccessβRequired: β Hire or allocate 1 experienced Data Engineer (Hudi/Spark expertise) β Commit to 6-month timeline (no rushing) β Define clear success metrics for pilot β Budget for $65K Phase 1 investment
Nice-to-Have: β’ Databricks or AWS EMR managed Spark (reduce operational burden) β’ Better Stack integration for Cube/Trino monitoring β’ Dedicated staging environment for data backendAlternative Recommendation: INCREMENTAL IMPROVEMENTS TO CURRENT SYSTEM
Section titled βAlternative Recommendation: INCREMENTAL IMPROVEMENTS TO CURRENT SYSTEMβIf data backend is deemed too complex, consider:
Option B: Optimize Current PostgreSQL Architecture β’ Separate OLAP workload to read replica β’ Implement TimescaleDB for historical analytics β’ Add Redis caching layer for product searches β’ Keep IERPSyncService, improve batch sizes
Investment: ~$15K (3-4 weeks) Cost Savings: Minimal (~$100/month) Benefits: Lower complexity, faster implementation Drawbacks: No multi-ERP federation, no time-travel, scaling limitsDecision Framework
Section titled βDecision FrameworkβChoose Data Backend IF: β Planning to support 5+ ERP systems β Need historical analytics (pricing trends, inventory patterns) β Scaling to 100+ organizations β Willing to invest 6 months + $65K β Team has (or can hire) data engineering expertise
Choose Incremental Improvements IF: β Current scale is sufficient (<50 orgs) β Limited engineering resources β Need faster time-to-value (<1 month) β Risk-averse to architectural changesAppendix A: Comparison Matrix
Section titled βAppendix A: Comparison Matrixβ| Aspect | Current (PostgreSQL Replica) | Proposed (Hudi Lakehouse) |
|---|---|---|
| Storage Cost (4.5 TB) | $450/month | $40/month |
| Query Performance (product search) | 800ms p95 (tsvector) | 200ms p95 (Hudi Lucene index) |
| Historical Analytics | β Snapshot only | β Time-travel queries |
| Multi-ERP Queries | β οΈ Complex unions | β Native federation via Cube |
| Schema Changes | β οΈ Migrations required | β Schema-on-read (flexible) |
| Full-Text Search | PostgreSQL tsvector | Hudi Lucene index |
| Scalability Limit | ~5 TB (practical) | Petabytes |
| AI/LLM Integration | β οΈ Direct SQL (security risk) | β Semantic layer (RLS, caching) |
| Operational Complexity | Low (familiar) | Medium (new stack) |
| Team Expertise Required | PostgreSQL DBA | Data Engineer (Spark, Hudi) |
Appendix B: Sample Cube API Queries for AI Agents
Section titled βAppendix B: Sample Cube API Queries for AI AgentsβQuery 1: Federated Product Search Across All ERPs
Section titled βQuery 1: Federated Product Search Across All ERPsβquery FederatedProductSearch($searchTerm: String!) { cube( measures: ["ERPProducts.count"] dimensions: [ "ERPProducts.product_id" "ERPProducts.name" "ERPProducts.description" "ERPProducts.erp_type" "ERPProducts.has_image" ] filters: [{ member: "ERPProducts.name", operator: "contains", values: [$searchTerm] }] segments: ["ERPProducts.active_products"] limit: 100 ) { ERPProducts { product_id name description erp_type has_image } }}
# Variables: { "searchTerm": "industrial pump" }
# Response:# [# { product_id: "P21-1234", name: "Industrial Pump A", erp_type: "prophet21" },# { product_id: "NS-5678", name: "Industrial Pump B", erp_type: "netsuite" }# ]Query 2: Historical Pricing Analysis
Section titled βQuery 2: Historical Pricing Analysisβquery ProductPricingTrend($productId: String!, $startDate: String!) { cube( measures: ["ERPPricing.avg_price", "ERPPricing.min_price", "ERPPricing.max_price"] dimensions: ["ERPPricing.snapshot_date"] filters: [ { member: "ERPProducts.product_id", operator: "equals", values: [$productId] } { member: "ERPPricing.snapshot_date", operator: "afterDate", values: [$startDate] } ] timeDimensions: [{ dimension: "ERPPricing.snapshot_date", granularity: "week" }] ) { ERPPricing { snapshot_date avg_price min_price max_price } }}
# Variables: { "productId": "P21-1234", "startDate": "2024-01-01" }
# Response: Weekly pricing trend (impossible with current architecture!)Query 3: Inventory Availability Across Locations
Section titled βQuery 3: Inventory Availability Across Locationsβquery InventoryByLocation($productIds: [String!]!) { cube( measures: [ "ERPInventory.total_on_hand" "ERPInventory.total_available" "ERPInventory.location_count" ] dimensions: ["ERPInventory.product_id", "ERPInventory.location_id"] filters: [{ member: "ERPInventory.product_id", operator: "equals", values: $productIds }] ) { ERPInventory { product_id location_id total_on_hand total_available } }}Appendix C: Infrastructure Diagram (Detailed)
Section titled βAppendix C: Infrastructure Diagram (Detailed)ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ DEPLOYMENT ARCHITECTURE βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€β ββ Cloudflare (CDN + WAF) ββ ββ Marketing site (static) ββ ββ Webapp frontend (proxied to Coolify) ββ ββ ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ ββ β COOLIFY (Docker Orchestration) β ββ β β ββ β Container: webapp (Astro + React) β ββ β Container: pdf-api (FastAPI) β ββ β Container: pdf-worker (Celery) β ββ β Container: redis (cache + queue) β ββ β Container: postgres (OLTP only, downsized) β ββ β β ββ β NEW Containers: β ββ β Container: cube-api (Cube semantic layer) β ββ β Container: trino-coordinator (query engine) β ββ β Container: trino-worker-1 (worker node) β ββ β Container: trino-worker-2 (auto-scale) β ββ β Container: spark-master (ETL orchestrator) β ββ β Container: spark-worker-1 β ββ βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ ββ ββ Cloudflare R2 (Object Storage) ββ ββ Bucket: erp-data ββ β ββ Hudi tables: ββ β ββ erp_products/ (Parquet files, partitioned by org_id) ββ β ββ erp_customers/ ββ β ββ erp_inventory/ ββ β ββ .hoodie/metadata/ (Hudi transaction logs) ββ β ββ ββ Bucket: erp-attachments (PDFs, images) β unchanged ββ ββ Better Stack (Observability) ββ ββ OpenTelemetry traces (webapp, pdf-api, cube, trino) ββ ββ Logs aggregation ββ ββ Dashboards ββ ββ Trigger.dev (Background Jobs) β unchanged ββ ββ Scheduled ERP syncs (now invoke dlt pipelines) ββ ββ PDF processing tasks ββ ββ System health checks ββ βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββDocument Status: Ready for Review
Next Steps:
- Technical review with engineering team
- Cost validation with finance
- Decision on Phase 1 pilot (go/no-go)
- Resource allocation if approved
Questions or Feedback? Reach out to the architecture team.