Initial Commit
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
/node_modules
|
||||
/data
|
||||
/dist
|
||||
.env
|
||||
1834
package-lock.json
generated
Normal file
1834
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
package.json
Normal file
34
package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "vending-machine",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/node": "^25.9.1",
|
||||
"prettier": "^3.8.3",
|
||||
"tsx": "^4.22.3",
|
||||
"typescript": "^6.0.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@types/debug": "^4.1.13",
|
||||
"@xo-cash/engine": "file:../../engine",
|
||||
"better-sqlite3": "^12.10.0",
|
||||
"debug": "^4.4.3",
|
||||
"fastify": "^5.8.5",
|
||||
"kysely": "^0.29.2",
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
}
|
||||
47
src/index.ts
Normal file
47
src/index.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import Debug from "debug";
|
||||
import { Engine } from "@xo-cash/engine";
|
||||
|
||||
import { Config } from "./services/config.js";
|
||||
|
||||
import { Database } from "./services/database/database.js";
|
||||
|
||||
import { ItemsRoute } from "./routes/items.js";
|
||||
import { OrdersRoute } from "./routes/orders.js";
|
||||
import { HTTPService } from "./services/http-router.js";
|
||||
|
||||
type VendingMachineDeps = {
|
||||
config: Config;
|
||||
httpService: HTTPService;
|
||||
database: Database;
|
||||
engine: Engine;
|
||||
}
|
||||
|
||||
export class VendingMachine {
|
||||
static async from(config: Config) {
|
||||
const debug = Debug("vending-machine");
|
||||
|
||||
const engine = await Engine.create(config.engine.mnemonic, { databasePath: config.engine.database.path });
|
||||
const database = new Database({ config: config.database, debug });
|
||||
|
||||
// Create the routes
|
||||
const routes = [
|
||||
new ItemsRoute({ database: database, engine: engine, debug }),
|
||||
new OrdersRoute({ database: database, engine: engine, debug }),
|
||||
];
|
||||
|
||||
// Create the HTTP service, passing in the routes and config.
|
||||
const httpService = new HTTPService({ routes: [], config: config.server, debug });
|
||||
|
||||
return new VendingMachine({ config, httpService, database, engine });
|
||||
}
|
||||
|
||||
private constructor(private readonly deps: VendingMachineDeps) {}
|
||||
|
||||
public async start() {
|
||||
await this.deps.httpService.start();
|
||||
}
|
||||
}
|
||||
|
||||
VendingMachine.from(Config.fromEnv()).then((vendingMachine) => {
|
||||
vendingMachine.start();
|
||||
});
|
||||
2
src/routes/index.ts
Normal file
2
src/routes/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './items.js';
|
||||
export * from './orders.js';
|
||||
73
src/routes/items.ts
Normal file
73
src/routes/items.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import type { Debugger as Debug } from 'debug';
|
||||
import type { RouteOptions, FastifyRequest, FastifyReply } from 'fastify';
|
||||
import type { Engine } from '@xo-cash/engine'
|
||||
import type { Database } from '../services/database/database.js'
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
export type ItemsRouteDeps = {
|
||||
database: Database;
|
||||
engine: Engine;
|
||||
debug: Debug;
|
||||
}
|
||||
|
||||
export class ItemsRoute {
|
||||
public constructor(private readonly deps: ItemsRouteDeps) {}
|
||||
|
||||
public async getRoutes(): Promise<Array<RouteOptions>> {
|
||||
return [
|
||||
{
|
||||
method: 'GET',
|
||||
url: '/items',
|
||||
handler: this.getItems.bind(this),
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
url: '/items/:id',
|
||||
handler: this.getItem.bind(this),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all items from the database
|
||||
* @param request
|
||||
* @param reply
|
||||
* @returns
|
||||
*/
|
||||
private async getItems(request: FastifyRequest, reply: FastifyReply) {
|
||||
// Get all items from the database.
|
||||
const items = await this.deps.database.db.selectFrom('items').selectAll().execute();
|
||||
|
||||
// Return the items.
|
||||
return reply.send(items);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an item from the database by id
|
||||
* @param request
|
||||
* @param reply
|
||||
* @returns
|
||||
*/
|
||||
private async getItem(request: FastifyRequest, reply: FastifyReply) {
|
||||
// Parse the request parameters.
|
||||
const { id } = ItemsRoute.getItemSchema.parse(request.params);
|
||||
|
||||
// Get the item from the database.
|
||||
const item = await this.deps.database.db.selectFrom('items').where('id', '=', id).selectAll().executeTakeFirst();
|
||||
|
||||
// If the item is not found, return a 404 error.
|
||||
if (!item) {
|
||||
return reply.status(404).send({
|
||||
error: 'Item not found'
|
||||
});
|
||||
}
|
||||
|
||||
// Return the item.
|
||||
return reply.send(item);
|
||||
}
|
||||
|
||||
static getItemSchema = z.object({
|
||||
id: z.string(),
|
||||
});
|
||||
}
|
||||
88
src/routes/orders.ts
Normal file
88
src/routes/orders.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import type { Debugger as Debug } from 'debug';
|
||||
import type { RouteOptions, FastifyRequest, FastifyReply } from 'fastify';
|
||||
import type { Engine } from '@xo-cash/engine'
|
||||
import type { Database } from '../services/database/database.js'
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
export type OrdersRouteDeps = {
|
||||
database: Database;
|
||||
engine: Engine
|
||||
debug: Debug;
|
||||
}
|
||||
|
||||
export class OrdersRoute {
|
||||
public constructor(private readonly deps: OrdersRouteDeps) {}
|
||||
|
||||
public async getRoutes(): Promise<Array<RouteOptions>> {
|
||||
return [
|
||||
{
|
||||
method: 'GET',
|
||||
url: '/orders',
|
||||
handler: this.getOrders.bind(this),
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
url: '/orders',
|
||||
handler: this.createOrder.bind(this),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
private async getOrders(request: FastifyRequest, reply: FastifyReply) {
|
||||
// Get all orders from the database.
|
||||
const orders = await this.deps.database.db.selectFrom('orders').selectAll().execute();
|
||||
|
||||
// Return the orders.
|
||||
return reply.send(orders);
|
||||
}
|
||||
|
||||
private async createOrder(request: FastifyRequest, reply: FastifyReply) {
|
||||
// Parse the request body.
|
||||
const { items: itemsInput } = OrdersRoute.createOrderSchema.parse(request.body);
|
||||
|
||||
// Get the items from the database.
|
||||
const items = await this.deps.database.db.selectFrom('items').where('id', 'in', itemsInput.map((item) => item.id)).selectAll().execute();
|
||||
|
||||
// If the items are not found, return a 404 error.
|
||||
if (items.length !== items.length) {
|
||||
return reply.status(404).send({
|
||||
error: 'Items not found'
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: Create an XO Engine Invitation with the relavent data in it so we can pass it back to the client.
|
||||
|
||||
// Create the order in the database.
|
||||
const order = await this.deps.database.db.insertInto('orders').values({
|
||||
// user_id: request.user.id,
|
||||
status: 'pending',
|
||||
total_price: 0,
|
||||
total_quantity: 0,
|
||||
items: JSON.stringify(items.map((item) => ({
|
||||
id: item.id,
|
||||
quantity: item.quantity,
|
||||
}))),
|
||||
}).execute();
|
||||
|
||||
// If the order is not created, return a 500 error.
|
||||
if (!order) {
|
||||
return reply.status(500).send({
|
||||
error: 'Failed to create order'
|
||||
});
|
||||
}
|
||||
|
||||
// Return the order.
|
||||
return reply.send(order);
|
||||
}
|
||||
|
||||
/**
|
||||
* Schema for creating an order.
|
||||
*/
|
||||
static createOrderSchema = z.object({
|
||||
items: z.array(z.object({
|
||||
id: z.string(),
|
||||
quantity: z.number(),
|
||||
})),
|
||||
});
|
||||
}
|
||||
82
src/services/config.ts
Normal file
82
src/services/config.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const configSchema = z.object({
|
||||
engine: z.object({
|
||||
mnemonic: z.string(),
|
||||
database: z.object({
|
||||
path: z.string().default("data/engine"),
|
||||
}),
|
||||
}),
|
||||
syncServer: z.object({
|
||||
url: z.string().default("http://localhost:3000"),
|
||||
}),
|
||||
database: z.object({
|
||||
path: z.string().default("data.db"),
|
||||
}),
|
||||
server: z.object({
|
||||
port: z.number().default(3000),
|
||||
host: z.string().default("0.0.0.0"),
|
||||
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", "Authorization"]),
|
||||
})
|
||||
.partial()
|
||||
.prefault({}),
|
||||
}),
|
||||
});
|
||||
|
||||
type ConfigInput = z.input<typeof configSchema>;
|
||||
type ConfigSchema = z.output<typeof configSchema>;
|
||||
|
||||
/**
|
||||
* Converts an object's keys to camelCase.
|
||||
* @param obj - The object to convert to camelCase.
|
||||
* @returns The camelCase object.
|
||||
*/
|
||||
const toCamelCaseObject = (obj: Record<string, string>): Record<string, string> => {
|
||||
return Object.fromEntries(Object.entries(obj).map(([key, value]) => {
|
||||
const camelCaseKey = key.toLowerCase().replace(/([-_][a-z])/g, (group) => group.toUpperCase().replace("-", "").replace("_", ""));
|
||||
return [camelCaseKey, value];
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* The Config class is used to load and parse the configuration for the vending machine.
|
||||
*/
|
||||
export class Config {
|
||||
static fromEnv(): Config {
|
||||
// Parse through process.env, and convert the upperCase keys to camelCase.
|
||||
const envConfig = toCamelCaseObject(Object(process.env));
|
||||
|
||||
// Parse the environment config.
|
||||
return this.from(configSchema.parse(envConfig));
|
||||
}
|
||||
|
||||
static from(config: ConfigInput): Config {
|
||||
return new Config(configSchema.parse(config));
|
||||
}
|
||||
|
||||
public get syncServer() {
|
||||
return this.config.syncServer;
|
||||
}
|
||||
|
||||
public get engine() {
|
||||
return this.config.engine;
|
||||
}
|
||||
|
||||
public get database() {
|
||||
return this.config.database;
|
||||
}
|
||||
|
||||
public get server() {
|
||||
return this.config.server;
|
||||
}
|
||||
|
||||
private constructor(private readonly config: ConfigSchema) {}
|
||||
}
|
||||
84
src/services/database/database.ts
Normal file
84
src/services/database/database.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { type Debugger } from "debug";
|
||||
|
||||
import DatabaseConstructor from "better-sqlite3";
|
||||
import { Kysely, SqliteDialect } from "kysely";
|
||||
import type { Database as DatabaseTables } from "./tables.js";
|
||||
|
||||
import { z } from "zod";
|
||||
import { debuggerSchema } from "../../types.js";
|
||||
|
||||
export const databaseOptionsSchema = z.object({
|
||||
config: z.object({
|
||||
path: z.string(),
|
||||
}),
|
||||
|
||||
debug: debuggerSchema,
|
||||
});
|
||||
|
||||
type DatabaseOptionsInput = z.input<typeof databaseOptionsSchema>;
|
||||
|
||||
/**
|
||||
* Database service that owns SQLite + Kysely connections.
|
||||
*
|
||||
* @remarks
|
||||
* This service is intentionally connection-only:
|
||||
* it manages lifecycle and exposes a typed Kysely client.
|
||||
*/
|
||||
export class Database {
|
||||
// Debugger instance used for logging.
|
||||
private readonly debug: Debugger;
|
||||
|
||||
// SQLite connection instance.
|
||||
private readonly sqlite: DatabaseConstructor.Database;
|
||||
|
||||
// Kysely database client instance.
|
||||
private readonly kysely: Kysely<DatabaseTables>;
|
||||
|
||||
public constructor(options: DatabaseOptionsInput) {
|
||||
// Parse the options with the zod schema.
|
||||
const { config, debug } = databaseOptionsSchema.parse(options);
|
||||
|
||||
// Extend the debug instance.
|
||||
this.debug = debug.extend("database");
|
||||
|
||||
// Create the SQLite database instance.
|
||||
this.sqlite = new DatabaseConstructor(config.path);
|
||||
|
||||
// Configure the SQLite database.
|
||||
this.configurePragmas();
|
||||
|
||||
// Create the Kysely database client.
|
||||
this.kysely = new Kysely<DatabaseTables>({ dialect: new SqliteDialect({ database: this.sqlite }) });
|
||||
}
|
||||
|
||||
/**
|
||||
* Accessor for the typed Kysely database client.
|
||||
*/
|
||||
public get db(): Kysely<DatabaseTables> {
|
||||
return this.kysely;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gracefully closes all storage resources.
|
||||
*/
|
||||
public async destroy(): Promise<void> {
|
||||
this.debug("Destroying storage resources");
|
||||
await this.kysely.destroy();
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies required SQLite pragmas for correctness and performance.
|
||||
*/
|
||||
private configurePragmas(): void {
|
||||
this.debug("Configuring SQLite pragmas");
|
||||
/**
|
||||
* WAL improves concurrent read behavior, useful for streaming-heavy APIs.
|
||||
*/
|
||||
this.sqlite.pragma("journal_mode = WAL");
|
||||
|
||||
/**
|
||||
* Foreign keys are disabled by default in SQLite; enable explicitly.
|
||||
*/
|
||||
this.sqlite.pragma("foreign_keys = ON");
|
||||
}
|
||||
}
|
||||
50
src/services/database/migrate.ts
Normal file
50
src/services/database/migrate.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
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.js";
|
||||
|
||||
/**
|
||||
* Handles migration execution for the storage layer.
|
||||
*/
|
||||
export class MigrationService {
|
||||
private readonly migrator: Migrator;
|
||||
|
||||
public constructor(database: Database) {
|
||||
const currentFilePath = fileURLToPath(import.meta.url);
|
||||
const currentDirectory = path.dirname(currentFilePath);
|
||||
const migrationsPath = path.join(currentDirectory, "migrations");
|
||||
|
||||
this.migrator = new Migrator({
|
||||
db: database.db,
|
||||
provider: new FileMigrationProvider({
|
||||
fs,
|
||||
path,
|
||||
migrationFolder: migrationsPath,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs all pending migrations.
|
||||
*
|
||||
* @throws Error when one or more migrations fail.
|
||||
*/
|
||||
public async migrateToLatest(): Promise<void> {
|
||||
const { error, results } = await this.migrator.migrateToLatest();
|
||||
|
||||
for (const result of results ?? []) {
|
||||
if (result.status === "Success") {
|
||||
console.info(`[migration] Applied: ${result.migrationName}`);
|
||||
} else if (result.status === "Error") {
|
||||
console.error(`[migration] Failed: ${result.migrationName}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
153
src/services/database/migrations/001-initial-schema.ts
Normal file
153
src/services/database/migrations/001-initial-schema.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { Kysely, sql } from "kysely";
|
||||
|
||||
import type { Database } from "../tables.js";
|
||||
|
||||
/**
|
||||
* UUID v4 default for primary key columns.
|
||||
*
|
||||
* @remarks
|
||||
* SQLite has no native UUID type, so we generate v4 strings in SQL.
|
||||
*/
|
||||
const uuid4Default = sql`(lower(hex(randomblob(4))) || '-' || lower(hex(randomblob(2))) || '-4' || substr(lower(hex(randomblob(2))),2) || '-' || substr('89ab',abs(random()) % 4 + 1,1) || substr(lower(hex(randomblob(2))),2) || '-' || lower(hex(randomblob(6))))`;
|
||||
|
||||
/**
|
||||
* Millisecond timestamp for created_at/updated_at column defaults.
|
||||
*
|
||||
* @remarks
|
||||
* unixepoch('subsec') returns seconds with a fractional part; multiplying by
|
||||
* 1000 and casting to INTEGER gives a millisecond epoch value.
|
||||
*/
|
||||
const millisecondTime = sql`(CAST(unixepoch('subsec') * 1000 AS INTEGER))`;
|
||||
|
||||
/**
|
||||
* Same expression as {@link millisecondTime}, but as a raw SQL string.
|
||||
*
|
||||
* @remarks
|
||||
* SQLite triggers cannot use Kysely's `sql` tagged templates directly, so we
|
||||
* keep a plain string copy for trigger bodies.
|
||||
*/
|
||||
const millisecondTimeRaw = `(CAST(unixepoch('subsec') * 1000 AS INTEGER))`;
|
||||
|
||||
/**
|
||||
* Tables that receive an automatic updated_at trigger on row modification.
|
||||
*/
|
||||
const UPDATED_AT_TRIGGER_TABLES = ["users", "items", "orders"] as const;
|
||||
|
||||
/**
|
||||
* Initial schema for the vending machine.
|
||||
*
|
||||
* @remarks
|
||||
* Creates users, catalog items, and orders. Orders store a JSON snapshot of
|
||||
* line items in the `items` column rather than a normalized join table.
|
||||
*/
|
||||
export async function up(db: Kysely<Database>): Promise<void> {
|
||||
// -------------------------------------------------------------------------
|
||||
// Users
|
||||
// -------------------------------------------------------------------------
|
||||
// Authentication credentials for vending machine operators / customers.
|
||||
await db.schema
|
||||
.createTable("users")
|
||||
.addColumn("id", "text", (col) => col.primaryKey().defaultTo(uuid4Default))
|
||||
.addColumn("username", "text", (col) => col.notNull().unique())
|
||||
.addColumn("password", "text", (col) => col.notNull())
|
||||
.addColumn("salt", "text", (col) => col.notNull())
|
||||
.addColumn("created_at", "integer", (col) => col.notNull().defaultTo(millisecondTime))
|
||||
.addColumn("updated_at", "integer", (col) => col.notNull().defaultTo(millisecondTime))
|
||||
.execute();
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Items
|
||||
// -------------------------------------------------------------------------
|
||||
// Vending machine catalog entries available for purchase.
|
||||
await db.schema
|
||||
.createTable("items")
|
||||
.addColumn("id", "text", (col) => col.primaryKey().defaultTo(uuid4Default))
|
||||
.addColumn("name", "text", (col) => col.notNull())
|
||||
.addColumn("description", "text", (col) => col.notNull().defaultTo(""))
|
||||
// Price stored as an integer in the smallest currency unit (e.g. sats).
|
||||
.addColumn("price", "integer", (col) => col.notNull())
|
||||
// Current stock level for this slot/product.
|
||||
.addColumn("quantity", "integer", (col) => col.notNull().defaultTo(0))
|
||||
// URL or path to the product image shown in the UI.
|
||||
.addColumn("image", "text", (col) => col.notNull().defaultTo(""))
|
||||
.addColumn("created_at", "integer", (col) => col.notNull().defaultTo(millisecondTime))
|
||||
.addColumn("updated_at", "integer", (col) => col.notNull().defaultTo(millisecondTime))
|
||||
.addCheckConstraint("items_price_check", sql`price >= 0`)
|
||||
.addCheckConstraint("items_quantity_check", sql`quantity >= 0`)
|
||||
.execute();
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Orders
|
||||
// -------------------------------------------------------------------------
|
||||
// A purchase attempt. Line items are denormalized into JSON at creation time.
|
||||
await db.schema
|
||||
.createTable("orders")
|
||||
.addColumn("id", "text", (col) => col.primaryKey().defaultTo(uuid4Default))
|
||||
.addColumn("status", "text", (col) => col.notNull().defaultTo("pending"))
|
||||
.addColumn("total_price", "integer", (col) => col.notNull().defaultTo(0))
|
||||
.addColumn("total_quantity", "integer", (col) => col.notNull().defaultTo(0))
|
||||
// JSON array of { id, quantity } objects; serialized by application code.
|
||||
.addColumn("items", "text", (col) => col.notNull().defaultTo("[]"))
|
||||
.addColumn("created_at", "integer", (col) => col.notNull().defaultTo(millisecondTime))
|
||||
.addColumn("updated_at", "integer", (col) => col.notNull().defaultTo(millisecondTime))
|
||||
.addCheckConstraint(
|
||||
"orders_status_check",
|
||||
sql`status in ('pending', 'paid', 'completed', 'cancelled')`,
|
||||
)
|
||||
.addCheckConstraint("orders_total_price_check", sql`total_price >= 0`)
|
||||
.addCheckConstraint("orders_total_quantity_check", sql`total_quantity >= 0`)
|
||||
.execute();
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Indexes
|
||||
// -------------------------------------------------------------------------
|
||||
// Look up catalog entries by display name.
|
||||
await db.schema.createIndex("idx_items_name").on("items").column("name").execute();
|
||||
|
||||
// Filter orders by lifecycle state (e.g. pending payments).
|
||||
await db.schema
|
||||
.createIndex("idx_orders_status")
|
||||
.on("orders")
|
||||
.column("status")
|
||||
.execute();
|
||||
|
||||
// List recent orders chronologically.
|
||||
await db.schema
|
||||
.createIndex("idx_orders_created_at")
|
||||
.on("orders")
|
||||
.columns(["created_at"])
|
||||
.execute();
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Updated-at triggers
|
||||
// -------------------------------------------------------------------------
|
||||
// Keep updated_at in sync without requiring every query to set it explicitly.
|
||||
for (const tableName of UPDATED_AT_TRIGGER_TABLES) {
|
||||
await sql
|
||||
.raw(`
|
||||
CREATE TRIGGER trg_${tableName}_updated_at
|
||||
AFTER UPDATE ON ${tableName}
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
UPDATE ${tableName}
|
||||
SET updated_at = ${millisecondTimeRaw}
|
||||
WHERE id = NEW.id;
|
||||
END;
|
||||
`)
|
||||
.execute(db);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops the full schema in reverse dependency order.
|
||||
*/
|
||||
export async function down(db: Kysely<Database>): Promise<void> {
|
||||
// Remove triggers before dropping the tables they reference.
|
||||
for (const tableName of UPDATED_AT_TRIGGER_TABLES) {
|
||||
await sql.raw(`DROP TRIGGER IF EXISTS trg_${tableName}_updated_at`).execute(db);
|
||||
}
|
||||
|
||||
await db.schema.dropTable("orders").ifExists().execute();
|
||||
await db.schema.dropTable("items").ifExists().execute();
|
||||
await db.schema.dropTable("users").ifExists().execute();
|
||||
}
|
||||
71
src/services/database/tables.ts
Normal file
71
src/services/database/tables.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { ColumnType, Generated } from "kysely";
|
||||
|
||||
/**
|
||||
* SQLite timestamp column represented as INTEGER.
|
||||
*
|
||||
* @remarks
|
||||
* The application stores timestamps as numbers and may provide explicit
|
||||
* values or rely on database defaults.
|
||||
*/
|
||||
export type Timestamp = ColumnType<number, number | undefined, number | undefined>;
|
||||
|
||||
/**
|
||||
* SQLite JSON column represented as TEXT.
|
||||
*
|
||||
* @remarks
|
||||
* Serialize values with JSON.stringify and parse with JSON.parse in app code.
|
||||
*/
|
||||
export type JsonText = ColumnType<string, string | undefined, string | undefined>;
|
||||
|
||||
/**
|
||||
* SQLite boolean emulation represented as INTEGER (0/1).
|
||||
*/
|
||||
export type SqliteBoolean = ColumnType<number, number | boolean | undefined, number | boolean | undefined>;
|
||||
|
||||
/**
|
||||
* Users table.
|
||||
*/
|
||||
export interface UsersTable {
|
||||
id: Generated<string>;
|
||||
username: string;
|
||||
password: string;
|
||||
salt: string;
|
||||
created_at: Generated<Timestamp>;
|
||||
updated_at: Generated<Timestamp>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Items table.
|
||||
*/
|
||||
export interface ItemsTable {
|
||||
id: Generated<string>;
|
||||
name: string;
|
||||
description: string;
|
||||
price: number;
|
||||
quantity: number;
|
||||
image: string;
|
||||
created_at: Generated<Timestamp>;
|
||||
updated_at: Generated<Timestamp>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Orders table.
|
||||
*/
|
||||
export interface OrdersTable {
|
||||
id: Generated<string>;
|
||||
status: string;
|
||||
total_price: number;
|
||||
total_quantity: number;
|
||||
items: JsonText;
|
||||
created_at: Generated<Timestamp>;
|
||||
updated_at: Generated<Timestamp>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kysely database schema.
|
||||
*/
|
||||
export interface Database {
|
||||
users: UsersTable;
|
||||
items: ItemsTable;
|
||||
orders: OrdersTable;
|
||||
}
|
||||
153
src/services/http-router.ts
Normal file
153
src/services/http-router.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import Debug from "debug";
|
||||
import { z } from "zod";
|
||||
|
||||
import fastify, { type FastifyInstance, type RouteOptions } from "fastify";
|
||||
import cors from "@fastify/cors";
|
||||
|
||||
import { debuggerSchema } from "../types.js";
|
||||
|
||||
// Interface to add to our route classes so that we can register them.
|
||||
// NOTE: I hate this pattern. But ExpressJS is odd in that it is structured as a singleton that still needs registration.
|
||||
export interface APIRoutes {
|
||||
getRoutes(): Promise<Array<RouteOptions>>;
|
||||
}
|
||||
|
||||
// Zod schema for the API routes.
|
||||
export const apiRoutesSchema = z.array(z.custom<APIRoutes>());
|
||||
|
||||
// Zod schema for the server config.
|
||||
export const serverConfigSchema = z.object({
|
||||
port: z.number().default(3000),
|
||||
host: z.string().default("0.0.0.0"),
|
||||
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", "Authorization"]),
|
||||
}).prefault({}),
|
||||
});
|
||||
|
||||
// Zod schema for the server debug instance.
|
||||
export const serverDebugSchema = debuggerSchema.optional().default(Debug("vending-machine"));
|
||||
|
||||
// Zod schema for the HTTP service options.
|
||||
export const HTTPOptions = z.object({
|
||||
routes: apiRoutesSchema,
|
||||
config: serverConfigSchema,
|
||||
debug: serverDebugSchema,
|
||||
});
|
||||
|
||||
// Types.
|
||||
type HTTPServiceOptionsInput = z.input<typeof HTTPOptions>;
|
||||
type HTTPServiceOptions = z.output<typeof HTTPOptions>;
|
||||
|
||||
export class HTTPService {
|
||||
static schema() {
|
||||
return z.object({
|
||||
routes: apiRoutesSchema,
|
||||
config: serverConfigSchema,
|
||||
debug: serverDebugSchema,
|
||||
});
|
||||
}
|
||||
|
||||
// Public properties.
|
||||
private server: FastifyInstance;
|
||||
|
||||
// Private properties.
|
||||
private debug: debug.Debugger;
|
||||
private config: HTTPServiceOptions["config"];
|
||||
private routes: HTTPServiceOptions["routes"];
|
||||
|
||||
constructor(options: HTTPServiceOptionsInput) {
|
||||
const { routes, config, debug } = HTTPOptions.parse(options);
|
||||
|
||||
// Extend the debug instance.
|
||||
this.debug = debug.extend("http-router");
|
||||
|
||||
// Set the config.
|
||||
this.config = config;
|
||||
|
||||
// Set the routes.
|
||||
this.routes = routes;
|
||||
|
||||
// Create the server.
|
||||
this.server = fastify({
|
||||
logger: false,
|
||||
});
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
// Debug the server starting.
|
||||
this.debug(
|
||||
`Starting HTTP server on http://${this.config.host}:${this.config.port}`,
|
||||
);
|
||||
|
||||
// Setup Error Handling (to give more verbose Zod errors)
|
||||
this.handleErrors();
|
||||
|
||||
// Allow CORS requests. This allows requests from any origin/domain.
|
||||
// TODO: Set this to a meaningful value. For now, we allow all origins since we dont know what the origin will bes.
|
||||
await this.server.register(cors);
|
||||
|
||||
// Register your routes here before starting the server
|
||||
this.server.get("/health", async () => {
|
||||
return { status: "ok" };
|
||||
});
|
||||
|
||||
// Register each route.
|
||||
for (const routes of this.routes) {
|
||||
for (const routeOptions of await routes.getRoutes()) {
|
||||
this.server.route(routeOptions);
|
||||
}
|
||||
}
|
||||
|
||||
// Ready the server.
|
||||
await this.server.ready();
|
||||
|
||||
// Listen on the configured port and host.
|
||||
await this.server.listen({
|
||||
port: this.config.port,
|
||||
host: this.config.host,
|
||||
});
|
||||
|
||||
// Debug the server started.
|
||||
this.debug(
|
||||
`Started HTTP server on http://${this.config.host}:${this.config.port}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Helper method to access the server instance
|
||||
getInstance(): FastifyInstance {
|
||||
return this.server;
|
||||
}
|
||||
|
||||
private handleErrors() {
|
||||
// Customize our error handler to give better errors.
|
||||
// NOTE: This will nicely format the Zod validation errors.
|
||||
this.server.setErrorHandler((error, _request, reply) => {
|
||||
// If the error is a Zod error, format the errors and send them to the client.
|
||||
if (error instanceof z.ZodError) {
|
||||
// Format the errors.
|
||||
const formattedErrors = error.issues.map((issue) => ({
|
||||
path: issue.path.join("."),
|
||||
message: issue.message,
|
||||
}));
|
||||
|
||||
// Debug the validation error.
|
||||
this.debug(`Validation Error: ${error}`);
|
||||
|
||||
// Send the validation error to the client.
|
||||
return reply.status(400).send({
|
||||
statusCode: 400,
|
||||
error: "Validation Error",
|
||||
details: formattedErrors,
|
||||
});
|
||||
}
|
||||
|
||||
// Debug the internal server error.
|
||||
this.debug(`Internal Server Error: ${error}`);
|
||||
|
||||
// Handle other types of errors
|
||||
reply.status(500).send({ error: "Internal Server Error" });
|
||||
});
|
||||
}
|
||||
}
|
||||
270
src/services/sse-broadcaster.ts
Normal file
270
src/services/sse-broadcaster.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
import type { FastifyReply, FastifyRequest } from "fastify";
|
||||
|
||||
import debug, { type Debugger } from "debug";
|
||||
|
||||
/**
|
||||
* Represents an event stored in the history buffer.
|
||||
* Used for replaying missed events to reconnecting clients.
|
||||
*/
|
||||
interface HistoricalEvent {
|
||||
/** The event topic/type (e.g., 'invitation-created', 'invitation-updated') */
|
||||
topic: string;
|
||||
/** The event payload data */
|
||||
data: unknown;
|
||||
/** Unix timestamp in milliseconds when the event was created */
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for configuring the SSE service.
|
||||
*/
|
||||
interface SSEOptions {
|
||||
/** Maximum age of events to keep in history (in milliseconds). Default: 5 minutes */
|
||||
maxHistoryAge?: number;
|
||||
/** Maximum number of events to keep per user. Default: 1000 */
|
||||
maxHistorySize?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-Sent Events broadcaster with event history support.
|
||||
*
|
||||
* Maintains a per-user event history buffer that allows clients to replay
|
||||
* missed events when reconnecting. This makes reconnections robust against
|
||||
* network interruptions.
|
||||
*/
|
||||
export class SSEBroadcaster {
|
||||
/**
|
||||
* Factory method to create and start an SSE broadcaster.
|
||||
* @param options - Configuration options for the SSE service
|
||||
* @returns A started SSE instance
|
||||
*/
|
||||
static async from(options?: SSEOptions) {
|
||||
const broadcaster = new SSEBroadcaster(options);
|
||||
await broadcaster.start();
|
||||
return broadcaster;
|
||||
}
|
||||
|
||||
/** Map of Invitation IDs to their connected SSE response streams */
|
||||
private clients: Map<(string), Set<FastifyReply>> = new Map();
|
||||
|
||||
/** Map of Invitation IDs to their event history buffers */
|
||||
private eventHistory: Map<string, HistoricalEvent[]> = new Map();
|
||||
|
||||
/** Maximum age of events to keep in history (in milliseconds) */
|
||||
private maxHistoryAge: number;
|
||||
|
||||
/** Maximum number of events to keep per user */
|
||||
private maxHistorySize: number;
|
||||
|
||||
private debug: Debugger;
|
||||
|
||||
constructor(options?: SSEOptions) {
|
||||
this.clients = new Map();
|
||||
this.eventHistory = new Map();
|
||||
this.maxHistoryAge = options?.maxHistoryAge ?? 20 * 60 * 1000; // 20 minutes default
|
||||
this.maxHistorySize = options?.maxHistorySize ?? 1000; // 1000 events default
|
||||
this.debug = debug('xo:sse');
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the SSE broadcaster.
|
||||
* @returns The SSE instance for chaining
|
||||
*/
|
||||
start() {
|
||||
this.debug('SSE broadcaster is running (maxHistoryAge: %dms, maxHistorySize: %d)',
|
||||
this.maxHistoryAge, this.maxHistorySize);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an event to a client.
|
||||
*
|
||||
* @param client - The client to send the event to
|
||||
* @param topic - The event topic/type
|
||||
* @param data - The event payload data
|
||||
*/
|
||||
static sendEvent(client: FastifyReply, topic: string, data: unknown) {
|
||||
const timestamp = Date.now();
|
||||
client.raw.write(`id: ${timestamp}\n`);
|
||||
client.raw.write(`event: ${topic}\n`);
|
||||
client.raw.write(`data: ${JSON.stringify(data)}\n\n`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an event to a client.
|
||||
*
|
||||
* @param client - The client to send the event to
|
||||
* @param topic - The event topic/type
|
||||
* @param data - The event payload data
|
||||
*/
|
||||
sendEvent(client: FastifyReply, topic: string, data: unknown) {
|
||||
try {
|
||||
SSEBroadcaster.sendEvent(client, topic, data);
|
||||
} catch (error) {
|
||||
this.debug('Error sending event to client', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcasts an event to all connected clients for a user and stores it in history.
|
||||
*
|
||||
* @param clientId - The user ID to broadcast to
|
||||
* @param topic - The event topic/type
|
||||
* @param data - The event payload data
|
||||
*/
|
||||
async broadcast(clientId: string, topic: string, data: unknown) {
|
||||
const timestamp = Date.now();
|
||||
|
||||
// Store the event in history for potential replay
|
||||
this.storeEvent(clientId, topic, data, timestamp);
|
||||
|
||||
// Broadcast to all connected clients
|
||||
this.clients.get(clientId)?.forEach((client: FastifyReply) => {
|
||||
try {
|
||||
this.sendEvent(client, topic, data);
|
||||
} catch (error) {
|
||||
this.debug('Error sending event to client', error);
|
||||
}
|
||||
});
|
||||
|
||||
this.debug('SSE broadcasted message', topic, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes a client to receive SSE events.
|
||||
*
|
||||
* If lastEventTime is provided, all events that occurred after that timestamp
|
||||
* will be replayed to the client before starting the live stream.
|
||||
*
|
||||
* @param req - The authenticated request containing the user ID
|
||||
* @param res - The Express response object to use for SSE streaming
|
||||
* @param lastEventTime - Optional timestamp to replay events from (in milliseconds)
|
||||
*/
|
||||
async subscribe(req: FastifyRequest, res: FastifyReply, lastEventTime?: number) {
|
||||
// Get the invitation ID from the request
|
||||
const { invitationIdentifier } = req.query as { invitationIdentifier?: string };
|
||||
if (!invitationIdentifier) {
|
||||
throw new Error('Invitation Identifier is required');
|
||||
}
|
||||
|
||||
// Initialize client set for this user if needed
|
||||
if (!this.clients.has(invitationIdentifier)) {
|
||||
this.clients.set(invitationIdentifier, new Set());
|
||||
}
|
||||
|
||||
// Manually include the CORS header since `writeHead` bypasses the auto-injection by the @fastify/cors plugin.
|
||||
// This statement grabs the CORS header that the CORS plugin would have added to the response, configured in the HTTP service.
|
||||
const corsHeader = res.getHeader("access-control-allow-origin");
|
||||
|
||||
// Disable timeout for the connection. Without this, the connection will drop and the client will have to reconnect.
|
||||
req.raw.socket.setTimeout(0);
|
||||
|
||||
// Set up SSE headers
|
||||
// Set headers for SSE
|
||||
res.raw.writeHead(200, {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
"Access-Control-Allow-Origin": corsHeader,
|
||||
});
|
||||
|
||||
// Force the headers to be dispatched to the client.
|
||||
// NOTE: This is very important: A `fetch` call will NOT resolve until it has received the headers.
|
||||
// And Fastify, unless otherwise specified, will not send the headers until it sends the body.
|
||||
res.raw.flushHeaders();
|
||||
|
||||
// Set retry interval for automatic reconnection
|
||||
res.raw.write('retry: 3000\n\n');
|
||||
|
||||
// Replay missed events if a lastEventTime was provided
|
||||
if (lastEventTime !== undefined) {
|
||||
const missedEvents = this.getEventsAfter(invitationIdentifier, lastEventTime);
|
||||
this.debug('SSE replaying %d missed events for invitation %s (since %d)',
|
||||
missedEvents.length, invitationIdentifier, lastEventTime);
|
||||
|
||||
for (const event of missedEvents) {
|
||||
try {
|
||||
await this.sendEvent(res, event.topic, event.data);
|
||||
} catch (error) {
|
||||
this.debug('Error sending event to client', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add client to the set for live updates
|
||||
this.clients.get(invitationIdentifier)?.add(res);
|
||||
|
||||
this.debug('SSE subscribed to client (invitationId: %s, lastEventTime: %s)',
|
||||
invitationIdentifier, lastEventTime ?? 'none');
|
||||
|
||||
// Clean up when client disconnects
|
||||
res.raw.on('close', () => {
|
||||
this.clients.get(invitationIdentifier)?.delete(res);
|
||||
this.debug('SSE client disconnected (invitationIdentifier: %s)', invitationIdentifier);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores an event in the user's history buffer.
|
||||
* Automatically prunes old events based on age and size limits.
|
||||
*
|
||||
* @param invitationId - The invitation ID to store the event for
|
||||
* @param topic - The event topic/type
|
||||
* @param data - The event payload data
|
||||
* @param timestamp - The event timestamp
|
||||
*/
|
||||
private storeEvent(invitationId: string, topic: string, data: unknown, timestamp: number) {
|
||||
// Initialize history array for this user if needed
|
||||
if (!this.eventHistory.has(invitationId)) {
|
||||
this.eventHistory.set(invitationId, []);
|
||||
}
|
||||
|
||||
const history = this.eventHistory.get(invitationId)!;
|
||||
|
||||
// Add the new event
|
||||
history.push({ topic, data, timestamp });
|
||||
|
||||
// Prune old events
|
||||
this.pruneHistory(invitationId, timestamp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes old events from a invitation's history based on age and size limits.
|
||||
*
|
||||
* @param invitationId - The invitation ID whose history to prune
|
||||
* @param currentTime - The current timestamp for age calculations
|
||||
*/
|
||||
private pruneHistory(invitationId: string, currentTime: number) {
|
||||
const history = this.eventHistory.get(invitationId);
|
||||
if (!history) return;
|
||||
|
||||
const cutoffTime = currentTime - this.maxHistoryAge;
|
||||
|
||||
// Remove events older than maxHistoryAge
|
||||
const prunedByAge = history.filter(event => event.timestamp > cutoffTime);
|
||||
|
||||
// If still over size limit, remove oldest events
|
||||
const prunedBySize = prunedByAge.length > this.maxHistorySize
|
||||
? prunedByAge.slice(-this.maxHistorySize)
|
||||
: prunedByAge;
|
||||
|
||||
this.eventHistory.set(invitationId, prunedBySize);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves all events for a user that occurred after a given timestamp.
|
||||
*
|
||||
* @param invitationId - The invitation ID to get events for
|
||||
* @param afterTimestamp - The timestamp to get events after (exclusive)
|
||||
* @returns Array of events that occurred after the timestamp
|
||||
*/
|
||||
private getEventsAfter(invitationId: string, afterTimestamp: number): HistoricalEvent[] {
|
||||
const history = this.eventHistory.get(invitationId);
|
||||
if (!history) return [];
|
||||
|
||||
// First prune old events to ensure we don't return stale data
|
||||
this.pruneHistory(invitationId, Date.now());
|
||||
|
||||
return history.filter(event => event.timestamp > afterTimestamp);
|
||||
}
|
||||
}
|
||||
18
src/types.ts
Normal file
18
src/types.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { type Debugger } from "debug";
|
||||
import { z } from "zod";
|
||||
|
||||
export const isDebugger = (value: unknown): value is Debugger => {
|
||||
if (typeof value !== "function") return false;
|
||||
|
||||
const candidate = value as Partial<Debugger>;
|
||||
|
||||
return (
|
||||
typeof candidate.namespace === "string" &&
|
||||
typeof candidate.extend === "function" &&
|
||||
typeof candidate.enabled === "boolean"
|
||||
);
|
||||
};
|
||||
|
||||
export const debuggerSchema = z.custom<Debugger>(isDebugger, {
|
||||
message: "Expected a debug.Debugger instance",
|
||||
});
|
||||
40
tsconfig.json
Normal file
40
tsconfig.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
// Visit https://aka.ms/tsconfig to read more about this file
|
||||
"compilerOptions": {
|
||||
// File Layout
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
|
||||
// Environment Settings
|
||||
// See also https://aka.ms/tsconfig/module
|
||||
"module": "nodenext",
|
||||
"target": "esnext",
|
||||
"types": ["node"],
|
||||
|
||||
// Other Outputs
|
||||
"sourceMap": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
|
||||
// Stricter Typechecking Options
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
|
||||
// Style Options
|
||||
// "noImplicitReturns": true,
|
||||
// "noImplicitOverride": true,
|
||||
// "noUnusedLocals": true,
|
||||
// "noUnusedParameters": true,
|
||||
// "noFallthroughCasesInSwitch": true,
|
||||
// "noPropertyAccessFromIndexSignature": true,
|
||||
|
||||
// Recommended Options
|
||||
"strict": true,
|
||||
"jsx": "react-jsx",
|
||||
"verbatimModuleSyntax": true,
|
||||
"isolatedModules": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
"moduleDetection": "force",
|
||||
"skipLibCheck": true,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user