Секунда — единица маленькая, пока не начнёшь записывать по одной каждую секунду рабочего дня. Восемь часов дискретизации на 1 Гц — это 28 800 строк. Полный год человека за столом — это где-то за семью миллионами строк, и каждая из них маленькая, скучная и в точности похожа на соседнюю. Интересный вопрос для архитектора не в том, как хранить семь миллионов строк — это любая база делает во сне, — а в том, как хранить их на одной машине, принадлежащей одному человеку, без какого-либо сервера в цепочке, так чтобы файл по-прежнему открывался, запрашивался и заслуживал доверия в 2050 году.

Это и есть задача проектирования за Timex — нашим автоматическим трекером времени для Mac. Timex — продукт с закрытым исходным кодом, приложение, которое вы покупаете на gettimex.app, но архитектура под ним — это ровно то, о чём рано или поздно приходится рассуждать любому инженеру, строящему десктоп-инструмент, и ничего из этого не секрет. Так что этот пост — руководство по проектированию, а не дамп кода. Я расскажу про паттерн: local-first, один встроенный файл SQLite, дискретизация на 1 Гц и сворачивание сырых секунд в сессии, которые человек реально узнаёт. Схему и запросы ниже я написал для этого поста, чтобы показать форму, — это не копия продукта.

Дев-стек Muvon — Octomind, Octocode и остальное — открыт под Apache-2.0. Timex — тот, что мы держим закрытым. Архитектуру всё равно стоит рассказать честно.


Local-first — это решение, а не настроение

«Local-first» используют как маркетинговое слово, и это обидно, потому что на самом деле это несущее архитектурное решение с последствиями, на которые вы подписываетесь с первого дня.

Решение такое: источник истины живёт на устройстве пользователя, и приложение полностью работоспособно с отключённой сетью. Не «работает офлайн и синхронизируется потом» — это offline-first, то есть архитектура синхронизации с прикрученным сверху локальным кешем. Local-first означает, что никакого потом нет. Нет сервера, который держит каноническую копию. Файл на диске и есть память продукта, а всё остальное — представление над ним.

Для однопользовательского десктоп-трекера времени это не сложный выбор. Пройдёмся по альтернативам:

  • Облачная база данных. Теперь вы держите сервер. У вас сетевой round-trip на пути записи для данных, которые генерируются раз в секунду, вечно, каждым пользователем. У вас бюджет на простои, стратегия резервного копирования, GDPR-поверхность, нечто, что можно взломать, и регулярный счёт, который вы обязаны переложить на пользователя в виде подписки. Для инструмента, единственная работа которого — записать, что сделал один человек на одном Mac, сервер — чистая ответственность.
  • Свой бинарный лог. Соблазнительно — только-append, быстро, компактно. Но теперь формат — ваш навсегда. Вы пишете читатель, инструмент миграции, логику компактизации, код восстановления после сбоя. В день, когда пользователь захочет «просто сделать запрос к моим данным», вам придётся поставлять движок запросов. Вы переизобрели базу данных, плохо, и заперли пользователя в формате, который понимает только ваш код.
  • Встроенная SQL-база в одном файле. Один файл. Без сервера. ACID-транзакции. Язык запросов, который все уже знают. Формат файла, стабильный уже больше двух десятилетий и официально входящий в список рекомендованных форматов хранения Библиотеки Конгресса США для наборов данных. Читаемый сотнями инструментов, которые писали не вы.

Третий вариант — это SQLite, и для однописательского десктоп-приложения это не компромисс, а правильный ответ. История про приватность, которую все любят («без облака, без телеметрии, ваши данные остаются на вашем Mac»), — следствие этого. Это не политика, которую мы написали. Это архитектура: нет эндпоинта, через который могли бы утечь ваши данные активности, потому что они никогда не покидают файл. (Единственный сетевой вызов Timex — активация лицензии; ваша история трекинга в нём не участвует.)


Почему именно SQLite

SQLite — самая развёрнутая база данных в мире: она в вашем телефоне, браузере, машине, — и причины, по которым она подходит десктоп-приложению, стоит проговорить вслух, потому что это те же причины, по которым её отметают люди, мыслящие только в клиент-серверных терминах.

Она встроенная, а не сервер. Нет процесса, которым нужно управлять, нет порта, который нужно слушать, нет пула соединений, нет pg_hba.conf. База данных — это библиотека, слинкованная в ваше приложение. «Соединение» — это вызов функции. Для однопользовательского приложения это снимает целый класс эксплуатационных забот.

Это один файл. Ваша история про бэкап — это cp. Ваш «экспорт» — это «вот файл». Ваша синхронизация, если она вам когда-нибудь понадобится, — это «переместите файл», с оговорками, до которых я дойду. Пользователь может найти его, скопировать на флешку, бросить в папку, за которой следит iCloud или Dropbox, и ничего в вашем приложении не сломается. Сравните это с объяснением нетехническому пользователю, как экспортировать его данные из облачного аккаунта.

К ней может обратиться кто угодно. Это самая важная часть, чтобы «владей своими данными» было правдой, а не лозунгом. Данные пользователя в формате, который CLI sqlite3, DB Browser for SQLite, модуль sqlite3 в Python, DuckDB, Datasette и практически любой аналитический инструмент на земле читают напрямую. Вы не привратник данных собственных пользователей. Они могут отвечать на вопросы, под которые вы никогда не строили экран.

Она долговечная и транзакционная. SQLite — это ACID. COMMIT либо происходит полностью, либо нет, даже если питание умерло на середине записи. Для инструмента, пишущего непрерывный поток, у вопроса «что станет с базой, если крышку захлопнут в неудачную микросекунду» есть скучный, правильный ответ вместо повреждённого файла.

Возражение, которое вы услышите: «SQLite не масштабируется / не умеет конкурентных писателей». И то и другое верно и здесь нерелевантно. У однопользовательского десктоп-приложения ровно один писатель — оно само. То, в чём SQLite плох — множество машин, молотящих записями по сети, — эта архитектура не делает никогда. Выбрать базу под паттерн доступа — это вся игра, а паттерн доступа здесь — один писатель, преимущественно append, эпизодические аналитические чтения. Это родное поле SQLite.


Цикл дискретизации на 1 Гц

Работа трекера — непрерывно отвечать на «что делал пользователь». Подход — сэмплер: раз в секунду задать ОС три вопроса.

  1. Какое приложение на переднем плане?
  2. Каков заголовок его сфокусированного окна?
  3. (С разрешением, для браузеров) какой URL у активной вкладки?

В macOS эти ответы приходят из API фронтального приложения и дерева Accessibility (AXUIElement) — то же разрешение, что открывает заголовки окон, открывает и адресную строку браузера, поэтому Timex читает URL вкладок без отдельного запроса Automation для большинства браузеров. Ничего из этого не проприетарно; это документированная поверхность macOS, которую использует любой трекер. То, что вы делаете с сэмплами, — вот где живёт проектирование.

Наивная реализация пишет одну строку на сэмпл. Секунда — строка. Это правильно и расточительно: 28 800 почти идентичных строк за 8-часовой день, где вы два часа провели в одном окне редактора. Хранить «редактор, редактор, редактор, …» 7200 раз — это не данные, это битый пиксель.

Лучшая модель трактует посекундный поток как вход, а сегмент как выход. Сегмент — это непрерывный отрезок, где ответ на все три вопроса оставался одним и тем же. Вы держите в памяти текущий открытый сегмент — (bundleId, title, url, startAt) — и на каждом тике сравниваете новый сэмпл с ним:

  • Тот же контекст? Продлите открытый сегмент. На диск ничего не идёт.
  • Контекст сменился? Закройте открытый сегмент (проштампуйте endAt, вычислите duration), запишите его и откройте новый.

Так двухчасовая сессия редактора — это одна строка с длительностью в два часа, а не 7200 строк. Диск видит запись только тогда, когда что-то реально меняется, — что для настоящей человеческой работы куда реже, чем раз в секунду. Цикл на 1 Гц даёт разрешение; модель сегментов даёт вменяемое хранение. Вы получаете и то и другое.

тик (1 Гц) ─▶ сэмпл(app, title, url)
                   │
                   ├─ равен открытому сегменту? ──▶ продлить в памяти, без записи
                   │
                   └─ отличается? ──▶ закрыть + сбросить открытый сегмент ──▶ открыть новый

Простой — это не контекст

Сэмплер, работающий вслепую, радостно запишет восемь часов «редактора», потому что редактор был на переднем плане, пока вы были на обеде. Эти данные — ложь, а трекер времени, который врёт, хуже, чем отсутствие трекера.

Поэтому нужен гейт простоя: отслеживайте активность ввода, и после некоторого порога без клавиатуры и мыши (Timex использует две минуты) перестаньте приписывать время приложению на переднем плане. Честный способ это смоделировать — трактовать простой как переход состояния, закрывающий активный сегмент, ровно как смену контекста. Когда ввод возобновляется, открывается свежий сегмент. Разрыв между ними реален — это время, когда машина была включена, а человека не было, — и вы можете хранить этот разрыв как факт первого класса в схеме или выводить его из промежутка между сегментами — Timex делает второе.

Здесь зарыто решение на грани суждения: секунды прямо перед тем, как вы ушли в простой, всё равно приписываются тому, что было на переднем плане. Идеального ответа нет (нельзя знать, что пользователь встал, пока он не вернётся), так что проектирование выбирает порог и документирует его. Честность про размытый край лучше ложной точности, делающей вид, что дискретизация умеет читать мысли.


Схема для временных интервалов

Вот иллюстративная схема — моя, написанная для этого поста, — которая фиксирует модель сегментов. Это форма, а не реальный DDL продукта, но это вполне разумная вещь, чтобы построить.

-- Закрытые сегменты активности: свёрнутая единица, не сырые сэмплы.
CREATE TABLE activity (
  id         INTEGER PRIMARY KEY,
  bundle_id  TEXT    NOT NULL,        -- напр. com.apple.dt.Xcode
  app_name   TEXT    NOT NULL,
  title      TEXT,                    -- заголовок сфокусированного окна (nullable)
  url        TEXT,                    -- активная вкладка, только браузеры
  domain     TEXT,                    -- извлечённый хост, для быстрой группировки
  started_at INTEGER NOT NULL,        -- секунды unix epoch
  ended_at   INTEGER NOT NULL,
  duration   INTEGER NOT NULL,        -- ended_at - started_at, денормализовано
  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             -- вес продуктивности, -100..100
);

-- Сопоставляет приложению категорию по умолчанию. Переопределяется пользователем.
CREATE TABLE app_category (
  bundle_id   TEXT PRIMARY KEY,
  category_id INTEGER NOT NULL REFERENCES category(id)
);

Три проектных решения внутри стоит защитить:

Хранить закрытые интервалы, а не сырые тики. Таблица activity — это сегменты. Посекундные сэмплы вообще не персистятся — они живут один тик в памяти и сворачиваются в открытый сегмент. Это разница между базой, растущей на ~7 млн строк в год, и базой, растущей ровно столько раз, сколько вы реально переключали окна, что для большинства людей на один-два порядка меньше.

Денормализовать duration. Она выводима из ended_at - started_at, и хранить её всё равно — это намеренное нарушение принципа «не храни то, что можешь вычислить». Обоснование: каждый запрос интерфейса суммирует длительности, и хранимая целочисленная колонка с индексом лучше, чем пересчитывать вычитание по миллионам строк на каждый вопрос «куда ушла моя неделя». Денормализация — инструмент; правило — денормализовать то, что читаешь постоянно, и выводить то, что читаешь редко.

Полуоткрытые интервалы [started_at, ended_at). Начало включается, конец исключается. Это скучная-но-правильная конвенция для временных диапазонов: два соседних сегмента делят пограничную метку времени без перекрытия и без разрыва, а «попадает ли этот сегмент в этот день» — чистое started_at < day_end AND ended_at > day_start без ошибки на единицу в полночь. Ошибитесь здесь — и каждая агрегация будет тонко проклята.

Чего я не показываю — это реальную историю миграций продукта, его точные индексы и логику посева категорий: это проприетарно, и, честно говоря, это ещё и скучная часть. Паттерн выше — вот переносимое знание.


Запросы, которые вы можете выполнить к своему файлу

Вот где «владей своими данными» перестаёт быть лозунгом. Поскольку хранилище — это обычный файл SQLite, пользователь — а не только приложение — может задавать ему вопросы. Вот запросы к иллюстративной схеме выше, которые любой пользователь мог бы адаптировать и выполнить через CLI sqlite3 к своей базе.

Куда ушёл сегодняшний день, по приложениям:

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;

Топ сайтов за неделю:

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;

Сфокусированное против рассеянного времени, по весам категорий:

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;

Ни один из них не потребовал заявки на фичу, экспорта в CSV или API. Пользователь скармливает файл DuckDB, если хочет оконные функции, Datasette, если хочет веб-интерфейс, ноутбуку pandas, если хочет графики. Продукт поставил вид «Сегодня»; данные поставили способность ответить на всё остальное. Это дивиденд от выбора формата, который умеет читать весь мир.


Долговечность: WAL, сбои и рост файла

Непрерывно пишущему приложению на ноутбуке, которому захлопывают крышку, нужен ответ на «что станет с файлом, когда процесс умрёт на середине записи». Ответ SQLite хорош, но в хорошую версию надо явно зайти.

Используйте режим WAL. Журнал отката по умолчанию сериализует читателей против писателя и делает больше работы fsync. Write-Ahead Logging (PRAGMA journal_mode=WAL) пишет изменения в боковой файл -wal и позволяет чтениям идти параллельно с единственным писателем — что ровно соответствует форме трекера, дописывающего сегменты, пока интерфейс читает их, чтобы нарисовать таймлайн. Поэтому вы увидите на диске три файла: timex.sqlite, timex.sqlite-wal и timex.sqlite-shm. Это не повреждение; это WAL делает свою работу. -wal периодически делает checkpoint обратно в основной файл.

Устойчивость к сбоям реальна, но настройте sync. При synchronous=NORMAL под WAL потеря питания может стоить вам последней не закоммиченной чекпойнтом транзакции, но не может повредить базу — сделка, на которую большинству десктоп-приложений стоит пойти, потому что потеря последних секунд «вы были в редакторе» — это ошибка округления, а сделка долговечность/пропускная-способность того стоит. synchronous=FULL безопаснее и медленнее; для данных активности с посекундным разрешением NORMAL — вменяемое значение по умолчанию. Чего нельзя делать — это бегать с выключенной синхронизацией в погоне за цифрами бенчмарка, а потом удивляться, когда kernel panic съест файл.

Группируйте записи. Хотя сегменты редки по сравнению с сэмплами, оборачивать сбросы в транзакции и не вызывать fsync на каждую крошечную запись — это держит диск довольным. Сэмплер может ненадолго придержать записи и сбросить маленький батч; интерфейсу не нужно, чтобы сегмент был на диске в ту микросекунду, когда он закрылся.

Планируйте рост и удержание. Сегменты растут медленно, но растут вечно, а «вечно» — это долго на ноутбуке с 256 ГБ. Честный дизайн даёт пользователю ручку удержания — хранить сырые сегменты N месяцев, опционально сворачивать старые данные в более грубые дневные агрегаты и время от времени делать VACUUM, чтобы вернуть место после удалений. Не нужно строить всё это в первый день, но нужно знать, куда оно идёт, потому что «база, которая только растёт» — это баг в замедленной съёмке.


Честные компромиссы local-first

Я продавал бы вам ту же сказку, которую критикую, если бы остановился на плюсах. Local-first покупает вам владение и приватность, тратя две вещи, и вам стоит знать, чего они стоят.

Нет встроенной синхронизации. Если человек пользуется десктопом и ноутбуком, у каждой машины свой файл и своя истина. Нет сервера, который их примиряет, потому что отсутствие этого сервера — это и есть весь смысл. Ответ «принеси своё» — положить файл в папку, которую синхронизирует iCloud или Dropbox, — но поймите, что это синхронизация файлов, а не базы данных, и SQLite явно предупреждает, что WAL-база плохо уживается с наивными инструментами синхронизации файлов, копирующими .sqlite, пока -wal на середине сброса. Это может сработать, если приложение закрыто, когда файл перемещается; это может повредиться, если две машины пишут в одну папку одновременно. Так что честная позиция такова: local-first даёт вам одну идеальную копию на машину, а мульти-устройство — это отдельная, opt-in функция, которую вы строите намеренно или не строите вовсе.

Бэкап — задача пользователя, так что сделайте его тривиальным. Без облака историю пользователя не бэкапит никто, кроме самого пользователя. Смягчение в том, что бэкап — это один файл, который можно скопировать, — самая простая история бэкапа в софте. Но продукт обязан сказать это прямо, а не делать вид, что облако его прикрывает, потому что оно не прикрывает, по дизайну.

И вот часть, о которой стоит сказать без обиняков: в тот момент, когда вам захочется настоящей мульти-устройственной синхронизации, вы записались на одну из по-настоящему трудных задач в вычислениях. Синхронизация базы между устройствами, которые редактируют независимо и уходят в офлайн, — это область разрешения конфликтов, CRDT, векторных часов и сожалений last-writer-wins. Это не «просто rsync файл». Многие продукты, начинающие как local-first, а потом прикручивающие синхронизацию, в итоге пересобирают облачную базу, которой гордились, что её избежали, — теперь с поверхностью багов распределённых систем сверху. Защитимый ход — поставить local-first как честное значение по умолчанию, оставить дверь открытой для opt-in синхронизации как намеренной, отдельно спроектированной функции, и никогда не позволять удобству синхронизации тихо утащить источник истины обратно на сервер. В день, когда каноническая копия живёт где-то, кроме диска пользователя, вы уже не local-first — вы облачное приложение с приятным офлайн-кешем, что вполне нормально быть, просто не тем, чем вы себя назвали.


Что это даёт пользователю

Снимите всё лишнее, и архитектура даёт ровно одно обещание: файл — ваш. Он переживёт поглощение компании, сделавшей приложение. Он переживёт упавший в полёте wifi. Он переживёт следующий заголовок про утечку, потому что вашей истории трекинга нет ни на чьём сервере, чтобы утечь. Он читается инструментами, которых ещё десятилетие не будет, потому что формат старше большинства существующих сейчас. Нет аккаунта, который вас заблокирует, нет подписки, чей пропуск сотрёт вашу историю, нет мастера экспорта, у которого нужно вымаливать собственные данные.

В этом и состоит всё ценностное предложение local-first, и поэтому мы построили Timex на одном файле SQLite, а не на бэкенде, который пришлось бы хостить, защищать и в итоге выключать. Тот же инстинкт пронизывает открытый стек Muvon — ваш код, ваши инструменты, ваш репозиторий — и единственный закрытый продукт, который мы поставляем: ваше время, ваш файл, ваша машина.

Секунда — единица маленькая. Семь миллионов из них, в файле, которым вы владеете и который можете запросить любым инструментом на земле, оказывается разницей между тем, чтобы «куда ушёл мой год» было риторическим вопросом и отвечаемым.

— Владимир


Частые вопросы

Почему SQLite, а не облачная база для десктоп-приложения?

Потому что паттерн доступа — один писатель на одной машине, преимущественно append, с эпизодическими аналитическими чтениями — это ровно то, где сияет встроенная однофайловая база, а клиент-серверная — чистый эксплуатационный оверхед. Нет сервера с вашими данными — нет бюджета на простои, нет регулярного счёта, нет поверхности утечки с вашей историей, а история бэкапа — это копия файла.

Зачем сворачивать сэмплы 1 Гц в сегменты, а не хранить каждую секунду?

Посекундное разрешение — это то, что нужно для точности; посекундное хранение — это расход, потому что реальная работа проводит минуты или часы в одном контексте. Свёртка идентичных подряд идущих сэмплов в один закрытый интервал снижает число строк на один-два порядка без потери точности.

Я правда могу запрашивать свои данные?

Да — в этом и смысл выбора SQLite. Файл открывается в CLI sqlite3, DB Browser, DuckDB, Datasette, ноутбуке Python или чём угодно, что читает SQLite. Иллюстративные запросы из этого поста выполняются к показанной схеме; адаптируйте их под свой файл.

Local-first — это то же, что offline-first?

Нет. Offline-first — это облачное приложение с локальным кешем, синхронизирующееся когда может: каноническую копию всё ещё держит сервер. Local-first означает, что устройство держит источник истины и канонической серверной копии нет вообще. Другая архитектура, другие гарантии.

В чём подвох local-first?

Нет встроенной мульти-устройственной синхронизации, и бэкап на вас. И то и другое — реальные издержки. Смягчение в том, что «ваши данные» — это один файл, который можно скопировать куда угодно, — но если вам нужно, чтобы устройства автоматически сливали правки, это отдельная трудная задача (разрешение конфликтов, CRDT), которую никакое копирование файлов не решает.

Timex с открытым кодом?

Нет. Timex — продукт с закрытым исходным кодом. Дев-инструменты Muvon — Octomind, Octocode и остальное — открыты под Apache-2.0. Этот пост про архитектуру и паттерны, которые являются общим инженерным знанием, а не про исходники продукта.


Timex — это автоматический трекер времени для Mac с закрытым кодом и local-first: дискретизация на 1 Гц в один локальный файл SQLite, которым владеете вы, — без облачного хранения вашей активности, без телеметрии о том, что вы делаете; единственный сетевой вызов — активация лицензии. Схема и запросы здесь иллюстративны и написаны для этого поста, а не взяты из исходников продукта. Дев-стек Muvon — Octomind и компания — открыт под Apache-2.0.