Чому ми поки не використовуємо базу даних
Ми оцінили Turso/libSQL для застосунку рукописного введення на iPad, усе виміряли й обрали плоскі файли знімків стану. Цьому робочому навантаженню не потрібна база даних, тож кожен крок має оплачувати лише проблеми, які вже виникли.
Lulucat Notes — застосунок для рукописного введення на iPad. До минулого тижня в ньому було лише одне полотно, і поняття другої нотатки не існувало. Ми збиралися додати бібліотеку нотаток — кілька документів, у кожному з яких кілька сторінок, — і першим архітектурним питанням було зберігання.
База даних здавалася очевидною відповіддю. Застосунки для нотаток зберігають структуровані дані. Структуровані дані зберігають у базах даних. Ми оцінили Turso і його Swift SDK, запустили його в симуляторі iOS, протестували на реальних даних штрихів, а потім вирішили не використовувати.
Натомість ми обрали плоскі файли. Нижче — результати й причини цього рішення.

Фото Gabriel Cox для Unsplash. Unsplash License.
Що застосунок насправді робить із даними
Застосунок для рукописного введення має вузький і передбачуваний шаблон доступу до даних. Читання означає відкрити сторінку й одразу завантажити в пам’ять кожен її елемент — усі штрихи та всі зображення. Полотно містить усе й ніколи не виконує частковий запит. Запис означає завершити штрих пера й додати до сторінки один елемент. У рідкісних випадках користувач стирає частину штриха, переміщує виділення або видаляє щось, але це все одно операції в межах однієї сторінки й одного елемента.
Паралельного доступу немає. Одна людина за раз пише на одній сторінці одного документа. Пошуку між документами теж немає — бібліотеці нотаток потрібні лише назва, часова мітка, кількість сторінок і мініатюра обкладинки кожного документа; читати вміст сторінок не потрібно.
Бази даних створені для запитів, індексів і координації паралельного доступу. Наш застосунок не використовує нічого з цього.
Оцінювання Turso
Ми оцінили libsql-swift — офіційний Swift SDK для рушія libSQL від Turso.
SDK працює. Усі 9 тестових випадків проходять. Ми інтегрували SDK у копію застосунку, зібрали її для симулятора iOS, запустили й створили локальну базу даних у пісочниці застосунку. В одній транзакції ми записали 100 штрихів, кожен із 3,400 точками зразків, — 4,080,000 байтів даних BLOB. На нашому Mac для розробки це зайняло приблизно 0.019s.
Після запуску PRAGMA wal_checkpoint(TRUNCATE) файл WAL зменшився до нуля. Ми змогли скопіювати лише основний файл .db в інше місце, відкрити його й прочитати всі дані. Сам рушій працює надійно.
SDK має ціну. CLibsql.xcframework займає 161 MB. Після підключення розмір збірки Debug для симулятора зріс приблизно з 1.9 MB до приблизно 8.2 MB. API синхронний і блокувальний, обгорток для Swift Concurrency немає. Явного методу close() немає. Transaction.commit() не викидає помилку, бо базовий C API повертає void. README репозиторію позначає SDK як technical preview, а найновіший коміт було зроблено приблизно за рік до нашого оцінювання, у липні 2025 року.
В екосистемі Turso є прогалина. Тепер Turso рекомендує для нових проєктів новий рушій Turso Database і протокол Turso Sync. У Turso Sync є клієнтські SDK для TypeScript, Python, Go та Rust, але немає SDK для Swift. Старий режим Embedded Replica існує в libsql-swift, але його Swift-ініціалізатор не відкриває параметр offline, потрібний повністю локальному мобільному застосунку. Якщо сьогодні використати libsql-swift, ми отримаємо локальну версію SQLite, але не можливості синхронізації, які вирізняють Turso.
Скільки коштувала б нам база даних зараз
Навіть якби SDK був зрілим, ми все одно оплачували б витрати, які нічого не дають нашому робочому навантаженню:
Керування побічними файлами WAL. Запущена база даних створює супровідні файли -wal і -shm. Щоб скопіювати документ, потрібно або спочатку виконати checkpoint, або атомарно скопіювати всі три файли. Експорт пакета .lnote у Files чи AirDrop тепер вимагав би попереднього кроку, якого користувач не бачить, а розробник не може забути.
Шар адаптації. Штрихи довелося б серіалізувати в BLOB і десеріалізувати назад. Елементи сторінки мають природний порядок масиву, який полотно може безпосередньо відтворювати; база даних додала б порядок рядків і стовпці z-index. Нам довелося б написати шар перетворення між двома представленнями одних і тих самих даних і підтримувати його після кожної зміни схеми.
Залежність на 161 MB. Для застосунку, чия збірка Debug важить менше 2 MB, залежність, більша за сам застосунок у 80×, — це вартість, яку варто помітити. Особливо якщо її позначено як technical preview, а активності в репозиторії не було вже рік.
Ці витрати не гіпотетичні. Вони починаються в момент підключення залежності. Натомість ми отримуємо можливості — запити, індексацію, паралельний запис, — якими застосунок не користується.
Рішення, яке ми випустили: пакети файлів зі знімками стану
Документ .lnote — це пакет-каталог:
Documents/Notes/<UUID>.lnote/
manifest.json # library cache: title, time, page count, cover
document.json # source of truth: document metadata + page order
pages/
<page-uuid>.content # one snapshot per page
assets/ # document-level shared resources
<asset-uuid>.jpg
thumbnails/
<page-uuid>.jpg # per-page thumbnail; first page doubles as cover
document.json — джерело істини для структури документа: його ID, назва, часові мітки й упорядкований список сторінок. Для кожної сторінки там також зберігаються розмір полотна, часові мітки та кількість елементів. Файли вмісту сторінок зберігають масив елементів у тому самому квантизованому цілочисельному кодуванні, яке застосунок уже використовує: координати й радіуси з точністю до 0.1 точки, тиск у тисячних, часові мітки у відносних мілісекундах.
Бібліотека нотаток читає лише manifest.json і мініатюри обкладинок. Вона не розбирає document.json і жодного вмісту сторінок. Відкриття сторінки завантажує один файл .content. Це єдине читання файлу, яке торкається даних штрихів.
Сторінки вирішують проблему підсилення запису
У застосунку для рукописного введення вже є поняття сторінки: це одиниця, про яку думає користувач, і те, між чим він перегортає. Якщо зробити сторінку одиницею збереження, автозбереження переписуватиме лише змінені сторінки.
Одна рукописна сторінка — скажімо, 1,000–2,000 штрихів — у нашому квантизованому форматі займає приблизно 3–5 MB. Один запис із 21 штриха та 3,400 точками зразків після квантування займає близько 55 KB. Запис знімка сторінки у флеш-сховище на сучасному обладнанні займає 10–20 ms. За debounce у 0.5s збереження непомітне для користувача.
Вартість збереження залежить від обсягу письма на поточній сторінці, а не від загальної кількості сторінок у документі. Блокнот на 200 сторінок зберігається так само швидко, як блокнот на 2 сторінки, бо переписується лише змінена сторінка.
Кожен запис використовує атомарні файлові операції — запис у тимчасовий файл, а потім перейменування, — тому збій під час збереження не створить урізаний файл сторінки. Перехід у фоновий режим негайно скидає всі змінені сторінки, як і раніше, коли в застосунку було одне полотно.
Узгодженість без транзакцій
Файлові пакети не мають транзакцій, але мають чіткі правила власності, які виконують ту саму функцію:
Ресурси перед посиланнями. Коли користувач вставляє зображення, файл ресурсу одразу записується в assets/. Знімок сторінки, який посилається на ресурс за ID, пізніше записується відкладеним автозбереженням. Сторінка не може посилатися на ресурс, якого ще немає на диску.
Джерело істини має перевагу. document.json і каталог pages/ — джерело істини. manifest.json — кеш. Якщо вони розходяться, наступне збереження узгоджує кеш із джерелом. Мініатюри є похідними даними й можуть бути відтворені будь-коли.
Сирітські файли замість висячих посилань. Найгірший наслідок збою — сирітський ресурс: файл в assets/, на який не посилається жодна сторінка. Сирітські ресурси видаляються під час закриття документа. Зворотна ситуація — сторінка посилається на відсутній файл — неможлива, бо ресурс записується до знімка сторінки, що на нього посилається.
Ці правила простіше аналізувати, ніж WAL checkpointing і ізоляцію транзакцій, і вони точно відповідають шаблону доступу застосунку: один процес, одна сторінка.
Шлях оновлення визначено
Вибір плоских файлів зараз не є остаточним рішенням. Структуру пакета спроєктовано так, щоб оновити рушій зберігання, не змінюючи сам пакет, — змінюється лише його вміст.
Рівень 1: знімок + журнал дописування. Якщо підсилення запису стане помітним — наприклад, безперервне письмо на сторінці з тисячами штрихів спричинятиме відчутну затримку збереження, — кожен файл сторінки розділиться на знімок і журнал, доступний лише для дописування. Нові елементи додаватимуться як фрейми [length][CRC][type][payload]. Під час повторного відтворення журналу фрейми з невідповідним CRC відкидатимуться, що забезпечить захист від збоїв. Коли журнал перевищить поріг або сторінку закриють, його буде об’єднано назад зі знімком. Це приблизно ~200 LOC без зовнішніх залежностей.
Оскільки знімки на рівні сторінки вже усувають підсилення запису між сторінками, цей рівень може довго не знадобитися. Перезапис 5 MB сторінки кожні 0.5s залишається в межах допустимого бюджету запису флеш-пам’яті.
Рівень 2: база даних SQLite. Якщо застосунку знадобиться повнотекстовий пошук між нотатками, синхронізація окремих елементів або індексація між документами, SQLite стане правильним інструментом. Найімовірнішим рушієм тоді буде GRDB — зріла Swift-обгортка, скомпільована з вихідного коду, майже без накладних витрат на розмір бінарного файлу. До libsql-swift ми повернемося лише тоді, коли екосистема Turso — зокрема Turso Sync for Swift — стане реальною потребою продукту.
Шлях міграції механічний: масив елементів кожної сторінки відображається на таблиці strokes / images, для кожного штриха є один незмінний BLOB, а кожна точка зразка займає 16 bytes у little-endian binary. Бенчмарк 0.019s для 100 штрихів підтверджує життєздатність підходу. Перед випуском міграція перевірятиме, чи збігаються кількості елементів і ресурсів у старому пакеті та новій базі даних, а також чи проходять перевірки відновлення після збою, меж WAL і фонового скидання.
Коли варто платити
Цей вибір відповідає поточним потребам застосунку. SQLite обробляє паралельні записи, складні запити й відновлення після збоїв у спільному стані, але нашому застосунку зараз нічого з цього не потрібно. Оплата за ці можливості до появи відповідних проблем збільшує витрати, не даючи застосунку користі зараз.
Витрати бази даних — залежність, керування WAL, шар адаптації та розмір бінарного файлу — починаються в момент підключення бібліотеки. Переваги з’являються, коли застосунок має запити для виконання, індекси для підтримки або паралельні записи для координації. Зараз немає нічого з цього.
Кожен крок розвитку нашого сховища оплачуватиме лише проблеми, які вже з’явилися. Знімки на рівні сторінки вирішують сьогоднішню проблему: збереження багатосторінкових документів без переписування всього файлу. Якщо підсилення запису стане вимірюваним, журнал дописування вирішить цю проблему. Якщо пошук або синхронізація стануть потребою продукту, цю проблему вирішить база даних.
Структура пакета, manifest і схема документа не прив’язані до жодного рушія зберігання. Вартість переходу низька, бо межі проведено в правильних місцях. Коли нам справді знадобиться база даних, ми використаємо її для конкретної, уже виміряної проблеми, а не для гіпотетичної.