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:
@@ -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(),
|
||||
});
|
||||
Reference in New Issue
Block a user