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
+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(),
});