Skip to content
leoco/dev
← Volver a proyectos
Backend, sync offline y núcleo KMP (contribuidor principal, equipo de 3)·2026·Repo privado

Roda · SaaS para talleres mecánicos

En un taller venezolano la señal se cae a mitad de una petición, los precios van en dólares con una tasa que cambia cada día, y el IGTF depende de con qué paga el cliente, no de lo que compra. Roda separa dos clases de datos. Los documentos que una persona puede reconciliar (orden, inspección, cotización, cliente) se editan sin señal y se sincronizan por Last-Write-Wins. Los contadores (stock, caja) solo los mueve el servidor. Todo monto se guarda como entero en la unidad mínima, con moneda ISO 4217 y la tasa congelada. El núcleo KMP reúne modelos, repositorios sobre watch() de PowerSync, reglas de negocio y ViewModels, y no depende de ningún framework de UI: la misma lógica puede alimentar Compose y SwiftUI, aunque hoy la app construida es la de Android. Cada taller se aísla por un taller_id firmado en el JWT, y cada migración llega con tests SQL que corren en CI contra un Postgres desechable. Un portal en Next.js le muestra al cliente las fotos de recepción con las marcas de daño y lo que encontró la inspección, y ahí mismo califica el servicio, desde un enlace con token y sin login.

Disponible bajo NDA · puedo hacer un walkthrough en una llamada.
STACK TÉCNICO
Kotlin MultiplatformJetpack ComposeSupabasePostgreSQLPowerSyncNext.js
DESAFÍOS

Un rechazo congela la cola de sync

PowerSync no resuelve conflictos y reintenta cualquier error de la subida. Una escritura que el servidor nunca aceptará —un contador colado por LWW, un salto de etapa que rechaza el gate del pipeline— se queda a la cabeza de la cola para siempre, y detrás se atasca todo lo pendiente del teléfono. Solución: el conector distingue tres casos. Lo transitorio (sin red, 5xx, token vencido) se relanza para que PowerSync reintente. Lo permanente se anota en una bitácora y se descarta, y la cola avanza. Un 23505 sobre la clave primaria de un PUT se da por subido, porque es el reintento de una escritura que llegó y cuya respuesta se perdió.

RLS no ve lo que baja al teléfono

Las policies gobiernan lo que entra por PostgREST, pero el rol de replicación de PowerSync corre con BYPASSRLS: una suite de RLS en verde no dice nada sobre la descarga. Solución: cada Sync Stream repite el predicado de su policy, el taller_id sale del JWT que firma un Custom Access Token Hook —nunca de un parámetro del cliente— y las columnas son explícitas en vez de SELECT *. Un test en CI levanta Supabase y PowerSync self-hosted con dos talleres sembrados y verifica las dos rutas: ninguna fila cruza de taller y el costo/margen no baja a roles no autorizados.

service_role se salta la policy del bucket

El portal no tiene sesión: resuelve el token en el servidor con service_role, que ignora la policy de Storage que ata cada objeto a la carpeta de su taller. Las rutas de las fotos son texto que escribe la app, y nada impedía que una fila del taller A apuntara a un objeto del taller B y el portal lo firmara para quien tuviera el link. Solución: el resolver solo devuelve rutas que empiezan con el taller_id del token y sin segmentos «..»; el portal las firma en el servidor por una hora y el navegador nunca recibe la ruta cruda.

SOLUCIONES

Stock y caja solo por RPC

Existencias, kardex, reservas, pagos y arqueos tienen RLS con una sola policy, la de SELECT: el usuario lee y el motor le niega escribir. La única vía de escritura son funciones security definer que filtran el tenant contra el JWT. reservar_stock bloquea la fila con FOR UPDATE para que dos asesores no reserven la última pieza, y registrar_pago exige una clave de idempotencia única por taller: la cola reintenta, y una red que se cae después del cobro no puede cobrar dos veces. Postgres calcula el IGTF en su propia columna y también el esperado del arqueo. Estas tablas no se replican al teléfono.

LWW acotado por columna

Un PATCH sube solo las columnas que cambiaron, así que dos técnicos que editan campos distintos de la misma orden no se pisan; los PATCH seguidos a una fila se fusionan antes de subir, porque Postgres evalúa sus CHECK en cada UPDATE. El régimen de escritura de cada tabla (documento, hecho inmutable, solo lectura o server-authoritative) se inyecta desde el mismo AppSchema que define la base local, así que una tabla nueva no puede colarse por LWW por olvido. En CI, un verificador cruza los predicados de sync_rules.yaml con los de las policies y otro, las consultas locales con el AppSchema.

¿Te interesa este proyecto o tienes una idea similar?