diff --git a/src/services/storage/database.ts b/src/services/storage/database.ts new file mode 100644 index 0000000..f89832b --- /dev/null +++ b/src/services/storage/database.ts @@ -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; + + /** + * 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({ + dialect: new SqliteDialect({ database: this.sqlite }), + }); + } + + /** + * Gets the Kysely database. + * + * @returns The typed Kysely query builder for DatabaseTables. + */ + get db(): Kysely { + return this.kysely; + } + + /** + * Destroys the database connection. + */ + async destroy(): Promise { + 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'); + } +} diff --git a/src/services/storage/index.ts b/src/services/storage/index.ts new file mode 100644 index 0000000..6f1fcbe --- /dev/null +++ b/src/services/storage/index.ts @@ -0,0 +1,3 @@ +/** Public storage module surface re-exported for application wiring. */ +export { Database } from './database.ts'; +export { MigrationService } from './migrate.ts'; diff --git a/src/services/storage/migrate.ts b/src/services/storage/migrate.ts new file mode 100644 index 0000000..26d15cb --- /dev/null +++ b/src/services/storage/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 { + 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'); + } +} diff --git a/src/services/storage/migrations/001-resources.ts b/src/services/storage/migrations/001-resources.ts new file mode 100644 index 0000000..0e548c9 --- /dev/null +++ b/src/services/storage/migrations/001-resources.ts @@ -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): Promise => { + // 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): Promise => { + await db.schema.dropTable('resource_data').ifExists().execute(); +}; diff --git a/src/services/storage/tables.ts b/src/services/storage/tables.ts new file mode 100644 index 0000000..cedf815 --- /dev/null +++ b/src/services/storage/tables.ts @@ -0,0 +1,29 @@ +import type { ColumnType } from 'kysely'; + +/** Kysely column type for millisecond epoch timestamps stored as integers. */ +export type Timestamp = ColumnType; + +/** Kysely column type for binary blobs accepting Buffer or Uint8Array on insert. */ +export type BlobColumn = ColumnType; + +/** + * 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; +}