Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/danielitoCode/AlejoTaller/llms.txt

Use this file to discover all available pages before exploring further.

AlejoTaller is designed from the ground up for environments where connectivity cannot be assumed. All read operations use a try-remote, fallback-to-local pattern so the app continues to function when Appwrite is unreachable. Write behaviour differs by platform: on Android, sales are persisted to Room first and pushed remotely as a best-effort step; on web, reads fall back to Dexie but writes attempt the remote call first and surface errors to the caller if it fails.

Philosophy

The guiding principle is that the network is an optimisation, not a requirement. When a customer browses the product catalogue or navigates categories, the interaction falls back to the local store immediately if the network is unavailable. For sale creation on Android, the local write happens first and the remote push is retried or queued — meaning the app works even when Appwrite is temporarily unreachable. This approach maps directly to real-world operating conditions: Cuban mobile and home internet connections are frequently intermittent, and the app must remain useful regardless of whether Appwrite is reachable at the moment of interaction.
Android writes:
  User action ──→ Room local write (immediate)
                      ↓  async / best-effort
               Appwrite push; retry on failure; sync reconciles on reconnect

Web reads:
  User action ──→ Appwrite fetch → cache in Dexie → return data
                      ↓  on network failure
               Dexie local fallback (stale data)

Web writes (create / update):
  User action ──→ Appwrite call (remote first) → cache result in Dexie
                      ↓  on network failure
               Error thrown to caller (no silent local fallback)

Android: Room as Local Source of Truth

On Android, Room provides the SQLite-backed local database. The main app database class (AppBD) registers one DAO per feature:
@Database(
    entities = [
        CategoryDto::class,
        ProductDto::class,
        SaleDto::class,
        PromotionDto::class,
        CupExchangeLocalDto::class
    ],
    version = 10,
)
@TypeConverters(DateTimeConverter::class, ListConverter::class)
abstract class AppBD : RoomDatabase() {
    abstract fun categoriesDao(): CategoryDao
    abstract fun productsDao(): ProductDao
    abstract fun saleDao(): SaleDao
    abstract fun promotionDao(): PromotionDao
    abstract fun exchangeDao(): ExchangeDao
}
Each DAO follows a consistent interface. SaleDao is representative:
@Dao
interface SaleDao {
    @Query("SELECT * FROM saledto")
    fun getAllFlow(): Flow<List<SaleDto>>

    @Query("SELECT * FROM saledto")
    fun getAll(): List<SaleDto>

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertAll(items: List<SaleDto>)

    @Transaction
    suspend fun replaceAll(sales: List<SaleDto>) {
        deleteAll()
        insertAll(sales)
    }

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insert(items: SaleDto)

    @Query("SELECT * FROM saledto WHERE id = :id")
    suspend fun getById(id: String): SaleDto

    @Query("DELETE FROM saledto")
    suspend fun deleteAll()
}
OnConflictStrategy.REPLACE means that any incoming record with the same primary key silently wins, making upserts the default operation across all feature DAOs.

Web: Dexie (IndexedDB) as Local Source of Truth

On the web client, Dexie wraps IndexedDB. The single AppDatabase instance (exported as db) serves all features through typed Table<T> properties:
class AppDatabase extends Dexie {
    products!: Table<ProductDTO>
    categories!: Table<CategoryDTO>
    promotions!: Table<PromotionDTO>
    sales!: Table<SaleDTO>
    exchangeRates!: Table<CupExchange>

    constructor() {
        super("alejo-taller-business-db")

        this.version(1).stores({
            products: "$id, name, categoryId",
            categories: "$id, name",
            promotions: "$id, validUntilEpochMillis",
            sales: "$id, userId, verified"
        })

        this.version(2).stores({
            products: "$id, name, categoryId",
            categories: "$id, name",
            promotions: "$id, validUntilEpochMillis",
            sales: "$id, user_id, buy_state",
            exchanges: "$id, usd_reference",
        })

        this.version(3).stores({
            products: "$id, name, categoryId",
            categories: "$id, name",
            promotions: "$id, validUntilEpochMillis",
            sales: "$id, user_id, buy_state",
            exchangeRates: "id, updatedAt, source"
        })
    }
}

export const db = new AppDatabase()
The schema has evolved through three versions, tracking the addition of the exchanges table and the rename of the verified index on sales to buy_state, which aligns with the domain enum used across all surfaces.
The $id primary key on all feature tables is the Appwrite document ID. Using the remote ID as the local key makes upserts idempotent — receiving the same document twice is safe.

Offline-First Repository Pattern

All features share the same two-step contract: try remote first, fall back to local on failure. For reads the pattern is: fetch remote → cache in local store → return mapped domain objects. On network failure, return whatever is in the local store. For writes, the failure behaviour depends on whether the operation can be deferred.

Sale repository (web)

SaleOfflineFirstRepository is the canonical example of the read-side pattern on web:
export class SaleOfflineFirstRepository implements SaleRepository {
    constructor(private readonly net: SaleNetRepository) {}

    async getAllSales(): Promise<Sale[]> {
        try {
            const remote = await this.net.getAll()
            await db.sales.bulkPut(remote)        // refresh local cache
            return remote.map(saleFromDTO)
        } catch {
            const local = await db.sales.toArray() // serve stale local copy
            return local.map(saleFromDTO)
        }
    }

    async getByUser(userId: string): Promise<Sale[]> {
        try {
            const remote = await this.net.getByUser(userId)
            await db.sales.bulkPut(remote)
            return remote.map(saleFromDTO)
        } catch {
            const local = await db.sales
                .where("user_id").equals(userId).toArray()
            return local.map(saleFromDTO)
        }
    }

    async create(sale: Sale): Promise<Sale> {
        try {
            const created = await this.net.create(saleToDTO(sale))
            await db.sales.put(created)           // mirror remote result locally
            return saleFromDTO(created)
        } catch (error: any) {
            logger.error(
                `Error al crear venta en Appwrite: ${error?.message ?? "desconocido"}`,
                error?.stack
            )
            throw error                           // creation errors are not silently swallowed
        }
    }
}

Product repository (web)

ProductOfflineFirstRepository adds a sync() method that performs a full remote-wins reconciliation — useful for the operator app when bringing a freshly connected device up to date:
async sync(): Promise<void> {
    const remote = await this.net.getAll()
    await db.products.clear()
    await db.products.bulkPut(remote)
}
The read methods follow the same try-remote/fallback-to-local pattern. getAll() additionally sorts the local fallback result by $createdAt descending so the most recent products surface first even when offline:
async getAll(): Promise<Product[]> {
    try {
        const remote = await this.net.getAll()
        await db.products.bulkPut(remote)
        return remote.map(productFromDTO)
    } catch (error: any) {
        logger.error(error.message, error.stack)
        const local = await db.products.toArray()
        return sortNewestFirst(local).map(productFromDTO)
    }
}

Android Sale repository (shared-data module)

The Android SaleOfflineFirstRepository in shared-data handles writes with explicit structured logging and a retry loop:
override suspend fun save(item: Sale) {
    val saleDto = item.toDto()
    Log.i(TAG, "event=sale_save_local_start saleId=${saleDto.id} userId=${saleDto.userId}")
    bd.insert(saleDto)                        // 1. local write — always succeeds
    Log.i(TAG, "event=sale_save_local_success saleId=${saleDto.id}")
    runCatching {
        Log.i(TAG, "event=sale_save_remote_push_start saleId=${saleDto.id}")
        pushWithRetry(net, saleDto)           // 2. remote push — best-effort
        Log.i(TAG, "event=sale_save_remote_push_success saleId=${saleDto.id}")
    }.onFailure { error ->
        Log.w(TAG, "event=sale_save_remote_push_failed saleId=${saleDto.id} cause=${error.message}", error)
        // failure is logged but NOT re-thrown; the sale survives locally
    }
}
The remote push is wrapped in runCatching, so a network failure does not roll back the local write. The sale remains in Room and is picked up by the next sync pass.

Repository Layer Summary

1

Local operation

On Android, the Room DAO receives the write immediately before any remote call. On web, the Dexie table is written only after a successful remote call (acting as a local mirror, not a primary store). For reads on both platforms, the local table is always the fallback.
2

Remote operation

The net repository calls Appwrite. For reads, the response is bulk-put into the local store. For writes, the result is inserted/updated locally to mirror the canonical remote state.
3

Reconciliation

On failure the local store is used as-is for reads. For write operations, the behaviour differs by platform: on Android, save() writes locally first and the failed remote push is retried by SyncSalesCaseUse. On web, create() and updateVerified() attempt the remote call first — if that fails, the error is thrown and the caller must handle it; there is no silent local fallback for writes.

SyncSalesCaseUse: Draining the Pending Queue

SyncSalesCaseUse in shared-sale is the entry point for explicit sync triggers:
class SyncSalesCaseUse(
    private val repository: SaleRepository
) {
    suspend operator fun invoke(userId: String): Result<Unit> =
        repository.sync(userId)
}
The underlying SaleOfflineFirstRepository.sync() in shared-data implements a four-step reconciliation algorithm:
override suspend fun sync(userId: String): Result<Unit> = runCatching {
    val localAllSales      = bd.getAll()
    val localTargetUser    = localAllSales.filter { it.userId == userId }
    val localOtherUsers    = localAllSales.filter { it.userId != userId }

    val remoteBeforePush   = net.getAll(userId)

    // 1. Compute pending: local records absent from remote
    val pendingQueue = resolvePendingLocals(localTargetUser, remoteBeforePush)

    // 2. Push each pending sale with up to 3 retries
    val failedToPush = pendingQueue.filter { sale ->
        runCatching { pushWithRetry(net, sale) }.isFailure
    }

    // 3. Fetch authoritative remote state after push
    val remoteAfterPush = net.getAll(userId)

    // 4. Merge: remote wins; failed-to-push sales kept locally
    val merged = mergeSyncResult(remoteAfterPush, failedToPush)
    bd.replaceAll(localOtherUsers + merged)
}
The pushWithRetry helper attempts up to MAX_SYNC_RETRIES (3) times before giving up:
private suspend fun pushWithRetry(
    net: SaleNetRepository,
    sale: SaleDto,
    maxRetries: Int = MAX_SYNC_RETRIES
) {
    var lastError: Throwable? = null
    repeat(maxRetries) { attempt ->
        runCatching { net.upsert(sale) }
            .onSuccess { return }
            .onFailure { error ->
                lastError = error
                Log.w(TAG, "event=sale_push_retry_failed saleId=${sale.id} " +
                           "attempt=${attempt + 1} cause=${error.message}")
            }
    }
    throw (lastError ?: IllegalStateException("No se pudo sincronizar la venta ${sale.id}"))
}

Conflict Strategy

The reconciliation policy is remote wins for records that exist on both sides. The mergeSyncResult function builds a map keyed by sale ID, starting from the remote set, and only adds failed-pending sales for IDs that do not already exist remotely:
internal fun mergeSyncResult(
    remoteSales: List<SaleDto>,
    failedPendingSales: List<SaleDto>
): List<SaleDto> {
    val mergedById = remoteSales.associateBy { it.id }.toMutableMap()
    failedPendingSales.forEach { failedSale ->
        if (failedSale.id !in mergedById) {
            mergedById[failedSale.id] = failedSale  // keep pending locally if absent remotely
        }
    }
    return mergedById.values.toList()
}
This means that if a sale was modified on both the device and the server between sync passes, the server version takes precedence.
Conflict resolution is remote-wins at the record level. Field-level merging (last-write-wins per field) is not implemented in the current MVP. A sale modified locally while offline may have local changes overwritten when sync runs.

What Happens When Appwrite Is Unreachable

OperationBehaviour when offline
getAllSales() / getAll()Returns full local cache
getByUser(userId)Filters local Dexie/Room store by user_id
create(sale)Remote write attempted first; if it fails, error is thrown — no local fallback, no queue (Android: local write first, then best-effort remote push)
updateVerified(id, state)Throws; caller must handle and surface error to UI
sync(userId)Local records without a matching remote ID remain in the pending set
The application surfaces different UX states (loading, error, notice) through immutable StateFlow on Android and Svelte stores on web, so the UI always reflects the current connectivity status without crashing.
The offline-first design was a deliberate architectural choice shaped by the Cuban internet context. Home and mobile connectivity in Cuba is frequently unavailable for hours at a time. Designing around intermittent connectivity from the beginning — rather than treating it as an edge case — makes the app genuinely usable in day-to-day conditions, not just when the network cooperates.
Known limitation from the README: Conflict policies are not fully hardened across all features in the current MVP. The sale feature has the most complete sync logic; other features (categories, products, exchange rates) use simpler remote-wins full-replace strategies in their sync() methods, which may discard locally-accumulated state. Hardening conflict resolution across all features is tracked as technical debt for a future core.

Build docs developers (and LLMs) love