Offline mode enables BitCode applications to work without internet connectivity. Data is stored locally in SQLite, synced to the server when connectivity is restored. The UI layer (Stencil components) runs inside a Tauri native shell that provides access to device capabilities (camera, GPS, biometrics, etc.).
Key principle: One toggle (mode: "offline") enables everything. All complexity is handled by the engine automatically.
# bitcode.toml
[app]
mode = "offline" # "online" (default) | "offline"// modules/pos/module.json
{
"app": {
"mode": "offline"
}
}// modules/crm/models/lead.json — lead is offline
{
"name": "lead",
"app": { "mode": "offline" },
"fields": { ... }
}
// modules/crm/models/contact.json — contact inherits module default (online)
{
"name": "contact",
"fields": { ... }
}// modules/pos/views/settings.json
{
"app": {
"mode": "online"
}
}Resolution chain: model.app.mode → module.app.mode → project.app.mode → default("online")
[app.offline]
max_offline_hours = 72 # Force re-auth after N hours offline. Default: 72
sync_batch_size = 100 # Operations per sync batch. Default: 100
inventory_oversell = "allow" # "allow" (default) | "block" | "warn"
conflict_on_same_field = "latest" # "latest" (default) | "ask_user" | "server_wins"ONLINE MODE:
User → Stencil component → fetch() → Go Engine API → PostgreSQL
OFFLINE MODE:
User → Stencil component → BcNative → Local SQLite
│ (background, when online)
└→ Sync engine → Go Engine API → PostgreSQL
Offline mode uses Tauri as the native shell. Stencil components are unchanged — they run inside Tauri's native WebView. The bridge abstraction layer (bc-native.ts) detects the environment and routes to Tauri IPC or Web API fallback.
OfflineStore intercepts CRUD operations and routes based on model config:
OfflineStore.find('lead', params)
→ BcSetup.isModelOffline('lead') → true
→ BcNative.dbSelect() → Local SQLite
OfflineStore.find('contact', params)
→ BcSetup.isModelOffline('contact') → false
→ fetch() → Server API
On app init, OfflineStore.initFromServer() calls GET /api/v1/sync/schema to discover which models are offline and registers them via BcSetup.registerOfflineModels().
Write operations on offline models (create/update/delete) automatically record in _off_outbox for later sync.
GET /api/v1/sync/schema returns the list of offline models with their field definitions:
{
"models": [
{
"name": "lead",
"module": "crm",
"table_name": "crm_lead",
"fields": [
{ "name": "name", "type": "string", "required": true },
{ "name": "email", "type": "string" }
],
"primary_key": { "strategy": "uuid", "version": "v7" }
}
]
}| Strategy | Offline Support | Extra Column Needed |
|---|---|---|
uuid (v4, v7, format) |
✅ Full — recommended | No — UUID is the PK everywhere |
auto_increment |
Yes — _off_uuid added as temporary identity |
|
natural_key |
Yes — _off_uuid added as backup identity |
|
naming_series |
Yes — _off_uuid added as temporary identity |
|
composite |
❌ Not supported | Engine rejects with error |
manual |
Yes — _off_uuid added, collision risk |
UUIDv7 is generated on the client device. The same UUID becomes the permanent PK on the server. No ID remapping needed. No collision possible.
"primary_key": { "strategy": "uuid", "version": "v7" }Auto-increment IDs are assigned by the server. Client cannot generate valid IDs offline. Engine adds _off_uuid as temporary identity and remaps all FK references after sync. This adds complexity.
Engine warning when auto_increment + offline:
⚠️ WARNING: Module "pos" uses pk:"auto_increment" with mode:"offline".
Auto-increment PK requires additional sync complexity (ID remapping).
Consider using pk:"uuid" for better offline support.
When mode = "offline", the engine automatically adds these columns to every table in the module. Developers do NOT define these — they are invisible in model JSON.
| Column | Type | Default | Purpose |
|---|---|---|---|
_off_device_id |
TEXT NOT NULL | (from device registration) | ID of the device that created/modified this record. Used for receipt numbering prefix, conflict attribution, audit trail. Example: "DEV-A" |
_off_status |
INTEGER NOT NULL | 0 |
Sync status. 0 = PENDING (not yet synced), 1 = SYNCED (confirmed on server), 2 = CONFLICT (needs review). UI shows ⏳/✅/ |
_off_version |
INTEGER NOT NULL | 1 |
Version counter. Increments on every edit. Used for optimistic concurrency: if client version < server version, someone else edited while offline. Server compares versions to detect conflicts |
_off_deleted |
INTEGER NOT NULL | 0 |
Soft delete flag. 0 = active, 1 = deleted. Records are never hard-deleted from local SQLite so the sync engine can propagate deletions to the server |
_off_created_at |
TEXT NOT NULL | now() |
ISO 8601 timestamp when record was created. Used for sorting, audit trail, tiered sync (recent data syncs first) |
_off_updated_at |
TEXT NOT NULL | now() |
ISO 8601 timestamp of last modification. Updated on every business field change. Used for delta sync (only sync records changed since last sync) |
_off_hlc |
TEXT NOT NULL | (generated) | Hybrid Logical Clock. Format: "{wall_time}:{counter}:{device_id}". Handles clock skew between devices. Used for conflict resolution ordering — determines "who edited last" even when device clocks disagree |
_off_envelope_id |
TEXT | NULL |
Sync batch ID. Groups related operations into one atomic envelope. NULL until sync engine batches the record. All records in one envelope are processed as a single server transaction |
| Column | Type | When Added | Purpose |
|---|---|---|---|
_off_uuid |
TEXT NOT NULL UNIQUE | Only when PK is NOT uuid (i.e., auto_increment, natural_key, naming_series, manual) |
Temporary/backup identity for sync. Client-generated UUIDv7 used as record identity while offline. After sync, server assigns the real PK (auto-increment ID or validates natural key) |
Every CREATE, UPDATE, DELETE operation is recorded here before being sent to the server.
| Column | Type | Purpose |
|---|---|---|
id |
INTEGER PK AUTOINCREMENT | Local ordering of operations |
envelope_id |
TEXT NOT NULL | Groups related operations into one atomic batch. Example: 1 sale header + 3 items + 1 payment = 5 operations sharing one envelope_id |
table_name |
TEXT NOT NULL | Which table this operation affects. Example: "sales", "sale_items" |
record_id |
TEXT NOT NULL | UUID of the affected record |
operation |
TEXT NOT NULL | "CREATE", "UPDATE", or "DELETE" |
payload |
TEXT NOT NULL | JSON data. CREATE: full record. UPDATE: only changed fields (delta). DELETE: empty {} |
status |
TEXT NOT NULL DEFAULT 'PENDING' | Lifecycle: PENDING → IN_FLIGHT → SYNCED or ERROR or DEAD |
idempotency_key |
TEXT NOT NULL UNIQUE | Prevents duplicate sync. Format: "{record_id}:{operation}:{version}". If sync succeeds but response is lost, retry with same key → server returns cached response |
created_at |
TEXT NOT NULL | When this operation was recorded |
retry_count |
INTEGER NOT NULL DEFAULT 0 | Retry attempts. After 5 retries → status becomes DEAD |
One row per device. Tracks sync progress and offline auth.
| Column | Type | Purpose |
|---|---|---|
device_id |
TEXT PK | This device's unique ID. Example: "DEV-A" |
device_prefix |
TEXT NOT NULL | Receipt numbering prefix. Example: "001-A" (store 001, device A) |
last_sync_at |
TEXT | ISO 8601 timestamp of last successful sync. NULL if never synced |
last_pull_version |
INTEGER NOT NULL DEFAULT 0 | Server's version counter at last pull. Used for delta sync: "give me changes since version X" |
registered_at |
TEXT NOT NULL | When this device was first registered |
auth_cached_at |
TEXT | When auth credentials were cached. If (now - auth_cached_at) > max_offline_hours → force re-login |
user_id |
TEXT | Currently logged-in user ID (cached for offline auth) |
user_hash |
TEXT | Bcrypt hash of password/PIN for offline authentication |
Records every conflict detected during sync and how it was resolved.
| Column | Type | Purpose |
|---|---|---|
id |
INTEGER PK AUTOINCREMENT | Auto-increment ID |
table_name |
TEXT NOT NULL | Which table had the conflict |
record_id |
TEXT NOT NULL | UUID of the conflicting record |
field_name |
TEXT NOT NULL | Which field conflicted. Example: "price" |
local_value |
TEXT | Value on this device |
remote_value |
TEXT | Value on the server (from another device) |
resolved_value |
TEXT | Final value after resolution |
resolution |
TEXT NOT NULL | How resolved: "auto_merge", "local_wins", "remote_wins", "user_resolved" |
resolved_at |
TEXT NOT NULL | When the conflict was resolved |
device_id |
TEXT NOT NULL | Which device detected this conflict |
Tracks sequential numbering per device for receipts, invoices, etc.
| Column | Type | Purpose |
|---|---|---|
id |
INTEGER PK AUTOINCREMENT | Auto-increment ID |
table_name |
TEXT NOT NULL | Which table this sequence is for. Example: "sales" |
prefix |
TEXT NOT NULL | Device-specific prefix. Example: "001-A" |
last_sequence |
INTEGER NOT NULL DEFAULT 0 | Last used number. Next = last_sequence + 1 |
| UNIQUE | (table_name, prefix) |
Server (Go engine + PostgreSQL) does NOT need _off_* columns on business tables. The sales table on the server stays clean — just business fields + standard created_at/updated_at/created_by/updated_by.
Server adds these infrastructure tables for sync.
These tables are Go-generated, NOT JSON models. They are infrastructure tables (like audit_log, data_revisions, ir_migration) created directly via sync_schema.go DDL at startup. They do NOT appear in admin panel, do NOT get auto-CRUD, and developers never define them. This is consistent with how all other infrastructure tables work in BitCode. Future consideration: when the base/core module is migrated from Go code to JSON model definitions, these sync tables should be evaluated for migration too.
Prevents duplicate sync processing. When a client sends an envelope, the server records it here. If the same envelope is sent again (e.g., client retried because response was lost), server returns the cached response instead of processing again.
| Column | Type | Default | Purpose |
|---|---|---|---|
envelope_id |
UUID PRIMARY KEY | The envelope ID from the client's _off_outbox.envelope_id. This is the idempotency key — if the server sees the same envelope_id twice, it knows it's a retry and returns the cached response |
|
device_id |
TEXT NOT NULL | Which device sent this envelope. Example: "DEV-A". Used for audit trail and debugging sync issues |
|
received_at |
TIMESTAMPTZ NOT NULL | NOW() |
When the server received this envelope. Server timestamp, not client timestamp — authoritative for ordering |
status |
TEXT NOT NULL | Result of processing: "applied" (all operations succeeded), "rejected" (validation/business rule failed), "conflict" (one or more fields conflicted, resolved automatically or flagged for review) |
|
operations_count |
INTEGER NOT NULL | How many operations were in this envelope. Example: a POS sale with 1 header + 3 items + 1 payment = 5 operations. Used for monitoring and debugging | |
response |
JSONB | Cached response payload. Returned as-is on idempotent retry. Contains: operation results, assigned server IDs (for auto_increment PK), conflict details if any | |
error_message |
TEXT | NULL |
If status is "rejected", the error message explaining why. Example: "Foreign key violation: customer_id '019xxx' does not exist" |
processing_time_ms |
INTEGER | How long the server took to process this envelope in milliseconds. Used for performance monitoring |
Example data:
┌──────────────────┬───────────┬──────────────────────┬──────────┬─────┬──────────────────────────────┐
│ envelope_id │ device_id │ received_at │ status │ ops │ response │
├──────────────────┼───────────┼──────────────────────┼──────────┼─────┼──────────────────────────────┤
│ 019env01-... │ DEV-A │ 2026-04-28T10:35:00Z │ applied │ 5 │ {"synced_ids": [...]} │
│ 019env02-... │ DEV-A │ 2026-04-28T11:00:00Z │ applied │ 1 │ {"synced_ids": [...]} │
│ 019env03-... │ DEV-B │ 2026-04-28T11:05:00Z │ conflict │ 2 │ {"conflicts": [{...}]} │
│ 019env04-... │ DEV-A │ 2026-04-28T11:10:00Z │ rejected │ 3 │ {"error": "FK violation..."}│
└──────────────────┴───────────┴──────────────────────┴──────────┴─────┴──────────────────────────────┘
Tracks all registered devices. A device must register before it can sync. Registration happens on first app launch (requires internet).
| Column | Type | Default | Purpose |
|---|---|---|---|
device_id |
TEXT PRIMARY KEY | Unique device identifier. Generated during first registration. Example: "DEV-A". Format is configurable but must be unique across all devices in the tenant |
|
device_prefix |
TEXT NOT NULL UNIQUE | Prefix for receipt/invoice numbering. Example: "001-A" (store 001, device A). Must be unique — two devices cannot share a prefix, otherwise receipt numbers would collide |
|
device_name |
TEXT | NULL |
Human-readable name for the device. Example: "Kasir 1 - Toko Utama". Optional, for admin dashboard display |
platform |
TEXT NOT NULL | Device platform: "android", "ios", "windows", "macos", "linux". Detected automatically during registration. Used for platform-specific sync optimizations |
|
app_version |
TEXT | NULL |
Version of the Tauri app installed on this device. Example: "1.2.0". Used to detect outdated clients that might need forced update |
user_id |
UUID | NULL |
Which user is currently logged in on this device. Updated on every sync. NULL if no user logged in. A user can be logged in on multiple devices |
tenant_id |
UUID | NULL |
Which tenant this device belongs to (for multi-tenant deployments). Devices are isolated per tenant — a device registered for tenant A cannot sync data from tenant B |
store_id |
UUID | NULL |
Which store/branch this device is assigned to. Used for receipt prefix generation and data scoping (device only syncs data relevant to its store) |
registered_at |
TIMESTAMPTZ NOT NULL | NOW() |
When this device was first registered. Immutable after creation |
last_sync_at |
TIMESTAMPTZ | NULL |
When this device last successfully synced (push or pull). NULL if never synced after registration. Used for monitoring — admin can see which devices haven't synced recently |
last_sync_version |
INTEGER NOT NULL | 0 |
The server's change version at the time of last sync. Used for delta sync: server sends only changes with version > this value |
is_active |
BOOLEAN NOT NULL | true |
Whether this device is active. Set to false to revoke a device (e.g., lost/stolen phone). Inactive devices are rejected during sync with error "Device deactivated" |
deactivated_at |
TIMESTAMPTZ | NULL |
When the device was deactivated. NULL if still active |
deactivated_reason |
TEXT | NULL |
Why the device was deactivated. Example: "Device lost", "Employee terminated", "Replaced by new device" |
Example data:
┌───────────┬────────┬──────────────────┬──────────┬─────────┬──────────┬──────────────────────┬────────────┬───────────┐
│ device_id │ prefix │ device_name │ platform │ version │ user_id │ last_sync_at │ sync_ver │ is_active │
├───────────┼────────┼──────────────────┼──────────┼─────────┼──────────┼──────────────────────┼────────────┼───────────┤
│ DEV-A │ 001-A │ Kasir 1 - Utama │ android │ 1.2.0 │ usr-001 │ 2026-04-28T10:35:00Z │ 12345 │ true │
│ DEV-B │ 001-B │ Kasir 2 - Utama │ android │ 1.2.0 │ usr-002 │ 2026-04-28T11:05:00Z │ 12340 │ true │
│ DEV-C │ 002-A │ Kasir 1 - Cabang │ ios │ 1.1.0 │ usr-003 │ 2026-04-27T16:00:00Z │ 12200 │ true │
│ DEV-D │ 001-C │ Tablet Gudang │ android │ 1.0.0 │ NULL │ NULL │ 0 │ false │
└───────────┴────────┴──────────────────┴──────────┴─────────┴──────────┴──────────────────────┴────────────┴───────────┘
Records every conflict that occurred during sync across all devices. This is the server-side equivalent of the client's _off_conflict_log, but aggregated from all devices. Used by admin to review and audit conflict resolutions.
| Column | Type | Default | Purpose |
|---|---|---|---|
id |
SERIAL PRIMARY KEY | Auto-increment ID for this conflict record | |
envelope_id |
UUID NOT NULL | Which sync envelope triggered this conflict. FK to _sync_log.envelope_id. Used to trace back to the full sync batch |
|
device_id |
TEXT NOT NULL | Which device's data caused the conflict. Example: "DEV-A". The "losing" side of the conflict |
|
other_device_id |
TEXT | NULL |
Which other device's data was the "winning" side. NULL if conflict was with server-originated data (e.g., admin edit via web) |
table_name |
TEXT NOT NULL | Which table had the conflict. Example: "products" |
|
record_id |
UUID NOT NULL | UUID of the conflicting record. Example: "019aaa33-..." |
|
field_name |
TEXT NOT NULL | Which specific field conflicted. Example: "price". Each conflicting field gets its own row — if 3 fields conflict on the same record, there are 3 rows |
|
device_value |
TEXT | NULL |
The value from the device that lost the conflict. Stored as JSON-encoded string. Example: "15000" |
server_value |
TEXT | NULL |
The value that was already on the server (from another device or web edit). Example: "12000" |
resolved_value |
TEXT | NULL |
The final value after conflict resolution. Example: "15000" (device won because its HLC was newer) |
resolution |
TEXT NOT NULL | How the conflict was resolved: "auto_merge" (different fields, both kept), "device_wins" (device's HLC was newer), "server_wins" (server's HLC was newer), "user_resolved" (admin manually chose a value) |
|
auto_resolved |
BOOLEAN NOT NULL | Whether the conflict was resolved automatically (true) or requires manual review (false). Business-critical fields (e.g., price) may be flagged for manual review even if auto-resolved |
|
reviewed_by |
UUID | NULL |
If manually reviewed, which admin user reviewed it. NULL if auto-resolved or not yet reviewed |
reviewed_at |
TIMESTAMPTZ | NULL |
When the manual review happened. NULL if auto-resolved or not yet reviewed |
created_at |
TIMESTAMPTZ NOT NULL | NOW() |
When this conflict was recorded |
device_hlc |
TEXT | NULL |
The Hybrid Logical Clock value from the device. Used for debugging ordering issues |
server_hlc |
TEXT | NULL |
The HLC value from the server side. Used for debugging ordering issues |
Example data:
┌────┬──────────────┬────────┬────────┬────────────┬──────────────┬────────┬──────────────┬──────────────┬──────────────┬──────────────┬───────────────┐
│ id │ envelope_id │ device │ other │ table_name │ record_id │ field │ device_value │ server_value │ resolved │ resolution │ auto_resolved │
├────┼──────────────┼────────┼────────┼────────────┼──────────────┼────────┼──────────────┼──────────────┼──────────────┼──────────────┼───────────────┤
│ 1 │ 019env03-... │ DEV-B │ DEV-A │ products │ 019aaa33-... │ price │ "12000" │ "15000" │ "15000" │ server_wins │ true │
│ 2 │ 019env03-... │ DEV-B │ DEV-A │ products │ 019aaa33-... │ stock │ "50" │ "45" │ "50" │ device_wins │ true │
│ 3 │ 019env05-... │ DEV-A │ NULL │ customers │ 019bbb44-... │ phone │ "08123456" │ "08198765" │ NULL │ user_resolved│ false │
└────┴──────────────┴────────┴────────┴────────────┴──────────────┴────────┴──────────────┴──────────────┴──────────────┴──────────────┴───────────────┘
Row 1: DEV-B sent price=12000, but DEV-A already synced price=15000 (newer HLC). Server value wins.
Row 2: DEV-B sent stock=50, DEV-A had stock=45 (older HLC). Device value wins. Auto-merged with row 1.
Row 3: DEV-A sent phone="08123456", server had "08198765" (from web admin edit). Flagged for manual review.
Tracks the global version counter for delta sync. Every write operation (from any source — API, sync, admin) increments the version.
| Column | Type | Default | Purpose |
|---|---|---|---|
id |
SERIAL PRIMARY KEY | Auto-increment ID | |
table_name |
TEXT NOT NULL | Which table was changed. Example: "sales" |
|
record_id |
UUID NOT NULL | Which record was changed | |
operation |
TEXT NOT NULL | What happened: "INSERT", "UPDATE", "DELETE" |
|
version |
BIGINT NOT NULL | Global monotonically increasing version number. Every write gets the next version. Devices use this for delta sync: "give me all changes with version > my last_sync_version" | |
changed_fields |
JSONB | NULL |
For UPDATE operations: which fields changed. Example: ["price", "status"]. NULL for INSERT/DELETE. Used by sync engine to send only changed fields to devices |
changed_by |
TEXT | NULL |
Who made this change: device ID (e.g., "DEV-A") or user ID (e.g., "usr-001" for web admin). Used to avoid sending a device's own changes back to it |
created_at |
TIMESTAMPTZ NOT NULL | NOW() |
When this change was recorded |
Example data:
┌─────┬────────────┬──────────────┬───────────┬─────────┬──────────────────┬────────────┐
│ id │ table_name │ record_id │ operation │ version │ changed_fields │ changed_by │
├─────┼────────────┼──────────────┼───────────┼─────────┼──────────────────┼────────────┤
│ 100 │ sales │ 019abc12-... │ INSERT │ 12345 │ NULL │ DEV-A │
│ 101 │ sale_items │ 019abc16-... │ INSERT │ 12346 │ NULL │ DEV-A │
│ 102 │ sale_items │ 019abc17-... │ INSERT │ 12347 │ NULL │ DEV-A │
│ 103 │ products │ 019aaa33-... │ UPDATE │ 12348 │ ["price"] │ DEV-A │
│ 104 │ products │ 019aaa33-... │ UPDATE │ 12349 │ ["stock"] │ DEV-B │
│ 105 │ customers │ 019bbb44-... │ UPDATE │ 12350 │ ["phone"] │ usr-001 │
└─────┴────────────┴──────────────┴───────────┴─────────┴──────────────────┴────────────┘
Delta sync example:
DEV-B says: "give me changes since version 12346"
Server returns: rows 103, 105 (skips 104 because changed_by = DEV-B itself)
DEV-B now knows: product price changed (by DEV-A), customer phone changed (by admin)
Sync sends operations (what changed), not full records. This enables field-level merge.
✅ Operation-based: { id: "019abc", op: "UPDATE", changes: { price: 15000 } }
→ Only changed field sent. Other fields untouched.
❌ State-based: { id: "019abc", price: 15000, stock: 50, name: "Widget" }
→ Overwrites ALL fields, even unchanged ones. Data loss risk.
| Scenario | Strategy |
|---|---|
| Different fields edited on 2 devices | Auto-merge — both changes kept |
| Same field, different values | Last-Write-Wins (by HLC timestamp) — latest wins, user notified |
| Edit vs Delete | Edit wins — deleted record resurrected, edit applied |
| Parent deleted, child created | Resurrect parent — parent restored for referential integrity |
Device-prefixed sequential numbers. No collision possible.
Device A: 001-A-0001, 001-A-0002, 001-A-0003
Device B: 001-B-0001, 001-B-0002
Format: {store_code}-{device_letter}-{sequence_zero_padded}
Default: allow overselling offline (industry standard — Square, Shopify, Lightspeed all do this). Server detects negative stock during sync and alerts manager.
Single file at packages/components/src/core/bc-native.ts. Detects environment via window.__TAURI__ (requires withGlobalTauri: true in tauri.conf.json) and routes to Tauri IPC or Web API fallback.
| Method | Tauri | Browser Fallback |
|---|---|---|
getEnvironment() |
Returns 'tauri-desktop' or 'tauri-mobile' |
Returns 'browser' |
isTauri() |
Returns true |
Returns false |
takePhoto(options?) |
invoke('plugin:camera|take_photo') |
<input type="file" capture> + canvas compression |
getLocation() |
invoke('plugin:geolocation|get_position') |
navigator.geolocation |
dbExecute(sql, params?) |
invoke('plugin:sql|execute') |
IndexedDB fallback (returns { rowsAffected: 0 }) |
dbSelect(sql, params?) |
invoke('plugin:sql|select') |
IndexedDB fallback (returns []) |
setDbPath(path) |
Sets SQLite connection string | No-op |
scanBarcode() |
invoke('plugin:barcode-scanner|scan') |
Throws error (requires Tauri) |
authenticate() |
invoke('plugin:biometric|authenticate') |
Returns false (WebAuthn planned) |
saveFile(path, data) |
invoke('plugin:fs|write_file') |
Blob download via <a> tag |
notify(options) |
invoke('plugin:notification|notify') |
new Notification() Web API |
requestNotificationPermission() |
invoke('plugin:notification|request_permission') |
Notification.requestPermission() |
syncData() |
invoke('sync_data') |
Returns { success: true, synced: 0, errors: 0 } |
| File | Purpose |
|---|---|
engine/internal/compiler/parser/module.go |
ModuleAppConfig, OfflineConfig structs, IsOffline(), GetOfflineConfig() |
engine/internal/compiler/parser/model.go |
ModelAppConfig struct, IsOffline(), OfflineModule bool field on ModelDefinition |
engine/internal/config.go |
AppMode field in AppConfig, app.mode viper default |
engine/internal/app.go |
initSyncInfrastructure(), sync route registration, modelReg.ProjectAppMode wiring, auto-create _sync_oversell_alerts table |
engine/internal/domain/model/registry.go |
RegisterWithModule resolution chain (model→module→project), ProjectAppMode, validateOfflinePK() |
engine/internal/infrastructure/persistence/offline_schema.go |
OfflineColumns(), OfflineUUIDColumn(), 4 client _off_* table DDLs (with device_id column) |
engine/internal/infrastructure/persistence/sync_schema.go |
4 server _sync_* table DDLs (PostgreSQL/MySQL/SQLite) |
engine/internal/infrastructure/persistence/dynamic_model.go |
buildColumns() appends _off_* columns when OfflineModule=true |
engine/internal/presentation/api/sync_handler.go |
7 sync API endpoints: RegisterDevice, PushEnvelope (with inventory delta + conflict detection wiring), PullChanges, DeviceStatus, GetSchema, CacheAuth, UpdateDevice |
packages/components/src/core/bc-native.ts |
Bridge abstraction layer (16 methods, Tauri/Web fallback) — includes isOnline(), onConnectivityChange(), getPlatformInfo() |
packages/components/src/core/bc-native.spec.ts |
Bridge unit tests (10 tests) |
packages/components/src/core/bc-setup.ts |
registerOfflineModels(), isModelOffline(), getOfflineModels(), initOffline() bootstrap (auto-calls initFromServer + registerDevice), isOfflineReady() |
packages/components/src/core/data-fetcher.ts |
fetchData() and fetchOptions() route to OfflineStore when isModelOffline(model) is true, with HTTP fallback |
packages/components/src/core/offline-store.ts |
Full sync client: CRUD routing, SQL injection prevention, transactions, outbox, syncPush() with batch size + gzip compression, syncPull() with pagination, cacheAuth(), authenticateOffline() with brute-force protection + lockout, getSyncStatus(), syncAll(), configureSyncOptions(), getNextReceiptNumber(), HLC wiring, conflict detection, initFromServer() with retry + model registry persistence |
packages/components/src/core/offline-store.spec.ts |
Offline store tests (40 tests) |
packages/components/src/index.ts |
Exports OfflineStore, BcNative, and native bridge types |
packages/tauri/src-tauri/Cargo.toml |
Tauri 2.0 + plugins (sql, fs, notification, barcode, biometric) + encryption feature flag |
packages/tauri/src-tauri/src/main.rs |
Tauri entry point, plugin registration, SQLite migrations (6 migrations incl. _off_auth_cache, _off_model_registry), encrypted DB support via BITCODE_DB_KEY env var |
packages/tauri/src-tauri/tauri.conf.json |
Tauri config — hardened CSP (no unsafe-eval, object-src 'none', frame-ancestors 'none') |
packages/tauri/src-tauri/capabilities/default.json |
Permissions for core, sql, fs, notification |
packages/tauri/src-tauri/build.rs |
Tauri build script |
packages/tauri/package.json |
npm scripts for dev/build (desktop, android, ios) |
packages/components/src/core/hlc.ts |
Hybrid Logical Clock: hlcNow(), hlcReceive(), hlcCompare(), parseHLC(), formatHLC() |
packages/components/src/core/hlc.spec.ts |
HLC tests (18 tests — parse/format, monotonicity, clock skew, receive merge, compare, tie-breaking) |
engine/internal/runtime/sync/conflict.go |
Field-level conflict resolution: ResolveFieldConflicts(), ResolveEditVsDelete(), RecordConflictsToServer(), HLC comparison |
engine/internal/runtime/sync/conflict_test.go |
Conflict resolution tests (12 tests — auto-merge, HLC wins, edit-vs-delete, system fields, tie-breaking) |
engine/internal/runtime/sync/inventory.go |
Inventory delta tracking: ApplyInventoryDelta(), DetectInventoryFields(), CreateOversellAlertsTable(), oversell alert recording |
engine/internal/runtime/sync/inventory_test.go |
Inventory tests (8 tests — delta detection, multiple deltas, type coercion, validation) |
packages/components/src/components/widgets/bc-sync-status/bc-sync-status.tsx |
Sync status UI component — online/offline indicator, pending/error/conflict counts, last sync time, manual sync trigger |
packages/components/src/components/widgets/bc-sync-status/bc-sync-status.css |
Sync status component styles |
| # | Issue | Severity | Notes |
|---|---|---|---|
| 1 | takePhoto() / getLocation() unverified on mobile |
Tauri mobile plugins are less mature. Requires physical Android/iOS device to test. Desktop works. | |
| 2 | generateUUIDv7() is a custom implementation |
Low | Works correctly but not a battle-tested library. Consider replacing with a well-known UUID library if issues arise at scale. |
| 3 | Module-qualified model names not handled | Low | offline-store.ts does not strip module.model prefix (e.g. crm.lead). Only bare model names (e.g. lead) work. |
| 4 | Local table creation hardcoded in Rust migrations | Business tables must be manually added to main.rs migrations. Ideally should be dynamically created from server schema. Large effort to fix. |
|
| 5 | 'unsafe-inline' still required in CSP |
Low | Stencil generates inline event handlers and styles. Cannot remove without major Stencil refactor or migration to a different framework. |
| 6 | Offline auth uses SHA-256 instead of bcrypt | Low | Deliberate tradeoff — bcrypt cannot run in the browser. SHA-256 hash is stored locally on device (not transmitted). Mitigated by 72h expiry + 5-attempt lockout. |
| 7 | Sync API routes have no auth middleware | /api/v1/sync/* endpoints are currently open. Should add JWT validation middleware before production deployment. |