/** * @packageDocumentation * GS2-121 — **the history store's migration routine**: the schema of each kind of file it is made * of, the ordered steps that bring a file up to that schema, and the one store-wide step that splits * a single-file store from an earlier release into the directory layout described in * `historyLayout.ts`. * * ## Per-file, versioned, on open * * Each file records its own schema version in `PRAGMA user_version`. {@link migrateFile} applies, * in order, every step of that file kind newer than the recorded version, each in its own * `BEGIN IMMEDIATE` transaction that also writes the new version, so a crash leaves the file at the * last step that completed and the next open carries on from there. A file whose version is NEWER * than any step here was written by a later release, and is refused rather than guessed at. * * A future format change is a new step appended to {@link INDEX_SCHEMA_STEPS} or * {@link THREAD_SCHEMA_STEPS}. It runs on the index at the next open of the store, and on each * thread file the next time that thread is opened, never across every thread at once. * * ## The split * * {@link splitLegacyStore} is the step from "one file holds everything" to the directory layout. * It is store-wide by necessity, so it is the one step with a lock, and it is written to be resumed: * * 1. Take the lock (`.migrating`, created exclusively). Another process holding it means * "not now": the caller treats the store as unavailable for this open, fail-soft, and a later * open finds it split. A lock older than {@link STALE_LOCK_MS} is a crashed split, and is taken * over. * 2. Settle the old file through SQLite at its own path: bring its columns up to date (what every * open of it did before), fold any write-ahead log into it and switch it to a rollback journal, * so the rename that follows moves one self-contained file. Then rename it to `.pre-split`. * From here on `.pre-split` is what an interrupted split resumes from. * 3. Create the directory at the old path and copy each thread into its own file, one thread per * transaction, with `INSERT OR IGNORE … SELECT` from the old file ATTACHed. The copy is SQLite's * own, page by page: the old file is never read into this process's memory, which is what lets * a multi-gigabyte store split at all. Each thread's copy is checked by row count before it * commits. * 4. Build the index into a temporary file from the old file's conversations and turns, check its * row counts, and rename it into place. The index goes last, so a reader never sees an index * row whose file has not been written. * 5. Delete `.pre-split`, then the lock. * * Every conversation's record goes to a record file of its own, `legacy-conversation-`, never * to the file of the thread it names. The name is deterministic, so a resumed split puts each * record in the same file the first attempt did. */ import { DatabaseSync } from 'node:sqlite'; /** * How long a statement waits for another connection's lock before giving up. The recorder, the * checkpoint saver and a second terminal all reach the same files, so an overlap is ordinary and * must wait rather than surface as `SQLITE_BUSY`. */ export declare const HISTORY_BUSY_TIMEOUT_MS = 5000; /** * A split's lock older than this is a crashed split. The lock's time is refreshed after every * thread, and one thread's copy is a single `INSERT … SELECT`, so a live split never goes this * long without touching it. */ export declare const STALE_LOCK_MS: number; /** One ordered step of a file's schema. */ export interface SchemaStep { /** The `user_version` the file has once this step has run. Strictly increasing. */ version: number; /** What the step does, for a reader of this list. */ describe: string; /** Apply the step. Runs inside a transaction; must be idempotent (`IF NOT EXISTS`). */ up(db: DatabaseSync): void; } /** * The index's schema. `home_thread` names the record file holding the conversation's durable * record; `thread_id` is the resume link, which may be cut (set NULL) while the record stays. */ export declare const INDEX_SCHEMA_STEPS: readonly SchemaStep[]; /** * The schema of a file in `threads/`: its own id, the checkpoint tables a thread file fills, and * the record tables a conversation's record file fills. One schema for both kinds, so both are * opened, migrated and rebuilt the same way. */ export declare const THREAD_SCHEMA_STEPS: readonly SchemaStep[]; /** The schema version a file records. */ export declare function fileSchemaVersion(db: DatabaseSync): number; /** * Bring one open file up to the newest step of `steps`. Throws when the file is newer than every * step (written by a later release) or a step fails; the caller's open is fail-soft. */ export declare function migrateFile(db: DatabaseSync, steps: readonly SchemaStep[]): void; /** Open a connection with the store's busy timeout. */ export declare function openConnection(path: string, options?: { readOnly?: boolean; }): DatabaseSync; /** Close a connection, ignoring a connection that is already broken or closed. */ export declare function closeQuietly(db: DatabaseSync | undefined): void; /** The record file id the split gives a conversation. Deterministic on purpose. */ export declare function legacyHomeThread(conversationId: number): string; /** * Split the single-file store at `storePath` (or resume a split interrupted part-way) into the * directory layout. Returns `true` when the store is in the directory layout afterwards, `false` * when it could not be split now: another process holds the lock, the old file will not open, or a * copy failed its check. On `false` nothing has been lost: the old data is still at `storePath` or * at `.pre-split`, and the next open tries again. Never throws. */ export declare function splitLegacyStore(storePath: string): boolean; /** What is at `path`: a regular file, a directory, nothing, or something else. */ export declare function statKind(path: string): 'file' | 'directory' | 'absent' | 'other';