@datler/core exposes the data as repository interfaces. The UI calls domain methods and never writes SQL; behind each interface, a factory takes an injected driver. The driver is the only thing that knows the transport, so the database under the app can change without a change to the renderer. This is the boundary that keeps Datler’s layers apart (Architecture Overview).

The driver

packages/core/src/driver.ts declares SqlDriver: select and execute, shaped exactly like plugin-sql’s Database, and an optional transaction. Core never imports a concrete driver. Four bind to it:

driverwheretransaction
plugin-sql, through the Tauri shellapps/renderer/src/db/driver.ts, on Windows and Androidnone
SQLite in WASM, in a workerapps/renderer/src/db/web/, the browser buildnone (it sits behind the same connection shape)
node:sqlitescripts/db/lib.ts, the db:* toolingreal
node:sqlite in memorypackages/core/test/memory-driver.ts, the testsreal

The app’s driver is the only file that imports plugin-sql. It waits for the migrations to have run before any query, and tells its listeners after every write, which is how the sync runner knows to send a change. Drizzle is the query layer inside core, over the same driver through its sqlite-proxy bridge (packages/core/src/db.ts). It is there for what one TypeScript schema gives: the types, typed queries, and the migration SQL drizzle-kit generates from it; its sqlite-proxy driver lets that one query layer run over any SqlDriver, since on the desktop the SQL itself executes in Rust. The bridge hands Drizzle positional rows by taking each row object’s values in order, which holds because every driver returns a row’s columns in the order the SELECT names them. So no query may return two columns of the same name: the row object keeps one, and every later column shifts by one. A select of two whole joined tables does exactly that (two ids), so a join names its fields. Not every repository goes through it: the Rundown’s layers (rundown-repository.ts) and schedules (schedule.ts) send all their SQL to the driver, and entries and trackables send some of theirs; the Rundown’s does so to read a statement’s rowsAffected, which the proxy does not return.

The repositories

Each interface has a createSql* factory that takes the driver:

interfaceholds
TrackableRepositorytrackables, with their zones and visualizations
EntryRepositoryentries
MembershipRepositorywhich trackables a trackable holds
OptionRepositorya trackable’s options
CatalogRepositorycatalogs and their items
TemplateRepositorytemplates and their lines
VisualizationRepositorysaved visualizations
SettingsRepositorysettings, a key to a JSON value
AchievementRepositoryachievement rules
ZoneRepositoryzones
ScheduleRepositoryschedules
RundownRepositorythe Rundown’s layers
IssueRepositorydismissed issues
ModuleInstallRepositorywhich module made which trackables
TrackableVisualizationRepositorya trackable’s own visualizations, used inside TrackableRepository

The renderer binds them once, in apps/renderer/src/db/repositories.ts, with the catalog resolver and the derived evaluator over the same driver. TanStack Query hooks wrap them, and no component sees SQL. The tooling binds the same factories to node:sqlite, which is what makes the domain testable without Tauri. Sync needs nothing from them: triggers on the tables capture every write below the repositories (Synced Databases).

What a write checks

A repository write checks its payload against the matching zod schema in packages/core/src/schemas.ts. The check is validate-only: a malformed payload throws, and the input as given is what is written, so nothing is silently stripped. Every repository’s writes are checked: entries, trackables, visualizations, settings, catalogs, catalog items, templates and template lines, and also achievement rules (a record only on a measure that can be beaten), schedules, Rundown layers and their rules, options, memberships, issue dismissals, module installs and zones. A trackable’s capture and presentation are checked with it (captureConfigSchema, presentationSchema), their declared keys by kind; a key they do not know passes through, since an older profile may still hold one since removed (bands, format), and a test fails when a schema and its type (CaptureConfig, Presentation) drift apart. A row merged in from another device is written by the sync runner, not by a repository, so it was checked on the device that wrote it. A catalog item is also checked against its catalog’s fields (a value under a field it has, of that field’s kind, a Category value from its list). The kinds of issue sit in a module of their own (issue-kinds.ts), so the schemas can name them without importing what works issues out.

A write returns what it changed

An entry write has a form that returns its changes, read off its own statements (RETURNING): createReturning gives the new row and any Nothing it replaced, updateReturning the row as it now is, removeReturning the rows deleted and the parts taken out of a routine. The renderer applies them to the entries it keeps loaded instead of reading every entry again (Entry Cache). create, update and remove are the same writes, for callers that want only the id.

Writing without a transaction

runInTransaction runs its work in a real transaction when the driver has one, and as plain sequential statements when it does not. The app’s driver has none on purpose: plugin-sql runs on a sqlx connection pool, so a BEGIN and COMMIT sent from JS land on different connections and either deadlock (database is locked) or autocommit outside the transaction. What every driver does have is batch: statements given as a list run on one connection, all or none, which the desktop’s driver gets from the shell’s sql_batch command. The migration runner runs each migration as a batch (Migration Runner). A sync merge, which reads between its writes and so cannot be one batch, runs under the driver’s write hold instead (SqlDriver.hold), which keeps every other write waiting until it is done (Synced Databases). A repository write is not a batch, so in the app every multi-statement write must be safe if it stops part-way:

  • Replacing a set of child rows diffs it rather than deleting everything and inserting again: a trackable’s members (setMembers), a template’s lines (setLines) and a trackable’s options (OptionRepository.set, keyed by code) all go through planWrites (packages/core/src/write-plan.ts). An interrupted diff leaves a partial set, never an empty one, and empty is the dangerous state: a template with no lines logs nothing, and a group with no members reads as none. A trackable’s options never shrink at all: set keeps any code it is not handed as a stopped option, since entries may hold it. A tag’s summary flags (setAggregators) set the new ones before clearing the old, so a cut-off change leaves one flagged, not none.

An invariant is asserted in the narrowest function every write path that can break it goes through, not at each caller and not in the form. That only a tag holds members is canContain (packages/core/src/trackable-invariants.ts), and both writes that reach it from opposite directions assert it: adding a member (membership-repository.ts) and changing a trackable’s kind (trackable-repository.ts). Finding the paths means listing every write, not every guard.

A write that needs several tables atomically in the app would take a Rust command behind the same interface, holding one connection for its transaction. The app has no such write.

Architecture Overview · Monorepo Layout · Migration Runner · Entry Cache · Synced Databases