wordeck/apps/api/src/db/schema/reviews.ts
Till 5f67bd9f3e Phase 3 follow-up: type-check + tests grün, ts-fsrs v5 API
- tsconfig.base.json: allowImportingTsExtensions + noEmit (.ts-Imports
  in dev, kein tsc-Output, vitest/bun/vite handhaben Build)
- ts-fsrs v5.3.2 API-Updates:
  - scheduler.next(card, now, grade) statt repeat(card, now)[rating].card
  - Grade-Type für RATING_TO_FSRS (excluded Manual)
  - learning_steps-Feld auf Review (Schema, Drizzle-Column, Adapter,
    DTO-Konverter, Tests)
- apps/web: extends .svelte-kit/tsconfig.json (SvelteKit-Empfehlung),
  test-Script mit --passWithNoTests
- apps/api: dropped types: ['bun-types'] (stale)
- pnpm-lock.yaml committed

Status:
- pnpm run type-check  4/4 packages grün (api, domain, web mit
  svelte-check 0 errors)
- pnpm run test  46 Tests grün (cards-domain: 27, apps/api: 19,
  apps/web: --passWithNoTests)
- pnpm install  136 packages, 8s

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 14:41:04 +02:00

77 lines
2.8 KiB
TypeScript

import { index, integer, primaryKey, real, text, timestamp } from 'drizzle-orm/pg-core';
import { cardsSchema } from './_schema.ts';
import { cards } from './cards.ts';
import { decks } from './decks.ts';
/**
* FSRS-Review-State pro `(card, sub_index)`.
*
* `sub_index` granular:
* - basic: 1 Review (sub_index = 0)
* - basic-reverse: 2 Reviews (0 = front→back, 1 = back→front)
* - cloze: 1 Review pro Cluster-Index ({{c1::…}} → sub_index = 1)
*
* **Bewusst PLAINTEXT** (siehe `docs/LESSONS_FROM_MANA_MONOREPO.md` §3):
* Der Scheduler quert täglich `due <= now` — Encryption müsste das
* jedes Mal entschlüsseln. mana-monorepo hat das gleiche Pattern:
* cardReviews ist plaintext-allowlisted.
*/
export const reviews = cardsSchema.table(
'reviews',
{
cardId: text('card_id')
.notNull()
.references(() => cards.id, { onDelete: 'cascade' }),
subIndex: integer('sub_index').notNull().default(0),
userId: text('user_id').notNull(),
due: timestamp('due', { withTimezone: true, mode: 'date' }).notNull(),
stability: real('stability').notNull(),
difficulty: real('difficulty').notNull(),
elapsedDays: real('elapsed_days').notNull().default(0),
scheduledDays: real('scheduled_days').notNull().default(0),
learningSteps: integer('learning_steps').notNull().default(0),
reps: integer('reps').notNull().default(0),
lapses: integer('lapses').notNull().default(0),
state: text('state', { enum: ['new', 'learning', 'review', 'relearning'] })
.notNull()
.default('new'),
lastReview: timestamp('last_review', { withTimezone: true, mode: 'date' }),
},
(t) => ({
pk: primaryKey({ columns: [t.cardId, t.subIndex] }),
// Hot Path: Scheduler quert täglich `due <= now` für einen User.
userDueIdx: index('reviews_user_due_idx').on(t.userId, t.due),
})
);
export type ReviewRow = typeof reviews.$inferSelect;
export type ReviewInsert = typeof reviews.$inferInsert;
/**
* Study-Sessions als Statistik-Layer. Eine Session läuft pro
* `(user, deck)`-Studieren-Lauf, wird beim Start angelegt und beim
* Ende mit Total-Counts geupdatet.
*/
export const studySessions = cardsSchema.table(
'study_sessions',
{
id: text('id').primaryKey(),
userId: text('user_id').notNull(),
deckId: text('deck_id')
.notNull()
.references(() => decks.id, { onDelete: 'cascade' }),
startedAt: timestamp('started_at', { withTimezone: true, mode: 'date' })
.notNull()
.defaultNow(),
finishedAt: timestamp('finished_at', { withTimezone: true, mode: 'date' }),
cardsReviewed: integer('cards_reviewed').notNull().default(0),
cardsCorrect: integer('cards_correct').notNull().default(0),
},
(t) => ({
userStartedIdx: index('sessions_user_started_idx').on(t.userId, t.startedAt),
})
);
export type StudySessionRow = typeof studySessions.$inferSelect;
export type StudySessionInsert = typeof studySessions.$inferInsert;