first project commit

This commit is contained in:
2026-05-16 12:02:15 +01:00
parent 33789b13d1
commit c013b52f59
79 changed files with 5834 additions and 0 deletions
+28
View File
@@ -0,0 +1,28 @@
{
"name": "@repo/db",
"version": "0.0.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"postinstall": "prisma generate",
"seed": "tsx prisma/seed.ts",
"typecheck": "tsc --noEmit",
"clean": "rimraf .turbo node_modules"
},
"dependencies": {
"@prisma/client": "^6.1.0"
},
"devDependencies": {
"@repo/config": "workspace:*",
"dotenv": "^16.4.7",
"prisma": "^6.1.0",
"rimraf": "^6.0.1",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
}
}
+17
View File
@@ -0,0 +1,17 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { config as loadEnv } from 'dotenv';
import { defineConfig } from 'prisma/config';
// Load the repo-root .env so DATABASE_URL is visible when Prisma CLI runs
// from inside packages/db. The .env file is intentionally kept at the repo
// root (single source of truth, gitignored).
const here = path.dirname(fileURLToPath(import.meta.url));
loadEnv({ path: path.resolve(here, '../../.env') });
export default defineConfig({
schema: path.join('prisma', 'schema.prisma'),
migrations: {
seed: 'tsx prisma/seed.ts',
},
});
@@ -0,0 +1,78 @@
-- CreateEnum
CREATE TYPE "UserRole" AS ENUM ('ADMIN', 'SUPERVISOR', 'OPERATOR');
-- CreateTable
CREATE TABLE "Tenant" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Tenant_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"email" TEXT NOT NULL,
"passwordHash" TEXT,
"role" "UserRole" NOT NULL DEFAULT 'OPERATOR',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Workstation" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"code" TEXT NOT NULL,
"name" TEXT NOT NULL,
"area" TEXT NOT NULL,
CONSTRAINT "Workstation_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "DomainEvent" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"aggregateType" TEXT NOT NULL,
"aggregateId" TEXT NOT NULL,
"eventType" TEXT NOT NULL,
"payload" JSONB NOT NULL,
"occurredAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"processedAt" TIMESTAMP(3),
CONSTRAINT "DomainEvent_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "User_tenantId_idx" ON "User"("tenantId");
-- CreateIndex
CREATE UNIQUE INDEX "User_tenantId_email_key" ON "User"("tenantId", "email");
-- CreateIndex
CREATE INDEX "Workstation_tenantId_idx" ON "Workstation"("tenantId");
-- CreateIndex
CREATE UNIQUE INDEX "Workstation_tenantId_code_key" ON "Workstation"("tenantId", "code");
-- CreateIndex
CREATE INDEX "DomainEvent_tenantId_idx" ON "DomainEvent"("tenantId");
-- CreateIndex
CREATE INDEX "DomainEvent_tenantId_processedAt_idx" ON "DomainEvent"("tenantId", "processedAt");
-- CreateIndex
CREATE INDEX "DomainEvent_tenantId_aggregateType_aggregateId_idx" ON "DomainEvent"("tenantId", "aggregateType", "aggregateId");
-- AddForeignKey
ALTER TABLE "User" ADD CONSTRAINT "User_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Workstation" ADD CONSTRAINT "Workstation_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DomainEvent" ADD CONSTRAINT "DomainEvent_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
+74
View File
@@ -0,0 +1,74 @@
// FieldOps — initial scaffold schema.
//
// All models except Tenant carry tenantId. Tenant scoping is enforced at runtime
// by the Prisma extension in src/tenant-extension.ts — see that file's header for
// the operations it covers and (more importantly) those it does NOT.
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum UserRole {
ADMIN
SUPERVISOR
OPERATOR
}
model Tenant {
id String @id @default(cuid())
name String
createdAt DateTime @default(now())
users User[]
workstations Workstation[]
events DomainEvent[]
}
model User {
id String @id @default(cuid())
tenantId String
email String
passwordHash String?
role UserRole @default(OPERATOR)
createdAt DateTime @default(now())
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
@@unique([tenantId, email])
@@index([tenantId])
}
model Workstation {
id String @id @default(cuid())
tenantId String
code String
name String
area String
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
@@unique([tenantId, code])
@@index([tenantId])
}
model DomainEvent {
id String @id @default(cuid())
tenantId String
aggregateType String
aggregateId String
eventType String
payload Json
occurredAt DateTime @default(now())
processedAt DateTime?
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
@@index([tenantId])
@@index([tenantId, processedAt])
@@index([tenantId, aggregateType, aggregateId])
}
+55
View File
@@ -0,0 +1,55 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { config as loadEnv } from 'dotenv';
// Load repo-root .env so DATABASE_URL is visible when this script runs from
// any CWD (pnpm invokes it from packages/db).
const here = path.dirname(fileURLToPath(import.meta.url));
loadEnv({ path: path.resolve(here, '../../../.env') });
const { PrismaClient, UserRole } = await import('@prisma/client');
const prisma = new PrismaClient();
const DEMO_TENANT_NAME = 'Demo Factory';
const DEMO_ADMIN_EMAIL = 'admin@demo.local';
async function main() {
// Idempotent: if a prior run created the demo tenant, wipe it and recreate.
// Cascade deletes on the relations handle the children.
const existing = await prisma.tenant.findFirst({ where: { name: DEMO_TENANT_NAME } });
if (existing) {
await prisma.tenant.delete({ where: { id: existing.id } });
}
const tenant = await prisma.tenant.create({
data: { name: DEMO_TENANT_NAME },
});
await prisma.user.create({
data: {
tenantId: tenant.id,
email: DEMO_ADMIN_EMAIL,
role: UserRole.ADMIN,
},
});
await prisma.workstation.createMany({
data: [
{ tenantId: tenant.id, code: 'WS-001', name: 'Assembly A', area: 'Floor 1' },
{ tenantId: tenant.id, code: 'WS-002', name: 'Packaging B', area: 'Floor 2' },
],
});
console.warn(
`Seed complete — tenant=${tenant.id} (${tenant.name}), admin=${DEMO_ADMIN_EMAIL}, workstations=2`,
);
}
main()
.catch((err) => {
console.error('Seed failed:', err);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
+24
View File
@@ -0,0 +1,24 @@
import { PrismaClient } from '@prisma/client';
/**
* Singleton PrismaClient. In dev, Next's HMR causes module re-evaluation; the
* globalThis cache prevents leaking a new client per reload.
*
* This is the UNSCOPED root client. Application code MUST NOT use it directly
* for tenant-scoped reads/writes — always pass it through `tenantScoped(...)`
* with the tenantId from the request context.
*/
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['warn', 'error'] : ['error'],
});
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma;
}
export type DbClient = typeof prisma;
+4
View File
@@ -0,0 +1,4 @@
export { prisma, type DbClient } from './client';
export { tenantScoped, type TenantScopedClient } from './tenant-extension';
export { Prisma, UserRole } from '@prisma/client';
export type { User, Tenant, Workstation, DomainEvent } from '@prisma/client';
+180
View File
@@ -0,0 +1,180 @@
import type { PrismaClient } from '@prisma/client';
/**
* ============================================================================
* Multi-tenant Prisma extension
* ============================================================================
*
* PURPOSE
* -------
* Guarantee that every read and write against a tenant-scoped model is
* filtered/stamped with the current tenantId, so application code can never
* accidentally cross tenant boundaries. Call this once per request from the
* tRPC context, passing the tenantId resolved from the authenticated session.
*
* const db = tenantScoped(prisma, ctx.tenantId);
* await db.workstation.findMany(); // implicitly WHERE tenantId = ctx.tenantId
*
* HOW TO EXTEND THIS
* ------------------
* Adding a new tenant-scoped model:
* 1. Add `tenantId String` + `@@index([tenantId])` in schema.prisma.
* 2. Add a relation to Tenant with `onDelete: Cascade`.
* 3. Add the model's PascalCase name to TENANT_SCOPED_MODELS below.
* 4. Run `pnpm db:migrate`.
*
* OPERATIONS INTERCEPTED
* ----------------------
* Reads : findFirst, findFirstOrThrow, findMany, count, aggregate, groupBy
* Writes : create, createMany, createManyAndReturn, update, updateMany,
* upsert, delete, deleteMany
* Special : findUnique / findUniqueOrThrow are DOWNGRADED to
* findFirst / findFirstOrThrow + tenantId filter.
*
* Reason: Prisma's findUnique only accepts where clauses that match
* a declared unique constraint. Adding tenantId to the where would
* either (a) require every @unique to be redeclared as
* @@unique([tenantId, ...]), or (b) fail Prisma's validation. The
* downgrade preserves tenant isolation at the cost of the runtime
* guarantee that the result is unique. If you specifically need
* "throw on multiple", use `findFirstOrThrow` with a where clause
* that you know is unique within the tenant (most commonly the
* compound `(tenantId, id)`).
*
* OPERATIONS NOT INTERCEPTED — THESE BYPASS TENANT SCOPING
* --------------------------------------------------------
* The following are intentionally left alone. They are the known holes in this
* guarantee and MUST be audited at PR-review time:
*
* 1. $queryRaw, $queryRawUnsafe, $executeRaw, $executeRawUnsafe
* Raw SQL bypasses the extension entirely. Always include the tenant
* filter explicitly:
* await db.$queryRaw`SELECT ... FROM "Workstation" WHERE "tenantId" = ${tenantId}`;
* Use raw SQL only for migrations, admin tooling, or aggregations the
* Prisma query engine cannot express.
*
* 2. $transaction (interactive callback form) when the callback receives a
* client OTHER than the scoped one returned by this function.
* The extension does not re-wrap the inner transactional client. Either:
* - issue all operations through the outer scoped client and use the
* sequential array form: `await db.$transaction([op1, op2])`, OR
* - if you need the interactive form, scope explicitly:
* await prisma.$transaction(async (tx) => {
* const scopedTx = tenantScoped(tx as PrismaClient, tenantId);
* ...
* });
*
* 3. Models without tenantId (currently only `Tenant`). These are passed
* through unchanged. Code touching Tenant must take care not to leak
* cross-tenant data through joins or includes from the tenant side.
*
* INVARIANTS THIS EXTENSION ENFORCES
* ----------------------------------
* - On every intercepted read, `where.tenantId` is set to the bound tenantId,
* OVERWRITING any tenantId the caller may have supplied. This is on purpose:
* callers must not be able to read other tenants' data even by mistake.
* - On `create` and `createMany`, the bound tenantId is injected into `data`,
* OVERWRITING any value the caller supplied — same reason.
* - On `update` and `upsert.update`, any attempt to set `tenantId` is silently
* dropped. Records cannot be re-homed across tenants through this path.
*
* CHANGELOG
* ---------
* 2026-05-16 — initial version (scaffold).
* ============================================================================
*/
const TENANT_SCOPED_MODELS = ['User', 'Workstation', 'DomainEvent'] as const;
type TenantScopedModel = (typeof TENANT_SCOPED_MODELS)[number];
function isTenantScoped(model: string | undefined): model is TenantScopedModel {
return !!model && (TENANT_SCOPED_MODELS as readonly string[]).includes(model);
}
function modelAccessor(model: string): string {
return model.charAt(0).toLowerCase() + model.slice(1);
}
export function tenantScoped(prisma: PrismaClient, tenantId: string) {
return prisma.$extends({
name: 'tenant-scope',
query: {
$allModels: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async $allOperations({ model, operation, args, query }: any) {
if (!isTenantScoped(model)) {
return query(args);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const a = args as any;
switch (operation) {
case 'findFirst':
case 'findFirstOrThrow':
case 'findMany':
case 'count':
case 'aggregate':
case 'groupBy':
case 'updateMany':
case 'deleteMany': {
a.where = { ...(a.where ?? {}), tenantId };
return query(a);
}
case 'findUnique':
case 'findUniqueOrThrow': {
// Downgrade to findFirst[OrThrow]. See header.
const target =
operation === 'findUnique' ? 'findFirst' : 'findFirstOrThrow';
const where = { ...(a.where ?? {}), tenantId };
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const delegate = (prisma as any)[modelAccessor(model)];
return delegate[target]({ ...a, where });
}
case 'create': {
a.data = { ...(a.data ?? {}), tenantId };
return query(a);
}
case 'createMany':
case 'createManyAndReturn': {
const data = a.data;
if (Array.isArray(data)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
a.data = data.map((d: any) => ({ ...d, tenantId }));
} else {
a.data = { ...(data ?? {}), tenantId };
}
return query(a);
}
case 'update': {
a.where = { ...(a.where ?? {}), tenantId };
if (a.data && 'tenantId' in a.data) delete a.data.tenantId;
return query(a);
}
case 'upsert': {
a.where = { ...(a.where ?? {}), tenantId };
a.create = { ...(a.create ?? {}), tenantId };
if (a.update && 'tenantId' in a.update) delete a.update.tenantId;
return query(a);
}
case 'delete': {
a.where = { ...(a.where ?? {}), tenantId };
return query(a);
}
default:
return query(args);
}
},
},
},
});
}
export type TenantScopedClient = ReturnType<typeof tenantScoped>;
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "@repo/config/tsconfig/base.json",
"compilerOptions": {
"noEmit": true,
"module": "ESNext",
"moduleResolution": "Bundler"
},
"include": ["src/**/*.ts", "prisma/**/*.ts"]
}