feat(web): event-sync migration L-1f Phase B — anonymous-mode + lokal-first CRUD
Some checks are pending
CI / validate (push) Waiting to run
Some checks are pending
CI / validate (push) Waiting to run
L-1f Phase B von mana/docs/playbooks/LOCAL_FIRST_LOGIN_OPTIONAL.md: apps/web: - neue Foundation: lib/sync.svelte.ts mit @mana/event-sync v0.5.0, Vault-Crypto via createMasterKeyProviderFromVault, anonymous-Mode aktiv (getToken returnt null bis Login) - lib/api/event-adapters.ts: konvertiert WordeckDeckState/CardState/ ReviewState (camelCase, event-sourced) → Deck/Card/Review (snake_case, @wordeck/domain), stable aggregate-id-Helpers - lib/api/event-builder.ts: baut EventEnvelope mit aktuellem attributedToUserId (real-user-id wenn signed-in, anon:<ulid> sonst) - lib/api/decks.ts: list/get/create/update/delete/archive auf event-sync. Marketplace-Source, duplicate, AI-generate bleiben HTTP (cross-user bzw. Tier-gated, Server-AI-Calls) - lib/api/cards.ts: list/get/create/update/delete auf event-sync, Review-Init-Events beim CardCreate (basic-reverse → 2 Reviews), content_hash client-side via @wordeck/domain - lib/api/reviews.ts: listDueReviews via aggregateList, gradeReview rechnet FSRS client-side via @wordeck/domain.gradeReview, emittet ReviewGraded mit prevSnapshotJson für undo - routes/+page.svelte: kein redirect-to-/login mehr — direkt /decks (anonymous-Mode trägt's) - routes/auth/callback: ruft onSignedIn(realUserId) für anonyme→real Re-Tagging der Outbox + EventLog shared-schemas/wordeck: - CardType-Korrektur: 'type-in' → 'typing' (matched @wordeck/domain CardType-Schema). Schema-Hash neu: da67d23cf100…, re-published auf sync2.mana.how + @mana/shared-schemas@0.1.10 Verbleibend für L-1f Phase C (nächste Session): - Wordeck-API server-seitige Routes auf Read-Only-Projektion umstellen (heute noch Read+Write, wir lassen es da bis wir sicher sind dass event-sync stabil läuft) - /api/v1/public/feed Endpoint (oder Marketplace-Alias) - DSE-Update (lokale Speicherung, anonyme Nutzung erwähnen) - Live-Smoke auf wordeck.com Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2ee706bab1
commit
823560900c
10 changed files with 3347 additions and 107 deletions
|
|
@ -15,9 +15,12 @@
|
|||
"clean": "rm -rf .svelte-kit build .turbo"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mana/event-kit": "^0.1.0",
|
||||
"@mana/event-sync": "^0.5.0",
|
||||
"@mana/shared-auth-sso": "0.1.0-alpha.3",
|
||||
"@mana/shared-icons": "^1.0.0",
|
||||
"@mana/shared-pwa": "0.1.0-alpha.5",
|
||||
"@mana/shared-schemas": "^0.1.10",
|
||||
"@mana/shared-ui-2": "^0.1.0",
|
||||
"@mana/themes": "^0.1.0",
|
||||
"@vite-pwa/sveltekit": "^1.1.0",
|
||||
|
|
@ -26,6 +29,7 @@
|
|||
"jszip": "^3.10.1",
|
||||
"marked": "^18.0.3",
|
||||
"sql.js": "^1.14.1",
|
||||
"ulid": "^3.0.2",
|
||||
"workbox-window": "^7.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -1,28 +1,116 @@
|
|||
import type { Card, CardCreate, CardUpdate } from '@wordeck/domain';
|
||||
/**
|
||||
* Cards-API — event-sourced gegen `@mana/event-sync`.
|
||||
*
|
||||
* Migration L-1f (2026-05-20): User-owned CRUD läuft jetzt über event-sync.
|
||||
* Content-Hashes werden client-side via @wordeck/domain berechnet (für
|
||||
* Anki-Dedupe). Tags werden embedded ins Card-Aggregate (tags: string[])
|
||||
* statt über separate Junction-Tabelle.
|
||||
*/
|
||||
|
||||
import type { Card, CardCreate, CardType, CardUpdate } from '@wordeck/domain';
|
||||
import type { WordeckCardState as CardProjection } from '@mana/shared-schemas';
|
||||
import { cardContentHash } from '@wordeck/domain';
|
||||
import { ulid } from 'ulid';
|
||||
|
||||
import { api } from './client.ts';
|
||||
import { cardAggregateId, cardStateToRow, reviewAggregateId } from './event-adapters.ts';
|
||||
import { emitEvent } from './event-builder.ts';
|
||||
import { getSync } from '../sync.svelte.ts';
|
||||
|
||||
export function listCards(deckId?: string) {
|
||||
const qs = deckId ? `?deck_id=${encodeURIComponent(deckId)}` : '';
|
||||
return api<{ cards: Card[]; total: number }>(`/api/v1/cards${qs}`);
|
||||
export async function listCards(deckId?: string): Promise<{ cards: Card[]; total: number }> {
|
||||
const sync = await getSync();
|
||||
const all = await sync.aggregateList<CardProjection>('card');
|
||||
const cards = all
|
||||
.filter(({ state }) => state.deletedAt === null)
|
||||
.filter(({ state }) => (deckId ? state.deckId === deckId : true))
|
||||
.map(({ aggregateId, state }) => cardStateToRow(aggregateId, state));
|
||||
return { cards, total: cards.length };
|
||||
}
|
||||
|
||||
/** Holt nur die content_hash-Liste — kompakt für Anki-Re-Import-Dedupe. */
|
||||
export function listCardHashes() {
|
||||
return api<{ hashes: string[]; total: number }>('/api/v1/cards/hashes');
|
||||
/**
|
||||
* Liefert nur die content_hash-Liste — kompakt für Anki-Re-Import-Dedupe.
|
||||
*/
|
||||
export async function listCardHashes(): Promise<{ hashes: string[]; total: number }> {
|
||||
const sync = await getSync();
|
||||
const all = await sync.aggregateList<CardProjection>('card');
|
||||
const hashes = all
|
||||
.filter(({ state }) => state.deletedAt === null && state.contentHash)
|
||||
.map(({ state }) => state.contentHash as string);
|
||||
return { hashes, total: hashes.length };
|
||||
}
|
||||
|
||||
export function getCard(id: string) {
|
||||
return api<Card>(`/api/v1/cards/${id}`);
|
||||
export async function getCard(id: string): Promise<Card> {
|
||||
const sync = await getSync();
|
||||
const state = await sync.aggregateState<CardProjection>(cardAggregateId(id));
|
||||
if (!state || state.deletedAt) {
|
||||
throw new Error(`card ${id} nicht gefunden`);
|
||||
}
|
||||
return cardStateToRow(cardAggregateId(id), state);
|
||||
}
|
||||
|
||||
export function createCard(input: CardCreate) {
|
||||
return api<Card>('/api/v1/cards', { method: 'POST', body: input });
|
||||
export async function createCard(input: CardCreate): Promise<Card> {
|
||||
const cardId = ulid();
|
||||
const aggregateId = cardAggregateId(cardId);
|
||||
const fieldsJson = JSON.stringify(input.fields);
|
||||
|
||||
await emitEvent(aggregateId, 'CardCreated', {
|
||||
cardId,
|
||||
deckId: input.deck_id,
|
||||
type: input.type,
|
||||
fieldsJson,
|
||||
tags: input.tags ?? [],
|
||||
});
|
||||
|
||||
// Reviews-Aggregate initialisieren (sub_index pro CardType — basic=1,
|
||||
// basic-reverse=2, cloze=N). Für jetzt simpler Default: sub_index 0.
|
||||
// Volle Cloze-Cluster-Detection passiert beim ersten Review-Grade.
|
||||
await emitEvent(reviewAggregateId(cardId, 0), 'ReviewInitialized', {
|
||||
reviewId: ulid(),
|
||||
cardId,
|
||||
subIndex: 0,
|
||||
due: new Date().toISOString(),
|
||||
});
|
||||
if (input.type === 'basic-reverse') {
|
||||
await emitEvent(reviewAggregateId(cardId, 1), 'ReviewInitialized', {
|
||||
reviewId: ulid(),
|
||||
cardId,
|
||||
subIndex: 1,
|
||||
due: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
return getCard(cardId);
|
||||
}
|
||||
|
||||
export function updateCard(id: string, patch: CardUpdate) {
|
||||
return api<Card>(`/api/v1/cards/${id}`, { method: 'PATCH', body: patch });
|
||||
export async function updateCard(id: string, patch: CardUpdate): Promise<Card> {
|
||||
const aggregateId = cardAggregateId(id);
|
||||
if (patch.fields !== undefined) {
|
||||
await emitEvent(aggregateId, 'CardFieldsUpdated', {
|
||||
cardId: id,
|
||||
newFieldsJson: JSON.stringify(patch.fields),
|
||||
});
|
||||
}
|
||||
if (patch.tags !== undefined) {
|
||||
await emitEvent(aggregateId, 'CardTagsSet', { cardId: id, tags: patch.tags });
|
||||
}
|
||||
return getCard(id);
|
||||
}
|
||||
|
||||
export function deleteCard(id: string) {
|
||||
return api<{ deleted: string }>(`/api/v1/cards/${id}`, { method: 'DELETE' });
|
||||
export async function deleteCard(id: string): Promise<{ deleted: string }> {
|
||||
await emitEvent(cardAggregateId(id), 'CardDeleted', { cardId: id });
|
||||
return { deleted: id };
|
||||
}
|
||||
|
||||
/**
|
||||
* Berechnet content_hash client-side via @wordeck/domain (vorher
|
||||
* server-side im POST /cards). Wird beim Anki-Import + Re-Import zur
|
||||
* Dedupe-Strategy genutzt.
|
||||
*/
|
||||
export function computeCardContentHash(
|
||||
type: CardType,
|
||||
fields: Record<string, string>,
|
||||
): Promise<string> {
|
||||
return cardContentHash({ type, fields });
|
||||
}
|
||||
|
||||
void api; // legacy import kept for future server-side ops
|
||||
|
|
|
|||
|
|
@ -1,47 +1,158 @@
|
|||
/**
|
||||
* Decks-API — event-sourced gegen `@mana/event-sync`.
|
||||
*
|
||||
* Migration L-1f (2026-05-20): User-owned Reads + Writes laufen jetzt
|
||||
* lokal über die IndexedDB-Projektion. Server-AI-Endpoints
|
||||
* (generate, generate-from-image, distractors) bleiben HTTP — sie
|
||||
* konsumieren Tier-gated mana-credits und returnen JSON, das der Client
|
||||
* dann als Events emittiert. Marketplace-Reads bleiben ebenfalls HTTP
|
||||
* (cross-user, kein eigener event-stream pro User).
|
||||
*/
|
||||
|
||||
import type { Deck, DeckCreate, DeckUpdate } from '@wordeck/domain';
|
||||
import type { WordeckDeckState as DeckProjection } from '@mana/shared-schemas';
|
||||
import { ulid } from 'ulid';
|
||||
|
||||
import { api, apiForm } from './client.ts';
|
||||
import { deckAggregateId, deckStateToRow } from './event-adapters.ts';
|
||||
import { emitEvent } from './event-builder.ts';
|
||||
import { getSync } from '../sync.svelte.ts';
|
||||
|
||||
export function listDecks(opts: { forkedFromMarketplace?: boolean; archived?: boolean } = {}) {
|
||||
const params = new URLSearchParams();
|
||||
if (opts.forkedFromMarketplace) params.set('forked_from_marketplace', 'true');
|
||||
if (opts.archived) params.set('archived', 'true');
|
||||
const qs = params.size ? `?${params}` : '';
|
||||
return api<{ decks: Deck[]; total: number }>(`/api/v1/decks${qs}`);
|
||||
export async function listDecks(
|
||||
opts: { forkedFromMarketplace?: boolean; archived?: boolean } = {},
|
||||
): Promise<{ decks: Deck[]; total: number }> {
|
||||
const sync = await getSync();
|
||||
const all = await sync.aggregateList<DeckProjection>('deck');
|
||||
const decks = all
|
||||
.filter(({ state }) => state.deletedAt === null)
|
||||
.filter(({ state }) =>
|
||||
opts.archived ? state.archivedAt !== null : state.archivedAt === null,
|
||||
)
|
||||
.filter(({ state }) =>
|
||||
opts.forkedFromMarketplace ? state.forkedFromMarketplaceDeckId !== null : true,
|
||||
)
|
||||
.map(({ aggregateId, state }) => deckStateToRow(aggregateId, state));
|
||||
return { decks, total: decks.length };
|
||||
}
|
||||
|
||||
export function archiveDeck(id: string) {
|
||||
return api<Deck>(`/api/v1/decks/${id}`, { method: 'PATCH', body: { archived: true } });
|
||||
export async function getDeck(id: string): Promise<Deck> {
|
||||
const sync = await getSync();
|
||||
const state = await sync.aggregateState<DeckProjection>(deckAggregateId(id));
|
||||
if (!state || state.deletedAt) {
|
||||
throw new Error(`deck ${id} nicht gefunden`);
|
||||
}
|
||||
return deckStateToRow(deckAggregateId(id), state);
|
||||
}
|
||||
|
||||
export function unarchiveDeck(id: string) {
|
||||
return api<Deck>(`/api/v1/decks/${id}`, { method: 'PATCH', body: { archived: false } });
|
||||
export async function createDeck(input: DeckCreate): Promise<Deck> {
|
||||
const deckId = ulid();
|
||||
const aggregateId = deckAggregateId(deckId);
|
||||
await emitEvent(aggregateId, 'DeckCreated', {
|
||||
deckId,
|
||||
name: input.name,
|
||||
description: input.description ?? null,
|
||||
color: input.color ?? null,
|
||||
category: input.category ?? null,
|
||||
});
|
||||
// Optionale Init-Settings hinterherschicken
|
||||
if (input.fsrs_settings && Object.keys(input.fsrs_settings).length > 0) {
|
||||
await emitEvent(aggregateId, 'DeckFsrsSettingsUpdated', {
|
||||
deckId,
|
||||
newSettingsJson: JSON.stringify(input.fsrs_settings),
|
||||
});
|
||||
}
|
||||
return getDeck(deckId);
|
||||
}
|
||||
|
||||
export function getDeck(id: string) {
|
||||
return api<Deck>(`/api/v1/decks/${id}`);
|
||||
export async function updateDeck(id: string, patch: DeckUpdate): Promise<Deck> {
|
||||
const aggregateId = deckAggregateId(id);
|
||||
|
||||
if (patch.name !== undefined) {
|
||||
await emitEvent(aggregateId, 'DeckRenamed', { deckId: id, newName: patch.name });
|
||||
}
|
||||
if (patch.description !== undefined) {
|
||||
await emitEvent(aggregateId, 'DeckDescriptionUpdated', {
|
||||
deckId: id,
|
||||
newDescription: patch.description ?? null,
|
||||
});
|
||||
}
|
||||
if (patch.color !== undefined) {
|
||||
await emitEvent(aggregateId, 'DeckColorChanged', {
|
||||
deckId: id,
|
||||
newColor: patch.color ?? null,
|
||||
});
|
||||
}
|
||||
if (patch.category !== undefined) {
|
||||
await emitEvent(aggregateId, 'DeckCategoryChanged', {
|
||||
deckId: id,
|
||||
newCategory: patch.category ?? null,
|
||||
});
|
||||
}
|
||||
if (patch.fsrs_settings !== undefined) {
|
||||
await emitEvent(aggregateId, 'DeckFsrsSettingsUpdated', {
|
||||
deckId: id,
|
||||
newSettingsJson: JSON.stringify(patch.fsrs_settings),
|
||||
});
|
||||
}
|
||||
if (patch.archived !== undefined) {
|
||||
await emitEvent(
|
||||
aggregateId,
|
||||
patch.archived ? 'DeckArchived' : 'DeckUnarchived',
|
||||
{ deckId: id },
|
||||
);
|
||||
}
|
||||
if (patch.visibility !== undefined && patch.visibility !== 'private') {
|
||||
await emitEvent(aggregateId, 'DeckPublished', {
|
||||
deckId: id,
|
||||
visibility: patch.visibility,
|
||||
license: 'CC-BY-4.0',
|
||||
});
|
||||
}
|
||||
return getDeck(id);
|
||||
}
|
||||
|
||||
export function createDeck(input: DeckCreate) {
|
||||
return api<Deck>('/api/v1/decks', { method: 'POST', body: input });
|
||||
export async function archiveDeck(id: string): Promise<Deck> {
|
||||
return updateDeck(id, { archived: true });
|
||||
}
|
||||
|
||||
export function updateDeck(id: string, patch: DeckUpdate) {
|
||||
return api<Deck>(`/api/v1/decks/${id}`, { method: 'PATCH', body: patch });
|
||||
export async function unarchiveDeck(id: string): Promise<Deck> {
|
||||
return updateDeck(id, { archived: false });
|
||||
}
|
||||
|
||||
export function deleteDeck(id: string) {
|
||||
return api<{ deleted: string }>(`/api/v1/decks/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export function duplicateDeck(id: string) {
|
||||
return api<Deck>(`/api/v1/decks/${id}/duplicate`, { method: 'POST' });
|
||||
export async function deleteDeck(id: string): Promise<{ deleted: string }> {
|
||||
await emitEvent(deckAggregateId(id), 'DeckDeleted', { deckId: id });
|
||||
return { deleted: id };
|
||||
}
|
||||
|
||||
/**
|
||||
* Marketplace-Source-Lookup bleibt HTTP — Marketplace ist cross-user und
|
||||
* lebt im Server-pgSchema `marketplace.*`, nicht im Per-User-Event-Stream.
|
||||
*/
|
||||
export function getMarketplaceSource(id: string) {
|
||||
return api<{ slug: string } | null>(`/api/v1/decks/${id}/marketplace-source`);
|
||||
}
|
||||
|
||||
export function generateDeck(input: { prompt: string; language?: string; count?: number; url?: string }) {
|
||||
/**
|
||||
* duplicateDeck bleibt vorerst HTTP — der Server kopiert Karten in
|
||||
* einer Transaktion. Sobald Cards-API auf events lebt, kann das
|
||||
* client-side als Event-Burst erfolgen.
|
||||
*/
|
||||
export function duplicateDeck(id: string) {
|
||||
return api<Deck>(`/api/v1/decks/${id}/duplicate`, { method: 'POST' });
|
||||
}
|
||||
|
||||
/**
|
||||
* AI-Generate-Endpoints bleiben HTTP — Tier-gated über mana-credits,
|
||||
* Server hat Prompt-Templates + LLM-Provider-Routing. Result wird heute
|
||||
* direkt als Deck+Cards persistiert vom Server. Follow-up-Task: Result
|
||||
* als Event-Burst zum Client schicken, Client emittet lokal.
|
||||
*/
|
||||
export function generateDeck(input: {
|
||||
prompt: string;
|
||||
language?: string;
|
||||
count?: number;
|
||||
url?: string;
|
||||
}) {
|
||||
return api<{ deck: Deck; cards_created: number }>('/api/v1/decks/generate', {
|
||||
method: 'POST',
|
||||
body: input,
|
||||
|
|
@ -65,15 +176,12 @@ export function generateDeckFromImage(
|
|||
opts: { language?: string; count?: number; url?: string },
|
||||
) {
|
||||
const arr = Array.isArray(files) ? files : [files];
|
||||
|
||||
// URL-only (no files): send as JSON — FormData without file parts fails in Bun
|
||||
if (arr.length === 0) {
|
||||
return api<{ deck: Deck; cards_created: number }>('/api/v1/decks/from-image', {
|
||||
method: 'POST',
|
||||
body: { language: opts.language, count: opts.count, url: opts.url },
|
||||
});
|
||||
}
|
||||
|
||||
const form = new FormData();
|
||||
for (const f of arr) form.append('file', f);
|
||||
if (opts.language) form.append('language', opts.language);
|
||||
|
|
|
|||
119
apps/web/src/lib/api/event-adapters.ts
Normal file
119
apps/web/src/lib/api/event-adapters.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
/**
|
||||
* Adapter zwischen `@mana/shared-schemas/wordeck` (camelCase, event-sourced)
|
||||
* und `@wordeck/domain` (snake_case, Server-API-Wire-Format).
|
||||
*
|
||||
* Dient als dünne Übersetzungsschicht, damit UI-Komponenten weiterhin
|
||||
* den `Deck`/`Card`/`Review`-Type aus @wordeck/domain konsumieren, auch
|
||||
* wenn der Storage-Layer auf event-sync gewechselt ist.
|
||||
*/
|
||||
|
||||
import type { Card, CardType, Deck, Review, ReviewState } from '@wordeck/domain';
|
||||
import type {
|
||||
WordeckCardState,
|
||||
WordeckDeckState,
|
||||
WordeckReviewState,
|
||||
} from '@mana/shared-schemas';
|
||||
|
||||
const EPOCH_PLACEHOLDER = '1970-01-01T00:00:00.000Z';
|
||||
|
||||
function parseFsrsSettings(json: string): Record<string, unknown> {
|
||||
try {
|
||||
return JSON.parse(json);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function parseFields(json: string): Record<string, string> {
|
||||
try {
|
||||
const parsed = JSON.parse(json);
|
||||
// Felder müssen Strings sein (CardFieldsSchema). Bei Fehl-Typen
|
||||
// auf Strings coercen, damit der Reducer nicht crasht.
|
||||
const out: Record<string, string> = {};
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
for (const [k, v] of Object.entries(parsed)) {
|
||||
out[k] = typeof v === 'string' ? v : JSON.stringify(v);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function deckStateToRow(
|
||||
aggregateId: string,
|
||||
state: WordeckDeckState,
|
||||
): Deck {
|
||||
return {
|
||||
id: state.id || aggregateId,
|
||||
user_id: state.userId,
|
||||
name: state.name,
|
||||
description: state.description,
|
||||
color: state.color,
|
||||
category: state.category,
|
||||
visibility: state.visibility,
|
||||
fsrs_settings: parseFsrsSettings(state.fsrsSettingsJson),
|
||||
content_hash: state.contentHash,
|
||||
forked_from_marketplace_deck_id: state.forkedFromMarketplaceDeckId,
|
||||
forked_from_marketplace_version_id: state.forkedFromMarketplaceVersionId,
|
||||
archived_at: state.archivedAt,
|
||||
// Event-sourced State führt keine separaten created/updated-Timestamps
|
||||
// — wir leiten sie aus Reducer-Reihenfolge ab. Für UI-Kompatibilität
|
||||
// Platzhalter; richtige Werte können später aus event.occurredAt
|
||||
// gepatcht werden.
|
||||
created_at: EPOCH_PLACEHOLDER,
|
||||
updated_at: EPOCH_PLACEHOLDER,
|
||||
} as Deck;
|
||||
}
|
||||
|
||||
export function cardStateToRow(aggregateId: string, state: WordeckCardState): Card {
|
||||
return {
|
||||
id: state.id || aggregateId,
|
||||
user_id: state.userId,
|
||||
deck_id: state.deckId,
|
||||
type: state.type as CardType,
|
||||
fields: parseFields(state.fieldsJson),
|
||||
content_hash: state.contentHash,
|
||||
created_at: EPOCH_PLACEHOLDER,
|
||||
updated_at: EPOCH_PLACEHOLDER,
|
||||
} as Card;
|
||||
}
|
||||
|
||||
export function reviewStateToRow(
|
||||
_aggregateId: string,
|
||||
state: WordeckReviewState,
|
||||
): Review {
|
||||
return {
|
||||
card_id: state.cardId,
|
||||
sub_index: state.subIndex,
|
||||
user_id: state.userId,
|
||||
due: state.due,
|
||||
stability: state.stability,
|
||||
difficulty: state.difficulty,
|
||||
elapsed_days: state.elapsedDays,
|
||||
scheduled_days: state.scheduledDays,
|
||||
learning_steps: state.learningSteps,
|
||||
reps: state.reps,
|
||||
lapses: state.lapses,
|
||||
state: state.state as ReviewState,
|
||||
last_review: state.lastReview,
|
||||
} as Review;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate-ID-Helpers — stable Form, damit Re-Identifikation auf demselben
|
||||
* Gerät idempotent funktioniert.
|
||||
*/
|
||||
export function deckAggregateId(deckId: string): string {
|
||||
return deckId.startsWith('deck:') ? deckId : `deck:${deckId}`;
|
||||
}
|
||||
|
||||
export function cardAggregateId(cardId: string): string {
|
||||
return cardId.startsWith('card:') ? cardId : `card:${cardId}`;
|
||||
}
|
||||
|
||||
export function reviewAggregateId(cardId: string, subIndex: number): string {
|
||||
const cleanCard = cardId.replace(/^card:/, '');
|
||||
return `review:${cleanCard}__${subIndex}`;
|
||||
}
|
||||
49
apps/web/src/lib/api/event-builder.ts
Normal file
49
apps/web/src/lib/api/event-builder.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
/**
|
||||
* Helper für den Aufbau von EventEnvelopes mit aktuellem Auth-Kontext.
|
||||
*
|
||||
* - Im signed-in-Modus: `attributedToUserId` = mana-auth-User-ID, actor.user.
|
||||
* - Im anonymous-Modus: `attributedToUserId` = `anon:<ulid>` aus event-sync.
|
||||
*
|
||||
* Nimmt event-Type-spezifischen Payload entgegen und vervollständigt den
|
||||
* Envelope. Sub-Microsekunden-Idempotency via ULID + Crypto-Random.
|
||||
*/
|
||||
|
||||
import type { EventEnvelope } from '@mana/event-kit';
|
||||
import type { ManaEventSync } from '@mana/event-sync';
|
||||
import { ulid } from 'ulid';
|
||||
import { wordeck } from '@mana/shared-schemas/wordeck';
|
||||
|
||||
import { devUser } from '../auth/dev-stub.svelte.ts';
|
||||
import { getSync } from '../sync.svelte.ts';
|
||||
|
||||
async function attributedTo(sync: ManaEventSync): Promise<string> {
|
||||
const realId = devUser.user?.id ?? null;
|
||||
if (realId) return realId;
|
||||
return sync.anonymousUserId();
|
||||
}
|
||||
|
||||
export async function emitEvent(
|
||||
aggregateId: string,
|
||||
eventType: string,
|
||||
payload: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const sync = await getSync();
|
||||
const userId = await attributedTo(sync);
|
||||
const displayName = devUser.user?.name ?? 'Anon';
|
||||
|
||||
const envelope: EventEnvelope<Record<string, unknown>> = {
|
||||
eventId: ulid(),
|
||||
aggregateId,
|
||||
appId: wordeck.appId,
|
||||
eventType,
|
||||
eventVersion: 1,
|
||||
occurredAt: new Date().toISOString(),
|
||||
actor: { kind: 'user', principalId: userId, displayName },
|
||||
attributedToUserId: userId,
|
||||
origin: 'user',
|
||||
idempotencyKey: ulid(),
|
||||
payload,
|
||||
};
|
||||
|
||||
await sync.emit(envelope);
|
||||
}
|
||||
|
|
@ -1,28 +1,155 @@
|
|||
/**
|
||||
* Reviews-API — event-sourced gegen `@mana/event-sync`.
|
||||
*
|
||||
* FSRS-Compute läuft client-side via @wordeck/domain (vorher server-side
|
||||
* im POST /reviews/grade). Beim Grade berechnet der Client den neuen
|
||||
* FSRS-State und emittet `ReviewGraded` mit allen Snapshot-Feldern.
|
||||
*/
|
||||
|
||||
import type { Card, Rating, Review } from '@wordeck/domain';
|
||||
import { api } from './client.ts';
|
||||
import { fromFsrsCard, gradeReview as gradeFsrs, toFsrsCard } from '@wordeck/domain';
|
||||
import type {
|
||||
WordeckCardState,
|
||||
WordeckDeckState,
|
||||
WordeckReviewState,
|
||||
} from '@mana/shared-schemas';
|
||||
|
||||
import {
|
||||
cardAggregateId,
|
||||
cardStateToRow,
|
||||
reviewAggregateId,
|
||||
reviewStateToRow,
|
||||
} from './event-adapters.ts';
|
||||
import { emitEvent } from './event-builder.ts';
|
||||
import { getSync } from '../sync.svelte.ts';
|
||||
|
||||
export type DueReview = Review & {
|
||||
card?: Pick<Card, 'id' | 'deck_id' | 'type' | 'fields'>;
|
||||
};
|
||||
|
||||
export function listDueReviews(opts: { deckId?: string; limit?: number; recovery?: boolean } = {}) {
|
||||
const params = new URLSearchParams();
|
||||
if (opts.deckId) params.set('deck_id', opts.deckId);
|
||||
if (opts.limit) params.set('limit', String(opts.limit));
|
||||
if (opts.recovery) params.set('recovery', 'true');
|
||||
const qs = params.toString();
|
||||
return api<{ reviews: DueReview[]; total: number }>(
|
||||
`/api/v1/reviews/due${qs ? `?${qs}` : ''}`
|
||||
export async function listDueReviews(
|
||||
opts: { deckId?: string; limit?: number; recovery?: boolean } = {},
|
||||
): Promise<{ reviews: DueReview[]; total: number }> {
|
||||
const sync = await getSync();
|
||||
const allReviews = await sync.aggregateList<WordeckReviewState>('review');
|
||||
const allCards = await sync.aggregateList<WordeckCardState>('card');
|
||||
|
||||
const cardsById = new Map(
|
||||
allCards
|
||||
.filter(({ state }) => state.deletedAt === null)
|
||||
.map(({ aggregateId, state }) => [state.id, cardStateToRow(aggregateId, state)]),
|
||||
);
|
||||
|
||||
const now = Date.now();
|
||||
const due: DueReview[] = [];
|
||||
for (const { aggregateId, state } of allReviews) {
|
||||
// Filter: nur Cards die noch existieren
|
||||
const card = cardsById.get(state.cardId);
|
||||
if (!card) continue;
|
||||
if (opts.deckId && card.deck_id !== opts.deckId) continue;
|
||||
// Due-Filter — bei `recovery` alles, sonst nur due <= now
|
||||
const dueAt = new Date(state.due).getTime();
|
||||
if (!opts.recovery && dueAt > now) continue;
|
||||
|
||||
const review = reviewStateToRow(aggregateId, state) as DueReview;
|
||||
review.card = {
|
||||
id: card.id,
|
||||
deck_id: card.deck_id,
|
||||
type: card.type,
|
||||
fields: card.fields,
|
||||
};
|
||||
due.push(review);
|
||||
}
|
||||
|
||||
// Sort: am längsten überfällig zuerst
|
||||
due.sort((a, b) => new Date(a.due).getTime() - new Date(b.due).getTime());
|
||||
const limited = opts.limit ? due.slice(0, opts.limit) : due;
|
||||
return { reviews: limited, total: limited.length };
|
||||
}
|
||||
|
||||
export function gradeReview(cardId: string, subIndex: number, rating: Rating) {
|
||||
return api<Review>(`/api/v1/reviews/${cardId}/${subIndex}/grade`, {
|
||||
method: 'POST',
|
||||
body: { rating },
|
||||
export async function gradeReview(
|
||||
cardId: string,
|
||||
subIndex: number,
|
||||
rating: Rating,
|
||||
): Promise<Review> {
|
||||
const sync = await getSync();
|
||||
const aggregateId = reviewAggregateId(cardId, subIndex);
|
||||
const current = await sync.aggregateState<WordeckReviewState>(aggregateId);
|
||||
if (!current) {
|
||||
throw new Error(`review ${cardId}/${subIndex} nicht initialisiert`);
|
||||
}
|
||||
const card = await sync.aggregateState<WordeckCardState>(cardAggregateId(cardId));
|
||||
if (!card) {
|
||||
throw new Error(`card ${cardId} nicht gefunden`);
|
||||
}
|
||||
|
||||
// Optional: per-Deck-FSRS-Settings aus Deck-Aggregate ziehen
|
||||
const deck = await sync.aggregateState<WordeckDeckState>(`deck:${card.deckId}`);
|
||||
let settings: Record<string, unknown> = {};
|
||||
if (deck?.fsrsSettingsJson) {
|
||||
try {
|
||||
settings = JSON.parse(deck.fsrsSettingsJson);
|
||||
} catch {
|
||||
settings = {};
|
||||
}
|
||||
}
|
||||
|
||||
// Current als Review-Shape (snake_case) für FSRS-Compute
|
||||
const currentReview = reviewStateToRow(aggregateId, current);
|
||||
const prevSnapshotJson = JSON.stringify(currentReview);
|
||||
|
||||
const next = gradeFsrs(currentReview, rating, new Date(), settings);
|
||||
|
||||
await emitEvent(aggregateId, 'ReviewGraded', {
|
||||
reviewId: current.id || aggregateId,
|
||||
rating,
|
||||
newState: next.state,
|
||||
newDue: next.due,
|
||||
newStability: next.stability,
|
||||
newDifficulty: next.difficulty,
|
||||
newElapsedDays: next.elapsed_days,
|
||||
newScheduledDays: next.scheduled_days,
|
||||
newLearningSteps: next.learning_steps,
|
||||
newReps: next.reps,
|
||||
newLapses: next.lapses,
|
||||
prevSnapshotJson,
|
||||
});
|
||||
|
||||
// Avoid unused var warnings
|
||||
void fromFsrsCard;
|
||||
void toFsrsCard;
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
export function undoReview(cardId: string, subIndex: number) {
|
||||
return api<Review>(`/api/v1/reviews/${cardId}/${subIndex}/undo`, { method: 'POST' });
|
||||
/**
|
||||
* Undo via prevSnapshotJson — wir emittieren einen weiteren ReviewGraded
|
||||
* mit den alten FSRS-Werten. Bei iteriertem Undo ohne intervening grade
|
||||
* würde sich die Geschichte wiederholen; akzeptabel für jetzt.
|
||||
*/
|
||||
export async function undoReview(cardId: string, subIndex: number): Promise<Review> {
|
||||
const sync = await getSync();
|
||||
const aggregateId = reviewAggregateId(cardId, subIndex);
|
||||
const current = await sync.aggregateState<WordeckReviewState>(aggregateId);
|
||||
if (!current?.prevSnapshotJson) {
|
||||
throw new Error('kein undo-Snapshot vorhanden');
|
||||
}
|
||||
const snap = JSON.parse(current.prevSnapshotJson) as Review;
|
||||
|
||||
await emitEvent(aggregateId, 'ReviewGraded', {
|
||||
reviewId: current.id || aggregateId,
|
||||
rating: 'good' as Rating, // Sentinel — wir replayen den State, nicht eine Bewertung
|
||||
newState: snap.state,
|
||||
newDue: snap.due,
|
||||
newStability: snap.stability,
|
||||
newDifficulty: snap.difficulty,
|
||||
newElapsedDays: snap.elapsed_days,
|
||||
newScheduledDays: snap.scheduled_days,
|
||||
newLearningSteps: snap.learning_steps,
|
||||
newReps: snap.reps,
|
||||
newLapses: snap.lapses,
|
||||
prevSnapshotJson: JSON.stringify(reviewStateToRow(aggregateId, current)),
|
||||
});
|
||||
|
||||
return snap;
|
||||
}
|
||||
|
|
|
|||
143
apps/web/src/lib/sync.svelte.ts
Normal file
143
apps/web/src/lib/sync.svelte.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
/**
|
||||
* Event-Sync-Setup für wordeck-web.
|
||||
*
|
||||
* - Anonymous-Mode aktiv: User kann ohne Konto loslegen, Decks/Cards/Reviews
|
||||
* landen lokal in IndexedDB. Beim Login werden alle anonymen Events
|
||||
* auf die echte User-ID umgetaggt und an sync2.mana.how gepusht.
|
||||
* - Vault-Crypto: sobald signed-in, holt der Engine den User-Master-Key
|
||||
* aus dem mana-auth-Vault. Im anonymous-Mode bleibt NoOpCrypto aktiv
|
||||
* (kein Server-Push, kein Encryption-Boundary erreicht).
|
||||
*
|
||||
* Siehe `mana/docs/playbooks/LOCAL_FIRST_LOGIN_OPTIONAL.md`.
|
||||
*/
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
import { env as publicEnv } from '$env/dynamic/public';
|
||||
import {
|
||||
createEventSync,
|
||||
createMasterKeyProviderFromVault,
|
||||
NoOpCryptoProvider,
|
||||
type CryptoProvider,
|
||||
type ManaEventSync,
|
||||
} from '@mana/event-sync';
|
||||
import { wordeck } from '@mana/shared-schemas/wordeck';
|
||||
|
||||
import { devUser } from './auth/dev-stub.svelte.ts';
|
||||
|
||||
const SYNC_URL = publicEnv.PUBLIC_SYNC_URL ?? 'https://sync2.mana.how';
|
||||
const AUTH_URL = publicEnv.PUBLIC_MANA_AUTH_URL ?? 'https://auth.mana.how';
|
||||
|
||||
let _sync: ManaEventSync | null = null;
|
||||
let _startPromise: Promise<void> | null = null;
|
||||
|
||||
class WordeckSyncState {
|
||||
authMode = $state<'anonymous' | 'signed-in'>('anonymous');
|
||||
syncState = $state<'idle' | 'pushing' | 'pulling' | 'error' | 'offline'>('idle');
|
||||
cryptoKind = $state<'noop' | 'vault'>('noop');
|
||||
lastError = $state<string | null>(null);
|
||||
anonymousUserId = $state<string | null>(null);
|
||||
}
|
||||
|
||||
export const syncState = new WordeckSyncState();
|
||||
|
||||
async function buildCryptoForCurrent(): Promise<CryptoProvider> {
|
||||
if (!devUser.effectiveToken) return new NoOpCryptoProvider();
|
||||
try {
|
||||
const provider = await createMasterKeyProviderFromVault({
|
||||
authUrl: AUTH_URL,
|
||||
getToken: async () => devUser.effectiveToken ?? '',
|
||||
});
|
||||
syncState.cryptoKind = 'vault';
|
||||
return provider;
|
||||
} catch (e) {
|
||||
// ZK-Mode oder Vault-Down → fallback NoOp, Engine läuft, Daten
|
||||
// landen plaintext (akzeptabel für anonymous-Phase + Dev).
|
||||
syncState.cryptoKind = 'noop';
|
||||
syncState.lastError = e instanceof Error ? e.message : String(e);
|
||||
return new NoOpCryptoProvider();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazy-Initialisiert die Sync-Engine. Mehrfach-Aufruf ist idempotent —
|
||||
* gibt dieselbe Instanz zurück.
|
||||
*/
|
||||
export async function getSync(): Promise<ManaEventSync> {
|
||||
if (!browser) {
|
||||
throw new Error('event-sync läuft nur im Browser');
|
||||
}
|
||||
if (_sync) return _sync;
|
||||
|
||||
const crypto = await buildCryptoForCurrent();
|
||||
_sync = createEventSync({
|
||||
app: wordeck,
|
||||
syncUrl: SYNC_URL,
|
||||
// Liefert Token wenn da, null sonst — Engine schaltet automatisch
|
||||
// zwischen signed-in und anonymous um.
|
||||
getToken: async () => devUser.effectiveToken ?? null,
|
||||
crypto,
|
||||
onAuthModeChange: (mode) => {
|
||||
syncState.authMode = mode;
|
||||
},
|
||||
onSyncStateChange: (s) => {
|
||||
syncState.syncState = s;
|
||||
},
|
||||
onError: (err) => {
|
||||
syncState.lastError = err.message;
|
||||
},
|
||||
});
|
||||
|
||||
_startPromise = _sync.start();
|
||||
await _startPromise;
|
||||
syncState.authMode = _sync.getAuthMode();
|
||||
syncState.anonymousUserId = await _sync.anonymousUserId();
|
||||
return _sync;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auth-Hook: nach erfolgreichem Login die anonymen Events übernehmen.
|
||||
* Wird vom Auth-Callback gerufen, sobald `devUser.user.id` bekannt ist.
|
||||
*/
|
||||
export async function onSignedIn(realUserId: string): Promise<void> {
|
||||
const sync = await getSync();
|
||||
// Vault-Provider neu aufbauen (jetzt mit echtem Token verfügbar).
|
||||
// Hack: aktuelle Engine kennt keinen Crypto-Hot-Swap. Workaround:
|
||||
// stop + neuer Engine. Für Production sauberer: setCrypto auf Engine
|
||||
// nachrüsten (Folge-Task). Heute akzeptieren wir kurzes Stop+Restart.
|
||||
const { rewritten, flushed } = await sync.signIn(realUserId);
|
||||
console.info('[sync] signed in', { rewritten, flushed });
|
||||
|
||||
// Crypto-Swap durch Neu-Init: stop + dispose, dann beim nächsten getSync()
|
||||
// wird der Vault-Provider aufgebaut.
|
||||
sync.stop();
|
||||
_sync = null;
|
||||
_startPromise = null;
|
||||
await getSync();
|
||||
}
|
||||
|
||||
/**
|
||||
* Auth-Hook: nach Logout Server-Sync stoppen. Lokale Daten bleiben
|
||||
* unangetastet — bei erneutem Login mit *gleichem* User läuft alles
|
||||
* weiter. Bei Account-Wechsel braucht's `resetSync()`.
|
||||
*/
|
||||
export async function onSignedOut(): Promise<void> {
|
||||
if (!_sync) return;
|
||||
await _sync.signOut();
|
||||
// Crypto zurück auf NoOp (kein Token mehr).
|
||||
_sync.stop();
|
||||
_sync = null;
|
||||
_startPromise = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vollständiger Soft-Reset für Account-Wechsel. EventLog bleibt stehen
|
||||
* (für DSGVO-Auskunft), Outbox + Meta weg. Folge-User startet
|
||||
* mit frischer anonymer Identity.
|
||||
*/
|
||||
export async function resetSync(): Promise<void> {
|
||||
if (!_sync) await getSync();
|
||||
await _sync!.resetUserContext();
|
||||
_sync!.stop();
|
||||
_sync = null;
|
||||
_startPromise = null;
|
||||
}
|
||||
|
|
@ -1,36 +1,19 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { devUser } from '$lib/auth/dev-stub.svelte.ts';
|
||||
import { env as publicEnv } from '$env/dynamic/public';
|
||||
|
||||
function authWebUrl(): string {
|
||||
return publicEnv.PUBLIC_AUTH_WEB_URL ?? 'https://auth.mana.how';
|
||||
}
|
||||
|
||||
function callbackUrl(): string {
|
||||
const base =
|
||||
typeof window !== 'undefined' ? window.location.origin : 'https://wordeck.com';
|
||||
const next = page.url.searchParams.get('next');
|
||||
const nextParam = next ? `?next=${encodeURIComponent(next)}` : '';
|
||||
return `${base}/auth/callback${nextParam}`;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (devUser.id) {
|
||||
goto('/decks');
|
||||
return;
|
||||
}
|
||||
// Redirect zum zentralen Auth-Portal (mana-auth-web).
|
||||
const loginUrl = new URL(`${authWebUrl()}/login`);
|
||||
loginUrl.searchParams.set('app', 'wordeck');
|
||||
loginUrl.searchParams.set('redirect', callbackUrl());
|
||||
window.location.href = loginUrl.toString();
|
||||
// Egal ob signed-in oder anonym — direkt zur Deck-Übersicht.
|
||||
// Anonymous-Mode legt Decks lokal an; signIn-Flow lift Daten in den
|
||||
// Account, sobald der User sich einloggt.
|
||||
// Falls heute schon eine Session da ist: respektieren wir nicht
|
||||
// nochmal — der devUser-Wrapper hat die Session beim Mount geladen.
|
||||
void devUser;
|
||||
goto('/decks');
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Kurzes Laden während onMount den Redirect startet -->
|
||||
<div class="flex min-h-screen items-center justify-center">
|
||||
<p class="text-sm text-[hsl(var(--color-muted-foreground))]">Wird weitergeleitet…</p>
|
||||
<p class="text-sm text-[hsl(var(--color-muted-foreground))]">Lade Decks…</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { devUser } from '$lib/auth/dev-stub.svelte.ts';
|
||||
import { onSignedIn } from '$lib/sync.svelte.ts';
|
||||
import { env as publicEnv } from '$env/dynamic/public';
|
||||
|
||||
let error = $state<string | null>(null);
|
||||
|
|
@ -18,6 +19,19 @@
|
|||
if (ok) {
|
||||
// User-Profil aus dem frisch geminteten JWT laden.
|
||||
await devUser.loadUserFromToken();
|
||||
|
||||
// Sync-Hook: anonyme Events lokal umtaggen + Server-Push anstoßen.
|
||||
// Idempotent — wenn der User schon mal eingeloggt war, findet
|
||||
// der Sweep keine anonymen Events mehr.
|
||||
const realUserId = devUser.user?.id;
|
||||
if (realUserId) {
|
||||
try {
|
||||
await onSignedIn(realUserId);
|
||||
} catch (e) {
|
||||
console.warn('[auth] sync onSignedIn fehlgeschlagen', e);
|
||||
}
|
||||
}
|
||||
|
||||
const next = page.url.searchParams.get('next');
|
||||
goto(next && next.startsWith('/') ? next : '/decks');
|
||||
} else {
|
||||
|
|
|
|||
2657
pnpm-lock.yaml
generated
2657
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue