Un segundo es una unidad pequeña hasta que escribes uno cada segundo de una jornada laboral. Ocho horas de muestreo a 1 Hz son 28.800 filas. Un año entero de una persona en su escritorio supera los siete millones de filas, y cada una es pequeña, aburrida y exactamente igual a su vecina. La pregunta interesante para un arquitecto no es cómo guardar siete millones de filas — cualquier base de datos hace eso dormida — sino cómo guardarlas en una máquina, propiedad de una persona, sin ningún servidor de por medio, de modo que el archivo siga siendo abrible, consultable y confiable en 2050.

Ese es el problema de diseño detrás de Timex, nuestro rastreador de tiempo automático para Mac. Timex es un producto de código cerrado — la app que compras en gettimex.app — pero la arquitectura que tiene debajo es justo el tipo de cosa que todo ingeniero que construye una herramienta de escritorio acaba teniendo que razonar, y nada de eso es secreto. Así que este artículo es la guía de diseño, no un volcado del código. Hablaré del patrón: local-first, un único archivo SQLite embebido, muestreo a 1 Hz, y la agrupación de segundos crudos en las sesiones que un humano de verdad reconoce. El esquema y las consultas de abajo los escribí para este artículo para ilustrar la forma, no son una copia del producto.

El stack de desarrollo de Muvon — Octomind, Octocode, y el resto — es de código abierto bajo Apache-2.0. Timex es el que mantenemos cerrado. La arquitectura igual vale la pena contarla con honestidad.


Local-first es una decisión, no una pose

"Local-first" se usa como palabra de marketing, lo cual es una lástima, porque en realidad es una decisión arquitectónica de fondo con consecuencias a las que te comprometes desde el día uno.

La decisión es esta: la fuente de verdad vive en el dispositivo del usuario, y la app es plenamente funcional con la red desconectada. No "funciona sin conexión y sincroniza después" — eso es offline-first, que es una arquitectura de sincronización con una caché local atornillada encima. Local-first significa que no hay un después. No hay servidor que tenga la copia canónica. El archivo en el disco es la memoria del producto, y todo lo demás es una vista de él.

Para un rastreador de tiempo de escritorio de un solo usuario, esto no es una decisión difícil. Recorre las alternativas:

  • Una base de datos en la nube. Ahora operas un servidor. Tienes un viaje de ida y vuelta de red en el camino de escritura para datos que se generan una vez por segundo, para siempre, por cada usuario. Tienes un presupuesto de caídas, una estrategia de respaldo, una superficie GDPR, algo que puede ser vulnerado y una factura recurrente que debes trasladar al usuario como suscripción. Para una herramienta cuyo único trabajo es registrar lo que hizo una persona en un Mac, el servidor es pura responsabilidad.
  • Un log binario propio. Tentador — solo-append, rápido, compacto. Pero ahora eres dueño del formato para siempre. Escribes el lector, la herramienta de migración, la lógica de compactación, el código de recuperación ante caídas. El día que un usuario quiera "simplemente consultar mis datos", tienes que entregar un motor de consultas. Has reinventado una base de datos, mal, y has encerrado al usuario en un formato que solo tu código entiende.
  • Una base de datos SQL embebida en un único archivo. Un archivo. Sin servidor. Transacciones ACID. Un lenguaje de consultas que todo el mundo ya conoce. Un formato de archivo que lleva más de dos décadas estable y es un formato de almacenamiento recomendado oficialmente por la Biblioteca del Congreso de EE. UU. para conjuntos de datos. Legible por cientos de herramientas que no escribiste tú.

La tercera opción es SQLite, y para una app de escritorio de un solo escritor no es una concesión — es la respuesta correcta. La historia de privacidad que a la gente le gusta ("sin nube, sin telemetría, tus datos se quedan en tu Mac") es consecuencia de esto. No es una política que escribimos. Es la arquitectura: no hay endpoint por el que tus datos de actividad puedan filtrarse, porque nunca salen del archivo. (La única llamada de red que hace Timex es la activación de licencia — tu historial de seguimiento no participa en ella.)


Por qué SQLite en concreto

SQLite es la base de datos más desplegada del mundo — está en tu teléfono, tu navegador, tu coche — y vale la pena decir en voz alta por qué encaja en una app de escritorio, porque son las mismas razones por las que la descartan quienes solo piensan en términos cliente-servidor.

Es embebida, no un servidor. No hay proceso que gestionar, ni puerto que enlazar, ni pool de conexiones, ni pg_hba.conf. La base de datos es una librería enlazada en tu app. La "conexión" es una llamada de función. Para una app de un solo usuario esto elimina toda una categoría de preocupación operativa.

Es un archivo. Tu estrategia de respaldo es cp. Tu "exportación" es "aquí está el archivo". Tu sincronización, si alguna vez la quieres, es "mueve el archivo" — con salvedades que mencionaré. El usuario puede encontrarlo, copiarlo a un USB, soltarlo en una carpeta que iCloud o Dropbox vigilen, y nada en tu app se rompe. Compara eso con explicarle a un usuario no técnico cómo exportar sus datos de una cuenta en la nube.

Es consultable por todos. Esta es la parte que más importa para que "sé dueño de tus datos" sea verdad y no un eslogan. Los datos del usuario están en un formato que la CLI sqlite3, DB Browser for SQLite, el módulo sqlite3 de Python, DuckDB, Datasette y prácticamente toda herramienta de análisis del planeta pueden leer directamente. No eres el guardián de los datos de tus propios usuarios. Ellos pueden responder preguntas para las que tú nunca construiste una pantalla.

Es duradera y transaccional. SQLite es ACID. Un COMMIT ocurre completo o no ocurre, aunque se corte la luz a mitad de la escritura. Para una herramienta que graba un flujo continuo, la pregunta "qué le pasa a la base de datos si la tapa se cierra de golpe en el microsegundo equivocado" tiene una respuesta aburrida y correcta en lugar de un archivo corrupto.

La objeción que oirás es "SQLite no escala / no admite escritores concurrentes". Ambas ciertas y ambas irrelevantes aquí. Una app de escritorio de un solo usuario tiene exactamente un escritor: ella misma. Aquello en lo que SQLite es malo — muchas máquinas martilleando escrituras por red — es algo que esta arquitectura nunca hace. Elegir la base de datos para que encaje con el patrón de acceso es todo el juego, y el patrón de acceso aquí es un escritor, mayormente append, lecturas analíticas ocasionales. Ese es el terreno propio de SQLite.


El bucle de muestreo a 1 Hz

El trabajo del rastreador es responder "qué estaba haciendo el usuario" de forma continua. El enfoque es un muestreador: una vez por segundo, hacerle al sistema operativo tres preguntas.

  1. ¿Qué aplicación está al frente?
  2. ¿Cuál es el título de su ventana enfocada?
  3. (Con permiso, para navegadores) ¿cuál es la URL de la pestaña activa?

En macOS esas respuestas vienen de la API de la aplicación frontal y del árbol de Accesibilidad (AXUIElement) — el mismo permiso que expone los títulos de ventana también expone la barra de direcciones del navegador, por eso Timex lee las URLs de pestañas sin un prompt de Automatización separado en la mayoría de navegadores. Nada de eso es propietario; es la superficie documentada de macOS que usa cualquier rastreador. Lo que haces con las muestras es donde vive el diseño.

La implementación ingenua escribe una fila por muestra. Un segundo, una fila. Es correcta y es un desperdicio: 28.800 filas casi idénticas para una jornada de 8 horas donde pasaste dos horas en la misma ventana del editor. Guardar "editor, editor, editor, …" 7.200 veces no es dato, es un píxel quemado.

El mejor modelo trata el flujo segundo a segundo como la entrada y un segmento como la salida. Un segmento es una racha contigua donde la respuesta a las tres preguntas se mantuvo igual. Mantienes en memoria el segmento abierto actual — (bundleId, title, url, startAt) — y en cada tic comparas la nueva muestra con él:

  • ¿Mismo contexto? Extiende el segmento abierto. Nada toca el disco.
  • ¿Cambió el contexto? Cierra el segmento abierto (sella su endAt, calcula duration), escríbelo y abre uno nuevo.

Así, una sesión de editor de dos horas es una fila con una duración de dos horas, no 7.200 filas. El disco solo ve una escritura cuando algo cambia de verdad — que, para trabajo humano real, es mucho menos frecuente que una vez por segundo. El bucle a 1 Hz te da resolución; el modelo de segmentos te da un almacenamiento sensato. Obtienes ambos.

tic (1 Hz) ─▶ muestra(app, title, url)
                   │
                   ├─ ¿igual al segmento abierto? ──▶ extender en memoria, sin escritura
                   │
                   └─ ¿difiere? ──▶ cerrar + volcar segmento abierto ──▶ abrir nuevo

La inactividad no es un contexto

Un muestreador que corre a ciegas grabará tan campante ocho horas de "editor" porque el editor estaba al frente mientras almorzabas. Ese dato es mentira, y un rastreador de tiempo que miente es peor que no tener rastreador.

Por eso necesitas una compuerta de inactividad: rastrea la actividad de entrada, y tras cierto umbral sin teclado ni ratón (Timex usa dos minutos), deja de atribuir tiempo a la app en primer plano. La forma honesta de modelarlo es tratar la inactividad como una transición de estado que cierra el segmento activo, exactamente como un cambio de contexto. Cuando la entrada se reanuda, se abre un segmento nuevo. El hueco entre ambos es real — es tiempo en que la máquina estaba encendida y el humano no — y puedes conservar ese hueco como un hecho de primera clase en el esquema o inferirlo del vacío entre segmentos — Timex hace lo segundo.

Aquí hay una decisión de criterio enterrada: los segundos justo antes de quedarte inactivo igual se atribuyen a lo que estuviera al frente. No hay respuesta perfecta (no puedes saber que el usuario se levantó hasta que no vuelve), así que el diseño elige un umbral y lo documenta. La honestidad sobre el borde difuso le gana a una falsa precisión que finge que el muestreo puede leer la mente.


Un esquema para intervalos de tiempo

Aquí va un esquema ilustrativo — mío, escrito para este artículo — que captura el modelo de segmentos. Es la forma, no el DDL real del producto, pero es algo perfectamente razonable de construir.

-- Segmentos de actividad cerrados: la unidad agrupada, no muestras crudas.
CREATE TABLE activity (
  id         INTEGER PRIMARY KEY,
  bundle_id  TEXT    NOT NULL,        -- p. ej. com.apple.dt.Xcode
  app_name   TEXT    NOT NULL,
  title      TEXT,                    -- título de la ventana enfocada (anulable)
  url        TEXT,                    -- pestaña activa, solo navegadores
  domain     TEXT,                    -- host extraído, para agrupar rápido
  started_at INTEGER NOT NULL,        -- segundos epoch unix
  ended_at   INTEGER NOT NULL,
  duration   INTEGER NOT NULL,        -- ended_at - started_at, desnormalizado
  category_id INTEGER REFERENCES category(id)
);

CREATE INDEX idx_activity_started ON activity(started_at);
CREATE INDEX idx_activity_bundle  ON activity(bundle_id);
CREATE INDEX idx_activity_domain  ON activity(domain) WHERE domain IS NOT NULL;

CREATE TABLE category (
  id    INTEGER PRIMARY KEY,
  name  TEXT NOT NULL,
  color TEXT NOT NULL,                -- hex
  score INTEGER NOT NULL             -- peso de productividad, -100..100
);

-- Asigna una app a una categoría por defecto. Anulable por el usuario.
CREATE TABLE app_category (
  bundle_id   TEXT PRIMARY KEY,
  category_id INTEGER NOT NULL REFERENCES category(id)
);

Tres decisiones de diseño ahí dentro merecen defensa:

Guardar intervalos cerrados, no tics crudos. La tabla activity son segmentos. Las muestras por segundo nunca se persisten — viven un tic en memoria y se pliegan en el segmento abierto. Esta es la diferencia entre una base de datos que crece ~7M filas al año y una que crece tantas veces como realmente cambiaste de ventana, que para la mayoría es uno o dos órdenes de magnitud menos.

Desnormalizar duration. Es derivable de ended_at - started_at, y guardarla igual es una violación deliberada de "no guardes lo que puedes calcular". La justificación: cada consulta que la interfaz ejecuta suma duraciones, y una columna entera guardada con índice le gana a recalcular una resta sobre millones de filas en cada pregunta de "adónde se fue mi semana". La desnormalización es una herramienta; la regla es desnormalizar lo que lees constantemente y derivar lo que lees rara vez.

Intervalos semiabiertos [started_at, ended_at). El inicio es inclusivo, el final exclusivo. Es la convención aburrida-pero-correcta para rangos de tiempo: dos segmentos adyacentes comparten una marca de tiempo de frontera sin solapamiento ni hueco, y "¿cae este segmento en este día?" es un limpio started_at < day_end AND ended_at > day_start sin error por uno a medianoche. Equivócate en esto y cada agregación queda sutilmente embrujada.

Lo que no te estoy mostrando es el historial real de migraciones del producto, sus índices exactos ni su lógica de siembra de categorías — eso es propietario, y francamente también es la parte aburrida. El patrón de arriba es el conocimiento transferible.


Consultas que puedes correr sobre tu propio archivo

Aquí es donde "sé dueño de tus datos" deja de ser un eslogan. Como el almacén es un archivo SQLite plano, el usuario — no solo la app — puede hacerle preguntas. Estas son consultas contra el esquema ilustrativo de arriba que cualquier usuario podría adaptar y correr con la CLI sqlite3 sobre su propia base de datos.

Adónde se fue hoy, por app:

SELECT app_name, SUM(duration) / 3600.0 AS hours
FROM activity
WHERE started_at >= strftime('%s', 'now', 'start of day')
GROUP BY bundle_id
ORDER BY hours DESC;

Sitios web más usados esta semana:

SELECT domain, SUM(duration) / 60 AS minutes
FROM activity
WHERE domain IS NOT NULL
  AND started_at >= strftime('%s', 'now', '-7 days')
GROUP BY domain
ORDER BY minutes DESC
LIMIT 20;

Tiempo concentrado vs. distraído, usando los pesos de categoría:

SELECT c.name,
       SUM(a.duration) / 3600.0 AS hours
FROM activity a
JOIN category c ON c.id = a.category_id
WHERE a.started_at >= strftime('%s', 'now', '-30 days')
GROUP BY c.id
ORDER BY hours DESC;

Ninguna de estas requirió una solicitud de función, una exportación a CSV ni una API. El usuario canaliza el archivo a DuckDB si quiere funciones de ventana, a Datasette si quiere una UI web, a un cuaderno de pandas si quiere gráficos. El producto entregó una vista de Hoy; los datos entregaron la capacidad de responder todo lo demás. Ese es el dividendo de elegir un formato que el mundo entero puede leer.


Durabilidad: WAL, caídas y crecimiento del archivo

Una app que escribe de forma continua en un portátil al que le cierran la tapa de golpe necesita una respuesta a "qué le pasa al archivo cuando el proceso muere a mitad de escritura". La respuesta de SQLite es buena, pero tienes que optar por la versión buena.

Usa el modo WAL. El journal de rollback por defecto serializa los lectores contra el escritor y hace más trabajo de fsync. El Write-Ahead Logging (PRAGMA journal_mode=WAL) escribe los cambios en un archivo lateral -wal y deja que las lecturas procedan en paralelo con el único escritor — que es exactamente la forma de un rastreador que añade segmentos mientras la UI los lee para dibujar la línea de tiempo. Por eso verás tres archivos en disco: timex.sqlite, timex.sqlite-wal y timex.sqlite-shm. Eso no es corrupción; es el WAL haciendo su trabajo. El -wal hace checkpoint periódicamente de vuelta al archivo principal.

La seguridad ante caídas es real, pero ajusta el sync. Con synchronous=NORMAL bajo WAL, un corte de luz puede costarte la última transacción sin checkpoint pero no puede corromper la base de datos — un trato que la mayoría de apps de escritorio debería aceptar, porque perder los últimos segundos de "estabas en tu editor" es un error de redondeo y el trato durabilidad/rendimiento vale la pena. synchronous=FULL es más seguro y más lento; para datos de actividad con resolución de segundo, NORMAL es el valor por defecto sensato. Lo que no debes hacer es correr con la sincronización apagada para perseguir números de benchmark y luego sorprenderte cuando un kernel panic se come el archivo.

Agrupa las escrituras. Aunque los segmentos son poco frecuentes comparados con las muestras, envolver los volcados en transacciones y no llamar a fsync en cada escritura diminuta mantiene contento al disco. El muestreador puede retener escrituras brevemente y volcar un lote pequeño; la UI no necesita que el segmento esté en disco el microsegundo en que se cierra.

Planifica el crecimiento y la retención. Los segmentos crecen despacio, pero crecen para siempre, y "para siempre" es mucho tiempo en un portátil de 256 GB. El diseño honesto le da al usuario una perilla de retención — conservar segmentos crudos N meses, opcionalmente agrupar datos viejos en agregados diarios más gruesos, y hacer VACUUM de vez en cuando para recuperar espacio de los borrados. No tienes que construir todo el día uno, pero deberías saber adónde va, porque "la base de datos que solo crece" es un bug en cámara lenta.


Las concesiones honestas de local-first

Te estaría vendiendo el mismo cuento de hadas que critico si parara en lo positivo. Local-first te compra propiedad y privacidad gastando dos cosas, y deberías saber lo que cuestan.

Sin sincronización integrada. Si una persona usa un escritorio y un portátil, cada máquina tiene su propio archivo y su propia verdad. No hay servidor reconciliándolas, porque la ausencia de ese servidor es todo el punto. La respuesta trae-tu-propia es poner el archivo en una carpeta que iCloud o Dropbox sincronice — pero entiende que eso es sincronización de archivos, no de base de datos, y SQLite advierte explícitamente que una base WAL no se lleva bien con herramientas ingenuas de sincronización de archivos que copian el .sqlite mientras el -wal está a mitad de volcado. Puede funcionar si la app está cerrada cuando el archivo se mueve; puede corromperse si dos máquinas escriben la misma carpeta a la vez. Así que la postura honesta es: local-first te da una copia perfecta por máquina, y el multidispositivo es una función separada, opt-in, que construyes deliberadamente o no construyes en absoluto.

El respaldo es tarea del usuario — así que hazlo trivial. Sin nube, nadie respalda el historial del usuario salvo el usuario. La mitigación es que el respaldo es un archivo que puedes copiar, que es la historia de respaldo más fácil del software. Pero el producto tiene que decirlo claramente en lugar de fingir que la nube le cubre las espaldas, porque no lo hace, por diseño.

Y aquí está la parte que vale la pena decir sin rodeos: en el momento en que quieras sincronización multidispositivo de verdad, te has apuntado a uno de los problemas genuinamente difíciles de la computación. Sincronizar una base de datos entre dispositivos que editan de forma independiente y se quedan sin conexión es el dominio de la resolución de conflictos, los CRDT, los relojes vectoriales y el arrepentimiento del último-escritor-gana. No es "solo rsync el archivo". Muchos productos que empiezan local-first y luego le atornillan sincronización acaban reconstruyendo la base de datos en la nube de la que estaban orgullosos de haber evitado — ahora con una superficie de bugs de sistemas distribuidos encima. La jugada defendible es entregar local-first como el valor por defecto honesto, dejar la puerta abierta a sincronización opt-in como función deliberada y separadamente diseñada, y nunca dejar que la comodidad de la sincronización arrastre en silencio la fuente de verdad de vuelta a un servidor. El día que la copia canónica viva en algún lugar que no sea el disco del usuario, ya no eres local-first — eres una app en la nube con una bonita caché sin conexión, que es algo perfectamente válido de ser, solo que no lo que dijiste que eras.


Qué le compra esto al usuario

Quítale todo y la arquitectura hace exactamente una promesa: el archivo es tuyo. Sobrevive a que adquieran a la empresa que hizo la app. Sobrevive a que se caiga tu wifi en un vuelo. Sobrevive al próximo titular de filtración, porque tu historial de seguimiento no está en el servidor de nadie para ser filtrado. Es legible por herramientas que no existirán hasta dentro de una década, porque el formato es anterior a la mayoría de las que existen ahora. No hay cuenta que te deje fuera, ni suscripción cuyo lapso borre tu historial, ni asistente de exportación al que rogarle tus propios datos.

Esa es toda la propuesta de valor de local-first, y por eso construimos Timex sobre un único archivo SQLite en lugar de un backend que tendríamos que hospedar, asegurar y, con el tiempo, apagar. El mismo instinto recorre el stack de código abierto de Muvon — tu código, tus herramientas, tu repo — y el único producto cerrado que entregamos: tu tiempo, tu archivo, tu máquina.

Un segundo es una unidad pequeña. Siete millones de ellos, en un archivo que posees y puedes consultar con cualquier herramienta del planeta, resulta ser la diferencia entre que "adónde se fue mi año" sea una pregunta retórica y una respondible.

— Vladimir


Preguntas frecuentes

¿Por qué SQLite en lugar de una base de datos en la nube para una app de escritorio?

Porque el patrón de acceso es un escritor en una máquina, mayormente append, con lecturas analíticas ocasionales — que es justo donde brilla una base de datos embebida de archivo único y donde una cliente-servidor es puro sobrecoste operativo. Sin servidor para tus datos no hay presupuesto de caídas, ni factura recurrente, ni superficie de filtración con tu historial, y la historia de respaldo es una copia de archivo.

¿Por qué agrupar muestras de 1 Hz en segmentos en lugar de guardar cada segundo?

La resolución por segundo es lo que quieres para la precisión; el almacenamiento por segundo es desperdicio, porque el trabajo real pasa minutos u horas en el mismo contexto. Plegar muestras consecutivas idénticas en un intervalo cerrado reduce el conteo de filas en uno o dos órdenes de magnitud sin pérdida de fidelidad.

¿De verdad puedo consultar mis propios datos?

Sí — ese es el punto de elegir SQLite. El archivo se abre en la CLI sqlite3, DB Browser, DuckDB, Datasette, un cuaderno de Python o cualquier cosa que lea SQLite. Las consultas ilustrativas de este artículo corren contra el esquema mostrado; adáptalas a tu archivo.

¿Es local-first lo mismo que offline-first?

No. Offline-first es una app en la nube con una caché local que sincroniza cuando puede — el servidor aún tiene la copia canónica. Local-first significa que el dispositivo tiene la fuente de verdad y no hay ninguna copia canónica en servidor. Arquitectura distinta, garantías distintas.

¿Cuál es la trampa de local-first?

Sin sincronización multidispositivo integrada, y el respaldo corre por tu cuenta. Ambos son costes reales. La mitigación es que "tus datos" son un único archivo que puedes copiar a cualquier parte — pero si necesitas que los dispositivos fusionen ediciones automáticamente, eso es un problema difícil aparte (resolución de conflictos, CRDT) que ninguna cantidad de copiado de archivos resuelve.

¿Es Timex de código abierto?

No. Timex es un producto de código cerrado. Las herramientas de desarrollo de Muvon — Octomind, Octocode y el resto — son de código abierto bajo Apache-2.0. Este artículo trata de la arquitectura y los patrones, que son conocimiento de ingeniería general, no del código del producto.


Timex es un rastreador de tiempo automático para Mac, de código cerrado y local-first: muestreo a 1 Hz en un único archivo SQLite local que posees — sin almacenamiento en la nube de tu actividad, sin telemetría sobre lo que haces; la única llamada de red es la activación de licencia. El esquema y las consultas de aquí son ilustrativos y escritos para este artículo, no el código del producto. El stack de desarrollo de Muvon — Octomind y compañía — es de código abierto bajo Apache-2.0.