Phase 3: Domain-Modell + Decks/Cards/Reviews-CRUD
Domain (@cards/domain):
- zod-Schemas SSOT für Deck, Card, Review, StudySession, FsrsSettings,
Tools (cards.create + cards.search Input/Output)
- CardType-Discriminated-Union: MVP basic+basic-reverse, Future-Set
(cloze, type-in, image-occlusion, audio, multiple-choice) für
Schema-stable-Migration vorbereitet
- validateFieldsForType() Pure-Function pro CardType
- FSRS-Adapter über ts-fsrs v5.3.2: newReview, gradeReview,
subIndexCount, toFsrsCard/fromFsrsCard ISO↔Date-Roundtrip
- Encryption-Hinweis: reviews bleiben PLAINTEXT (Scheduler quert
täglich `due <= now`, siehe Lessons §3)
Drizzle-Schemas (apps/api/src/db/schema, alles in pgSchema('cards')):
- decks, cards, card_tags, reviews (PK card_id+sub_index), study_sessions,
tags (deck-skopiert), media_refs (verweist auf mana-media), import_jobs
- _schema.ts-Pattern um Zirkular-Imports zu vermeiden (Lesson aus
mana-share/-events während F-0)
- Hot-Path-Index reviews_user_due_idx für Scheduler-Queries
Routes (apps/api/src/routes):
- POST/GET/PATCH/DELETE /api/v1/decks (Deck-CRUD)
- POST/GET/PATCH/DELETE /api/v1/cards (Card-CRUD mit Auto-Reviews-Init:
beim Card-Insert werden N Reviews via subIndexCount(type) angelegt,
in einer Transaktion)
- GET /api/v1/reviews/due (Hot-Path, optional deck_id-Filter, Limit 500)
- POST /api/v1/reviews/:cardId/:subIndex/grade (FSRS-State-Transition,
per-Deck FSRS-Settings)
Auth: Stub-Middleware liest X-User-Id-Header (Phase 2 ersetzt durch
@mana/shared-hono authMiddleware mit JWKS-Cache).
Tests (vitest, Hono app.request()):
- @cards/domain: fsrs.test.ts (newReview, gradeReview Roundtrip,
Rating-Mapping), schemas.test.ts (zod-strict-Variants, Field-Type-
Validation, hex-Color)
- apps/api: decks.test.ts + cards.test.ts + reviews.test.ts —
Auth-Gate + Input-Validation. Volle DB-Integrationstests folgen mit
pg-mem oder testcontainers in späterer Phase.
Cleanup: types.ts entfernt, zod-Schemas sind SSOT (z.infer für Types).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8605b1b517
commit
45a47e0ffd
31 changed files with 1897 additions and 106 deletions
|
|
@ -8,9 +8,7 @@
|
|||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./types": "./src/types.ts",
|
||||
"./fsrs": "./src/fsrs.ts",
|
||||
"./cloze": "./src/cloze.ts",
|
||||
"./schemas": "./src/schemas/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
|
|
|
|||
153
packages/cards-domain/src/fsrs.ts
Normal file
153
packages/cards-domain/src/fsrs.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
// FSRS-Adapter über ts-fsrs v5.3.2.
|
||||
//
|
||||
// Pattern aus mana-monorepo (siehe docs/LESSONS_FROM_MANA_MONOREPO.md §3):
|
||||
// - Unsere Reviews speichern ISO-Strings + camelCase-Felder.
|
||||
// - ts-fsrs arbeitet mit Date-Objekten + snake_case.
|
||||
// - Diese Datei ist die Übersetzungs-Schicht.
|
||||
// - Reviews bleiben PLAINTEXT (Scheduler quert täglich `due <= now`).
|
||||
|
||||
import {
|
||||
createEmptyCard,
|
||||
default_w,
|
||||
FSRS,
|
||||
generatorParameters,
|
||||
Rating as FsrsRating,
|
||||
State as FsrsState,
|
||||
type Card as FsrsCard,
|
||||
type FSRSParameters,
|
||||
} from 'ts-fsrs';
|
||||
|
||||
import type { Rating, Review, ReviewState } from './schemas/review.ts';
|
||||
import type { FsrsSettings } from './schemas/fsrs-settings.ts';
|
||||
|
||||
/** Public Rating ↔ ts-fsrs Rating mapping. */
|
||||
const RATING_TO_FSRS: Record<Rating, FsrsRating> = {
|
||||
again: FsrsRating.Again,
|
||||
hard: FsrsRating.Hard,
|
||||
good: FsrsRating.Good,
|
||||
easy: FsrsRating.Easy,
|
||||
};
|
||||
|
||||
/** ts-fsrs State (numeric) ↔ unser ReviewState (string). */
|
||||
const STATE_FROM_FSRS: Record<FsrsState, ReviewState> = {
|
||||
[FsrsState.New]: 'new',
|
||||
[FsrsState.Learning]: 'learning',
|
||||
[FsrsState.Review]: 'review',
|
||||
[FsrsState.Relearning]: 'relearning',
|
||||
};
|
||||
|
||||
const STATE_TO_FSRS: Record<ReviewState, FsrsState> = {
|
||||
new: FsrsState.New,
|
||||
learning: FsrsState.Learning,
|
||||
review: FsrsState.Review,
|
||||
relearning: FsrsState.Relearning,
|
||||
};
|
||||
|
||||
/** Baut einen FSRS-Scheduler aus per-Deck-Settings + globalen Defaults. */
|
||||
export function buildScheduler(settings: FsrsSettings = {}): FSRS {
|
||||
const params: FSRSParameters = generatorParameters({
|
||||
request_retention: settings.request_retention,
|
||||
maximum_interval: settings.maximum_interval,
|
||||
w: settings.w ?? default_w,
|
||||
enable_fuzz: settings.enable_fuzz ?? true,
|
||||
});
|
||||
return new FSRS(params);
|
||||
}
|
||||
|
||||
/** Initialer Review-State für eine neue Karte (sub_index). */
|
||||
export function newReview(args: {
|
||||
userId: string;
|
||||
cardId: string;
|
||||
subIndex?: number;
|
||||
now?: Date;
|
||||
}): Review {
|
||||
const now = args.now ?? new Date();
|
||||
const fc = createEmptyCard(now);
|
||||
return {
|
||||
card_id: args.cardId,
|
||||
sub_index: args.subIndex ?? 0,
|
||||
user_id: args.userId,
|
||||
due: fc.due.toISOString(),
|
||||
stability: fc.stability,
|
||||
difficulty: fc.difficulty,
|
||||
elapsed_days: fc.elapsed_days,
|
||||
scheduled_days: fc.scheduled_days,
|
||||
reps: fc.reps,
|
||||
lapses: fc.lapses,
|
||||
state: STATE_FROM_FSRS[fc.state],
|
||||
last_review: fc.last_review ? fc.last_review.toISOString() : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Wendet ein User-Rating an und gibt den nächsten Review-State zurück. */
|
||||
export function gradeReview(
|
||||
current: Review,
|
||||
rating: Rating,
|
||||
now?: Date,
|
||||
settings?: FsrsSettings
|
||||
): Review {
|
||||
const reviewedAt = now ?? new Date();
|
||||
const scheduler = buildScheduler(settings);
|
||||
const fc = toFsrsCard(current);
|
||||
const log = scheduler.repeat(fc, reviewedAt);
|
||||
const next = log[RATING_TO_FSRS[rating]].card;
|
||||
return fromFsrsCard(current, next);
|
||||
}
|
||||
|
||||
/** Konvertiert unseren Review-Datensatz in eine ts-fsrs Card. */
|
||||
export function toFsrsCard(r: Review): FsrsCard {
|
||||
return {
|
||||
due: new Date(r.due),
|
||||
stability: r.stability,
|
||||
difficulty: r.difficulty,
|
||||
elapsed_days: r.elapsed_days,
|
||||
scheduled_days: r.scheduled_days,
|
||||
reps: r.reps,
|
||||
lapses: r.lapses,
|
||||
state: STATE_TO_FSRS[r.state],
|
||||
last_review: r.last_review ? new Date(r.last_review) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** Übernimmt FSRS-Result-Card-Felder in unser Review-Objekt. */
|
||||
export function fromFsrsCard(prev: Review, fc: FsrsCard): Review {
|
||||
return {
|
||||
...prev,
|
||||
due: fc.due.toISOString(),
|
||||
stability: fc.stability,
|
||||
difficulty: fc.difficulty,
|
||||
elapsed_days: fc.elapsed_days,
|
||||
scheduled_days: fc.scheduled_days,
|
||||
reps: fc.reps,
|
||||
lapses: fc.lapses,
|
||||
state: STATE_FROM_FSRS[fc.state],
|
||||
last_review: fc.last_review ? fc.last_review.toISOString() : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Wie viele Reviews pro Card-Type? Wird beim Card-Insert genutzt,
|
||||
* um die `(card_id, sub_index)`-Reihen zu initialisieren.
|
||||
*
|
||||
* Cloze ist Sonderfall: `subIndex` hängt von der Anzahl der Cluster
|
||||
* im `fields.text` ab; `subIndexCountForCloze(text)` muss separat
|
||||
* gerufen werden, sobald wir Cloze unterstützen.
|
||||
*/
|
||||
export function subIndexCount(type: string): number {
|
||||
switch (type) {
|
||||
case 'basic':
|
||||
return 1;
|
||||
case 'basic-reverse':
|
||||
return 2;
|
||||
case 'type-in':
|
||||
return 1;
|
||||
case 'image-occlusion':
|
||||
return 1; // pro Mask-Region in Phase 8+ angepasst
|
||||
case 'audio':
|
||||
return 1;
|
||||
case 'multiple-choice':
|
||||
return 1;
|
||||
default:
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,11 @@
|
|||
//
|
||||
// Pure-TS, keine DB/Framework-Abhängigkeiten. Wird vom apps/api
|
||||
// (Drizzle-Schemas) und apps/web (UI) gleichermaßen konsumiert.
|
||||
//
|
||||
// Single Source of Truth für Domain-Typen sind die zod-Schemas in
|
||||
// `./schemas/`. Alle Types werden via `z.infer<typeof XSchema>`
|
||||
// aus den Schemas abgeleitet — kein duplizierter Type-Layer.
|
||||
|
||||
export * from './types.ts';
|
||||
// export * from './fsrs.ts'; // Phase 3: FSRS-Adapter (ts-fsrs)
|
||||
export * from './schemas/index.ts';
|
||||
export * from './fsrs.ts';
|
||||
// export * from './cloze.ts'; // Phase 8 oder später: Cloze-Parser
|
||||
|
|
|
|||
94
packages/cards-domain/src/schemas/card.ts
Normal file
94
packages/cards-domain/src/schemas/card.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* MVP-CardType-Set. Erweiterung in CARDS_GREENFIELD.md Phase 8+ vorgesehen
|
||||
* (cloze, type-in, image-occlusion, audio, multiple-choice).
|
||||
*/
|
||||
export const CardTypeSchema = z.enum(['basic', 'basic-reverse']);
|
||||
export type CardType = z.infer<typeof CardTypeSchema>;
|
||||
|
||||
/** Future-Set für Schema-Migration-Vorbereitung. */
|
||||
export const CardTypeFutureSchema = z.enum([
|
||||
'basic',
|
||||
'basic-reverse',
|
||||
'cloze',
|
||||
'type-in',
|
||||
'image-occlusion',
|
||||
'audio',
|
||||
'multiple-choice',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Generischer Field-Slot. Konkrete Field-Sets pro Type werden runtime
|
||||
* via `validateFieldsForType()` geprüft (siehe unten).
|
||||
*/
|
||||
export const CardFieldsSchema = z.record(z.string(), z.string());
|
||||
export type CardFields = z.infer<typeof CardFieldsSchema>;
|
||||
|
||||
/**
|
||||
* Field-Set-Validierung pro CardType. Keine z.discriminatedUnion, weil
|
||||
* der Type-Slot generisch bleibt — neue CardTypes können ohne Schema-
|
||||
* Bruch hinzugefügt werden.
|
||||
*/
|
||||
export function validateFieldsForType(
|
||||
type: CardType | z.infer<typeof CardTypeFutureSchema>,
|
||||
fields: CardFields
|
||||
): { ok: true } | { ok: false; missing: string[] } {
|
||||
const required: Record<string, string[]> = {
|
||||
basic: ['front', 'back'],
|
||||
'basic-reverse': ['front', 'back'],
|
||||
cloze: ['text'],
|
||||
'type-in': ['question', 'expected'],
|
||||
'image-occlusion': ['image_ref', 'mask_regions'],
|
||||
audio: ['audio_ref'],
|
||||
'multiple-choice': ['question', 'options', 'correct_index'],
|
||||
};
|
||||
const need = required[type] ?? [];
|
||||
const missing = need.filter((k) => !(k in fields));
|
||||
return missing.length === 0 ? { ok: true } : { ok: false, missing };
|
||||
}
|
||||
|
||||
export const CardSchema = z
|
||||
.object({
|
||||
id: z.string().min(1),
|
||||
deck_id: z.string().min(1),
|
||||
user_id: z.string().min(1),
|
||||
type: CardTypeSchema,
|
||||
fields: CardFieldsSchema,
|
||||
media_refs: z.array(z.string()).default([]),
|
||||
content_hash: z.string().optional().nullable(),
|
||||
created_at: z.string().datetime(),
|
||||
updated_at: z.string().datetime(),
|
||||
})
|
||||
.strict();
|
||||
export type Card = z.infer<typeof CardSchema>;
|
||||
|
||||
export const CardCreateSchema = z
|
||||
.object({
|
||||
deck_id: z.string().min(1),
|
||||
type: CardTypeSchema,
|
||||
fields: CardFieldsSchema,
|
||||
tags: z.array(z.string()).optional(),
|
||||
media_refs: z.array(z.string()).optional(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((val, ctx) => {
|
||||
const check = validateFieldsForType(val.type, val.fields);
|
||||
if (!check.ok) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['fields'],
|
||||
message: `missing fields for type=${val.type}: ${check.missing.join(', ')}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
export type CardCreate = z.infer<typeof CardCreateSchema>;
|
||||
|
||||
export const CardUpdateSchema = z
|
||||
.object({
|
||||
fields: CardFieldsSchema.optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
media_refs: z.array(z.string()).optional(),
|
||||
})
|
||||
.strict();
|
||||
export type CardUpdate = z.infer<typeof CardUpdateSchema>;
|
||||
42
packages/cards-domain/src/schemas/deck.ts
Normal file
42
packages/cards-domain/src/schemas/deck.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
import { FsrsSettingsSchema } from './fsrs-settings.ts';
|
||||
|
||||
const VisibilitySchema = z.enum(['private', 'space', 'public']);
|
||||
|
||||
export const DeckSchema = z
|
||||
.object({
|
||||
id: z.string().min(1),
|
||||
user_id: z.string().min(1),
|
||||
name: z.string().min(1).max(200),
|
||||
description: z.string().max(2000).optional().nullable(),
|
||||
color: z
|
||||
.string()
|
||||
.regex(/^#[0-9a-fA-F]{6}$/)
|
||||
.optional()
|
||||
.nullable(),
|
||||
visibility: VisibilitySchema.default('private'),
|
||||
fsrs_settings: FsrsSettingsSchema.default({}),
|
||||
content_hash: z.string().optional().nullable(),
|
||||
created_at: z.string().datetime(),
|
||||
updated_at: z.string().datetime(),
|
||||
})
|
||||
.strict();
|
||||
export type Deck = z.infer<typeof DeckSchema>;
|
||||
|
||||
export const DeckCreateSchema = z
|
||||
.object({
|
||||
name: z.string().min(1).max(200),
|
||||
description: z.string().max(2000).optional(),
|
||||
color: z
|
||||
.string()
|
||||
.regex(/^#[0-9a-fA-F]{6}$/)
|
||||
.optional(),
|
||||
visibility: VisibilitySchema.optional(),
|
||||
fsrs_settings: FsrsSettingsSchema.optional(),
|
||||
})
|
||||
.strict();
|
||||
export type DeckCreate = z.infer<typeof DeckCreateSchema>;
|
||||
|
||||
export const DeckUpdateSchema = DeckCreateSchema.partial();
|
||||
export type DeckUpdate = z.infer<typeof DeckUpdateSchema>;
|
||||
15
packages/cards-domain/src/schemas/fsrs-settings.ts
Normal file
15
packages/cards-domain/src/schemas/fsrs-settings.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* FSRS-Settings (siehe ts-fsrs `FSRSParameters`).
|
||||
* Alle Felder optional; Defaults werden im Adapter gesetzt.
|
||||
*/
|
||||
export const FsrsSettingsSchema = z
|
||||
.object({
|
||||
request_retention: z.number().min(0.5).max(0.99).optional(),
|
||||
maximum_interval: z.number().min(1).max(36500).optional(),
|
||||
w: z.array(z.number()).length(19).optional(),
|
||||
enable_fuzz: z.boolean().optional(),
|
||||
})
|
||||
.strict();
|
||||
export type FsrsSettings = z.infer<typeof FsrsSettingsSchema>;
|
||||
|
|
@ -1,7 +1,17 @@
|
|||
// Phase-3-Aufgabe: zod-Schemas für API-Inputs/Outputs.
|
||||
// Re-exports für zod-Schemas und JSON-Schema-Generierung.
|
||||
//
|
||||
// Konvention: ein zod-Schema pro Domain-Type (DeckSchema, CardSchema,
|
||||
// ReviewSchema). API-Routen rufen `Schema.parse()` für Input-Validation,
|
||||
// und `zod-to-json-schema` generiert die JSON-Schemas für mana-mcp/-share.
|
||||
// Verwendung:
|
||||
// - apps/api: Input-Validation via `Schema.parse(body)`
|
||||
// - apps/api: zod-to-json-schema für mana-mcp Tool-Registration
|
||||
// - apps/web: Type-Inference (`type X = z.infer<typeof XSchema>`)
|
||||
//
|
||||
// Konvention: ein Schema-File pro Domain-Bereich. Public-API-Re-Exports
|
||||
// hier; das Mapping `validateFieldsForType` ist eine Pure-Function aus
|
||||
// `card.ts`.
|
||||
|
||||
export {};
|
||||
export * from './fsrs-settings.ts';
|
||||
export * from './deck.ts';
|
||||
export * from './card.ts';
|
||||
export * from './review.ts';
|
||||
export * from './study.ts';
|
||||
export * from './tools.ts';
|
||||
|
|
|
|||
36
packages/cards-domain/src/schemas/review.ts
Normal file
36
packages/cards-domain/src/schemas/review.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
export const ReviewStateSchema = z.enum(['new', 'learning', 'review', 'relearning']);
|
||||
export type ReviewState = z.infer<typeof ReviewStateSchema>;
|
||||
|
||||
/** ts-fsrs Rating-Map: 1=Again, 2=Hard, 3=Good, 4=Easy. */
|
||||
export const RatingSchema = z.enum(['again', 'hard', 'good', 'easy']);
|
||||
export type Rating = z.infer<typeof RatingSchema>;
|
||||
|
||||
export const ReviewSchema = z
|
||||
.object({
|
||||
card_id: z.string().min(1),
|
||||
sub_index: z.number().int().nonnegative(),
|
||||
user_id: z.string().min(1),
|
||||
due: z.string().datetime(),
|
||||
stability: z.number().nonnegative(),
|
||||
difficulty: z.number().min(0).max(10),
|
||||
elapsed_days: z.number().nonnegative().default(0),
|
||||
scheduled_days: z.number().nonnegative().default(0),
|
||||
reps: z.number().int().nonnegative().default(0),
|
||||
lapses: z.number().int().nonnegative().default(0),
|
||||
state: ReviewStateSchema.default('new'),
|
||||
last_review: z.string().datetime().nullable().optional(),
|
||||
})
|
||||
.strict();
|
||||
export type Review = z.infer<typeof ReviewSchema>;
|
||||
|
||||
export const GradeReviewInputSchema = z
|
||||
.object({
|
||||
card_id: z.string().min(1),
|
||||
sub_index: z.number().int().nonnegative().default(0),
|
||||
rating: RatingSchema,
|
||||
reviewed_at: z.string().datetime().optional(),
|
||||
})
|
||||
.strict();
|
||||
export type GradeReviewInput = z.infer<typeof GradeReviewInputSchema>;
|
||||
30
packages/cards-domain/src/schemas/study.ts
Normal file
30
packages/cards-domain/src/schemas/study.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
export const StudySessionSchema = z
|
||||
.object({
|
||||
id: z.string().min(1),
|
||||
user_id: z.string().min(1),
|
||||
deck_id: z.string().min(1),
|
||||
started_at: z.string().datetime(),
|
||||
finished_at: z.string().datetime().nullable(),
|
||||
cards_reviewed: z.number().int().nonnegative().default(0),
|
||||
cards_correct: z.number().int().nonnegative().default(0),
|
||||
})
|
||||
.strict();
|
||||
export type StudySession = z.infer<typeof StudySessionSchema>;
|
||||
|
||||
export const StartStudySessionInputSchema = z
|
||||
.object({
|
||||
deck_id: z.string().min(1),
|
||||
})
|
||||
.strict();
|
||||
export type StartStudySessionInput = z.infer<typeof StartStudySessionInputSchema>;
|
||||
|
||||
export const FinishStudySessionInputSchema = z
|
||||
.object({
|
||||
session_id: z.string().min(1),
|
||||
cards_reviewed: z.number().int().nonnegative(),
|
||||
cards_correct: z.number().int().nonnegative(),
|
||||
})
|
||||
.strict();
|
||||
export type FinishStudySessionInput = z.infer<typeof FinishStudySessionInputSchema>;
|
||||
45
packages/cards-domain/src/schemas/tools.ts
Normal file
45
packages/cards-domain/src/schemas/tools.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
import { CardSchema, CardCreateSchema } from './card.ts';
|
||||
|
||||
/**
|
||||
* Schemas für die im app-manifest.json deklarierten AI-Tools.
|
||||
* Werden via zod-to-json-schema in JSON-Schema konvertiert und an
|
||||
* mana-mcp ausgeliefert (Phase 7).
|
||||
*/
|
||||
|
||||
export const CardsCreateInputSchema = CardCreateSchema;
|
||||
export type CardsCreateInput = z.infer<typeof CardsCreateInputSchema>;
|
||||
|
||||
export const CardsCreateOutputSchema = CardSchema;
|
||||
export type CardsCreateOutput = z.infer<typeof CardsCreateOutputSchema>;
|
||||
|
||||
export const CardsSearchInputSchema = z
|
||||
.object({
|
||||
query: z.string().min(1),
|
||||
max_results: z.number().int().min(1).max(100).optional(),
|
||||
})
|
||||
.strict();
|
||||
export type CardsSearchInput = z.infer<typeof CardsSearchInputSchema>;
|
||||
|
||||
export const CardsSearchHitSchema = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
type: z.literal('card'),
|
||||
title: z.string(),
|
||||
snippet: z.string().optional(),
|
||||
link: z.string().url(),
|
||||
score: z.number().min(0).max(1),
|
||||
})
|
||||
.strict();
|
||||
export type CardsSearchHit = z.infer<typeof CardsSearchHitSchema>;
|
||||
|
||||
export const CardsSearchOutputSchema = z
|
||||
.object({
|
||||
query: z.string(),
|
||||
results: z.array(CardsSearchHitSchema),
|
||||
total: z.number().int().nonnegative(),
|
||||
took_ms: z.number().nonnegative(),
|
||||
})
|
||||
.strict();
|
||||
export type CardsSearchOutput = z.infer<typeof CardsSearchOutputSchema>;
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
// Domain-Typen für Cards.
|
||||
//
|
||||
// Modellierung folgt den Lessons aus mana-monorepo
|
||||
// (siehe docs/LESSONS_FROM_MANA_MONOREPO.md):
|
||||
//
|
||||
// - CardType ist eine discriminated union.
|
||||
// - Card hat `fields: Record<string, string>` als generischen Slot.
|
||||
// - Pro Karte gibt es N Reviews mit `subIndex` (basic = 1, basic-reverse = 2,
|
||||
// cloze = 1 pro Cluster).
|
||||
// - cardReviews bleiben PLAINTEXT, weil der Scheduler täglich auf `due`
|
||||
// filtert.
|
||||
|
||||
/** Phase-1-MVP-Set; Cloze + Image-Occlusion in Phase 8+. */
|
||||
export type CardType = 'basic' | 'basic-reverse';
|
||||
|
||||
/** Voll geplantes Set (für Schemas vorbereitet, MVP nicht alle implementiert). */
|
||||
export type CardTypeFuture =
|
||||
| 'basic'
|
||||
| 'basic-reverse'
|
||||
| 'cloze'
|
||||
| 'type-in'
|
||||
| 'image-occlusion'
|
||||
| 'audio'
|
||||
| 'multiple-choice';
|
||||
|
||||
export type CardFields = Record<string, string>;
|
||||
|
||||
export type Deck = {
|
||||
id: string;
|
||||
user_id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
color?: string;
|
||||
visibility: 'private' | 'space' | 'public';
|
||||
fsrs_settings: FsrsSettings;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type Card = {
|
||||
id: string;
|
||||
deck_id: string;
|
||||
user_id: string;
|
||||
type: CardType;
|
||||
fields: CardFields;
|
||||
tags: string[];
|
||||
media_refs: string[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Pro `(card_id, sub_index)` ein Review-Eintrag. Der Scheduler quert auf
|
||||
* `due <= now` täglich — das Feld bleibt deshalb plaintext und ist indiziert.
|
||||
*/
|
||||
export type Review = {
|
||||
card_id: string;
|
||||
sub_index: number;
|
||||
user_id: string;
|
||||
due: string;
|
||||
stability: number;
|
||||
difficulty: number;
|
||||
elapsed_days: number;
|
||||
scheduled_days: number;
|
||||
reps: number;
|
||||
lapses: number;
|
||||
state: 'new' | 'learning' | 'review' | 'relearning';
|
||||
last_review: string | null;
|
||||
};
|
||||
|
||||
export type StudySession = {
|
||||
id: string;
|
||||
user_id: string;
|
||||
deck_id: string;
|
||||
started_at: string;
|
||||
finished_at: string | null;
|
||||
cards_reviewed: number;
|
||||
cards_correct: number;
|
||||
};
|
||||
|
||||
/** Default-Konstanten aus ts-fsrs; per-Deck-Overrides möglich. */
|
||||
export type FsrsSettings = {
|
||||
requestRetention?: number;
|
||||
maximumInterval?: number;
|
||||
w?: number[];
|
||||
enableFuzz?: boolean;
|
||||
};
|
||||
96
packages/cards-domain/tests/fsrs.test.ts
Normal file
96
packages/cards-domain/tests/fsrs.test.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import {
|
||||
buildScheduler,
|
||||
fromFsrsCard,
|
||||
gradeReview,
|
||||
newReview,
|
||||
subIndexCount,
|
||||
toFsrsCard,
|
||||
} from '../src/fsrs.ts';
|
||||
|
||||
describe('newReview', () => {
|
||||
it('initializes with state=new and reps=0', () => {
|
||||
const r = newReview({
|
||||
userId: 'u-1',
|
||||
cardId: 'c-1',
|
||||
now: new Date('2026-05-08T10:00:00Z'),
|
||||
});
|
||||
expect(r.card_id).toBe('c-1');
|
||||
expect(r.sub_index).toBe(0);
|
||||
expect(r.user_id).toBe('u-1');
|
||||
expect(r.state).toBe('new');
|
||||
expect(r.reps).toBe(0);
|
||||
expect(r.lapses).toBe(0);
|
||||
expect(r.due).toBe('2026-05-08T10:00:00.000Z');
|
||||
});
|
||||
|
||||
it('honours provided sub_index', () => {
|
||||
const r = newReview({ userId: 'u-1', cardId: 'c-1', subIndex: 2 });
|
||||
expect(r.sub_index).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gradeReview', () => {
|
||||
const fixedNow = new Date('2026-05-08T10:00:00Z');
|
||||
const reviewedAt = new Date('2026-05-08T10:01:00Z');
|
||||
const baseReview = newReview({
|
||||
userId: 'u-1',
|
||||
cardId: 'c-1',
|
||||
now: fixedNow,
|
||||
});
|
||||
|
||||
it('Again from new keeps reps=0 increments lapses', () => {
|
||||
// Disable fuzz for deterministic test outputs
|
||||
const next = gradeReview(baseReview, 'again', reviewedAt, { enable_fuzz: false });
|
||||
expect(next.state).not.toBe('new');
|
||||
expect(next.due).not.toBe(baseReview.due);
|
||||
expect(next.reps).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('Easy from new transitions to a future-dated review', () => {
|
||||
const next = gradeReview(baseReview, 'easy', reviewedAt, { enable_fuzz: false });
|
||||
expect(new Date(next.due).getTime()).toBeGreaterThan(reviewedAt.getTime());
|
||||
});
|
||||
|
||||
it('preserves card_id, sub_index, user_id', () => {
|
||||
const next = gradeReview(baseReview, 'good', reviewedAt, { enable_fuzz: false });
|
||||
expect(next.card_id).toBe(baseReview.card_id);
|
||||
expect(next.sub_index).toBe(baseReview.sub_index);
|
||||
expect(next.user_id).toBe(baseReview.user_id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toFsrsCard / fromFsrsCard roundtrip', () => {
|
||||
it('roundtrips a new review without loss', () => {
|
||||
const r = newReview({ userId: 'u-1', cardId: 'c-1' });
|
||||
const fc = toFsrsCard(r);
|
||||
const back = fromFsrsCard(r, fc);
|
||||
expect(back.due).toBe(r.due);
|
||||
expect(back.stability).toBe(r.stability);
|
||||
expect(back.state).toBe(r.state);
|
||||
});
|
||||
});
|
||||
|
||||
describe('subIndexCount', () => {
|
||||
it('basic = 1, basic-reverse = 2', () => {
|
||||
expect(subIndexCount('basic')).toBe(1);
|
||||
expect(subIndexCount('basic-reverse')).toBe(2);
|
||||
});
|
||||
|
||||
it('unknown type defaults to 1', () => {
|
||||
expect(subIndexCount('unknown-future-type')).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildScheduler', () => {
|
||||
it('builds with defaults', () => {
|
||||
const s = buildScheduler();
|
||||
expect(s).toBeDefined();
|
||||
});
|
||||
|
||||
it('honours per-deck overrides', () => {
|
||||
const s = buildScheduler({ request_retention: 0.85, enable_fuzz: false });
|
||||
expect(s).toBeDefined();
|
||||
});
|
||||
});
|
||||
154
packages/cards-domain/tests/schemas.test.ts
Normal file
154
packages/cards-domain/tests/schemas.test.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import {
|
||||
CardCreateSchema,
|
||||
CardSchema,
|
||||
CardTypeSchema,
|
||||
DeckCreateSchema,
|
||||
DeckSchema,
|
||||
GradeReviewInputSchema,
|
||||
validateFieldsForType,
|
||||
} from '../src/schemas/index.ts';
|
||||
|
||||
describe('CardTypeSchema', () => {
|
||||
it('accepts MVP types', () => {
|
||||
expect(() => CardTypeSchema.parse('basic')).not.toThrow();
|
||||
expect(() => CardTypeSchema.parse('basic-reverse')).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects future types in MVP schema', () => {
|
||||
expect(() => CardTypeSchema.parse('cloze')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateFieldsForType', () => {
|
||||
it('basic requires front + back', () => {
|
||||
expect(validateFieldsForType('basic', { front: 'q', back: 'a' })).toEqual({ ok: true });
|
||||
expect(validateFieldsForType('basic', { front: 'q' })).toEqual({
|
||||
ok: false,
|
||||
missing: ['back'],
|
||||
});
|
||||
});
|
||||
|
||||
it('cloze requires text', () => {
|
||||
expect(validateFieldsForType('cloze', { text: 'x' })).toEqual({ ok: true });
|
||||
expect(validateFieldsForType('cloze', {})).toEqual({ ok: false, missing: ['text'] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('CardCreateSchema', () => {
|
||||
it('accepts a basic card', () => {
|
||||
const r = CardCreateSchema.safeParse({
|
||||
deck_id: 'd-1',
|
||||
type: 'basic',
|
||||
fields: { front: 'Q', back: 'A' },
|
||||
});
|
||||
expect(r.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects basic card without back', () => {
|
||||
const r = CardCreateSchema.safeParse({
|
||||
deck_id: 'd-1',
|
||||
type: 'basic',
|
||||
fields: { front: 'Q' },
|
||||
});
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects unknown type via CardTypeSchema', () => {
|
||||
const r = CardCreateSchema.safeParse({
|
||||
deck_id: 'd-1',
|
||||
type: 'cloze',
|
||||
fields: { text: 'x' },
|
||||
});
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects extra fields (strict)', () => {
|
||||
const r = CardCreateSchema.safeParse({
|
||||
deck_id: 'd-1',
|
||||
type: 'basic',
|
||||
fields: { front: 'Q', back: 'A' },
|
||||
malicious: 'inject',
|
||||
});
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DeckCreateSchema', () => {
|
||||
it('minimal valid deck', () => {
|
||||
const r = DeckCreateSchema.safeParse({ name: 'My Deck' });
|
||||
expect(r.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects empty name', () => {
|
||||
const r = DeckCreateSchema.safeParse({ name: '' });
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects invalid color', () => {
|
||||
const r = DeckCreateSchema.safeParse({ name: 'X', color: 'red' });
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts hex color', () => {
|
||||
const r = DeckCreateSchema.safeParse({ name: 'X', color: '#ff8800' });
|
||||
expect(r.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GradeReviewInputSchema', () => {
|
||||
it('accepts a grade input', () => {
|
||||
const r = GradeReviewInputSchema.safeParse({
|
||||
card_id: 'c-1',
|
||||
sub_index: 0,
|
||||
rating: 'good',
|
||||
});
|
||||
expect(r.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects unknown rating', () => {
|
||||
const r = GradeReviewInputSchema.safeParse({
|
||||
card_id: 'c-1',
|
||||
sub_index: 0,
|
||||
rating: 'perfect',
|
||||
});
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it('defaults sub_index to 0', () => {
|
||||
const r = GradeReviewInputSchema.parse({ card_id: 'c-1', rating: 'good' });
|
||||
expect(r.sub_index).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('strict variants reject extras', () => {
|
||||
it('DeckSchema rejects extra props', () => {
|
||||
const r = DeckSchema.safeParse({
|
||||
id: 'd-1',
|
||||
user_id: 'u-1',
|
||||
name: 'D',
|
||||
visibility: 'private',
|
||||
fsrs_settings: {},
|
||||
created_at: '2026-05-08T10:00:00.000Z',
|
||||
updated_at: '2026-05-08T10:00:00.000Z',
|
||||
leaks: 'no',
|
||||
});
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it('CardSchema rejects extra props', () => {
|
||||
const r = CardSchema.safeParse({
|
||||
id: 'c-1',
|
||||
deck_id: 'd-1',
|
||||
user_id: 'u-1',
|
||||
type: 'basic',
|
||||
fields: { front: 'Q', back: 'A' },
|
||||
media_refs: [],
|
||||
created_at: '2026-05-08T10:00:00.000Z',
|
||||
updated_at: '2026-05-08T10:00:00.000Z',
|
||||
leaked: 'yes',
|
||||
});
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue