Architecture
How Tally UI's connector and trait system works
The Problem
Every POS backend shapes its product data differently. Look at how WooCommerce and Medusa represent the same espresso machine:
WooCommerce calls it name, Medusa calls it title. WooCommerce puts price as a string on the product, Medusa stores it as a number in major units nested inside variants. Images use src in one and url in the other.
Forcing all this into one universal schema would lose data. Leaving it raw would mean every component needs if (connector === 'woocommerce') branches everywhere. Neither works.
The Trait Layer
Tally UI solves this with a trait layer — a thin set of accessor functions that each connector implements. Components program against traits, not raw data.
UI Component → calls getName(doc)
↓
Trait Layer → WooCommerce: doc.name
Medusa: doc.title
Vendure: doc.name
↓
RxDB Document → stored in connector-specific schemaThe active connector determines which implementation runs. Components never import connector-specific code — they use the useProductTraits() hook which reads from the nearest ConnectorProvider.
// This component works identically with WooCommerce, Medusa, or any connector
function ProductCard({ doc }) {
const { getName, getPrice, getImageUrl } = useProductTraits();
return (
<View className="bg-card rounded-lg p-4 gap-2">
<Image source={{ uri: getImageUrl(doc) }} className="rounded w-16 h-16" />
<Text className="text-base font-semibold text-foreground">{getName(doc)}</Text>
<Text className="text-sm text-price">${getPrice(doc)}</Text>
</View>
);
}Connectors
A connector is a package that knows how to talk to a specific backend. It implements the TallyConnector interface:
interface TallyConnector {
id: string; // 'woocommerce', 'medusa', etc.
name: string; // Human-readable name
auth: ConnectorAuth; // How to authenticate
schemas: { // RxDB schemas (per-connector shape)
products: RxJsonSchema;
};
traits: { // Accessor functions
product: ProductTraits;
};
replication: { // Pull-only sync via RxDB replication
products: ReplicationAdapter;
};
}Per-Connector Schemas
Each connector stores data in its own RxDB schema — faithful to the source API. No data is lost or flattened. The WooCommerce schema has meta_data, attributes, and variations arrays. The Medusa schema has nested variants with prices and options. Both are preserved exactly as the API returns them.
When you need connector-specific fields that aren't covered by traits, you can always access the raw document directly.
Data Flow
┌─────────────────────────────────────────┐
│ Backend API │
│ WooCommerce REST / Medusa Admin / ... │
└──────────────────┬──────────────────────┘
│ replication (pull)
┌──────────────────▼──────────────────────┐
│ ReplicationAdapter │
│ Checkpoint-based sync per connector │
│ Server-wins conflict resolution │
└──────────────────┬──────────────────────┘
│ replicateRxCollection
┌──────────────────▼──────────────────────┐
│ RxDB (Local Database) │
│ Connector-specific schemas │
│ SQLite-wasm (web) / SQLite (mobile) │
└──────────────────┬──────────────────────┘
│ documents
┌──────────────────▼──────────────────────┐
│ Trait Layer │
│ getName() getPrice() getSku() ... │
└──────────────────┬──────────────────────┘
│ hooks
┌──────────────────▼──────────────────────┐
│ UI Components │
│ <ProductTitle> <ProductPrice> ... │
│ Same component, any backend │
└─────────────────────────────────────────┘Replication
Tally UI uses RxDB's built-in replication protocol to sync the local database from remote APIs. Each connector implements a ReplicationAdapter with a checkpoint-based pull handler. Catalogue replication is pull-only; POS-created data such as orders will go through a command outbox.
The replication layer handles:
- Checkpoint tracking -- only fetches data that changed since the last sync
- Pull-only catalogue sync -- pulls remote changes; the POS never writes products
- Conflict resolution -- server-wins strategy (the backend is the source of truth)
- Live updates -- real-time streams where backends support them, polling fallback elsewhere
- Error recovery -- automatic retries with configurable intervals
Platform Storage
Storage is platform-aware. The getStorage() factory returns the right
adapter for Node/SSR/tests automatically. On the web and React Native it
throws with guidance instead: both need a storage built and bundled outside
@tallyui/database (ADR-061, ADR-031).
| Platform | Storage | Package |
|---|---|---|
| Web browser | RxDB Premium SQLite-wasm (opfs-sahpool), one live tab | @tallyui/storage-sqlite |
| React Native | RxDB Premium SQLite (via expo-sqlite) | @tallyui/storage-sqlite |
| Node / SSR / tests | In-memory | Built into @tallyui/database |
See the Replication guide for setup and usage details, including the web storage's breaking change from Dexie.
Packages
| Package | Purpose |
|---|---|
@tallyui/core | Connector interfaces, trait types, React context |
@tallyui/database | RxDB factory, replication orchestrator, platform storage |
@tallyui/storage-sqlite | RxDB Premium SQLite storage: expo-sqlite on native, sqlite-wasm (opfs-sahpool) on the web |
@tallyui/components | UI primitives (ProductTitle, ProductPrice, etc.) |
@tallyui/pos | POS business logic — tax, currency, orders, receipts, logging |
@tallyui/theme | Shared design tokens, theming, and cn() utility |
@tallyui/connector-woocommerce | WooCommerce connector |
@tallyui/connector-medusa | MedusaJS v2 connector |
@tallyui/connector-shopify | Shopify Admin API connector |
@tallyui/connector-vendure | Vendure GraphQL connector |