Rename src to source

This commit is contained in:
2026-07-27 10:14:56 +00:00
parent c0a8936828
commit c89946cf65
16 changed files with 3 additions and 3 deletions
+127
View File
@@ -0,0 +1,127 @@
import 'dotenv/config';
import { z } from 'zod';
/**
* The configuration schema for the server.
*/
const configSchema = z.object({
/**
* The database configuration.
*/
database: z.object({
path: z.string().default('data.db'),
}),
/**
* The server configuration.
*/
server: z.object({
port: z.coerce.number().int()
.positive()
.default(3000),
host: z.string().default('0.0.0.0'),
/** Maximum encoded HTTP body or WebSocket message size in bytes. */
maxRequestBodyBytes: z.coerce
.number()
.int()
.positive()
.default(1024 * 1024),
cors: z
.object({
origin: z.string().default('*'),
methods: z.array(z.string()).default([ 'GET', 'POST', 'PUT', 'DELETE', 'OPTIONS' ]),
allowedHeaders: z.array(z.string()).default([ 'Content-Type', 'cache-control', 'X-Timestamp', 'X-PublicKey', 'X-Signature' ]),
})
.partial()
.prefault({}),
}),
/**
* The authentication configuration.
*/
auth: z
.object({
timestampWindowMs: z.coerce
.number()
.int()
.positive()
.default(5 * 60 * 1000),
})
.prefault({}),
});
/** Raw configuration object accepted before Zod parsing. */
type ConfigInput = z.input<typeof configSchema>;
/** Fully parsed and defaulted configuration shape. */
type ConfigSchema = z.output<typeof configSchema>;
/**
* Typed, validated server configuration loaded from environment or objects.
*/
export class Config {
/**
* Creates a new Config from the environment variables.
* @returns The Config.
*/
static fromEnv(): Config {
return this.from({
database: {
path: process.env.DATABASE_PATH,
},
server: {
port: process.env.SERVER_PORT,
maxRequestBodyBytes: process.env.SERVER_MAX_REQUEST_BODY_BYTES,
host: process.env.SERVER_HOST,
cors: {
origin: process.env.CORS_ORIGIN,
methods: process.env.CORS_METHODS?.split(','),
allowedHeaders: process.env.CORS_ALLOWED_HEADERS?.split(','),
},
},
auth: {
timestampWindowMs: process.env.AUTH_TIMESTAMP_WINDOW_MS ? Number(process.env.AUTH_TIMESTAMP_WINDOW_MS) : undefined,
},
});
}
/**
* Creates a new Config from a configuration object.
* @param config - The configuration object.
* @returns The Config.
*/
static from(config: ConfigInput): Config {
return new Config(configSchema.parse(config));
}
/**
* Gets the database configuration.
* @returns The database configuration.
*/
public get database(): Readonly<ConfigSchema['database']> {
return this.config.database;
}
/**
* Gets the server configuration.
* @returns The server configuration.
*/
public get server(): Readonly<ConfigSchema['server']> {
return this.config.server;
}
/**
* Gets the authentication configuration.
* @returns The authentication configuration.
*/
public get auth(): Readonly<ConfigSchema['auth']> {
return this.config.auth;
}
/**
* @param config - Parsed configuration produced by the Zod schema.
*/
private constructor(private readonly config: ConfigSchema) {}
}
+75
View File
@@ -0,0 +1,75 @@
import { CompiledQuery, Kysely } from 'kysely';
import { NodeNativeSqliteDialect } from 'kysely-node-native-sqlite';
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 Kysely and NodeNativeSqliteDialect (which uses node:sqlite).
*
* Owns connection setup, pragma configuration, and graceful teardown.
*/
export class Database {
private readonly debug: Logger;
private readonly dialect: NodeNativeSqliteDialect;
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.dialect = new NodeNativeSqliteDialect(options.path);
// Create the Kysely database.
this.kysely = new Kysely<DatabaseTables>({
dialect: this.dialect,
});
// Configure the SQLite pragmas.
this.configurePragmas();
}
/**
* 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.kysely.executeQuery(CompiledQuery.raw('PRAGMA journal_mode = WAL'));
this.kysely.executeQuery(CompiledQuery.raw('PRAGMA foreign_keys = ON'));
}
}
+3
View File
@@ -0,0 +1,3 @@
/** Public storage module surface re-exported for application wiring. */
export { Database } from './database.ts';
export { MigrationService } from './migrate.ts';
+56
View File
@@ -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();
};
+29
View File
@@ -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;
}