This commit is contained in:
Morten Olsen
2025-11-03 13:05:37 +01:00
commit 0c70f363df
41 changed files with 6643 additions and 0 deletions

4
packages/server/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
/node_modules/
/dist/
/coverage/
/.env

View File

@@ -0,0 +1,27 @@
FROM node:23-slim AS base
RUN corepack enable
WORKDIR /app
FROM base AS builder
RUN npm i -g turbo
COPY . .
RUN turbo prune @morten-olsen/reservoir-server --docker
FROM base AS installer
COPY --from=builder /app/out/json/ .
RUN pnpm install --prod --frozen-lockfile
COPY --from=builder /app/out/full/ .
FROM base AS runner
ENV \
SERVER_HOST=0.0.0.0 \
DB_URL=/data/db.sqlite
RUN \
addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 nodejs \
&& mkdir /data \
&& chown nodejs:nodejs /data
USER nodejs
COPY --from=installer /app /app
CMD ["node", "/app/packages/server/src/start.ts"]

View File

@@ -0,0 +1,42 @@
{
"type": "module",
"main": "dist/exports.js",
"scripts": {
"build": "tsc --build",
"test:unit": "vitest --run --passWithNoTests",
"test": "pnpm run \"/^test:/\""
},
"packageManager": "pnpm@10.6.0",
"files": [
"dist"
],
"exports": {
".": "./dist/exports.js"
},
"devDependencies": {
"@morten-olsen/reservoir-configs": "workspace:*",
"@morten-olsen/reservoir-tests": "workspace:*",
"@types/node": "24.10.0",
"@vitest/coverage-v8": "4.0.6",
"typescript": "5.9.3",
"vitest": "4.0.6"
},
"name": "@morten-olsen/reservoir-server",
"version": "1.0.0",
"imports": {
"#root/*": "./src/*"
},
"dependencies": {
"@fastify/swagger": "^9.5.2",
"@scalar/fastify-api-reference": "^1.38.1",
"better-sqlite3": "^12.4.1",
"fast-deep-equal": "^3.1.3",
"fastify": "^5.6.1",
"fastify-type-provider-zod": "^6.1.0",
"knex": "^3.1.0",
"pg": "^8.16.3",
"pino": "^10.1.0",
"pino-pretty": "^13.1.2",
"zod": "^4.1.12"
}
}

View File

@@ -0,0 +1,36 @@
import { z } from 'zod';
import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod';
import { DocumentsService } from '#root/services/documents/documents.ts';
import {
upsertDocumentRequestSchema,
upsertDocumentResponseSchema,
} from '#root/services/documents/documents.schemas.ts';
const documentsPlugin: FastifyPluginAsyncZod = async (app) => {
app.route({
method: 'POST',
url: '',
schema: {
operationId: 'v1.documents.put',
tags: ['documents'],
summary: 'Upsert documents',
body: z.object({
items: z.array(upsertDocumentRequestSchema),
}),
response: {
200: z.object({
items: z.array(upsertDocumentResponseSchema),
}),
},
},
handler: async (req, reply) => {
const documentsService = app.services.get(DocumentsService);
const { items } = req.body;
const results = await Promise.all(items.map((item) => documentsService.upsert(item)));
return reply.send({ items: results });
},
});
};
export { documentsPlugin };

View File

@@ -0,0 +1,52 @@
import fastify from 'fastify';
import fastifySwagger from '@fastify/swagger';
import fastifyScalar from '@scalar/fastify-api-reference';
import { jsonSchemaTransform, serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod';
import { documentsPlugin } from './api.documents.ts';
import { Services } from '#root/utils/utils.services.ts';
import { DatabaseService } from '#root/database/database.ts';
const createApi = async (services: Services = new Services()) => {
const db = services.get(DatabaseService);
await db.ready();
const app = fastify({
logger: {
level: 'warn',
transport: {
target: 'pino-pretty',
},
},
});
app.setValidatorCompiler(validatorCompiler);
app.setSerializerCompiler(serializerCompiler);
app.decorate('services', services);
await app.register(fastifySwagger, {
openapi: {
info: {
title: 'Reservoir',
version: '1.0.0',
},
servers: [],
},
transform: jsonSchemaTransform,
});
await app.register(fastifyScalar, {
routePrefix: '/docs',
});
await app.register(documentsPlugin, {
prefix: '/api/v1/documents',
});
app.addHook('onReady', async () => {
app.swagger();
});
await app.ready();
return app;
};
export { createApi };

View File

@@ -0,0 +1,10 @@
class ConfigService {
public get database() {
return {
client: process.env.DB_CLIENT || 'better-sqlite3',
connection: process.env.DB_URL || ':memory:',
};
}
}
export { ConfigService };

View File

@@ -0,0 +1,52 @@
import knex, { type Knex } from 'knex';
import { migrationSource } from './migrations/migrations.ts';
import { destroy, Services } from '#root/utils/utils.services.ts';
import { ConfigService } from '#root/config/config.ts';
class DatabaseService {
#services: Services;
#instance?: Promise<Knex>;
constructor(services: Services) {
this.#services = services;
}
#setup = async () => {
const configService = this.#services.get(ConfigService);
const db = knex({
client: configService.database.client,
connection: configService.database.connection,
useNullAsDefault: true,
});
await db.migrate.latest({
migrationSource,
});
return db;
};
public getInstance = () => {
if (!this.#instance) {
this.#instance = this.#setup();
}
return this.#instance;
};
[destroy] = async () => {
if (!this.#instance) {
return;
}
const instance = await this.#instance;
await instance.destroy();
};
public ready = async () => {
await this.getInstance();
};
}
export { tableNames, type Tables } from './migrations/migrations.ts';
export { DatabaseService };

View File

@@ -0,0 +1,41 @@
import type { Migration } from './migrations.types.ts';
const tableNames = {
documents: 'documents',
};
const init: Migration = {
name: 'init',
up: async (knex) => {
await knex.schema.createTable(tableNames.documents, (table) => {
table.string('id').notNullable();
table.string('type').notNullable();
table.string('source').nullable();
table.jsonb('data').notNullable();
table.datetime('createdAt').notNullable();
table.datetime('updatedAt').notNullable();
table.datetime('deletedAt').nullable();
table.primary(['id', 'type']);
});
},
down: async (knex) => {
await knex.schema.dropTableIfExists(tableNames.documents);
},
};
type DocumentRow = {
id: string;
type: string;
source: string | null;
data: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
};
type Tables = {
document: DocumentRow;
};
export type { Tables };
export { init, tableNames };

View File

@@ -0,0 +1,15 @@
import type { Knex } from 'knex';
import { init, tableNames, type Tables } from './migrations.001-init.ts';
import type { Migration } from './migrations.types.ts';
const migrations = [init];
const migrationSource: Knex.MigrationSource<Migration> = {
getMigration: async (migration) => migration,
getMigrationName: (migration: Migration) => migration.name,
getMigrations: async () => migrations,
};
export { tableNames, type Tables };
export { migrationSource };

View File

@@ -0,0 +1,9 @@
import type { Knex } from 'knex';
type Migration = {
name: string;
up: (knex: Knex) => Promise<void>;
down: (knex: Knex) => Promise<void>;
};
export type { Migration };

9
packages/server/src/global.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
import 'fastify';
import type { Services } from './utils/utils.services.ts';
declare module 'fastify' {
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
interface FastifyInstance {
services: Services;
}
}

View File

@@ -0,0 +1,22 @@
import { z } from 'zod';
const upsertDocumentRequestSchema = z.object({
id: z.string().min(1).optional(),
type: z.string().min(1),
source: z.string().min(1).nullable(),
data: z.unknown(),
});
type UpsertDocumentRequest = z.infer<typeof upsertDocumentRequestSchema>;
const upsertDocumentResponseSchema = upsertDocumentRequestSchema.extend({
createdAt: z.iso.datetime(),
updatedAt: z.iso.datetime(),
deletedAt: z.iso.datetime().nullable(),
action: z.enum(['inserted', 'updated', 'skipped']),
});
type UpsertDocumentResponse = z.input<typeof upsertDocumentResponseSchema>;
export type { UpsertDocumentRequest, UpsertDocumentResponse };
export { upsertDocumentRequestSchema, upsertDocumentResponseSchema };

View File

@@ -0,0 +1,77 @@
import equal from 'fast-deep-equal';
import type { UpsertDocumentRequest, UpsertDocumentResponse } from './documents.schemas.ts';
import { DatabaseService, tableNames, type Tables } from '#root/database/database.ts';
import type { Services } from '#root/utils/utils.services.ts';
class DocumentsService {
#services: Services;
constructor(services: Services) {
this.#services = services;
}
public upsert = async (document: UpsertDocumentRequest): Promise<UpsertDocumentResponse> => {
const dbService = this.#services.get(DatabaseService);
const db = await dbService.getInstance();
const id = document.id || crypto.randomUUID();
const [current] = await db<Tables['document']>(tableNames).where({
id,
type: document.type,
});
const now = new Date();
if (!current) {
await db<Tables['document']>(tableNames.documents).insert({
id,
type: document.type,
createdAt: now.toISOString(),
updatedAt: now.toISOString(),
data: JSON.stringify(document.data),
});
return {
data: document.data,
id,
type: document.type,
source: document.source || null,
createdAt: now.toISOString(),
updatedAt: now.toISOString(),
deletedAt: null,
action: 'inserted',
};
}
const currentData = JSON.parse(current.data);
if (equal(currentData, document.data)) {
return {
...current,
data: currentData,
id,
createdAt: current.createdAt,
updatedAt: current.updatedAt,
deletedAt: current.deletedAt || null,
action: 'skipped',
};
}
await db<Tables['document']>(tableNames.documents)
.update({
source: document.source,
data: JSON.stringify(document.data),
updatedAt: now.toISOString(),
})
.where({ id, type: document.type });
return {
...current,
id,
data: document.data,
createdAt: current.createdAt,
updatedAt: now.toISOString(),
deletedAt: current.deletedAt || null,
action: 'updated',
};
};
}
export { DocumentsService };

View File

@@ -0,0 +1,7 @@
import { createApi } from './api/api.ts';
const app = await createApi();
await app.listen({
port: 9111,
host: process.env.SERVER_HOST,
});

View File

@@ -0,0 +1,51 @@
const destroy = Symbol('destroy');
const instanceKey = Symbol('instances');
type ServiceDependency<T> = new (services: Services) => T & {
[destroy]?: () => Promise<void> | void;
};
class Services {
[instanceKey]: Map<ServiceDependency<unknown>, unknown>;
constructor() {
this[instanceKey] = new Map();
}
public get = <T>(service: ServiceDependency<T>) => {
if (!this[instanceKey].has(service)) {
this[instanceKey].set(service, new service(this));
}
const instance = this[instanceKey].get(service);
if (!instance) {
throw new Error('Could not generate instance');
}
return instance as T;
};
public set = <T>(service: ServiceDependency<T>, instance: Partial<T>) => {
this[instanceKey].set(service, instance);
};
public clone = () => {
const services = new Services();
services[instanceKey] = Object.fromEntries(this[instanceKey].entries());
};
public destroy = async () => {
await Promise.all(
this[instanceKey].values().map(async (instance) => {
if (
typeof instance === 'object' &&
instance &&
destroy in instance &&
typeof instance[destroy] === 'function'
) {
await instance[destroy]();
}
}),
);
};
}
export { Services, destroy };

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": [
"src/**/*.ts"
],
"extends": "@morten-olsen/reservoir-configs/tsconfig.json"
}

View File

@@ -0,0 +1,12 @@
import { defineConfig } from 'vitest/config';
import { getAliases } from '@morten-olsen/reservoir-tests/vitest';
// eslint-disable-next-line import/no-default-export
export default defineConfig(async () => {
const aliases = await getAliases();
return {
resolve: {
alias: aliases,
},
};
});