Skip to content

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


This document proposes migrating ERP-Unlocked’s current PostgreSQL-based ERP replica system to a modern data backend architecture consisting of:

  1. Apache Hudi Lakehouse - Analytical storage for ERP data (products, customers, inventory, pricing)
  2. Cube Semantic Layer - Unified API for AI agents and analytics with built-in RLS
  3. dlt + Spark ETL - Modern data ingestion replacing current sync services
  4. Dual Database Strategy - PostgreSQL for OLTP (orders, documents), Hudi for OLAP (ERP data)

Should we replace IERPSyncService and erp_replica PostgreSQL tables with a lakehouse-based data backend to power AI-driven features?

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:

  1. Phase 1 (Pilot): Implement for ONE entity type (products) alongside current system
  2. Phase 2: Migrate remaining entities if pilot succeeds
  3. Phase 3: Deprecate old replica system

  1. Current Architecture Analysis
  2. Proposed Data Backend Architecture
  3. Detailed Component Design
  4. Migration Strategy
  5. Cost-Benefit Analysis
  6. Risk Assessment
  7. Implementation Roadmap
  8. Recommendation

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ 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 β”‚ β”‚
β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
β”‚ β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
AspectCurrent Implementation
StoragePostgreSQL for both OLTP and OLAP
Sync MechanismProphet21SyncService with batch upserts
SearchPostgreSQL tsvector (full-text search)
OrchestrationTrigger.dev scheduled tasks
Multi-tenancyclerk_organization_id + connection_id
ConcurrencyQueue with concurrencyLimit: 1 per connection
Retry Logic3 attempts, exponential backoff
Machine ResourcesMedium-2x (2 vCPU, 4 GB RAM)

PostgreSQL is designed for transactional workloads (orders, documents), not analytical queries (product search, inventory analysis).

// Current: Analytical query on transactional DB
const 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
// 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.

// 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.

// Every new ERP field requires schema migration
export 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.

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 TB

PostgreSQL can handle this, but it’s expensive and not its optimal use case.

// Current: AI must query raw replica tables
const context = await db.query.erpProducts.findMany({
where: /* complex SQL */
});
// No semantic abstraction layer
// No pre-computed aggregations
// No business logic in query layer

LLMs need semantic APIs, not raw SQL tables.


β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ 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!) β”‚ β”‚ β”‚
β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚
β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
β”‚ β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
ComponentCurrentProposed
Orders, PDFs, MappingsPostgreSQLPostgreSQL (no change)
ERP ProductsPostgreSQL replicaHudi lakehouse
ERP CustomersPostgreSQL replicaHudi lakehouse
ERP InventoryPostgreSQL replicaHudi lakehouse
Sync LogicIERPSyncService (TypeScript)dlt pipelines (Python)
OrchestrationTrigger.devTrigger.dev (no change)
Query APIDirect Drizzle queriesCube semantic layer
AuthClerkClerk (no change)
FrontendAstro + ReactNo change

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/products.yaml
table:
name: erp_products
type: 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/*.parquet
primary_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: 128MB
cleaner:
retain_commits: 30 # 30-day time-travel window
-- 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 HUDI
PARTITIONED BY (clerk_organization_id, erp_type);
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/models/erp_products.yml
cubes:
- name: ERPProducts
sql_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: string
shown: 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'"
// AI Tool: Search products across all ERPs
import { 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)
etl/pipelines/prophet21_products.py
import dlt
from dlt.sources import incremental
from typing import Iterator, Dict, Any
import 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 configuration
pipeline = 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"
)
apps/webapp/src/trigger/tasks/erp-sync.ts
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,
});
}
},
});
-- 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 p
LEFT 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 %}

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 β”‚
β”‚ β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
Terminal window
# 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 products
spark-submit create_products_table.py
# 3. Deploy Cube model
cube deploy
# Develop prophet21_products.py pipeline
# Run parallel to existing sync
# Compare record counts, data quality
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 approach

Goal: Migrate all ERP entities to Hudi lakehouse.

Entities to Migrate:
1. Products (already done in Phase 1)
2. Customers
3. Cross-references
4. Shipping addresses
5. Inventory locations
6. Sync logs (for analytics)
// Week 1-2: Customers
syncERPCustomersV2; // dlt pipeline
// Parallel run for 2 weeks, then cut over
// Week 3-4: Cross-references
syncERPCrossReferencesV2;
// Week 5-6: Shipping addresses
syncERPShippingAddressesV2;
// Week 7-8: Inventory locations
syncERPInventoryV2;
// Week 9-10: Testing & validation
// Week 11-12: Cutover & deprecation

Goal: Remove old PostgreSQL replica tables and IERPSyncService.

-- Mark tables for deletion
ALTER 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 tables
DROP 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 tasks

PostgreSQL (OLTP + OLAP):
Instance: db.r6g.2xlarge (8 vCPU, 64 GB RAM)
Cost: ~$800/month
Storage (4.5 TB SSD):
Cost: ~$450/month (100 IOPS/GB)
Backup & Replication:
Cost: ~$200/month
Total: ~$1,450/month for database
Compute (Trigger.dev tasks):
Medium-2x machines (2 vCPU, 4 GB)
Sync duration: ~30 min/org/day
Cost: ~$0.50/org/month
100 orgs: ~$50/month
Total Current Monthly Cost: ~$1,500
PostgreSQL (OLTP only, downsized):
Instance: db.r6g.xlarge (4 vCPU, 32 GB RAM)
Cost: ~$400/month
Storage (500 GB SSD - 90% reduction):
Cost: ~$50/month
Total PostgreSQL: ~$450/month
Hudi Lakehouse:
Storage (Cloudflare R2):
4.5 TB Parquet (compressed ~60%): 2.7 TB
Cost: $0.015/GB/month = ~$40/month
Egress (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/month
Total Proposed Monthly Cost: ~$875/month
Current: $1,500/month
Proposed: $875/month
Savings: $625/month (~42% reduction)
Annual Savings: $7,500
3-Year Savings: $22,500
Scalability:
Current: PostgreSQL struggles > 5 TB
Proposed: Hudi scales to petabytes on object storage
Historical 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, caching
Developer Experience:
Current: Schema migrations for every new ERP field
Proposed: Schema-on-read (flexible metadata JSONB)

RiskLikelihoodImpactMitigation
Hudi performance < PostgreSQLLowHighRun Phase 1 pilot with benchmarks
Cube adds latencyMediumMediumEnable aggressive caching, pre-aggregations
dlt pipeline bugsMediumMediumDual-run with current system during pilot
Spark complexityMediumMediumUse managed Spark (Databricks or EMR)
Migration data lossLowCriticalChecksums, row counts, reconciliation
Learning curveHighLowInvest in training, documentation
RiskLikelihoodImpactMitigation
Increased operational complexityHighMediumPhased rollout, comprehensive monitoring
Vendor lock-in (Hudi/Trino)LowMediumOpen-source tools (no vendor lock-in)
R2 outageLowHighDual-region replication, fallback to S3
Team unfamiliarityHighLowPilot phase builds expertise
RiskLikelihoodImpactMitigation
Customer-facing downtimeLowCriticalBlue-green deployment, rollback plan
Feature development slowdownMediumMediumPilot in parallel, no disruption to app layer
Cost overrunsLowMediumMonitor actual costs vs estimates in pilot

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)
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: ~$65K

Rationale:

  1. βœ… Strong Technical Fit: Hudi lakehouse + Cube semantic layer aligns perfectly with ERP-Unlocked’s analytical workload (product search, inventory queries, pricing analytics).

  2. βœ… Future-Proofs Architecture: Enables multi-ERP federation, historical analytics, AI-powered features that are impossible with current architecture.

  3. βœ… Cost Savings: 42% reduction in infrastructure costs ($7,500/year) while improving performance and capabilities.

  4. βœ… Phased Risk Mitigation: Pilot with products only validates architecture before full commitment.

  5. βœ… No Customer Disruption: Can run parallel to current system, gradual cutover.

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 backend

Alternative 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 limits
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 changes

AspectCurrent (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 SearchPostgreSQL tsvectorHudi Lucene index
Scalability Limit~5 TB (practical)Petabytes
AI/LLM Integration⚠️ Direct SQL (security risk)βœ… Semantic layer (RLS, caching)
Operational ComplexityLow (familiar)Medium (new stack)
Team Expertise RequiredPostgreSQL DBAData Engineer (Spark, Hudi)

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 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 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
}
}
}

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ 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:

  1. Technical review with engineering team
  2. Cost validation with finance
  3. Decision on Phase 1 pilot (go/no-go)
  4. Resource allocation if approved

Questions or Feedback? Reach out to the architecture team.