feat(db): drizzle-schema, db-client und initiale migration

Vollstaendiges Datenmodell: Katalog (manufacturer, theme, set, tag,
instruction), Besitz (collection_item, location, missing_part, photo),
Wunschlisten (wishlist, wishlist_item) und die Auth-Tabellen von
better-auth inklusive passkey und invite.

Kernregel: ein set ist ein Katalogeintrag, ein collection_item die
Verknuepfung mit dem tatsaechlichen Besitz. Preise liegen als Cent in
INTEGER-Spalten, damit Summen exakt bleiben.

SQLite laeuft mit WAL, foreign_keys=ON und busy_timeout.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
unknown
2026-09-06 22:25:22 +02:00
co-authored by Claude Opus 5
parent 0de49a9f94
commit 06268bcfd6
14 changed files with 2879 additions and 0 deletions
+41
View File
@@ -0,0 +1,41 @@
import fs from 'node:fs';
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import { env } from '@/env';
import * as schema from './schema';
/**
* SQLite connection for the whole app.
*
* Cached on globalThis so Next's dev-mode module reloading does not open a new
* file handle on every hot reload.
*/
function createConnection() {
fs.mkdirSync(env.DB_DIR, { recursive: true });
const sqlite = new Database(env.DB_PATH);
// WAL keeps readers from blocking the single writer - required for a server
// that renders pages while a background action writes.
sqlite.pragma('journal_mode = WAL');
// NORMAL is the recommended durability level in WAL mode.
sqlite.pragma('synchronous = NORMAL');
// SQLite has foreign keys off by default; our schema relies on them.
sqlite.pragma('foreign_keys = ON');
// Wait instead of throwing SQLITE_BUSY when another write is in flight.
sqlite.pragma('busy_timeout = 5000');
return drizzle(sqlite, { schema });
}
const globalForDb = globalThis as unknown as {
bricktrackDb?: ReturnType<typeof createConnection>;
};
export const db = globalForDb.bricktrackDb ?? createConnection();
if (!env.isProduction) {
globalForDb.bricktrackDb = db;
}
export { schema };
export type Db = typeof db;
+126
View File
@@ -0,0 +1,126 @@
import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
/**
* Tables owned by better-auth.
*
* Column names are camelCase here (unlike the rest of the schema) because that
* is exactly what `@better-auth/cli generate` produces. Renaming them is a
* reliable source of breakage whenever a plugin is added or updated.
*/
export const user = sqliteTable('user', {
id: text('id').primaryKey(),
name: text('name').notNull(),
email: text('email').notNull().unique(),
emailVerified: integer('emailVerified', { mode: 'boolean' }).notNull().default(false),
image: text('image'),
/** App specific: the first registered user becomes admin, everyone else member. */
role: text('role', { enum: ['admin', 'member'] })
.notNull()
.default('member'),
createdAt: integer('createdAt', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updatedAt', { mode: 'timestamp' }).notNull(),
});
export const session = sqliteTable(
'session',
{
id: text('id').primaryKey(),
token: text('token').notNull().unique(),
userId: text('userId')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
expiresAt: integer('expiresAt', { mode: 'timestamp' }).notNull(),
ipAddress: text('ipAddress'),
userAgent: text('userAgent'),
createdAt: integer('createdAt', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updatedAt', { mode: 'timestamp' }).notNull(),
},
(t) => [index('session_user_idx').on(t.userId)],
);
/** Credential provider only for now; `password` holds the hashed password. */
export const account = sqliteTable(
'account',
{
id: text('id').primaryKey(),
accountId: text('accountId').notNull(),
providerId: text('providerId').notNull(),
userId: text('userId')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
accessToken: text('accessToken'),
refreshToken: text('refreshToken'),
idToken: text('idToken'),
accessTokenExpiresAt: integer('accessTokenExpiresAt', { mode: 'timestamp' }),
refreshTokenExpiresAt: integer('refreshTokenExpiresAt', { mode: 'timestamp' }),
scope: text('scope'),
password: text('password'),
createdAt: integer('createdAt', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updatedAt', { mode: 'timestamp' }).notNull(),
},
(t) => [index('account_user_idx').on(t.userId)],
);
export const verification = sqliteTable(
'verification',
{
id: text('id').primaryKey(),
identifier: text('identifier').notNull(),
value: text('value').notNull(),
expiresAt: integer('expiresAt', { mode: 'timestamp' }).notNull(),
createdAt: integer('createdAt', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updatedAt', { mode: 'timestamp' }).notNull(),
},
(t) => [index('verification_identifier_idx').on(t.identifier)],
);
/** @better-auth/passkey - a user may register any number of passkeys. */
export const passkey = sqliteTable(
'passkey',
{
id: text('id').primaryKey(),
name: text('name'),
publicKey: text('publicKey').notNull(),
userId: text('userId')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
credentialID: text('credentialID').notNull().unique(),
counter: integer('counter').notNull(),
deviceType: text('deviceType').notNull(),
backedUp: integer('backedUp', { mode: 'boolean' }).notNull(),
transports: text('transports'),
aaguid: text('aaguid'),
createdAt: integer('createdAt', { mode: 'timestamp' }),
},
(t) => [index('passkey_user_idx').on(t.userId)],
);
/** Persistent rate limiting so restarts do not reset login throttling. */
export const rateLimit = sqliteTable('rateLimit', {
id: text('id').primaryKey(),
key: text('key'),
count: integer('count'),
lastRequest: integer('lastRequest'),
});
/**
* Self-built invitations: after the first user, registration is only possible
* through a link an admin created. Used from phase 2 on.
*/
export const invite = sqliteTable('invite', {
id: text('id').primaryKey(),
/** SHA-256 of the link token - the plaintext only ever lives in the link. */
tokenHash: text('token_hash').notNull().unique(),
email: text('email'),
role: text('role', { enum: ['admin', 'member'] })
.notNull()
.default('member'),
createdByUserId: text('created_by_user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull(),
usedAt: integer('used_at', { mode: 'timestamp' }),
usedByUserId: text('used_by_user_id').references(() => user.id, { onDelete: 'set null' }),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
});
+127
View File
@@ -0,0 +1,127 @@
import { sql, type AnyColumn } from 'drizzle-orm';
import {
check,
index,
integer,
primaryKey,
sqliteTable,
text,
uniqueIndex,
} from 'drizzle-orm/sqlite-core';
export const manufacturer = sqliteTable('manufacturer', {
id: text('id').primaryKey(),
name: text('name').notNull(),
slug: text('slug').notNull().unique(),
website: text('website'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
});
export const theme = sqliteTable(
'theme',
{
id: text('id').primaryKey(),
manufacturerId: text('manufacturer_id')
.notNull()
.references(() => manufacturer.id, { onDelete: 'cascade' }),
parentThemeId: text('parent_theme_id').references((): AnyColumn => theme.id, {
onDelete: 'set null',
}),
name: text('name').notNull(),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
},
(t) => [
index('theme_manufacturer_idx').on(t.manufacturerId),
uniqueIndex('theme_unique_name').on(t.manufacturerId, t.name),
],
);
/**
* A catalog entry. The set exists in the world, independent of whether we own
* it - ownership is expressed by `collection_item`.
*/
export const set = sqliteTable(
'set',
{
id: text('id').primaryKey(),
manufacturerId: text('manufacturer_id')
.notNull()
.references(() => manufacturer.id, { onDelete: 'restrict' }),
themeId: text('theme_id').references(() => theme.id, { onDelete: 'set null' }),
/** Manufacturer's own number, e.g. "60398", "71427", "2-30500". */
setNumber: text('set_number').notNull(),
name: text('name').notNull(),
year: integer('year'),
pieceCount: integer('piece_count'),
minifigCount: integer('minifig_count'),
ean: text('ean'),
ageRange: text('age_range'),
/** Either an external URL the user entered ... */
imageUrl: text('image_url'),
/** ... or a locally stored file below FILES_DIR. */
imagePath: text('image_path'),
/** EUR in cents - SQLite has no decimal type and sums must stay exact. */
retailPriceCents: integer('retail_price_cents'),
externalIds: text('external_ids', { mode: 'json' }).$type<Record<string, string>>(),
notes: text('notes'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
},
(t) => [
uniqueIndex('set_manufacturer_number_unique').on(t.manufacturerId, t.setNumber),
index('set_name_idx').on(t.name),
index('set_ean_idx').on(t.ean),
index('set_theme_idx').on(t.themeId),
index('set_year_idx').on(t.year),
],
);
export const tag = sqliteTable('tag', {
id: text('id').primaryKey(),
name: text('name').notNull(),
slug: text('slug').notNull().unique(),
});
export const setTag = sqliteTable(
'set_tag',
{
setId: text('set_id')
.notNull()
.references(() => set.id, { onDelete: 'cascade' }),
tagId: text('tag_id')
.notNull()
.references(() => tag.id, { onDelete: 'cascade' }),
},
(t) => [primaryKey({ columns: [t.setId, t.tagId] })],
);
export const instruction = sqliteTable(
'instruction',
{
id: text('id').primaryKey(),
setId: text('set_id')
.notNull()
.references(() => set.id, { onDelete: 'cascade' }),
kind: text('kind', { enum: ['pdf_upload', 'external_url'] }).notNull(),
/** Set when kind = pdf_upload. */
filePath: text('file_path'),
/** Set when kind = external_url. */
url: text('url'),
/** Free text label, e.g. "Buch 1 von 3". */
label: text('label'),
pageCount: integer('page_count'),
fileSizeBytes: integer('file_size_bytes'),
sortOrder: integer('sort_order').notNull().default(0),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
},
(t) => [
index('instruction_set_idx').on(t.setId),
check(
'instruction_source',
sql`(${t.kind} = 'pdf_upload' AND ${t.filePath} IS NOT NULL AND ${t.url} IS NULL)
OR (${t.kind} = 'external_url' AND ${t.url} IS NOT NULL AND ${t.filePath} IS NULL)`,
),
],
);
+95
View File
@@ -0,0 +1,95 @@
import { sql, type AnyColumn } from 'drizzle-orm';
import { check, index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
import { set } from './catalog';
export const location = sqliteTable('location', {
id: text('id').primaryKey(),
/** e.g. "Regal Kinderzimmer", "Kiste Keller 3". */
name: text('name').notNull(),
parentLocationId: text('parent_location_id').references((): AnyColumn => location.id, {
onDelete: 'set null',
}),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
});
/**
* Ownership: links a catalog set to a copy we actually have. Owning the same
* set twice means two rows.
*/
export const collectionItem = sqliteTable(
'collection_item',
{
id: text('id').primaryKey(),
setId: text('set_id')
.notNull()
.references(() => set.id, { onDelete: 'cascade' }),
status: text('status', {
enum: ['owned', 'built', 'disassembled', 'lent_out', 'sold'],
})
.notNull()
.default('owned'),
completeness: text('completeness', { enum: ['complete', 'missing_parts', 'unknown'] })
.notNull()
.default('unknown'),
condition: text('condition', {
enum: ['new_sealed', 'like_new', 'good', 'used', 'damaged'],
}),
locationId: text('location_id').references(() => location.id, { onDelete: 'set null' }),
acquiredAt: integer('acquired_at', { mode: 'timestamp' }),
acquiredPriceCents: integer('acquired_price_cents'),
acquiredFrom: text('acquired_from'),
giftedBy: text('gifted_by'),
lentOutTo: text('lent_out_to'),
notes: text('notes'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
},
(t) => [
index('collection_item_set_idx').on(t.setId),
index('collection_item_status_idx').on(t.status),
index('collection_item_location_idx').on(t.locationId),
],
);
export const missingPart = sqliteTable(
'missing_part',
{
id: text('id').primaryKey(),
collectionItemId: text('collection_item_id')
.notNull()
.references(() => collectionItem.id, { onDelete: 'cascade' }),
partNumber: text('part_number'),
colorName: text('color_name'),
quantity: integer('quantity').notNull().default(1),
resolved: integer('resolved', { mode: 'boolean' }).notNull().default(false),
notes: text('notes'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
},
(t) => [index('missing_part_item_idx').on(t.collectionItemId)],
);
/** Belongs to either a catalog set or one owned copy - never to both. */
export const photo = sqliteTable(
'photo',
{
id: text('id').primaryKey(),
setId: text('set_id').references(() => set.id, { onDelete: 'cascade' }),
collectionItemId: text('collection_item_id').references(() => collectionItem.id, {
onDelete: 'cascade',
}),
filePath: text('file_path').notNull(),
thumbnailPath: text('thumbnail_path'),
caption: text('caption'),
sortOrder: integer('sort_order').notNull().default(0),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
},
(t) => [
index('photo_set_idx').on(t.setId),
index('photo_item_idx').on(t.collectionItemId),
check(
'photo_single_owner',
sql`(${t.setId} IS NOT NULL) <> (${t.collectionItemId} IS NOT NULL)`,
),
],
);
+5
View File
@@ -0,0 +1,5 @@
export * from './auth';
export * from './catalog';
export * from './collection';
export * from './wishlist';
export * from './relations';
+109
View File
@@ -0,0 +1,109 @@
import { relations } from 'drizzle-orm';
import { passkey, session, user } from './auth';
import { instruction, manufacturer, set, setTag, tag, theme } from './catalog';
import { collectionItem, location, missingPart, photo } from './collection';
import { wishlist, wishlistItem } from './wishlist';
export const userRelations = relations(user, ({ many }) => ({
sessions: many(session),
passkeys: many(passkey),
wishlists: many(wishlist),
}));
export const sessionRelations = relations(session, ({ one }) => ({
user: one(user, { fields: [session.userId], references: [user.id] }),
}));
export const passkeyRelations = relations(passkey, ({ one }) => ({
user: one(user, { fields: [passkey.userId], references: [user.id] }),
}));
export const manufacturerRelations = relations(manufacturer, ({ many }) => ({
themes: many(theme),
sets: many(set),
}));
export const themeRelations = relations(theme, ({ one, many }) => ({
manufacturer: one(manufacturer, {
fields: [theme.manufacturerId],
references: [manufacturer.id],
}),
parent: one(theme, {
fields: [theme.parentThemeId],
references: [theme.id],
relationName: 'themeHierarchy',
}),
children: many(theme, { relationName: 'themeHierarchy' }),
sets: many(set),
}));
export const setRelations = relations(set, ({ one, many }) => ({
manufacturer: one(manufacturer, {
fields: [set.manufacturerId],
references: [manufacturer.id],
}),
theme: one(theme, { fields: [set.themeId], references: [theme.id] }),
collectionItems: many(collectionItem),
instructions: many(instruction),
photos: many(photo),
setTags: many(setTag),
wishlistItems: many(wishlistItem),
}));
export const tagRelations = relations(tag, ({ many }) => ({
setTags: many(setTag),
}));
export const setTagRelations = relations(setTag, ({ one }) => ({
set: one(set, { fields: [setTag.setId], references: [set.id] }),
tag: one(tag, { fields: [setTag.tagId], references: [tag.id] }),
}));
export const instructionRelations = relations(instruction, ({ one }) => ({
set: one(set, { fields: [instruction.setId], references: [set.id] }),
}));
export const locationRelations = relations(location, ({ one, many }) => ({
parent: one(location, {
fields: [location.parentLocationId],
references: [location.id],
relationName: 'locationHierarchy',
}),
children: many(location, { relationName: 'locationHierarchy' }),
collectionItems: many(collectionItem),
}));
export const collectionItemRelations = relations(collectionItem, ({ one, many }) => ({
set: one(set, { fields: [collectionItem.setId], references: [set.id] }),
location: one(location, {
fields: [collectionItem.locationId],
references: [location.id],
}),
missingParts: many(missingPart),
photos: many(photo),
}));
export const missingPartRelations = relations(missingPart, ({ one }) => ({
collectionItem: one(collectionItem, {
fields: [missingPart.collectionItemId],
references: [collectionItem.id],
}),
}));
export const photoRelations = relations(photo, ({ one }) => ({
set: one(set, { fields: [photo.setId], references: [set.id] }),
collectionItem: one(collectionItem, {
fields: [photo.collectionItemId],
references: [collectionItem.id],
}),
}));
export const wishlistRelations = relations(wishlist, ({ one, many }) => ({
owner: one(user, { fields: [wishlist.ownerUserId], references: [user.id] }),
items: many(wishlistItem),
}));
export const wishlistItemRelations = relations(wishlistItem, ({ one }) => ({
wishlist: one(wishlist, { fields: [wishlistItem.wishlistId], references: [wishlist.id] }),
set: one(set, { fields: [wishlistItem.setId], references: [set.id] }),
}));
+60
View File
@@ -0,0 +1,60 @@
import { sql } from 'drizzle-orm';
import { check, index, integer, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core';
import { user } from './auth';
import { set } from './catalog';
export const wishlist = sqliteTable(
'wishlist',
{
id: text('id').primaryKey(),
ownerUserId: text('owner_user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
/** e.g. "Weihnachten 2026". */
title: text('title').notNull(),
occasion: text('occasion', { enum: ['christmas', 'birthday', 'easter', 'other'] })
.notNull()
.default('other'),
eventDate: integer('event_date', { mode: 'timestamp' }),
/** 32 random bytes, base64url encoded - the only key to the public view. */
shareToken: text('share_token').notNull().unique(),
isPublic: integer('is_public', { mode: 'boolean' }).notNull().default(false),
/** When on, the owner sees "reserviert" but not by whom. */
surpriseMode: integer('surprise_mode', { mode: 'boolean' }).notNull().default(false),
/** Message shown to the gift givers, e.g. "Bitte keine Doppelungen". */
note: text('note'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
},
(t) => [index('wishlist_owner_idx').on(t.ownerUserId)],
);
export const wishlistItem = sqliteTable(
'wishlist_item',
{
id: text('id').primaryKey(),
wishlistId: text('wishlist_id')
.notNull()
.references(() => wishlist.id, { onDelete: 'cascade' }),
setId: text('set_id')
.notNull()
.references(() => set.id, { onDelete: 'cascade' }),
/** 1 = highest, 3 = lowest. */
priority: integer('priority').notNull().default(2),
note: text('note'),
sortOrder: integer('sort_order').notNull().default(0),
reservedByName: text('reserved_by_name'),
reservedAt: integer('reserved_at', { mode: 'timestamp' }),
/** SHA-256 of the cancellation token; plaintext only lives in the link. */
reservationTokenHash: text('reservation_token_hash'),
/** Set once the set showed up in the collection. */
fulfilledAt: integer('fulfilled_at', { mode: 'timestamp' }),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
},
(t) => [
// Makes "skip duplicates" a database guarantee, not just a UI rule.
uniqueIndex('wishlist_item_unique').on(t.wishlistId, t.setId),
index('wishlist_item_list_idx').on(t.wishlistId, t.sortOrder),
check('wishlist_item_priority', sql`${t.priority} BETWEEN 1 AND 3`),
],
);
+35
View File
@@ -0,0 +1,35 @@
import type {
collectionItem,
instruction,
location,
manufacturer,
missingPart,
photo,
set,
tag,
theme,
user,
wishlist,
wishlistItem,
} from './schema';
export type User = typeof user.$inferSelect;
export type Manufacturer = typeof manufacturer.$inferSelect;
export type Theme = typeof theme.$inferSelect;
export type Set = typeof set.$inferSelect;
export type NewSet = typeof set.$inferInsert;
export type Instruction = typeof instruction.$inferSelect;
export type Location = typeof location.$inferSelect;
export type CollectionItem = typeof collectionItem.$inferSelect;
export type NewCollectionItem = typeof collectionItem.$inferInsert;
export type MissingPart = typeof missingPart.$inferSelect;
export type Photo = typeof photo.$inferSelect;
export type Tag = typeof tag.$inferSelect;
export type Wishlist = typeof wishlist.$inferSelect;
export type WishlistItem = typeof wishlistItem.$inferSelect;
export type CollectionStatus = CollectionItem['status'];
export type Completeness = CollectionItem['completeness'];
export type Condition = NonNullable<CollectionItem['condition']>;
export type Occasion = Wishlist['occasion'];
export type UserRole = User['role'];
+72
View File
@@ -0,0 +1,72 @@
import path from 'node:path';
import { z } from 'zod';
/**
* Central, validated access to the runtime configuration.
*
* Everything the app needs to know about its environment is derived here so
* that a misconfigured container fails fast and loudly instead of misbehaving
* at request time. Only imported from server-side code and scripts.
*/
const schema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
/**
* Public origin the app is reached under, e.g. https://bricktrack.example.de.
* TLS is terminated by the reverse proxy; this value is what the browser sees
* and therefore defines the WebAuthn relying party ID.
*/
BASE_URL: z.url().default('http://localhost:3000'),
/** Signing secret for sessions and tokens. */
BETTER_AUTH_SECRET: z.string().min(32, 'BETTER_AUTH_SECRET braucht mindestens 32 Zeichen'),
/** Single directory that holds everything worth backing up. */
DATA_DIR: z.string().min(1).default('./data'),
/** Upload size limit per file, in megabytes. */
MAX_UPLOAD_MB: z.coerce.number().int().positive().max(200).default(25),
/** Optional, phase 2: Rebrickable set import. Import stays disabled without it. */
REBRICKABLE_API_KEY: z.string().min(1).optional(),
});
function parseEnv() {
const parsed = schema.safeParse(process.env);
if (!parsed.success) {
const issues = parsed.error.issues
.map((issue) => ` - ${issue.path.join('.')}: ${issue.message}`)
.join('\n');
throw new Error(`Ungueltige Umgebungsvariablen:\n${issues}`);
}
return parsed.data;
}
const raw = parseEnv();
const dataDir = path.resolve(raw.DATA_DIR);
const baseUrl = new URL(raw.BASE_URL);
export const env = {
...raw,
BASE_URL: baseUrl.origin,
/** Absolute paths derived from the single data directory. */
DATA_DIR: dataDir,
DB_DIR: path.join(dataDir, 'db'),
DB_PATH: path.join(dataDir, 'db', 'bricktrack.db'),
FILES_DIR: path.join(dataDir, 'files'),
/**
* WebAuthn relying party ID: the registrable domain of BASE_URL. Passkeys are
* bound to it, so changing the domain invalidates existing credentials.
*/
RP_ID: baseUrl.hostname,
RP_NAME: 'Bricktrack',
MAX_UPLOAD_BYTES: raw.MAX_UPLOAD_MB * 1024 * 1024,
isProduction: raw.NODE_ENV === 'production',
} as const;
export type Env = typeof env;