Rename src to source
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
import { Config } from './services/config.ts';
|
||||
import { Database, MigrationService } from './services/storage/index.ts';
|
||||
import { ApplicationRouter } from './services/router.ts';
|
||||
import { Logger } from './utils/logger.ts';
|
||||
|
||||
/** Application composition root. */
|
||||
export class App {
|
||||
/**
|
||||
* Construct infrastructure and validate all routes before returning a runnable app.
|
||||
*/
|
||||
static async create(): Promise<App> {
|
||||
const config = Config.fromEnv();
|
||||
const debug = new Logger('sync-server-v2');
|
||||
debug('config loaded: %O', config);
|
||||
|
||||
// Persistence must be ready before routes accept traffic.
|
||||
const database = new Database({ path: config.database.path, debug });
|
||||
const migrations = new MigrationService(database, debug);
|
||||
await migrations.migrateToLatest();
|
||||
|
||||
const routes = [];
|
||||
|
||||
// Route loading is an explicit startup phase, not first-request work.
|
||||
// ApplicationRouter.create validates every path and rejects duplicates
|
||||
// before any client can connect.
|
||||
const router = await ApplicationRouter.create(routes);
|
||||
|
||||
return new App(database, router);
|
||||
}
|
||||
|
||||
private stopPromise: Promise<void> | undefined;
|
||||
|
||||
constructor(
|
||||
private readonly database: Database,
|
||||
private readonly router: ApplicationRouter,
|
||||
) {}
|
||||
|
||||
async start(): Promise<void> {}
|
||||
|
||||
/** Stop transports before releasing the database they may still use. */
|
||||
async stop(): Promise<void> {
|
||||
this.stopPromise ??= this.database.destroy();
|
||||
await this.stopPromise;
|
||||
}
|
||||
}
|
||||
|
||||
const app = await App.create();
|
||||
let shuttingDown = false;
|
||||
|
||||
const shutdown = (signal: NodeJS.Signals): void => {
|
||||
if (shuttingDown) {
|
||||
return;
|
||||
}
|
||||
|
||||
shuttingDown = true;
|
||||
void app.stop().catch((error) => {
|
||||
console.error('Graceful shutdown failed after ' + signal, error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
};
|
||||
|
||||
process.once('SIGINT', (): void => shutdown('SIGINT'));
|
||||
process.once('SIGTERM', (): void => shutdown('SIGTERM'));
|
||||
|
||||
try {
|
||||
await app.start();
|
||||
} catch (error: unknown) {
|
||||
await app.stop();
|
||||
throw error;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/** An expected application failure whose message is safe to send to clients. */
|
||||
export class ApplicationError extends Error {
|
||||
/**
|
||||
* @param statusCode - HTTP-equivalent status returned to the client.
|
||||
* @param message - Client-safe error summary.
|
||||
*/
|
||||
constructor(
|
||||
readonly statusCode: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ApplicationError';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './application-error.ts';
|
||||
export * from './unauthorized-error.ts';
|
||||
export * from './utils.ts';
|
||||
@@ -0,0 +1,10 @@
|
||||
/** Authentication failure whose message is safe to return to clients. */
|
||||
export class UnauthorizedError extends Error {
|
||||
/**
|
||||
* @param message - Client-safe unauthorized summary.
|
||||
*/
|
||||
constructor(message = 'Unauthorized') {
|
||||
super(message);
|
||||
this.name = 'UnauthorizedError';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { UnauthorizedError } from './unauthorized-error.ts';
|
||||
import { ApplicationError } from './application-error.ts';
|
||||
|
||||
/** Stable error payload shared by HTTP, SSE, and WebSocket. */
|
||||
export type PublicError = {
|
||||
|
||||
/** Protocol-independent status carried by every transport. */
|
||||
statusCode: number;
|
||||
|
||||
/** Client-safe summary which never exposes an unexpected exception. */
|
||||
error: string;
|
||||
|
||||
/** Structured field failures supplied only for validation errors. */
|
||||
details?: Array<{ path: string; message: string }>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert application failures into the common public transport contract.
|
||||
*
|
||||
* @param error - Any thrown value from a route or transport boundary.
|
||||
* @returns A sanitized error payload safe to encode on the wire.
|
||||
*/
|
||||
export const normalizePublicError = (error: unknown): PublicError => {
|
||||
if (error instanceof z.ZodError) {
|
||||
return {
|
||||
statusCode: 400,
|
||||
error: 'Validation Error',
|
||||
details: error.issues.map((issue) => ({
|
||||
path: issue.path.join('.'),
|
||||
message: issue.message,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
if (error instanceof UnauthorizedError) {
|
||||
return { statusCode: 401, error: error.message };
|
||||
}
|
||||
|
||||
if (error instanceof ApplicationError) {
|
||||
return { statusCode: error.statusCode, error: error.message };
|
||||
}
|
||||
|
||||
// Unknown exceptions are logged by adapters, but their messages stay private.
|
||||
return { statusCode: 500, error: 'Internal Server Error' };
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { BaseStream } from '../services/stream/base-stream.js';
|
||||
|
||||
export type RouteSendOptions = {
|
||||
/** Defaults to `response`; any other value sends an application event. */
|
||||
type?: string;
|
||||
|
||||
/** Applies to normal responses. Defaults to 200, or 204 for undefined data. */
|
||||
statusCode?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Request-scoped application view over an underlying transport connection.
|
||||
*
|
||||
* The body and request ID are immutable for one dispatch. Subscription state and
|
||||
* connection lifetime are shared with other requests on the same connection.
|
||||
*/
|
||||
export interface RouteStream {
|
||||
/** Connection shared by every request on the same transport session. */
|
||||
readonly connection: BaseStream;
|
||||
|
||||
readonly body: unknown;
|
||||
readonly streaming: boolean;
|
||||
readonly bidirectional: boolean;
|
||||
|
||||
send(data: unknown, options?: RouteSendOptions): Promise<void>;
|
||||
}
|
||||
|
||||
export type RouteHandler = (stream: RouteStream) => void | Promise<void>;
|
||||
|
||||
/** An exact application route with no transport-specific metadata. */
|
||||
export type RouteDefinition = {
|
||||
/** Exact route name. Parameter and wildcard syntax are not supported. */
|
||||
url: string;
|
||||
handler: RouteHandler;
|
||||
};
|
||||
|
||||
/** Supplies and validates routes during application startup. */
|
||||
export interface RouteModule {
|
||||
getRoutes(): Promise<Array<RouteDefinition>>;
|
||||
}
|
||||
@@ -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) {}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { RouteSendOptions, RouteStream } from '../routes/types.ts';
|
||||
import type { BaseStream } from './stream/base-stream.ts';
|
||||
|
||||
/**
|
||||
* Binds one application request to a connection-level stream.
|
||||
*
|
||||
* Request correlation remains immutable even when multiple WebSocket handlers
|
||||
* execute concurrently. The underlying connection remains available to
|
||||
* connection-level services such as the broadcaster.
|
||||
*/
|
||||
export class ApplicationRouteStream implements RouteStream {
|
||||
/**
|
||||
* @param connection - Shared transport stream backing this request.
|
||||
* @param body - Transport-decoded application payload for the route handler.
|
||||
* @param requestId - Optional correlation ID for multiplexed transports.
|
||||
*/
|
||||
constructor(
|
||||
readonly connection: BaseStream,
|
||||
readonly body: unknown,
|
||||
private readonly requestId?: string,
|
||||
) {}
|
||||
|
||||
/** Whether the underlying connection can deliver server-pushed events. */
|
||||
get streaming(): boolean {
|
||||
return this.connection.streaming;
|
||||
}
|
||||
|
||||
/** Whether the underlying connection supports later unsubscribe requests. */
|
||||
get bidirectional(): boolean {
|
||||
return this.connection.bidirectional;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a response or event envelope through the shared connection.
|
||||
*
|
||||
* @param data - Response body or event payload.
|
||||
* @param options - Controls message type and HTTP status for responses.
|
||||
*/
|
||||
async send(data: unknown, options: RouteSendOptions = {}): Promise<void> {
|
||||
const type = options.type ?? 'response';
|
||||
|
||||
// Route responses carry an HTTP status and optional correlation ID.
|
||||
if (type === 'response') {
|
||||
await this.connection.send({
|
||||
...(this.requestId === undefined ? {} : { id: this.requestId }),
|
||||
type,
|
||||
statusCode: options.statusCode ?? (data === undefined ? 204 : 200),
|
||||
body: data ?? null,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Non-response messages are application events with a neutral envelope.
|
||||
await this.connection.send({
|
||||
...(this.requestId === undefined ? {} : { id: this.requestId }),
|
||||
type,
|
||||
data,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { RouteDefinition, RouteModule } from '../routes/types.ts';
|
||||
import { ApplicationError } from '../errors/index.ts';
|
||||
import { ApplicationRouteStream } from './route-stream.ts';
|
||||
import type { BaseStream } from './stream/base-stream.ts';
|
||||
|
||||
/** Canonical request produced by every transport adapter. */
|
||||
export type ApplicationRequest = {
|
||||
/** Exact application route name. */
|
||||
path: string;
|
||||
|
||||
/** Transport-decoded application payload. */
|
||||
body?: unknown;
|
||||
|
||||
/** Optional correlation ID supplied by a multiplexed transport. */
|
||||
requestId?: string;
|
||||
};
|
||||
|
||||
/** Exact-match application routing shared by every wire transport. */
|
||||
export class ApplicationRouter {
|
||||
/** @param routes - Validated route table keyed by exact path. */
|
||||
private constructor(private readonly routes: ReadonlyMap<string, RouteDefinition>) {}
|
||||
|
||||
/**
|
||||
* Load and validate the complete route table before accepting traffic.
|
||||
*
|
||||
* @param routeModules - Route modules whose handlers will be registered.
|
||||
* @returns A ready-to-dispatch router instance.
|
||||
*/
|
||||
static async create(routeModules: RouteModule[]): Promise<ApplicationRouter> {
|
||||
const routes = new Map<string, RouteDefinition>();
|
||||
|
||||
// Collect routes from every module and reject duplicates at startup.
|
||||
for (const routeModule of routeModules) {
|
||||
for (const route of await routeModule.getRoutes()) {
|
||||
ApplicationRouter.assertValidPath(route.url);
|
||||
if (routes.has(route.url)) {
|
||||
throw new Error(`Duplicate application route: ${route.url}`);
|
||||
}
|
||||
|
||||
routes.set(route.url, route);
|
||||
}
|
||||
}
|
||||
|
||||
return new ApplicationRouter(routes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute one route using a request-scoped facade over the connection.
|
||||
*
|
||||
* @param request - Transport-normalized application request.
|
||||
* @param connection - Shared connection stream for this transport session.
|
||||
*/
|
||||
async dispatch(request: ApplicationRequest, connection: BaseStream): Promise<void> {
|
||||
const route = this.routes.get(request.path);
|
||||
if (!route) {
|
||||
throw new ApplicationError(404, `No route found for ${request.path}`);
|
||||
}
|
||||
|
||||
const stream = new ApplicationRouteStream(connection, request.body, request.requestId);
|
||||
await route.handler(stream);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce the deliberately small exact-path routing grammar at startup.
|
||||
*
|
||||
* @param path - Candidate route path to validate.
|
||||
*/
|
||||
private static assertValidPath(path: string): void {
|
||||
if (!path.startsWith('/') || path.length === 1 || path.includes(':') || path.includes('?') || path.includes('#')) {
|
||||
throw new Error(`Invalid application route "${path}": routes must be exact paths beginning with /`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/** A normal request/response result before transport encoding. */
|
||||
export type StreamResponse = {
|
||||
/** Optional correlation ID for multiplexed transports. */
|
||||
id?: string;
|
||||
|
||||
/** Discriminator marking this message as a route response. */
|
||||
type: 'response';
|
||||
|
||||
/** HTTP-equivalent status code for the response body. */
|
||||
statusCode: number;
|
||||
|
||||
/** Serialized response payload. */
|
||||
body: unknown;
|
||||
};
|
||||
|
||||
/** An application event before a transport applies its wire encoding. */
|
||||
export type StreamEvent = {
|
||||
/** Optional event or correlation ID. */
|
||||
id?: string;
|
||||
|
||||
/** Application-defined event type name. */
|
||||
type: string;
|
||||
|
||||
/** Serialized event payload. */
|
||||
data: unknown;
|
||||
};
|
||||
|
||||
/** Union of every message a connection stream can emit. */
|
||||
export type StreamMessage = StreamResponse | StreamEvent;
|
||||
|
||||
/**
|
||||
* Connection-level output channel shared by route requests and the broadcaster.
|
||||
*
|
||||
* A WebSocket connection can back many request-scoped RouteStream instances.
|
||||
* SSE and normal HTTP each create one connection stream per request.
|
||||
*/
|
||||
export abstract class BaseStream {
|
||||
/** Whether this connection can remain open for server events. */
|
||||
abstract readonly streaming: boolean;
|
||||
|
||||
/** Whether later requests can modify this connection's subscriptions. */
|
||||
abstract readonly bidirectional: boolean;
|
||||
|
||||
/**
|
||||
* Send one response or event using the transport's wire encoding.
|
||||
*
|
||||
* @param message - Neutral response or event envelope to encode.
|
||||
*/
|
||||
abstract send(message: StreamMessage): Promise<void>;
|
||||
|
||||
/** Close the underlying connection and notify lifecycle observers. */
|
||||
abstract close(): void;
|
||||
|
||||
/**
|
||||
* Observe closure whether it occurs locally or at the remote peer.
|
||||
*
|
||||
* @param callback - Invoked once when the connection closes.
|
||||
*/
|
||||
abstract onClose(callback: () => void): void;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import Debug, { type Debugger } from 'debug';
|
||||
|
||||
type LogHandler = {
|
||||
(...args: Parameters<Debugger>): void;
|
||||
extend: (namespace: string) => LogHandler;
|
||||
};
|
||||
|
||||
/**
|
||||
* Declares that Logger instances may also be invoked as functions.
|
||||
*/
|
||||
// eslint-disable-next-line
|
||||
export interface Logger {
|
||||
(...args: unknown[]): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logger class, similar to 'debug' library but you can call `instanceof Logger` to check if a value is a Logger instance.
|
||||
*/
|
||||
// eslint-disable-next-line
|
||||
export class Logger {
|
||||
public readonly namespace!: string;
|
||||
|
||||
private readonly handler!: LogHandler;
|
||||
|
||||
public constructor(namespace: string, handler: LogHandler = Debug(namespace)) {
|
||||
/**
|
||||
* I'm going to be honest, this file is somewhat an experiment.
|
||||
* The logger from 'debug' is a fancy function with methods on it.
|
||||
* I wanted to extend that functionality to support 'extend' and also determine whether the object is a Logger instance.
|
||||
* This makes it trivial to perform a type check on the logger, since its no longer just a function. But, I wanted to keep the exact same API
|
||||
* as debug, so this uses gross, disgusting, blasphemous prototype methods to assign a function onto this class prototype.
|
||||
*/
|
||||
|
||||
// Make a function that just calls the 'debug' function with the given arguments
|
||||
const logger = ((...args: Parameters<Debugger>): void => {
|
||||
handler(...args);
|
||||
}) as Logger;
|
||||
|
||||
// Mutate the logger function to inherit from this class.
|
||||
// This allows us to use the 'instanceof' operator to check if the object is a Logger instance.
|
||||
Object.setPrototypeOf(logger, new.target.prototype);
|
||||
|
||||
// Add the namespace and the handler to the 'logger' function we defined above
|
||||
// Basically, we are combining this Class with the 'logger' function that we created above.
|
||||
Object.defineProperties(logger, {
|
||||
namespace: {
|
||||
value: namespace,
|
||||
enumerable: true,
|
||||
},
|
||||
handler: {
|
||||
value: handler,
|
||||
},
|
||||
});
|
||||
|
||||
// Instead of returning the class, we return the 'logger' function we created above.
|
||||
return logger;
|
||||
}
|
||||
|
||||
public extend(childNamespace: string): Logger {
|
||||
return new Logger(`${this.namespace}:${childNamespace}`, this.handler.extend(childNamespace));
|
||||
}
|
||||
|
||||
static isLogger(value: unknown): value is Logger {
|
||||
return value instanceof Logger;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user