wip: db schema overhaul

This commit is contained in:
Derock 2024-04-22 17:10:35 -04:00
parent 92b414b50d
commit f0877ebc83
No known key found for this signature in database
22 changed files with 455 additions and 182 deletions

View file

@ -50,6 +50,7 @@
"cookie": "^0.6.0",
"datastructures-js": "^13.0.0",
"date-fns": "^3.0.6",
"deterministic-object-hash": "^2.0.2",
"docker-cli-js": "^2.10.0",
"docker-modem": "^5.0.3",
"dockerode": "^4.0.2",
@ -59,6 +60,7 @@
"extensionless": "^1.9.6",
"framer-motion": "^11.0.3",
"ipaddr.js": "^2.1.0",
"jsondiffpatch": "^0.6.0",
"lucide-react": "^0.298.0",
"next": "14.0.4",
"next-nprogress-bar": "^2.1.2",

View file

@ -104,6 +104,9 @@ dependencies:
date-fns:
specifier: ^3.0.6
version: 3.0.6
deterministic-object-hash:
specifier: ^2.0.2
version: 2.0.2
docker-cli-js:
specifier: ^2.10.0
version: 2.10.0
@ -131,6 +134,9 @@ dependencies:
ipaddr.js:
specifier: ^2.1.0
version: 2.1.0
jsondiffpatch:
specifier: ^0.6.0
version: 0.6.0
lucide-react:
specifier: ^0.298.0
version: 0.298.0(react@18.2.0)
@ -2975,6 +2981,10 @@ packages:
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
dev: false
/@types/diff-match-patch@1.0.36:
resolution: {integrity: sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg==}
dev: false
/@types/docker-modem@3.0.6:
resolution: {integrity: sha512-yKpAGEuKRSS8wwx0joknWxsmLha78wNMe9R2S3UNsVOkZded8UqOrV8KoeDXoXsjndxwyF3eIhyClGbO1SEhEg==}
dependencies:
@ -3679,6 +3689,10 @@ packages:
/balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
/base-64@1.0.0:
resolution: {integrity: sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==}
dev: false
/base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
dev: false
@ -4386,9 +4400,20 @@ packages:
resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==}
dev: false
/deterministic-object-hash@2.0.2:
resolution: {integrity: sha512-KxektNH63SrbfUyDiwXqRb1rLwKt33AmMv+5Nhsw1kqZ13SJBRTgZHtGbE+hH3a1mVW1cz+4pqSWVPAtLVXTzQ==}
engines: {node: '>=18'}
dependencies:
base-64: 1.0.0
dev: false
/didyoumean@1.2.2:
resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
/diff-match-patch@1.0.5:
resolution: {integrity: sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==}
dev: false
/difflib@0.2.4:
resolution: {integrity: sha512-9YVwmMb0wQHQNr5J9m6BSj6fk4pfGITGQOOs+D9Fl+INODWFOfvhIU1hNv6GgR1RBoC/9NJcwu77zShxV0kT7w==}
dependencies:
@ -6194,6 +6219,16 @@ packages:
minimist: 1.2.8
dev: true
/jsondiffpatch@0.6.0:
resolution: {integrity: sha512-3QItJOXp2AP1uv7waBkao5nCvhEv+QmJAd38Ybq7wNI74Q+BBmnLn4EDKz6yI9xGAIQoUF87qHt+kc1IVxB4zQ==}
engines: {node: ^18.0.0 || >=20.0.0}
hasBin: true
dependencies:
'@types/diff-match-patch': 1.0.36
chalk: 5.3.0
diff-match-patch: 1.0.5
dev: false
/jsx-ast-utils@3.3.5:
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
engines: {node: '>=4.0'}

View file

@ -3,6 +3,7 @@ import chalk from "chalk";
import logger from "~/server/utils/logger";
const log = logger.child({ module: "trpc:server" });
export const loggerMiddleware = experimental_standaloneMiddleware().create(
async ({ type, path, next }) => {
const result = await next();

View file

@ -1,7 +1,6 @@
import { TRPCError, experimental_standaloneMiddleware } from "@trpc/server";
import { eq, or } from "drizzle-orm";
import { type db } from "~/server/db";
import { projects } from "~/server/db/schema";
import ProjectManager from "~/server/managers/ProjectManager";
export type BasicProjectDetails = {
id: string;
@ -21,21 +20,7 @@ export const projectMiddleware = experimental_standaloneMiddleware<{
});
}
const [project] = await ctx.db
.select({
id: projects.id,
friendlyName: projects.friendlyName,
internalName: projects.internalName,
createdAt: projects.createdAt,
})
.from(projects)
.where(
or(
eq(projects.id, input.projectId),
eq(projects.internalName, input.projectId),
),
)
.limit(1);
const project = await ProjectManager.findByNameOrId(input.projectId);
if (!project)
throw new TRPCError({
@ -45,7 +30,7 @@ export const projectMiddleware = experimental_standaloneMiddleware<{
return next({
ctx: {
project: project as BasicProjectDetails,
project: project,
},
});
});

View file

@ -23,20 +23,16 @@ export const serviceMiddleware = experimental_standaloneMiddleware<{
});
}
const [serviceDetails] = await ctx.db
.select({
id: service.id,
name: service.name,
createdAt: service.createdAt,
})
.from(service)
.where(
and(
eq(service.projectId, ctx.project.id),
or(eq(service.name, input.serviceId), eq(service.id, input.serviceId)),
),
)
.limit(1);
const serviceDetails = await ctx.db.query.service.findFirst({
where: and(
eq(service.projectId, ctx.project.id),
or(eq(service.name, input.serviceId), eq(service.id, input.serviceId)),
),
with: {
latestGeneration: true,
},
});
if (!serviceDetails)
throw new TRPCError({

View file

@ -1,7 +1,7 @@
import { eq } from "drizzle-orm";
import { z } from "zod";
import { BuildManager } from "~/server/build/BuildManager";
import { service } from "~/server/db/schema";
import { serviceGeneration } from "~/server/db/schema";
import { buildDockerStackFile } from "~/server/docker/stack";
import logger from "~/server/utils/logger";
import { projectMiddleware } from "../../middleware/project";
@ -23,7 +23,7 @@ export const deployProject = authenticatedProcedure
.use(projectMiddleware)
.mutation(async ({ ctx, input }) => {
const services = await ctx.db.query.service.findMany({
where: eq(service.projectId, input.projectId),
where: eq(serviceGeneration.serviceId, input.projectId),
with: {
domains: true,

View file

@ -1,7 +1,7 @@
import assert from "assert";
import { eq } from "drizzle-orm";
import { z } from "zod";
import { projects, service } from "~/server/db/schema";
import { projects, serviceGeneration } from "~/server/db/schema";
import { authenticatedProcedure, createTRPCRouter } from "../../trpc";
import { deployProject } from "./deploy";
import { getProject } from "./project";
@ -34,11 +34,11 @@ export const projectRouter = createTRPCRouter({
userProjects.map(async (project) => {
const projServices = await ctx.db
.select({
id: service.id,
name: service.name,
id: serviceGeneration.id,
name: serviceGeneration.name,
})
.from(service)
.where(eq(service.projectId, project.id));
.from(serviceGeneration)
.where(eq(serviceGeneration.serviceId, project.id));
return {
...project,

View file

@ -1,6 +1,6 @@
import { eq } from "drizzle-orm";
import { z } from "zod";
import { service } from "~/server/db/schema";
import { serviceGeneration } from "~/server/db/schema";
import { projectMiddleware } from "../../middleware/project";
import { authenticatedProcedure } from "../../trpc";
@ -18,11 +18,11 @@ export const getProject = authenticatedProcedure
.query(async ({ ctx }) => {
const projServices = await ctx.db
.select({
id: service.id,
name: service.name,
id: serviceGeneration.id,
name: serviceGeneration.name,
})
.from(service)
.where(eq(service.projectId, ctx.project.id));
.from(serviceGeneration)
.where(eq(serviceGeneration.serviceId, ctx.project.id));
// get docker stats
const stats = await ctx.docker.listServices({

View file

@ -6,7 +6,7 @@ import { env } from "~/env";
import { projectMiddleware } from "~/server/api/middleware/project";
import { serviceMiddleware } from "~/server/api/middleware/service";
import { authenticatedProcedure, createTRPCRouter } from "~/server/api/trpc";
import { service } from "~/server/db/schema";
import { serviceGeneration } from "~/server/db/schema";
import { DOCKER_DEPLOY_MODE_MAP, ServiceSource } from "~/server/db/types";
import { zDockerName } from "~/server/utils/zod";
import { getServiceContainers } from "./containers";
@ -35,7 +35,7 @@ export const serviceRouter = createTRPCRouter({
.use(serviceMiddleware)
.query(async ({ ctx }) => {
const fullServiceData = await ctx.db.query.service.findFirst({
where: eq(service.id, ctx.service.id),
where: eq(serviceGeneration.id, ctx.service.id),
with: {
domains: true,
ports: true,
@ -71,7 +71,7 @@ export const serviceRouter = createTRPCRouter({
.use(projectMiddleware)
.mutation(async ({ ctx, input }) => {
const [data] = await ctx.db
.insert(service)
.insert(serviceGeneration)
.values({
name: input.name,
projectId: ctx.project.id,
@ -83,7 +83,7 @@ export const serviceRouter = createTRPCRouter({
dockerImage: "traefik/whoami",
})
.returning({
id: service.id,
id: serviceGeneration.id,
})
.execute()
.catch((err) => {
@ -108,8 +108,8 @@ export const serviceRouter = createTRPCRouter({
.use(projectMiddleware)
.mutation(async ({ ctx, input }) => {
await ctx.db
.delete(service)
.where(eq(service.id, input.serviceId))
.delete(serviceGeneration)
.where(eq(serviceGeneration.id, input.serviceId))
.execute();
}),
});

View file

@ -4,7 +4,7 @@ import { z } from "zod";
import { projectMiddleware } from "~/server/api/middleware/project";
import { serviceMiddleware } from "~/server/api/middleware/service";
import { authenticatedProcedure } from "~/server/api/trpc";
import { service, serviceDomain } from "~/server/db/schema";
import { serviceDomain, serviceGeneration } from "~/server/db/schema";
import { DockerDeployMode } from "~/server/db/types";
import { zDockerDuration, zDockerImage, zDomain } from "~/server/utils/zod";
@ -27,7 +27,7 @@ export const updateServiceProcedure = authenticatedProcedure
serviceId: z.string(),
})
.merge(
createInsertSchema(service, {
createInsertSchema(serviceGeneration, {
dockerImage: zDockerImage,
gitUrl: (schema) =>
schema.gitUrl.regex(
@ -93,9 +93,9 @@ export const updateServiceProcedure = authenticatedProcedure
delete queryUpdate.id;
await ctx.db
.update(service)
.update(serviceGeneration)
.set(queryUpdate)
.where(eq(service.id, ctx.service.id))
.where(eq(serviceGeneration.id, ctx.service.id))
.execute();
return true;

View file

@ -1,14 +1,14 @@
import { authenticatedProcedure, createTRPCRouter } from "../../trpc";
import { observable } from "@trpc/server/observable";
import { updateTraefik } from "~/server/docker/traefik";
import { BasicServerStats, stats } from "~/server/modules/stats";
import { stats, type BasicServerStats } from "~/server/modules/stats";
import { authenticatedProcedure, createTRPCRouter } from "../../trpc";
export const systemRouter = createTRPCRouter({
currentStats: authenticatedProcedure.query(async ({ ctx }) => {
currentStats: authenticatedProcedure.query(async () => {
return stats.getCurrentStats();
}),
liveStats: authenticatedProcedure.subscription(async ({ ctx }) => {
liveStats: authenticatedProcedure.subscription(() => {
return observable<BasicServerStats>((observer) => {
const update = observer.next.bind(observer);
@ -19,14 +19,14 @@ export const systemRouter = createTRPCRouter({
});
}),
history: authenticatedProcedure.query(async ({ ctx }) => {
history: authenticatedProcedure.query(async () => {
return await stats.getStatsInRange(
new Date(Date.now() - 1000 * 60 * 60 * 24),
);
}),
// core container options
redeployTraefik: authenticatedProcedure.mutation(async ({ ctx }) => {
redeployTraefik: authenticatedProcedure.mutation(() => {
setTimeout(() => {
void updateTraefik();
}, 200);

View file

@ -1,13 +1,12 @@
import { eq } from "drizzle-orm";
import { db } from "../db";
import { users, sessions } from "../db/schema";
import { randomBytes } from "crypto";
import assert from "assert";
import { env } from "~/env";
import crypto, { randomBytes } from "crypto";
import { eq } from "drizzle-orm";
import { IncomingMessage } from "http";
import { env } from "~/env";
import type { ExtendedRequest } from "../api/trpc";
import { db } from "../db";
import { sessions, users } from "../db/schema/schema";
import logger from "../utils/logger";
import crypto from "crypto";
export type SessionUpdateData = Partial<{
ua: string;

View file

@ -1,7 +1,7 @@
import assert from "assert";
import { Queue } from "datastructures-js";
import { db } from "../db";
import { serviceDeployment } from "../db/schema";
import { serviceDeployment } from "../db/schema/schema";
import { ServiceDeploymentStatus, ServiceSource } from "../db/types";
import { type Service } from "../docker/stack";
import logger from "../utils/logger";

View file

@ -4,7 +4,7 @@ import { mkdirSync } from "fs";
import { rm, rmdir } from "fs/promises";
import path from "path";
import { db } from "../db";
import { service, serviceDeployment } from "../db/schema";
import { serviceDeployment, serviceGeneration } from "../db/schema/schema";
import {
ServiceBuildMethod,
ServiceDeploymentStatus,
@ -122,8 +122,8 @@ export default class BuildTask {
private async fetchServiceDetails() {
const [serviceDetails] = await db
.select()
.from(service)
.where(eq(service.id, this.serviceId));
.from(serviceGeneration)
.where(eq(serviceGeneration.id, this.serviceId));
assert(serviceDetails, "Service not found");

View file

@ -1,4 +1,4 @@
import { type service } from "../../db/schema";
import { type serviceGeneration } from "../../db/schema/schema";
import type BuilderLogger from "../utils/BuilderLogger";
export default class BaseBuilder {
@ -6,7 +6,7 @@ export default class BaseBuilder {
public readonly configuration: {
fileLogger: BuilderLogger;
workDirectory: string;
serviceConfiguration: typeof service.$inferSelect;
serviceConfiguration: typeof serviceGeneration.$inferSelect;
},
) {}

View file

@ -1,4 +1,4 @@
import { type service } from "~/server/db/schema";
import { type serviceGeneration } from "~/server/db/schema";
import type BuilderLogger from "../utils/BuilderLogger";
export default class BaseSource {
@ -6,7 +6,7 @@ export default class BaseSource {
public readonly configuration: {
fileLogger: BuilderLogger;
workDirectory: string;
serviceConfiguration: typeof service.$inferSelect;
serviceConfiguration: typeof serviceGeneration.$inferSelect;
},
) {}

View file

@ -0,0 +1,2 @@
export * from "./relations";
export * from "./schema";

View file

@ -0,0 +1,121 @@
import { relations } from "drizzle-orm";
import {
projectDeployment,
projects,
service,
serviceDeployment,
serviceDomain,
serviceGeneration,
servicePort,
serviceSysctl,
serviceUlimit,
serviceVolume,
sessions,
users,
} from "./schema";
export const userRelations = relations(users, ({ many }) => ({
sessions: many(sessions),
}));
export const sessionRelations = relations(sessions, ({ one }) => ({
user: one(users, {
fields: [sessions.userId],
references: [users.id],
}),
}));
export const projectRelations = relations(projects, ({ one, many }) => ({
owner: one(users, {
fields: [projects.ownerId],
references: [users.id],
}),
services: many(serviceGeneration),
}));
export const serviceGenerationRelations = relations(
serviceGeneration,
({ one, many }) => ({
service: one(service, {
fields: [serviceGeneration.serviceId],
references: [service.id],
}),
domains: many(serviceDomain),
ports: many(servicePort),
sysctls: many(serviceSysctl),
volumes: many(serviceVolume),
ulimits: many(serviceUlimit),
deployment: one(serviceDeployment, {
fields: [serviceGeneration.deploymentId],
references: [serviceDeployment.id],
}),
}),
);
export const serviceRelations = relations(service, ({ one, many }) => ({
project: one(projects, {
fields: [service.projectId],
references: [projects.id],
}),
generations: many(serviceGeneration),
latestGeneration: one(serviceGeneration, {
fields: [service.latestGenerationId],
references: [serviceGeneration.id],
}),
deployedGeneration: one(serviceGeneration, {
fields: [service.deployedGenerationId],
references: [serviceGeneration.id],
}),
}));
export const projectDeploymentRelations = relations(
projectDeployment,
({ one, many }) => ({
project: one(projects, {
fields: [projectDeployment.projectId],
references: [projects.id],
}),
serviceDeployments: many(serviceDeployment),
}),
);
export const serviceDomainRelations = relations(serviceDomain, ({ one }) => ({
service: one(serviceGeneration, {
fields: [serviceDomain.serviceId],
references: [serviceGeneration.id],
}),
}));
export const servicePortRelations = relations(servicePort, ({ one }) => ({
service: one(serviceGeneration, {
fields: [servicePort.serviceId],
references: [serviceGeneration.id],
}),
}));
export const serviceSysctlRelations = relations(serviceSysctl, ({ one }) => ({
service: one(serviceGeneration, {
fields: [serviceSysctl.serviceId],
references: [serviceGeneration.id],
}),
}));
export const serviceVolumeRelations = relations(serviceVolume, ({ one }) => ({
service: one(serviceGeneration, {
fields: [serviceVolume.serviceId],
references: [serviceGeneration.id],
}),
}));
export const serviceUlimitRelations = relations(serviceUlimit, ({ one }) => ({
service: one(serviceGeneration, {
fields: [serviceUlimit.serviceId],
references: [serviceGeneration.id],
}),
}));

View file

@ -1,4 +1,4 @@
import { relations, sql } from "drizzle-orm";
import { sql } from "drizzle-orm";
import {
blob,
index,
@ -7,6 +7,7 @@ import {
sqliteTable,
text,
unique,
type AnySQLiteColumn,
} from "drizzle-orm/sqlite-core";
import {
DockerDeployMode,
@ -16,12 +17,17 @@ import {
type ServiceDeploymentStatus,
type ServicePortType,
type ServiceSource,
} from "./types";
} from "../types";
// util
const uuidv7 = sql`(uuid_generate_v7())`;
const now = sql<number>`CURRENT_TIMESTAMP`;
// overview of the project schema layout
// https://drawsql.app/teams/derock/diagrams/hostforge
// does not include all fields, just the main ones
// easy to see the relations between tables
/**
* User table.
* Represents a global user.
@ -41,11 +47,6 @@ export const users = sqliteTable(
}),
);
export const userRelations = relations(users, ({ many }) => ({
sessions: many(sessions),
mfaRequestSessions: many(MFARequestSessions),
}));
/**
* User Session table.
* Represents a user's session.
@ -54,6 +55,7 @@ export const sessions = sqliteTable("session", {
token: text("token").primaryKey(),
lastUA: text("last_useragent"),
lastIP: text("last_ip"),
// NOT IN MILLISECONDS!
lastAccessed: integer("last_accessed", { mode: "timestamp" }),
createdAt: integer("created_at").default(now).notNull(),
@ -62,33 +64,6 @@ export const sessions = sqliteTable("session", {
.references(() => users.id),
});
export const sessionRelations = relations(sessions, ({ one }) => ({
user: one(users, {
fields: [sessions.userId],
references: [users.id],
}),
}));
/**
* MFA Request Session.
* The intermediate between successful basic/oauth login and a full session for users with MFA enabled
*/
export const MFARequestSessions = sqliteTable("mfa_request_sessions", {
token: text("token").primaryKey(),
userId: text("user_id").references(() => users.id),
createdAt: integer("created_at").default(now),
});
export const MFARequestSessionRelations = relations(
MFARequestSessions,
({ one }) => ({
user: one(users, {
fields: [MFARequestSessions.userId],
references: [users.id],
}),
}),
);
/**
* System Statistics
* Historical data about the system's usage
@ -119,9 +94,35 @@ export const projects = sqliteTable("projects", {
createdAt: integer("created_at").default(now).notNull(),
ownerId: text("owner_id")
.notNull()
.references(() => users.id),
.references(() => users.id, {
onDelete: "cascade",
}),
});
/**
* Aggregate of service deployments for a project
*/
export const projectDeployment = sqliteTable(
"project_deployment",
{
id: text("id").default(uuidv7).primaryKey(),
projectId: text("project_id")
.notNull()
.references(() => projects.id, {
onDelete: "cascade",
}),
deployedAt: integer("deployed_at").default(now).notNull(),
status: integer("status").$type<ServiceDeploymentStatus>().notNull(),
},
(table) => ({
proj_deployment_idx: index("proj_deployment_idx").on(
table.id,
table.projectId,
),
}),
);
/**
* Project services
* Represents a service running in a project
@ -131,11 +132,49 @@ export const service = sqliteTable(
"service",
{
id: text("id").default(uuidv7).primaryKey(),
name: text("name").notNull(),
projectId: text("project_id")
.notNull()
.references(() => projects.id),
.references(() => projects.id, {
onDelete: "cascade",
}),
name: text("name").notNull(),
latestGenerationId: text("latest_generation_id")
.notNull()
.references(() => serviceGeneration.id),
deployedGenerationId: text("deployed_generation_id")
.notNull()
.references(() => serviceGeneration.id),
createdAt: integer("created_at").default(now).notNull(),
},
(table) => ({
name_project_idx: index("name_project_idx").on(table.name, table.projectId),
name_project_unq: unique("name_project_unq").on(
table.name,
table.projectId,
),
}),
);
/**
* Configuration at a point in time for a service
*/
export const serviceGeneration = sqliteTable(
"service_generation",
{
id: text("id").default(uuidv7).primaryKey(),
serviceId: text("service_id")
.notNull()
.references((): AnySQLiteColumn => service.id, {
onDelete: "cascade",
}),
deploymentId: text("deployment_id")
.notNull()
.references((): AnySQLiteColumn => serviceDeployment.id),
// service configuration
source: integer("source").$type<ServiceSource>().notNull(),
@ -174,6 +213,7 @@ export const service = sqliteTable(
.$type<DockerDeployMode>()
.default(DockerDeployMode.Replicated)
.notNull(),
zeroDowntime: integer("zero_downtime", { mode: "boolean" })
.default(false)
.notNull(),
@ -213,48 +253,38 @@ export const service = sqliteTable(
createdAt: integer("created_at").default(now).notNull(),
},
(table) => ({
name_project_idx: index("name_project_idx").on(table.name, table.projectId),
name_project_unq: unique("name_project_unq").on(
table.name,
table.projectId,
proj_generation_idx: index("proj_generation_idx").on(
table.id,
table.serviceId,
),
}),
);
// relations
export const projectRelations = relations(projects, ({ one, many }) => ({
owner: one(users, {
fields: [projects.ownerId],
references: [users.id],
}),
services: many(service),
}));
export const serviceRelations = relations(service, ({ one, many }) => ({
project: one(projects, {
fields: [service.projectId],
references: [projects.id],
}),
domains: many(serviceDomain),
ports: many(servicePort),
sysctls: many(serviceSysctl),
volumes: many(serviceVolume),
ulimits: many(serviceUlimit),
deployments: many(serviceDeployment),
}));
/**
* Service deployments
*/
export const serviceDeployment = sqliteTable("service_deployment", {
id: text("id").default(uuidv7).primaryKey(),
projectDeploymentId: text("project_deployment_id")
.notNull()
.references(() => projectDeployment.id, {
onDelete: "cascade",
}),
serviceId: text("service_id")
.notNull()
.references(() => service.id),
.references(() => serviceGeneration.id, {
onDelete: "cascade",
}),
createdAt: integer("created_at").default(now).notNull(),
deployedAt: integer("created_at").default(now).notNull(),
deployedBy: text("deployed_by").references(() => users.id),
// logs will be pretty small,
// so we can store them as blobs
// https://www.sqlite.org/intern-v-extern-blob.html
buildLogs: blob("build_logs"), // COMPRESSED!
status: integer("status").$type<ServiceDeploymentStatus>().notNull(),
});
@ -265,7 +295,7 @@ export const serviceDomain = sqliteTable("service_domain", {
id: text("id").default(uuidv7).primaryKey(),
serviceId: text("service_id")
.notNull()
.references(() => service.id),
.references(() => serviceGeneration.id),
domain: text("domain").notNull(),
internalPort: integer("internal_port").notNull(),
@ -273,13 +303,6 @@ export const serviceDomain = sqliteTable("service_domain", {
forceSSL: integer("force_ssl", { mode: "boolean" }).default(false).notNull(),
});
export const serviceDomainRelations = relations(serviceDomain, ({ one }) => ({
service: one(service, {
fields: [serviceDomain.serviceId],
references: [service.id],
}),
}));
/**
* Project exposed ports
*/
@ -287,7 +310,7 @@ export const servicePort = sqliteTable("service_port", {
id: text("id").default(uuidv7).primaryKey(),
serviceId: text("service_id")
.notNull()
.references(() => service.id),
.references(() => serviceGeneration.id),
internalPort: integer("internal_port").notNull(),
externalPort: integer("external_port").notNull(),
@ -295,13 +318,6 @@ export const servicePort = sqliteTable("service_port", {
type: integer("type").$type<ServicePortType>().notNull(),
});
export const servicePortRelations = relations(servicePort, ({ one }) => ({
service: one(service, {
fields: [servicePort.serviceId],
references: [service.id],
}),
}));
/**
* Project sysctls
*/
@ -309,19 +325,12 @@ export const serviceSysctl = sqliteTable("service_sysctl", {
id: text("id").default(uuidv7).primaryKey(),
serviceId: text("service_id")
.notNull()
.references(() => service.id),
.references(() => serviceGeneration.id),
key: text("key").notNull(),
value: text("value").notNull(),
});
export const serviceSysctlRelations = relations(serviceSysctl, ({ one }) => ({
service: one(service, {
fields: [serviceSysctl.serviceId],
references: [service.id],
}),
}));
/**
* Project volumes
*/
@ -329,20 +338,13 @@ export const serviceVolume = sqliteTable("service_volume", {
id: text("id").default(uuidv7).primaryKey(),
serviceId: text("service_id")
.notNull()
.references(() => service.id),
.references(() => serviceGeneration.id),
source: text("source"),
target: text("target").notNull(),
type: text("type").$type<DockerVolumeType>().notNull(),
});
export const serviceVolumeRelations = relations(serviceVolume, ({ one }) => ({
service: one(service, {
fields: [serviceVolume.serviceId],
references: [service.id],
}),
}));
/**
* Project ulimits
*/
@ -350,16 +352,9 @@ export const serviceUlimit = sqliteTable("service_ulimit", {
id: text("id").default(uuidv7).primaryKey(),
serviceId: text("service_id")
.notNull()
.references(() => service.id),
.references(() => serviceGeneration.id),
name: text("name").notNull(),
soft: integer("soft").notNull(),
hard: integer("hard").notNull(),
});
export const serviceUlimitRelations = relations(serviceUlimit, ({ one }) => ({
service: one(service, {
fields: [serviceUlimit.serviceId],
references: [service.id],
}),
}));

View file

@ -1,12 +1,12 @@
import assert from "assert";
import {
type service,
type serviceDomain,
type serviceGeneration,
type servicePort,
type serviceSysctl,
type serviceUlimit,
type serviceVolume,
} from "../db/schema";
} from "../db/schema/schema";
import {
DOCKER_DEPLOY_MODE_MAP,
DOCKER_RESTART_CONDITION_MAP,
@ -19,7 +19,7 @@ import {
type Ulimits,
} from "./compose";
export type Service = typeof service.$inferSelect & {
export type Service = typeof serviceGeneration.$inferSelect & {
domains: (typeof serviceDomain.$inferSelect)[];
ports: (typeof servicePort.$inferSelect)[];
sysctls: (typeof serviceSysctl.$inferSelect)[];

View file

@ -0,0 +1,37 @@
import { eq, or } from "drizzle-orm";
import { db } from "../db";
import { projects, service } from "../db/schema";
import ServiceManager from "./ServiceManager";
export default class ProjectManager {
constructor(private projectData: typeof projects.$inferSelect) {}
/**
* Finds a project by name or ID.
*/
static async findByNameOrId(nameOrId: string) {
const data = await db.query.projects.findFirst({
where: or(eq(projects.internalName, nameOrId), eq(projects.id, nameOrId)),
});
return data ? new ProjectManager(data) : null;
}
/**
* Returns the project data.
*/
public getData() {
return this.projectData;
}
/**
* Returns the project services.
*/
public async getServices() {
const serviceData = await db.query.service.findMany({
where: eq(service.projectId, this.projectData.id),
});
return serviceData.map((data) => new ServiceManager(data));
}
}

View file

@ -0,0 +1,100 @@
import { deterministicString } from "deterministic-object-hash";
import { and, eq, or } from "drizzle-orm";
import { create } from "jsondiffpatch";
import { db } from "../db";
import { service, serviceGeneration } from "../db/schema";
import logger from "../utils/logger";
export default class ServiceManager {
private static LOGGER = logger.child({
module: "ServiceManager",
});
private static JSON_DIFF = create({
objectHash: (obj: unknown) => {
if (typeof obj !== "object" || obj === null) {
return deterministicString(obj);
}
if ("id" in obj && typeof obj.id === "string") {
return obj.id;
}
this.LOGGER.warn("Unexpected object in JSON diff.", { obj });
return deterministicString(obj);
},
});
constructor(
private serviceData: typeof service.$inferSelect & {
latestGeneration?: typeof serviceGeneration.$inferSelect;
deployedGeneration?: typeof serviceGeneration.$inferSelect;
},
) {}
/**
* Finds a service by name or ID.
*/
static async findByNameOrId(nameOrId: string, projectId: string) {
const data = await db.query.service.findFirst({
where: and(
eq(service.projectId, projectId),
or(eq(service.name, nameOrId), eq(service.id, nameOrId)),
),
});
return data ? new ServiceManager(data) : null;
}
/**
* Returns a list of changed parameters between the current deployed service and the latest generation.
*/
public async buildDeployDiff() {
if (
this.serviceData.latestGenerationId ===
this.serviceData.deployedGenerationId
) {
return {};
}
// fetch the generations
const [latest, deployed] = await Promise.all([
this.fetchLatestGeneration(),
this.fetchDeployedGeneration(),
]);
// compare the two
return ServiceManager.JSON_DIFF.diff(deployed, latest);
}
/**
* Fetches the latest generation of the service.
* @param forceRefetch if true, will ignore what's cached internally
* @returns
*/
private async fetchLatestGeneration(forceRefetch?: boolean) {
if (!forceRefetch && this.serviceData.latestGeneration) {
return this.serviceData.latestGeneration;
}
return (this.serviceData.latestGeneration =
await db.query.serviceGeneration.findFirst({
where: eq(serviceGeneration.id, this.serviceData.latestGenerationId),
}));
}
/**
* Fetches the deployed generation of the service.
* @param forceRefetch if true, will ignore what's cached internally
*/
private async fetchDeployedGeneration(forceRefetch?: boolean) {
if (!forceRefetch && this.serviceData.deployedGeneration) {
return this.serviceData.deployedGeneration;
}
return (this.serviceData.deployedGeneration =
await db.query.serviceGeneration.findFirst({
where: eq(serviceGeneration.id, this.serviceData.deployedGenerationId),
}));
}
}