Database & migrations

Marginalia keeps all its data in one SQLite file. The schema is created and upgraded by Flyway migrations, and Hibernate maps the entities onto it but never changes it. This page explains the setup, the conventions of the schema and how to write a migration. What the tables contain is described in Domain model.

The database file

   
File <user.home>/.marginalia/marginalia.sqlite, with marginalia.sqlite-wal and -shm while it runs
JDBC URL jdbc:sqlite:<file>?busy_timeout=10000&journal_mode=WAL (built by Configuration.resolveDb); the pool of read-write transactions adds &transaction_mode=IMMEDIATE (Configuration.resolveWriteDb)
Driver org.sqlite.JDBC (sqlite-jdbc)
Pool commons-dbcp BasicDataSource, 10-20 connections, validated with SELECT 1; a second one (writeDataSource, 2-20) for read-write transactions
Journal WAL - readers don't block the writer; writers wait up to 10 s (busy_timeout) for each other
Foreign keys Not enforced (PRAGMA foreign_keys is off), see Conventions

The beans are in src/main/resources/META-INF/spring/datasources-config.xml:

flowchart LR
    conf["configuration<br/>resolveDb()"] --> ds["dataSource<br/>BasicDataSource"]
    conf --> wds["writeDataSource<br/>BEGIN IMMEDIATE"]
    ds --> fw["flyway<br/>init-method=migrate"]
    ds --> routing["routingDataSource<br/>ReadWriteRoutingDataSource"]
    wds --> routing
    routing --> proxy["ds<br/>TransactionAwareDataSourceProxy"]
    fw -. "depends-on" .-> emf
    proxy --> emf["emf<br/>EntityManagerFactory (PU)"]
    proxy --> jdbc["jdbcTemplate"]
    emf --> em["em<br/>SharedEntityManagerBean"]
    emf --> tm["transactionManager<br/>JpaTransactionManager 'common'"]
  • flyway migrates the database when the context starts; emf depends on it, so Hibernate only sees a migrated schema.

  • Hibernate and jdbcTemplate use the data source through TransactionAwareDataSourceProxy, so plain JDBC code joins the current JPA transaction.

  • Read-write transactions take the write lock when they begin. A deferred transaction reads, takes its snapshot and needs the lock at its first write; when another connection has committed in between, the write fails at once with SQLITE_BUSY or SQLITE_BUSY_SNAPSHOT and busy_timeout does not help. So routingDataSource (ReadWriteRoutingDataSource) gives a read-write transaction a connection of writeDataSource (transaction_mode=IMMEDIATE, writers queue for the busy timeout) and a read-only one (@CommonTxReadOnly) a plain connection of dataSource (never waits for a writer). The transaction manager asks for the connection before it publishes the read-only flag, so the jpaDialect bean (ReadWriteRoutingDataSource.Dialect) notes it while the transaction begins. Flyway and code outside a transaction use dataSource. ConcurrentTransactionsTest covers both sides.

  • The persistence unit PU is defined in src/main/webapp/config/persistence.xml: the list of entity classes (exclude-unlisted-classes), the dialect and hibernate.hbm2ddl.auto=validate.

Looking into the database

The file is an ordinary SQLite database. While Marginalia runs, open it read-only so you don't hold locks:

sqlite3 -readonly ~/.marginalia/marginalia.sqlite
sqlite> .tables
sqlite> select version, description, success from flyway_schema_history;
sqlite> select id, name, cast(extendedContent as text) from manuscripts;

extendedContent columns hold UTF-8 JSON, gzipped when it is 256 bytes or more (since app version 6, see Startup). Small ones can be read with cast(... as text); a big one starts with the bytes 1f 8b, unpack it outside of SQLite: sqlite3 -readonly marginalia.sqlite "select hex(extendedContent) from manuscripts where id = 1" | xxd -r -p | gunzip. It holds the extended attributes. Timestamps (creation, modification, lastOpened) are stored as milliseconds since the epoch, booleans as 0 / 1.

Startup: migrate, then validate

  1. Before the data source is created, Configuration.resolveDb swaps in a staged database restore, if there is one (see Database backups and restores). Then Configuration.backupBeforeMigration asks DatabaseCheck.pendingMigrationFrom whether the existing file is behind the newest bundled V<n> migration (or has no history table, which Flyway baselines at V1). If so it saves a VACUUM INTO copy as db-backups/pre-migration-<time>-V<from>-marginalia.sqlite and keeps the newest three. A new installation (no file or an empty one) and a current database are left alone, and a copy that fails is logged, not fatal. The check looks at the Flyway history only, so a Java Migration bean without a V<n> file does not trigger a copy.

  2. Flyway runs every pending migration from classpath:migration (src/main/resources/migration/) and records it in flyway_schema_history. baselineOnMigrate=true with baseline version 1 makes databases created before Flyway was introduced (V1 schema without a history table) start at V1 and get V2 onwards.

  3. Hibernate builds the EntityManagerFactory and validates the schema against the entities: every mapped table and column must exist with a compatible type. It never creates or alters anything.

If either step fails, the Spring context doesn't start and Marginalia doesn't serve any page; the cause is in the log. Typical messages:

Message Cause
Schema-validation: missing column [x] in table [y] An entity field was mapped as a column without a migration.
Schema-validation: missing table [x] A new entity without a migration, or the migration names the table differently.
Validate failed: Migrations have failed validation ... checksum mismatch An already applied migration file was edited.
Migration V<n>__... failed The SQL of a migration failed; Flyway rolls that migration back (SQLite DDL is transactional, each file is its own transaction, earlier files of the same run stay applied) and startup stops. Flyway Community has no undo, so a migration that succeeded is only reversible from the pre-migration-* copy.

There is also a second, Java-level version check: ApplicationInitializer compares AppSettings.dbVersion / appVersion with the versions set in container-config.xml and runs registered Migration beans for data changes that are easier in Java than in SQL. It runs after Hibernate is up, so a migration can use jdbcTemplate or services. Each migration gets the current version and returns the new one (or the same one when it has nothing to do), so a migration is written for one version step and ignores the others.

Type Version Class Change
APP 1 → 2 bound/migration/EncryptApiKeysMigration Encrypts the plain text ais_openaicompat.apiKey values, see Encrypted columns.
APP 2 → 3 bound/migration/FulltextMigration Fills _fulltext (added by V9) of the existing rows of every entity with @Fulltextable fields, deleted rows included: walks the table by id in batches of 200, one transaction per batch, and calls ExtendableEntityListener.updateFulltext on the loaded entity, so extendedContent and modification stay as they are. Repeating it is harmless. See Full-text column.
APP 3 → 4 bound/migration/OriginalResponseMigration Copies response of every ChatMessage (deleted ones too) into the extended attribute originalResponse when it has none: batches of 200 by id, one transaction per batch, the entity is re-serialized with ExtendableEntityListener.serialize, modification stays as it is. Repeating it is harmless.
APP 4 → 5 bound/migration/PermissionsMigration Gives the permissions CREATE_AI and HELPER to every existing (not deleted) user when the switches they replace (AppSettings.userAisAllowed, helpEnabled) were on; the permission has no owner. The new HELPER permissions also get helperApplyLimits and helperInteractions from AppSettings.helpApplyLimits / helpInteractions. Users that already have the permission are skipped (repeatable). Reads in one session and writes in another: the id generator updates its sequence table on a second connection and SQLite locks it out while the first holds a read lock.
APP 5 → 6 bound/migration/ExtendedContentMigration Two things, walking the table by id in batches of 25, deleted rows included. First the prompt of the parts (a batch is read in one session and each part is written in a transaction of its own, rows persisted before anything is read: SQLite refuses a write to a transaction that has read when the id generator updates its sequence on another connection, as noted for PermissionsMigration; a lock error is retried): builtPrompt and backgroundLore are taken out of the extended content of every ChatMessage that has them and stored as a row of message_prompts (V20; the row of a deleted part is deleted, a part that has a row already only loses the keys). Then every row of every ExtendableEntity (one transaction per batch) whose extended content is stored plain and has 256 bytes or more is rewritten gzipped (ExtendableEntity.needsCompression). When it changed anything it runs VACUUM once (the file of the database does not shrink by itself; it needs free disk space for a copy of the database and its failure is only logged). modification stays as it is, repeating it is harmless. See MessagePrompt and Compression.
APP 6 → 7 bound/migration/PartRevisionMigration The first versions of the existing parts (message_revisions, V22): a part a model wrote gets a GENERATE version of originalResponse and a HUMAN one of the text it has now when that is another, any other part a HUMAN one; deleted parts too; a part that has versions or no text gets none, so it can run again. See Version history.
APP 7 → 8 bound/migration/VersionHistoryPermissionMigration Gives every user (not deleted, administrators too) that has none the permission VERSION_HISTORY, granted, with historyMaxVersions 50.
APP 8 → 9 bound/migration/StatisticsBackfillMigration The writing statistics of the books that exist (V23): the counts and the size of every book (also one in the trash; not an agent's working copy) are rebuilt from its parts and their versions (StatisticsReconstruction, a class without a database) and written with reconstructed true, for the days before today; a book that has rows already is left alone. Every user without it gets UserSetting.statisticsSince, the day the time is recorded from (today, or the day of their first stretch). Repeatable.

To add one: implement Migration, register the bean in the migrations list of applicationInitializer and raise appVersion (or dbVersion). A fresh database starts at version 1, so it goes through all migrations too.

Migrations

Version File Change
V1 V1__initial.sql The initial schema, generated from the entities by Hibernate.
V2 V2__summaries.sql messages.summary_id - parts can have a summary.
V3 V3__lorebook_subbooks_shared.sql Rebuilds lorebooks_lorebooks without the unique constraint, so a lorebook can be a sub-lorebook of several lorebooks.
V4 V4__manuscript_published_last_opened.sql manuscripts.published and manuscripts.lastOpened.
V5 V5__protocol_optional_limits.sql Rebuilds protocols with nullable maxTokens / replyTokens and turns the old "not set" values (0, negative) into NULL.
V6 V6__indexes.sql Indexes matching the queries: tag relations by object and by tag, owner lookups (userId, is_deleted), lorebook entries in order, parts of a book in order, parts by summary, resources by owner and hash, settings by key. Drops the unused and duplicate ones. See Indexes.
V7 V7__user_login_backoff.sql users.failedLogins and users.lockedUntil (both not null default 0) for the login back-off, see Users.
V8 V8__resource_link.sql resources.objectId and resources.clazz - the loose link of an uploaded file to the object that uses it - and the indexes of the Resources tab ((userId, is_deleted, creation)) and of the link ((clazz, objectId)).
V9 V9__fulltext.sql _fulltext clob on every table of an extendable entity (ais, entries, lorebooks, manuscripts, messages, protocols, settings, summaries, t2e, tags), because the column is mapped in ExtendableEntity. Copies manuscripts.description into extendedContent where the JSON has no value (the extended attribute wins when both exist), then drops the column. The column is filled by FulltextMigration (app version 3).
V10 V10__ai_shared.sql ais.shared (not null default false), the flag of an AI that an administrator shares with all users, and its index (shared, is_deleted). See AI.
V11 V11__usages.sql The usages table (tokens a user used with an AI, one row per AI and user, unique (ai_id, userId)), its usages_SEQ id table and the index of the owner lookup. See Usage.
V12 V12__usage_limit_period.sql usages.periodPrefillTokens and usages.periodGeneratedTokens (bigint not null default 0): the tokens a user used since the last reset of their token limit on a shared AI. The limits themselves are not columns, they are the extended attribute AI.limits. See Limits of the users of a shared AI.
V13 V13__usage_ai_nullable.sql usages.ai_id allows NULL (table rebuilt, SQLite cannot drop NOT NULL): the cleanup nulls the reference to an AI before it purges it, which failed for an AI with usage rows.
V14 V14__extension_ai.sql New table ais_extension (extensionId, id): the subclass ExtensionAI of AI, an AI provided by an extension. The settings of the provider are not columns, they are in the attributes of the AI.
V15 V15__permissions.sql New table permissions (permissionType, holder_id, granted, owner userId, extended content) and its permissions_SEQ, the index (holder_id, permissionType, is_deleted): the entity Permission. See Permission.
V16 V16__agents.sql New tables agents (name, enabled, manuscript_id, owner userId, extended content), agent_runs (agent_id, status, statusReason, workingCopy_id, prefillTokens, generatedTokens, rounds, startedAt, finishedAt) and agent_messages (run_id, ordinal: the transcript of a run) with their _SEQ tables and indexes: the entities Agent, AgentRun and AgentMessage; the column agentic of manuscripts and lorebooks (a working copy of an agent). manuscript_id, agent_id are nullable (the cleanup nulls an OWNED_BY reference before the purge). A partial unique index ux_agent_runs_unfinished keeps one run per agent that is not over (the statuses are listed in the index, a new status that is not terminal needs a new migration). See Agent.
V17 V17__agent_run_pairs.sql New table agent_run_pairs (run_id, kind, copyUuid, originalUuid, handle) with its _SEQ table: the entity AgentRunPair, which item of the working copy of an agent run is which item of the original. Not a BaseEntity (no uuid, is_deleted or extended content columns). Unique indexes on (run_id, copyUuid) and (run_id, handle), an index on (run_id, originalUuid); there is no foreign key to agent_runs (the project uses none), the rows are deleted by AgentRunPairService.deleteForRun. See AgentRunPair.
V18 V18__agent_message_threads.sql The nullable column thread (varchar 16) of agent_messages, the handle of the sub-agent whose conversation a row belongs to (null: the agent's own), and the index ix_agent_messages_thread on (run_id, thread, is_deleted, ordinal). See AgentMessage.
V19 V19__annotations.sql New table annotations (part_id nullable like the other owned references, status, source, startOffset, endOffset, the standard columns and extended content: the note, the quote and its context, the type) with its _SEQ table and the indexes (part_id, is_deleted, status) and (userId, is_deleted): the entity Annotation. See Annotation.
V20 V20__message_prompts.sql New table message_prompts (message_id nullable like the other owned references, builtPrompt and backgroundLore as deflated blobs, the standard columns without extended content) with its _SEQ table and the indexes (message_id, is_deleted) and (userId, is_deleted): the entity MessagePrompt. The rows are made from the extended content of the parts by ExtendedContentMigration (app version 6). See MessagePrompt.
V21 V21__folders.sql New table folders (name, parent_id nullable, the standard columns and extended content: the description and the settings the books inherit) with its _SEQ table and the indexes (parent_id, is_deleted) and (userId, is_deleted): the entity Folder; and manuscripts.folder_id (null is the top level) with the index (folder_id, is_deleted). See Folders.
V22 V22__message_revisions.sql New table message_revisions (message_id nullable like the other owned references, source, is_agent, sourceDetail, label, wordCount, tokenCount, the standard columns with _fulltext and extended content: the text and the reasoning of the version) with its _SEQ table and the indexes (message_id, is_deleted, creation) and (userId, is_deleted): the entity MessageRevision, see Version history.
V23 V23__statistics.sql New tables manuscript_statistics (the counts per book, day and side: bookUuid, bookName, folderPath, day as ISO text, is_agent, the nine counters, reconstructed, lastChange; unique (bookUuid, day, is_agent)), activity_intervals (the time: kind, is_agent, day, startTime, endTime) and manuscript_sizes (the size of a book at the end of a day: bookWords, bookTokens, branchWords, branchTokens, leafUuid, baseline, reconstructed; unique (bookUuid, day)), each with its _SEQ table and an index (userId, day); the entities ManuscriptStatistic, ActivityInterval and ManuscriptSize, see Writing statistics. The book is a plain bookUuid, not a foreign key, on purpose: the rows outlive the book.

Rules

  • Never change a migration that has been released. Flyway stores a checksum of each applied file and refuses to start when it changes. Fix mistakes with a new migration.

  • Name files V<next number>__<what_it_does>.sql (two underscores). Versions are plain integers here.

  • One migration per change, together with the entity change in the same commit.

  • Keep data. Users upgrade in place - every migration must turn any existing database into the new schema without losing data. Convert old values in SQL (V5 is an example).

  • Only SQL that SQLite understands. See SQLite limitations.

  • Prefer extended attributes. A field stored in extendedContent needs no migration at all; add a column only when the value is used in queries (see Domain model).

Adding a column

-- V10__manuscript_archived.sql
ALTER TABLE manuscripts
    ADD COLUMN archived boolean not null default false;

ADD COLUMN with not null needs a default, otherwise existing rows can't get a value. Then map it in the entity:

@Column(nullable = false)
private boolean archived = false;

Adding a table

A new entity needs its table, its sequence table with a first row, and its indexes. Hibernate uses GenerationType.AUTO, which on SQLite means a table-backed sequence named <table>_SEQ with a pooled optimizer (ids are reserved in blocks of 50 - gaps in ids are normal):

-- V10__bookmarks.sql
create table bookmarks
(
    id              bigint      not null primary key,
    creation        timestamp,
    is_deleted      boolean     not null,
    modification    timestamp,
    uuid            varchar(36) not null unique,
    extendedContent blob,
    _fulltext       clob,
    userId          bigint,
    message_id      bigint
);

create index ix_bookmarks_user on bookmarks (userId, is_deleted);
create index ix_bookmarks_message on bookmarks (message_id);

create table bookmarks_SEQ
(
    next_val bigint
);

INSERT INTO bookmarks_SEQ (next_val) VALUES (1);

The columns of BaseEntity / OwnedEntity / ExtendableEntity are always the same: id, creation, is_deleted, modification, uuid, userId (owner), extendedContent and _fulltext. Then list the class in persistence.xml. For joined inheritance (a subtype of AI or Protocol), the subtype table has only id plus its own columns. A subtype of Setting is single table (@DiscriminatorValue, the settings table): StylesSetting and ExportSettings are only listed in persistence.xml and keep everything in extendedContent, so they need no migration.

Changing or removing a column

SQLite can rename a column (ALTER TABLE ... RENAME COLUMN) and, since 3.35, drop a simple one (ALTER TABLE ... DROP COLUMN), but it can't change a column's type, nullability, default or constraints. For those, rebuild the table - V3 and V5 show the pattern:

CREATE TABLE protocols_new ( ... new definition ... );

INSERT INTO protocols_new (id, ...)
SELECT id, ... FROM protocols;          -- convert values here

DROP TABLE protocols;

ALTER TABLE protocols_new RENAME TO protocols;

CREATE INDEX protocol_is_deleted_idx ON protocols (is_deleted);   -- indexes are dropped with the old table
CREATE INDEX protocol_user_id_ix ON protocols (userId);

Recreate every index of the old table. Removing an entity field without removing the column is harmless - validation only checks that mapped columns exist.

Generating the SQL

com.github.enerccio.tools.GenerateFlywayDiff (in src/main/java/com/github/enerccio/tools/) asks Hibernate what it would change to make an existing database match the entities, and prints the SQL. Run its main from the repository root (it reads marginalia/src/main/webapp/config/persistence.xml), with -Duser.home pointing to a development home whose database is at the current schema version. Treat the output as a draft:

  • Hibernate only creates and adds; it never drops or changes columns.
  • It doesn't know about data conversion, defaults for existing rows or the table rebuilds SQLite needs.
  • It may print Hibernate's temporary tables (HT_*, HTE_*) again - leave them out if they already exist.

Testing a migration

db/FlywayMigrationTest checks, without the rest of the application:

  • an empty database migrates through all versions and the schema validates against persistence.xml,
  • migrating twice is a no-op,
  • a database at V1 with data upgrades to the latest version, keeping the data,
  • a pre-Flyway database (V1 schema, no history table) is baselined and upgraded.

New migrations are picked up automatically by the first two tests. When a migration converts data, add rows to insertV1Data() and assertions to assertUpgradedData(), like the existing ones for V2-V5 and V9 (the description of three manuscripts: only in the column, merged into other JSON keys, and in both). A Java migration (Migration bean) needs the Spring context: see db/FulltextMigrationTest. Run it with mvn test -Dtest=FlywayMigrationTest.

Schema conventions

Names. Tables are plural (manuscripts, messages, entries), set by @Table(name = ...). Columns use the Java field name (maxTokens, lastOpened); relations end in _id (lorebook_id, parentScript_id), except the owner, which is userId, and the soft delete flag, is_deleted. Indexes exist only in the migrations, see Indexes.

No foreign key constraints. The tables have no REFERENCES clauses (V2's summary_id declares one, but SQLite doesn't enforce it because PRAGMA foreign_keys is off). Integrity is kept by the application: rows are soft deleted, so references stay valid, and the admin Cleanup follows the cleanup references to decide what may be purged. Code that hard deletes must clear or move references itself (see ChatMessageService.deleteNodeAndMigrateChildren).

Enums. AI.aiType and Protocol.protocolType are stored as ordinals (tinyint), all other enums by name. SaneSQLiteDialect turns off the CHECK constraints that Hibernate would otherwise generate for enum columns, so a new enum value doesn't need a table rebuild.

Large values. Text that can be long (name, uri, apiKey...) is @Lob → clob; extendedContent is a blob with JSON (gzipped when big, see ExtendableEntity). SQLite doesn't enforce lengths, so varchar(255) is only documentation.

Encrypted columns. Secrets are stored encrypted, see Encrypted columns.

Hibernate's temporary tables. V1__initial.sql contains HT_* and HTE_* tables. Hibernate uses them for bulk updates and deletes on entities with joined inheritance (AI, Protocol) and for inserts into such hierarchies. They are part of the schema - don't drop them.

Indexes

Indexes are created only by Flyway migrations; the entities don't declare any (@Table(indexes = ...)), because Hibernate only validates the schema and never creates them. Name them ix_<table>_<what>.

Index what the queries filter on, in the order of the WHERE clause, with the equality columns first and the ORDER BY column last:

Index Used by
t2e (objectId, clazz, negative) Tags of a book, lorebook or entry (findTagsForObject, lorebook activation on every generation).
t2e (tag_id, clazz, negative) Objects with a tag (findObjectIdsForTag, tag filters).
<table> (userId, is_deleted) Listing and finding the owner's entities (ais, protocols, manuscripts, lorebooks, tags).
ais (shared, is_deleted) The shared AIs every user sees next to their own.
usages (ai_id, userId) (unique), usages (userId, is_deleted) The usage of an AI by a user (the row UsageRepository.add updates), the usages of a user.
manuscript_statistics (bookUuid, day, is_agent) (unique), manuscript_statistics (userId, day) The row ManuscriptStatisticRepository.add updates; the sums of a range of days of a user.
activity_intervals (userId, day), activity_intervals (bookUuid, day) The stretches of a range of days of a user or a book.
manuscript_sizes (bookUuid, day) (unique), manuscript_sizes (userId, day) The size of a book on a day, the latest size at or before a day.
entries (lorebook_id, ordinal) Entries of a lorebook in order.
messages (parentScript_id, creation), messages (parent_id) Parts of a book in order, children of a part.
messages (summary_id) Parts using a summary (cleanup references).
resources (userId, hash) Deduplication of uploaded files.
resources (userId, is_deleted, creation) The Resources tab: the user's files, newest first.
resources (clazz, objectId) Resources used by an object (the loose link).
settings (key, userId) Loading a settings object.

A single-column index on is_deleted alone helps only the Cleanup (few rows are deleted); don't add new ones. Check a query with EXPLAIN QUERY PLAN in SQLite - SCAN <table> means a full table scan, SEARCH ... USING INDEX means the index is used. FlywayMigrationTest.tagRelationLookupsUseIndexes does that for the main lookups.

Encrypted columns

OpenAICompatible.apiKey is mapped with @Convert(converter = EncryptedStringConverter.class). The converter calls Configuration.encrypt / decrypt:

  • On the first start Configuration writes a random UUID to <data folder>/secret.key; the AES-256 key is the SHA-256 of that UUID. The file is outside the database, so a database backup alone doesn't reveal the secrets.

  • A stored value is enc: + Base64 of a random 12-byte IV and the AES/GCM ciphertext. Values without the prefix are read as they are (plain values from before the migration); a value that can't be decrypted (a database from another installation, a different secret.key) is logged and read as null - the user enters the key again.

  • The converter is a Spring bean: emf sets hibernate.resource.beans.container to a SpringBeanContainer, so Hibernate asks Spring for converters (and entity listeners) and @Autowired fields are injected. Use the same converter for any new secret column.

  • Code outside JPA (jdbcTemplate, migrations) sees the encrypted value and must use Configuration itself, as EncryptApiKeysMigration does.

SQLite limitations

Limitation Consequence
One writer at a time Writes are serialized; a read-write transaction holds the write lock from its start (see above), so a long one blocks other users' writes (up to busy_timeout, then SQLITE_BUSY); read-only transactions are not blocked. Keep transactions short and never call a model inside one - generation runs outside transactions (@NoTx).
ALTER TABLE is limited Type, nullability and constraint changes need a table rebuild.
DDL in transactions SQLite supports transactional DDL, so a failing migration is rolled back completely.
No VACUUM inside a transaction VACUUM INTO for backups runs with @NoTx.
Dynamic typing A column accepts any value; the entity mapping is what keeps types consistent.
500 terms in a compound SELECT The driver answers getColumns of all tables with one compound SELECT, so the columns of all tables together would be limited to 500 (the validation after the migrations hit it with V16). persistence.xml therefore sets hibernate.hbm2ddl.jdbc_metadata_extraction_strategy to individually, validate table by table - keep it.

Database backups and restores

DatabaseBackupServiceImpl backs up the live database with SQLite's VACUUM INTO '<file>', which writes a consistent, compacted copy without stopping the application. Backups go to <data folder>/db-backups/ as marginalia-<time>.sqlite (manual) or marginalia-scheduled-<time>.sqlite; scheduled ones are rotated (keep last N). Configuration writes two more kinds into the same folder, pre-restore-* (the database a restore replaced) and pre-migration-* (see Startup); the service lists every .sqlite file there, and only marginalia-scheduled-* counts as scheduled. The methods the UI calls (create, list, download, upload, delete, restore, schedule) call AdminGuard.requireAdmin(); the scheduled run (createScheduledBackup) and the rotation need no logged-in user.

A database can't be replaced while it's open, so restoring is done in two steps:

sequenceDiagram
    participant A as Admin UI
    participant B as DatabaseBackupService
    participant C as Configuration (next start)
    A->>B: scheduleRestore(backup)
    B->>B: DatabaseCheck.check(backup)
    B->>B: copy backup → marginalia.sqlite.restore (tmp file + atomic move)
    Note over A,C: restart
    C->>C: DatabaseCheck.check(marginalia.sqlite.restore)
    C->>C: move marginalia.sqlite (+ -wal, -shm) → db-backups/pre-restore-<time>-marginalia.sqlite
    C->>C: move marginalia.sqlite.restore → marginalia.sqlite
    C->>C: Flyway migrates the restored database
    Note over C: datasource, Flyway bean (nothing left to migrate), Hibernate validates

DatabaseCheck.check(File) is run on Upload Backup (importBackup), on scheduleRestore and on start. It opens the file read-only and refuses it (InvalidDatabaseException, an IllegalArgumentException with the reason) when:

  • it doesn't start with the SQLite header,

  • PRAGMA integrity_check reports problems,

  • core tables (users, settings, manuscripts, messages, lorebooks, entries, ais, protocols, tags, t2e) are missing - another application's database,

  • flyway_schema_history has a failed migration or a version newer than the newest bundled migration/V*__*.sql - a database of a newer Marginalia version. A database without the history table predates Flyway and is baselined at V1 as usual.

Configuration.applyPendingRestore() migrates the restored database itself, before the datasource is created, with the same Flyway configuration as the flyway bean (DatabaseCheck.flywayConfiguration(DataSource), used by datasources-config.xml too). If the check fails, the staged file is moved to db-backups/rejected-restore-<time>-marginalia.sqlite and the current database stays. If the migration fails, the restored file is moved there as well and the pre-restore-* database is moved back. Either way the reason is logged as an error and Marginalia starts with the previous database. A backup from an older version is upgraded on start.

API keys in a backup are encrypted with the installation's secret.key (see Encrypted columns). A backup restored on another installation loses them unless secret.key is copied along.

Tests and the database

Tests don't touch your data: MarginaliaTestBase starts the Spring configuration with the data folder in target/test-home/ctx-*, so every test context gets a fresh database migrated by the real Flyway configuration. See Testing.