Add SQLite storage and migrations
This commit is contained in:
@@ -0,0 +1,71 @@
|
|||||||
|
import DatabaseConstructor from 'better-sqlite3';
|
||||||
|
import { Kysely, SqliteDialect } from 'kysely';
|
||||||
|
import type { DatabaseTables } from './tables.ts';
|
||||||
|
import type { Logger } from '../../utils/logger.ts';
|
||||||
|
|
||||||
|
/** Options required to open a SQLite database connection. */
|
||||||
|
export type DatabaseOptions = {
|
||||||
|
/** Filesystem path to the SQLite database file. */
|
||||||
|
path: string;
|
||||||
|
|
||||||
|
/** Logger extended with a database namespace for diagnostics. */
|
||||||
|
debug: Logger;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thin wrapper around better-sqlite3 and Kysely.
|
||||||
|
*
|
||||||
|
* Owns connection setup, pragma configuration, and graceful teardown.
|
||||||
|
*/
|
||||||
|
export class Database {
|
||||||
|
private readonly debug: Logger;
|
||||||
|
private readonly sqlite: DatabaseConstructor.Database;
|
||||||
|
private readonly kysely: Kysely<DatabaseTables>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open a SQLite database and configure it for concurrent writes.
|
||||||
|
*
|
||||||
|
* @param options - Database file path and debug logger.
|
||||||
|
*/
|
||||||
|
constructor(options: DatabaseOptions) {
|
||||||
|
// Extend the debug logger to include the database namespace.
|
||||||
|
this.debug = options.debug.extend('database');
|
||||||
|
|
||||||
|
// Create the SQLite database.
|
||||||
|
this.sqlite = new DatabaseConstructor(options.path);
|
||||||
|
this.configurePragmas();
|
||||||
|
|
||||||
|
// Create the Kysely database.
|
||||||
|
this.kysely = new Kysely<DatabaseTables>({
|
||||||
|
dialect: new SqliteDialect({ database: this.sqlite }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the Kysely database.
|
||||||
|
*
|
||||||
|
* @returns The typed Kysely query builder for DatabaseTables.
|
||||||
|
*/
|
||||||
|
get db(): Kysely<DatabaseTables> {
|
||||||
|
return this.kysely;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Destroys the database connection.
|
||||||
|
*/
|
||||||
|
async destroy(): Promise<void> {
|
||||||
|
this.debug('destroying database connection');
|
||||||
|
await this.kysely.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configures the SQLite pragmas.
|
||||||
|
*
|
||||||
|
* WAL improves write concurrency; foreign keys enforce referential integrity.
|
||||||
|
*/
|
||||||
|
private configurePragmas(): void {
|
||||||
|
this.debug('configuring SQLite pragmas');
|
||||||
|
this.sqlite.pragma('journal_mode = WAL');
|
||||||
|
this.sqlite.pragma('foreign_keys = ON');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
/** Public storage module surface re-exported for application wiring. */
|
||||||
|
export { Database } from './database.ts';
|
||||||
|
export { MigrationService } from './migrate.ts';
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { promises as fs } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { FileMigrationProvider, Migrator } from 'kysely/migration';
|
||||||
|
import type { Database } from './database.ts';
|
||||||
|
import type { Logger } from '../../utils/logger.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies versioned schema migrations from the on-disk migrations folder.
|
||||||
|
*/
|
||||||
|
export class MigrationService {
|
||||||
|
private readonly debug: Logger;
|
||||||
|
private readonly migrator: Migrator;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param database - Open database whose schema will be migrated.
|
||||||
|
* @param debug - Root logger extended with a migrations namespace.
|
||||||
|
*/
|
||||||
|
constructor(database: Database, debug: Logger) {
|
||||||
|
// Extend the debug logger to include the migrations namespace.
|
||||||
|
this.debug = debug.extend('migrations');
|
||||||
|
|
||||||
|
// Resolve the migrations directory relative to this module file.
|
||||||
|
const currentFilePath = fileURLToPath(import.meta.url);
|
||||||
|
const currentDirectory = path.dirname(currentFilePath);
|
||||||
|
const migrationsPath = path.join(currentDirectory, 'migrations');
|
||||||
|
|
||||||
|
// Create the migrator backed by filesystem migration files.
|
||||||
|
this.migrator = new Migrator({
|
||||||
|
db: database.db,
|
||||||
|
provider: new FileMigrationProvider({
|
||||||
|
fs,
|
||||||
|
path,
|
||||||
|
migrationFolder: migrationsPath,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrates the database to the latest version.
|
||||||
|
*
|
||||||
|
* Throws when any pending migration fails so startup can abort cleanly.
|
||||||
|
*/
|
||||||
|
async migrateToLatest(): Promise<void> {
|
||||||
|
this.debug('migrating database to latest');
|
||||||
|
const { error } = await this.migrator.migrateToLatest();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
const errorInstance = error instanceof Error ? error : new Error(String(error));
|
||||||
|
this.debug('migration failed: %O', errorInstance);
|
||||||
|
throw errorInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.debug('database migrations complete');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import type { Kysely } from 'kysely';
|
||||||
|
import { sql } from 'kysely';
|
||||||
|
import type { DatabaseTables } from '../tables.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper for converting the current time to a millisecond timestamp.
|
||||||
|
*
|
||||||
|
* @returns SQLite expression producing the current time in milliseconds.
|
||||||
|
*/
|
||||||
|
const millisecondTime = sql`(CAST(unixepoch('subsec') * 1000 AS INTEGER))`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the resource_data table.
|
||||||
|
*
|
||||||
|
* @param db - Kysely database to apply the migration against.
|
||||||
|
*/
|
||||||
|
export const up = async (db: Kysely<DatabaseTables>): Promise<void> => {
|
||||||
|
// Composite primary key enforces one blob slot per (resource, public key).
|
||||||
|
await db.schema
|
||||||
|
.createTable('resource_data')
|
||||||
|
.ifNotExists()
|
||||||
|
.addColumn('resource_id', 'text', (col) => col.notNull())
|
||||||
|
.addColumn('public_key', 'text', (col) => col.notNull())
|
||||||
|
.addColumn('blob', 'blob', (col) => col.notNull())
|
||||||
|
.addColumn('timestamp', 'integer', (col) => col.notNull().defaultTo(millisecondTime))
|
||||||
|
.addPrimaryKeyConstraint('pk_resource_data', ['resource_id', 'public_key'])
|
||||||
|
.execute();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drops the resource_data table.
|
||||||
|
*
|
||||||
|
* @param db - Kysely database to apply the rollback against.
|
||||||
|
*/
|
||||||
|
export const down = async (db: Kysely<DatabaseTables>): Promise<void> => {
|
||||||
|
await db.schema.dropTable('resource_data').ifExists().execute();
|
||||||
|
};
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type { ColumnType } from 'kysely';
|
||||||
|
|
||||||
|
/** Kysely column type for millisecond epoch timestamps stored as integers. */
|
||||||
|
export type Timestamp = ColumnType<number, number | undefined, number | undefined>;
|
||||||
|
|
||||||
|
/** Kysely column type for binary blobs accepting Buffer or Uint8Array on insert. */
|
||||||
|
export type BlobColumn = ColumnType<Buffer, Buffer | Uint8Array, Buffer>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row per (resource_id, public_key). Each publicKey owns a slot within a shared resource.
|
||||||
|
*/
|
||||||
|
export interface ResourceDataTable {
|
||||||
|
/** Shared resource identifier grouping related instances. */
|
||||||
|
resource_id: string;
|
||||||
|
|
||||||
|
/** Owner identity for this instance slot within the resource. */
|
||||||
|
public_key: string;
|
||||||
|
|
||||||
|
/** Opaque serialized resource payload. */
|
||||||
|
blob: BlobColumn;
|
||||||
|
|
||||||
|
/** Millisecond timestamp of the last write. */
|
||||||
|
timestamp: Timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Complete Kysely schema mapping for the sync server database. */
|
||||||
|
export interface DatabaseTables {
|
||||||
|
resource_data: ResourceDataTable;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user