feat(web): event-sync migration L-1f Phase B — anonymous-mode + lokal-first CRUD
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:
Till JS 2026-05-20 21:37:34 +02:00
parent 2ee706bab1
commit 823560900c
10 changed files with 3347 additions and 107 deletions

View file

@ -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": {

View file

@ -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

View file

@ -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);

View 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}`;
}

View 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);
}

View file

@ -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;
}

View 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;
}

View file

@ -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>

View file

@ -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

File diff suppressed because it is too large Load diff