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
+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`),
],
);