diff --git a/STATUS.md b/STATUS.md index 6d38d0e..ffb8a25 100644 --- a/STATUS.md +++ b/STATUS.md @@ -98,7 +98,7 @@ Vollständiger Plan: [`mana/docs/playbooks/CARDS_GREENFIELD.md`](../mana/docs/pl | 9 | Polish (DSGVO-UI, Settings, Account, Statistik, i18n, A11y, Media, Image-Occlusion) | 🟡 weit | Card-Edit + Cloze-Editor + Inbox-Banner + Account/DSGVO + Statistik + Pre-Flight-Swap + i18n DE/EN + A11y-Pass + Cloze-Hint-Anzeige + Anki-Re-Import-Dedupe + MinIO-Media-Upload + Image-Occlusion durch (9a–9l). Verbleibend: type-in, audio, multiple-choice (Schema vorbereitet) | | 10 | Production-Deploy (Mac Mini, Cloudflare-Tunnel) | ✅ live 2026-05-08 | cardecky.mana.how + cardecky-api.mana.how, alte cards.* via nginx-301-Redirect | | 11 | Decommission Cards-Modul aus mana-monorepo | ✅ 2026-05-08 | apps/cards, services/cards-server, packages/cards-core, mana-app cards-Modul + cross-refs entfernt (4 Commits, type-check 0 errors) | -| 12 | Marketplace-Restore (R0–R6) | 🟡 R0+R1+R2 durch | Plan: [`docs/playbooks/MARKETPLACE_RESTORE.md`](docs/playbooks/MARKETPLACE_RESTORE.md). R0 (Doku-Archiv + Restore-Plan + Strategie-B-Klarstellung): ✅. R1 (16 Tabellen + 5 Enums in `marketplace`-pgSchema, CHECK-Constraint verifiziert): ✅. R2 (Backend α + β): ✅ — Author-Routen (`POST/GET /authors/me`, `GET /authors/:slug`), Deck-Init (`POST /decks`), Publish-Flow (`POST /decks/:slug/publish` mit @cards/domain-Hash + per-Version-Hash + AI-Mod-Stub-Log + atomarem latest_version_id-Bump), PATCH-Metadaten, Public-Detail mit optional-auth. 16 neue Tests (72 gesamt) grün, E2E-Smoke gegen lokale cards-api durch (Cardecky-Author + Deck `r2-stoische-ethik` mit 3 Karten v1.0.0, semver-409 + paid-422-Errors verifiziert). Code-Referenz aus altem cards-server unter `docs/marketplace/archive/code/` (3331 LOC). Verbleibend: R3 γ/δ (Discovery + Subscribe + Smart-Merge), R4 ε (PRs + Discussions), R5 Frontend-Routes, R6 voller E2E-Smoke. | +| 12 | Marketplace-Restore (R0–R6) | 🟡 R0+R1+R2+R3 durch | Plan: [`docs/playbooks/MARKETPLACE_RESTORE.md`](docs/playbooks/MARKETPLACE_RESTORE.md). R0 (Doku + Restore-Plan + Strategie-B-Klarstellung): ✅. R1 (16 Tabellen + 5 Enums in `marketplace`-pgSchema): ✅. R2 (Backend α + β: Author-Routen + Deck-Init + Publish-Flow): ✅. **R3 (γ + δ Discovery + Engagement + Subscribe + Smart-Merge): ✅** — `GET /explore` (featured + trending), `GET /decks` (browse mit q/tag/lang/author/sort/limit/offset), `GET /tags`, `POST/DELETE/GET /decks/:slug/star`, `POST/DELETE/GET /authors/:slug/follow`, `POST/DELETE/GET /decks/:slug/subscribe`, `GET /me/subscriptions` (mit update_available-Flag), `GET /decks/:slug/versions/:semver`, `GET /decks/:slug/diff?from=:semver`, `POST /decks/:slug/fork` (private cards.decks-Kopie mit `forked_from_marketplace_*`-Pointern + frischen FSRS-Reviews), `POST /private/:deckId/pull-update` (Smart-Merge-Pull: hash-equality dedupe via `@cards/domain.cardContentHash` lässt unveränderte Karten **inkl. ihrer FSRS-Reviews komplett in Ruhe**, neue/geänderte Karten kommen als private Insert dazu). 6 neue Diff-Heuristik-Unit-Tests, 78 gesamt grün. **End-to-End-Smoke verifiziert**: Cardecky publisht v1.0.0 → Till forkt → Till studiert Apatheia (state=review, stability=10, reps=3) → Cardecky publisht v1.1.0 (Logos geändert + Tugendlehre neu) → Till pull-update → Apatheia-Review intakt, +Tugendlehre + neue Logos-Karte als zusätzliche Inserts. Verbleibend: R4 ε (PRs + Discussions), R5 Frontend-Routes, R6 voller End-to-End-Smoke gegen UI. | Legende: ✅ erledigt + verifiziert · 🚧 blockiert · ⏸ noch nicht begonnen diff --git a/apps/api/src/db/schema/decks.ts b/apps/api/src/db/schema/decks.ts index 06e65e4..71045da 100644 --- a/apps/api/src/db/schema/decks.ts +++ b/apps/api/src/db/schema/decks.ts @@ -21,6 +21,14 @@ export const decks = cardsSchema.table( .default('private'), fsrsSettings: jsonb('fsrs_settings').notNull().default(sql`'{}'::jsonb`), contentHash: text('content_hash'), + // Marketplace-Lineage (Phase 12 R3): wenn dieses private Deck via + // `POST /api/v1/marketplace/decks/:slug/fork` aus einem + // öffentlichen Deck entstanden ist, zeigen diese Pointer auf + // die marketplace-Quelle. NULL = nicht-geforkt (einfach selbst + // angelegt). Wird beim Smart-Merge-Pull benutzt um die richtige + // Quelle nachzuladen. + forkedFromMarketplaceDeckId: text('forked_from_marketplace_deck_id'), + forkedFromMarketplaceVersionId: text('forked_from_marketplace_version_id'), createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }) .notNull() .defaultNow(), diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 551fb67..3c20b13 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -15,6 +15,10 @@ import { mediaRouter } from './routes/media.ts'; import { decksGenerateRouter } from './routes/decks-generate.ts'; import { authorsRouter as marketplaceAuthorsRouter } from './routes/marketplace/authors.ts'; import { marketplaceDecksRouter } from './routes/marketplace/decks.ts'; +import { exploreRouter as marketplaceExploreRouter } from './routes/marketplace/explore.ts'; +import { engagementRouter as marketplaceEngagementRouter } from './routes/marketplace/engagement.ts'; +import { subscriptionsRouter as marketplaceSubscriptionsRouter } from './routes/marketplace/subscriptions.ts'; +import { forkRouter as marketplaceForkRouter } from './routes/marketplace/fork.ts'; const app = new Hono(); @@ -50,6 +54,14 @@ app.route('/api/v1/decks/generate', decksGenerateRouter()); // Marketplace (Phase 12). Eigenes pgSchema, additive Routen unter /v1/marketplace/*. // Plan: docs/playbooks/MARKETPLACE_RESTORE.md. +// +// Mount-Reihenfolge ist signifikant: spezifischere Routes vor /authors +// und /decks, damit z.B. /marketplace/me/subscriptions nicht zu authors +// oder decks geroutet wird. +app.route('/api/v1/marketplace', marketplaceExploreRouter()); +app.route('/api/v1/marketplace', marketplaceEngagementRouter()); +app.route('/api/v1/marketplace', marketplaceSubscriptionsRouter()); +app.route('/api/v1/marketplace', marketplaceForkRouter()); app.route('/api/v1/marketplace/authors', marketplaceAuthorsRouter()); app.route('/api/v1/marketplace/decks', marketplaceDecksRouter()); diff --git a/apps/api/src/lib/marketplace/diff.ts b/apps/api/src/lib/marketplace/diff.ts new file mode 100644 index 0000000..6aadeef --- /dev/null +++ b/apps/api/src/lib/marketplace/diff.ts @@ -0,0 +1,89 @@ +/** + * Smart-Merge-Diff zwischen zwei Versionen oder zwischen einer Version + * und der Hash-Liste eines privaten geforkten Decks. + * + * Klassifikation: + * - **unchanged**: content_hash identisch zwischen alt und neu → + * unveränderte Karte, FSRS-State bleibt. + * - **added**: Hash nur in neu → komplett neue Karte. + * - **removed**: Hash nur in alt → Karte aus dem Deck entfernt. + * - **changed** (Heuristik): Karte am selben `ord` ist „added" und + * gleichzeitig an demselben `ord` eine „removed". Pull-Requests + * bringen später eine echte Karten-Lineage; bis dahin reicht die + * Position-Heuristik. + * + * Geport aus + * `cards-decommission-base:services/cards-server/src/services/subscriptions.ts` + * (`diffSince`-Funktion, ord-Pairing-Heuristik 1:1). + */ + +export interface DiffCardForHash { + contentHash: string; + ord: number; +} + +export interface DiffCardFull { + contentHash: string; + type: string; + fields: Record; + ord: number; +} + +export interface DiffPayload { + from: { semver?: string; versionId?: string }; + to: { semver: string; versionId: string }; + added: DiffCardFull[]; + changed: { previous: { contentHash: string }; next: DiffCardFull }[]; + unchanged: { contentHash: string; ord: number }[]; + removed: { contentHash: string }[]; +} + +export interface ComputeDiffInput { + from: DiffCardForHash[]; + to: DiffCardFull[]; + fromInfo: { semver?: string; versionId?: string }; + toInfo: { semver: string; versionId: string }; +} + +export function computeDiff(input: ComputeDiffInput): DiffPayload { + const { from, to, fromInfo, toInfo } = input; + const fromHashes = new Set(from.map((c) => c.contentHash)); + const toHashes = new Set(to.map((c) => c.contentHash)); + + const unchanged: { contentHash: string; ord: number }[] = []; + + // Build ord → hash map of removed cards (in `from` but not in `to`). + const removedByOrd = new Map(); + for (const c of from) { + if (!toHashes.has(c.contentHash)) removedByOrd.set(c.ord, c.contentHash); + } + + const added: DiffCardFull[] = []; + const changed: { previous: { contentHash: string }; next: DiffCardFull }[] = []; + + for (const c of to) { + if (fromHashes.has(c.contentHash)) { + unchanged.push({ contentHash: c.contentHash, ord: c.ord }); + } else if (removedByOrd.has(c.ord)) { + const previousHash = removedByOrd.get(c.ord)!; + removedByOrd.delete(c.ord); + changed.push({ previous: { contentHash: previousHash }, next: c }); + } else { + added.push(c); + } + } + + // Was in removedByOrd übrig bleibt = echte Removals (nicht ord-paired). + const removed: { contentHash: string }[] = [...removedByOrd.values()].map((h) => ({ + contentHash: h, + })); + + return { + from: fromInfo, + to: toInfo, + added, + changed, + unchanged, + removed, + }; +} diff --git a/apps/api/src/routes/marketplace/decks.ts b/apps/api/src/routes/marketplace/decks.ts index 9e90d4e..6659301 100644 --- a/apps/api/src/routes/marketplace/decks.ts +++ b/apps/api/src/routes/marketplace/decks.ts @@ -156,12 +156,12 @@ export function marketplaceDecksRouter( }); }); - // Authenticated endpoints folgen. - const auth = new Hono<{ Variables: AuthVars }>(); - auth.use('*', authMiddleware); + // Authenticated endpoints — per-route authMiddleware statt Sub- + // Router-Mount, damit das Wildcard nicht die Public-GET-Routes + // fängt. // POST / — Deck-Init. - auth.post('/', async (c) => { + r.post('/', authMiddleware, async (c) => { const userId = c.get('userId'); const body = await c.req.json().catch(() => null); const parsed = InitSchema.safeParse(body); @@ -226,7 +226,7 @@ export function marketplaceDecksRouter( }); // PATCH /:slug — Metadaten. - auth.patch('/:slug', async (c) => { + r.patch('/:slug', authMiddleware, async (c) => { const userId = c.get('userId'); const slug = c.req.param('slug'); const body = await c.req.json().catch(() => null); @@ -265,7 +265,7 @@ export function marketplaceDecksRouter( }); // POST /:slug/publish — neue Version. - auth.post('/:slug/publish', async (c) => { + r.post('/:slug/publish', authMiddleware, async (c) => { const userId = c.get('userId'); const slug = c.req.param('slug'); const body = await c.req.json().catch(() => null); @@ -372,9 +372,5 @@ export function marketplaceDecksRouter( ); }); - // Auth-Sub-Router gemountet auf '/'. Hono routet zuerst exakte - // Pfade auf `r` (GET /:slug), Rest fließt auf den auth-Mount. - r.route('/', auth); - return r; } diff --git a/apps/api/src/routes/marketplace/engagement.ts b/apps/api/src/routes/marketplace/engagement.ts new file mode 100644 index 0000000..821c629 --- /dev/null +++ b/apps/api/src/routes/marketplace/engagement.ts @@ -0,0 +1,130 @@ +import { and, eq } from 'drizzle-orm'; +import { Hono } from 'hono'; + +import { getDb, type CardsDb } from '../../db/connection.ts'; +import { authorFollows, authors, deckStars, publicDecks } from '../../db/schema/index.ts'; +import { authMiddleware, type AuthVars } from '../../middleware/auth.ts'; +import { optionalAuthMiddleware } from '../../middleware/marketplace/optional-auth.ts'; + +/** + * Engagement-Primitives — Stars (Bookmarks für Decks) + Follows + * (Folgen von Authoren). + * + * Stars sind idempotent: doppeltes POST = no-op. + * Follows sind idempotent: Self-Follow wird mit 409 abgelehnt. + * + * State-Endpoints (`GET /:slug/star`, `GET /:slug/follow`) sind + * optional-auth: anonym = false, signed-in = wahre Antwort. + */ + +export type MarketplaceEngagementDeps = { db?: CardsDb }; + +async function findDeckBySlug(db: CardsDb, slug: string) { + const [row] = await db.select().from(publicDecks).where(eq(publicDecks.slug, slug)).limit(1); + return row ?? null; +} + +async function findAuthorBySlug(db: CardsDb, slug: string) { + const [row] = await db.select().from(authors).where(eq(authors.slug, slug)).limit(1); + return row ?? null; +} + +export function engagementRouter( + deps: MarketplaceEngagementDeps = {} +): Hono<{ Variables: Partial }> { + const r = new Hono<{ Variables: Partial }>(); + const dbOf = () => deps.db ?? getDb(); + + // ─── Stars (Decks) ─────────────────────────────────────────────── + // Per-route authMiddleware statt Sub-Router-Mount, damit die + // Public-State-Routes (`GET /decks/:slug/star` mit optional-auth) + // nicht durch ein Wildcard-Middleware gefangen werden. + + r.post('/decks/:slug/star', authMiddleware, async (c) => { + const userId = c.get('userId'); + const slug = c.req.param('slug'); + const db = dbOf(); + const deck = await findDeckBySlug(db, slug); + if (!deck) return c.json({ error: 'not_found' }, 404); + await db.insert(deckStars).values({ userId, deckId: deck.id }).onConflictDoNothing(); + return c.json({ starred: true }); + }); + + r.delete('/decks/:slug/star', authMiddleware, async (c) => { + const userId = c.get('userId'); + const slug = c.req.param('slug'); + const db = dbOf(); + const deck = await findDeckBySlug(db, slug); + if (!deck) return c.json({ error: 'not_found' }, 404); + await db + .delete(deckStars) + .where(and(eq(deckStars.userId, userId), eq(deckStars.deckId, deck.id))); + return c.json({ starred: false }); + }); + + // GET /decks/:slug/star — own state, optional-auth (anon = false). + r.get('/decks/:slug/star', optionalAuthMiddleware, async (c) => { + const userId = c.get('userId'); + if (!userId) return c.json({ starred: false }); + const slug = c.req.param('slug'); + const db = dbOf(); + const rows = await db + .select({ id: deckStars.deckId }) + .from(deckStars) + .innerJoin(publicDecks, eq(publicDecks.id, deckStars.deckId)) + .where(and(eq(deckStars.userId, userId), eq(publicDecks.slug, slug))) + .limit(1); + return c.json({ starred: rows.length > 0 }); + }); + + // ─── Follows (Authors) ─────────────────────────────────────────── + + r.post('/authors/:slug/follow', authMiddleware, async (c) => { + const followerUserId = c.get('userId'); + const slug = c.req.param('slug'); + const db = dbOf(); + const author = await findAuthorBySlug(db, slug); + if (!author) return c.json({ error: 'not_found' }, 404); + if (author.userId === followerUserId) { + return c.json({ error: 'cannot_follow_self' }, 409); + } + await db + .insert(authorFollows) + .values({ followerUserId, authorUserId: author.userId }) + .onConflictDoNothing(); + return c.json({ following: true }); + }); + + r.delete('/authors/:slug/follow', authMiddleware, async (c) => { + const followerUserId = c.get('userId'); + const slug = c.req.param('slug'); + const db = dbOf(); + const author = await findAuthorBySlug(db, slug); + if (!author) return c.json({ error: 'not_found' }, 404); + await db + .delete(authorFollows) + .where( + and( + eq(authorFollows.followerUserId, followerUserId), + eq(authorFollows.authorUserId, author.userId) + ) + ); + return c.json({ following: false }); + }); + + r.get('/authors/:slug/follow', optionalAuthMiddleware, async (c) => { + const followerUserId = c.get('userId'); + if (!followerUserId) return c.json({ following: false }); + const slug = c.req.param('slug'); + const db = dbOf(); + const rows = await db + .select({ id: authorFollows.authorUserId }) + .from(authorFollows) + .innerJoin(authors, eq(authors.userId, authorFollows.authorUserId)) + .where(and(eq(authorFollows.followerUserId, followerUserId), eq(authors.slug, slug))) + .limit(1); + return c.json({ following: rows.length > 0 }); + }); + + return r; +} diff --git a/apps/api/src/routes/marketplace/explore.ts b/apps/api/src/routes/marketplace/explore.ts new file mode 100644 index 0000000..a0927f9 --- /dev/null +++ b/apps/api/src/routes/marketplace/explore.ts @@ -0,0 +1,225 @@ +import { and, asc, count, desc, eq, ilike, or, sql } from 'drizzle-orm'; +import { Hono } from 'hono'; +import { z } from 'zod'; + +import { getDb, type CardsDb } from '../../db/connection.ts'; +import { + authors, + deckTags, + publicDeckVersions, + publicDecks, + tagDefinitions, +} from '../../db/schema/index.ts'; +import type { AuthVars } from '../../middleware/auth.ts'; +import { optionalAuthMiddleware } from '../../middleware/marketplace/optional-auth.ts'; + +/** + * Discovery — Browse, Search, Featured, Trending, Tag-Tree. + * + * Pure read-only, optional-auth (anonymer Browse erlaubt; signed-in + * User kriegen später hier nicht zusätzliches, aber das State-Lesen + * für Star/Subscribe geht über die per-Deck-Endpoints). + * + * Search nutzt `ILIKE` über Title + Description — gut genug für + * Phase γ. tsvector-Upgrade hängt an Phase ι. + * + * Trending = Star-Velocity der letzten 7 Tage. Bei kleinem N gambar, + * fine sobald Volumen kommt — Algorithmus austauschbar ohne API- + * Änderung. + * + * Geport aus + * `cards-decommission-base:services/cards-server/src/services/explore.ts`, + * mit FQN-Anpassung von `cards.*` auf `marketplace.*`. + */ + +export type MarketplaceExploreDeps = { db?: CardsDb }; + +const SortEnum = z.enum(['recent', 'popular', 'trending']); + +const BrowseQuerySchema = z.object({ + q: z.string().max(200).optional(), + tag: z.string().max(60).optional(), + language: z + .string() + .regex(/^[a-z]{2}$/) + .optional(), + author: z.string().max(60).optional(), + sort: SortEnum.optional(), + limit: z.coerce.number().int().min(1).max(100).optional(), + offset: z.coerce.number().int().min(0).optional(), +}); + +interface DeckListEntry { + slug: string; + title: string; + description: string | null; + language: string | null; + license: string; + price_credits: number; + card_count: number; + star_count: number; + subscriber_count: number; + is_featured: boolean; + created_at: string; + owner: { + slug: string; + display_name: string; + verified_mana: boolean; + verified_community: boolean; + }; +} + +async function browseImpl( + db: CardsDb, + filter: z.infer +): Promise<{ items: DeckListEntry[]; total: number }> { + const limit = filter.limit ?? 20; + const offset = filter.offset ?? 0; + const sort = filter.sort ?? 'recent'; + + const conditions = [eq(publicDecks.isTakedown, false)]; + if (filter.language) conditions.push(eq(publicDecks.language, filter.language)); + if (filter.q) { + const like = `%${filter.q}%`; + const expr = or(ilike(publicDecks.title, like), ilike(publicDecks.description, like)); + if (expr) conditions.push(expr); + } + if (filter.author) { + conditions.push( + eq( + publicDecks.ownerUserId, + sql`(SELECT user_id FROM marketplace.authors WHERE slug = ${filter.author} LIMIT 1)` + ) + ); + } + if (filter.tag) { + conditions.push( + sql`EXISTS (SELECT 1 FROM marketplace.deck_tags dt JOIN marketplace.tag_definitions td ON td.id = dt.tag_id WHERE dt.deck_id = ${publicDecks.id} AND td.slug = ${filter.tag})` + ); + } + + const starCount = sql`(SELECT count(*)::int FROM marketplace.deck_stars s WHERE s.deck_id = ${publicDecks.id})`; + const subscriberCount = sql`(SELECT count(*)::int FROM marketplace.deck_subscriptions s WHERE s.deck_id = ${publicDecks.id})`; + const cardCountExpr = sql`COALESCE((SELECT v.card_count FROM marketplace.deck_versions v WHERE v.id = ${publicDecks.latestVersionId}), 0)`; + + const sortClause = + sort === 'popular' + ? desc(starCount) + : sort === 'trending' + ? desc( + sql`(SELECT count(*)::int FROM marketplace.deck_stars s WHERE s.deck_id = ${publicDecks.id} AND s.starred_at >= now() - interval '7 days')` + ) + : desc(publicDecks.createdAt); + + const rows = await db + .select({ + slug: publicDecks.slug, + title: publicDecks.title, + description: publicDecks.description, + language: publicDecks.language, + license: publicDecks.license, + priceCredits: publicDecks.priceCredits, + cardCount: cardCountExpr, + starCount, + subscriberCount, + isFeatured: publicDecks.isFeatured, + createdAt: publicDecks.createdAt, + ownerSlug: authors.slug, + ownerDisplayName: authors.displayName, + ownerVerifiedMana: authors.verifiedMana, + ownerVerifiedCommunity: authors.verifiedCommunity, + }) + .from(publicDecks) + .innerJoin(authors, eq(authors.userId, publicDecks.ownerUserId)) + .where(and(...conditions)) + .orderBy(sortClause) + .limit(limit) + .offset(offset); + + const totalRow = await db + .select({ value: count() }) + .from(publicDecks) + .innerJoin(authors, eq(authors.userId, publicDecks.ownerUserId)) + .where(and(...conditions)); + + return { + items: rows.map((r) => ({ + slug: r.slug, + title: r.title, + description: r.description, + language: r.language, + license: r.license, + price_credits: r.priceCredits, + card_count: Number(r.cardCount), + star_count: Number(r.starCount), + subscriber_count: Number(r.subscriberCount), + is_featured: r.isFeatured, + created_at: r.createdAt.toISOString(), + owner: { + slug: r.ownerSlug, + display_name: r.ownerDisplayName, + verified_mana: r.ownerVerifiedMana, + verified_community: r.ownerVerifiedCommunity, + }, + })), + total: totalRow[0]?.value ?? 0, + }; +} + +export function exploreRouter( + deps: MarketplaceExploreDeps = {} +): Hono<{ Variables: Partial }> { + const r = new Hono<{ Variables: Partial }>(); + const dbOf = () => deps.db ?? getDb(); + + r.use('*', optionalAuthMiddleware); + + // GET /explore — Featured + Trending Side-by-Side. + r.get('/explore', async (c) => { + const db = dbOf(); + const [featured, trending] = await Promise.all([ + browseImpl(db, { sort: 'popular', limit: 8 }).then((r) => + r.items.filter((d) => d.is_featured).slice(0, 8) + ), + browseImpl(db, { sort: 'trending', limit: 8 }), + ]); + return c.json({ featured, trending: trending.items }); + }); + + // GET /decks — Browse mit Filtern + Sortierung + Pagination. + r.get('/decks', async (c) => { + const parsed = BrowseQuerySchema.safeParse(Object.fromEntries(new URL(c.req.url).searchParams)); + if (!parsed.success) { + return c.json( + { error: 'invalid_query', issues: parsed.error.issues.map((i) => i.message) }, + 422 + ); + } + const result = await browseImpl(dbOf(), parsed.data); + return c.json(result); + }); + + // GET /tags — flacher Tag-Tree. + r.get('/tags', async (c) => { + const rows = await dbOf() + .select() + .from(tagDefinitions) + .orderBy(asc(tagDefinitions.name)); + return c.json({ + tags: rows.map((t) => ({ + id: t.id, + slug: t.slug, + name: t.name, + parent_id: t.parentId, + description: t.description, + curated: t.curated, + })), + }); + }); + + // Imports kept alive für künftige Routes (curated-tags, by-version-…). + void publicDeckVersions; + void deckTags; + + return r; +} diff --git a/apps/api/src/routes/marketplace/fork.ts b/apps/api/src/routes/marketplace/fork.ts new file mode 100644 index 0000000..93875f2 --- /dev/null +++ b/apps/api/src/routes/marketplace/fork.ts @@ -0,0 +1,343 @@ +import { and, asc, eq } from 'drizzle-orm'; +import { Hono } from 'hono'; +import { z } from 'zod'; + +import { + cardContentHash, + newReview, + subIndexCount, + subIndexCountForCloze, +} from '@cards/domain'; + +import { getDb, type CardsDb } from '../../db/connection.ts'; +import { cards, decks, reviews } from '../../db/schema/index.ts'; +import { + publicDeckCards, + publicDeckVersions, + publicDecks, +} from '../../db/schema/index.ts'; +import { authMiddleware, type AuthVars } from '../../middleware/auth.ts'; +import { ulid } from '../../lib/ulid.ts'; +import { computeDiff } from '../../lib/marketplace/diff.ts'; + +/** + * Fork + Smart-Merge-Pull-Update. + * + * **Fork**: kopiert ein Marketplace-Deck (latest published Version) in + * eine eigene private `cards.decks`-Row + erzeugt private `cards.cards`- + * Zeilen aus den `marketplace.deck_cards`. FSRS-Reviews kommen frisch + * (newReview pro subIndex). `cards.decks.forked_from_marketplace_deck_id` + * + `…version_id` werden gesetzt — das ist der Anker für spätere + * Smart-Merge-Pulls. + * + * **Pull-Update**: gegen einen privaten geforkten Deck. Berechnet Diff + * zwischen geforkter Version und aktueller Marketplace-Latest. Neue + * Karten werden eingefügt; geänderte Karten kriegen einen neuen Card- + * Insert (mit neuem content_hash) — der **alte** Card-Eintrag bleibt + * bestehen, damit existierende FSRS-Reviews intakt bleiben. Removed- + * Cards bleiben ebenfalls (User behält History; Pull entfernt nichts + * Lokales). Im UI werden sie später dezent als „nicht mehr im Original" + * markiert (R5). + * + * Architektur-Begründung: + * - **content_hash-basierter Dedupe** ist die Smart-Merge-Magic: + * `cards.cards.content_hash` matcht `marketplace.deck_cards.content_hash`, + * identisch berechnet via `@cards/domain.cardContentHash`. + * Unveränderte Karten = identisches Hash → schon da → Insert + * übersprungen → FSRS bleibt. + * - **Removed-Cards bleiben lokal**: anders als das alte Dexie-Sync + * ist hier der server-authoritative Stand: User entscheidet selbst, + * was er löscht. Re-Pull soll keine User-Daten löschen. + */ + +export type MarketplaceForkDeps = { db?: CardsDb }; + +const ForkBodySchema = z.object({ + color: z.string().max(20).optional(), +}); + +async function findDeckBySlug(db: CardsDb, slug: string) { + const [row] = await db.select().from(publicDecks).where(eq(publicDecks.slug, slug)).limit(1); + return row ?? null; +} + +async function loadVersionCards(db: CardsDb, versionId: string) { + return db + .select() + .from(publicDeckCards) + .where(eq(publicDeckCards.versionId, versionId)) + .orderBy(asc(publicDeckCards.ord)); +} + +function subIndexCountFor(type: string, fields: Record): number { + if (type === 'cloze') return subIndexCountForCloze(fields.text ?? ''); + if (type === 'image-occlusion') { + // image-occlusion hat dynamische subIndexes via mask_regions — + // im Marketplace-Fork bisher nicht unterstützt. Default 1. + return 1; + } + return subIndexCount(type); +} + +function buildInitialReviews( + userId: string, + cardId: string, + count: number, + now: Date +) { + return Array.from({ length: count }, (_, subIndex) => { + const review = newReview({ userId, cardId, subIndex, now }); + return { + cardId: review.card_id, + subIndex: review.sub_index, + userId: review.user_id, + due: new Date(review.due), + stability: review.stability, + difficulty: review.difficulty, + elapsedDays: review.elapsed_days, + scheduledDays: review.scheduled_days, + learningSteps: review.learning_steps, + reps: review.reps, + lapses: review.lapses, + state: review.state, + lastReview: review.last_review ? new Date(review.last_review) : null, + }; + }); +} + +export function forkRouter(deps: MarketplaceForkDeps = {}): Hono<{ Variables: AuthVars }> { + const r = new Hono<{ Variables: AuthVars }>(); + const dbOf = () => deps.db ?? getDb(); + + r.use('*', authMiddleware); + + // POST /decks/:slug/fork — Marketplace → privater Deck. + r.post('/decks/:slug/fork', async (c) => { + const userId = c.get('userId'); + const slug = c.req.param('slug'); + const body = await c.req.json().catch(() => ({})); + const parsed = ForkBodySchema.safeParse(body); + if (!parsed.success) { + return c.json( + { error: 'invalid_input', issues: parsed.error.issues.map((i) => i.message) }, + 422 + ); + } + + const db = dbOf(); + const sourceDeck = await findDeckBySlug(db, slug); + if (!sourceDeck) return c.json({ error: 'not_found' }, 404); + if (sourceDeck.isTakedown) return c.json({ error: 'takedown_active' }, 403); + if (!sourceDeck.latestVersionId) return c.json({ error: 'no_published_version' }, 409); + + const sourceCards = await loadVersionCards(db, sourceDeck.latestVersionId); + if (sourceCards.length === 0) return c.json({ error: 'empty_version' }, 409); + + const newDeckId = ulid(); + const now = new Date(); + + const newDeck = await db.transaction(async (tx) => { + const [deck] = await tx + .insert(decks) + .values({ + id: newDeckId, + userId, + name: sourceDeck.title, + description: sourceDeck.description, + color: parsed.data.color ?? '#0ea5e9', // sky-500 — Fork-Default + visibility: 'private', + fsrsSettings: {}, + forkedFromMarketplaceDeckId: sourceDeck.id, + forkedFromMarketplaceVersionId: sourceDeck.latestVersionId, + createdAt: now, + updatedAt: now, + }) + .returning(); + + for (const sourceCard of sourceCards) { + const cardId = ulid(); + await tx.insert(cards).values({ + id: cardId, + deckId: newDeckId, + userId, + type: sourceCard.type, + fields: sourceCard.fields, + mediaRefs: [], + contentHash: sourceCard.contentHash, + createdAt: now, + updatedAt: now, + }); + + const subIndexes = subIndexCountFor( + sourceCard.type, + sourceCard.fields as Record + ); + const initialReviews = buildInitialReviews(userId, cardId, subIndexes, now); + if (initialReviews.length > 0) { + await tx.insert(reviews).values(initialReviews); + } + } + + return deck; + }); + + return c.json( + { + deck: { + id: newDeck.id, + name: newDeck.name, + description: newDeck.description, + color: newDeck.color, + forked_from_marketplace_deck_id: newDeck.forkedFromMarketplaceDeckId, + forked_from_marketplace_version_id: newDeck.forkedFromMarketplaceVersionId, + }, + cards_created: sourceCards.length, + }, + 201 + ); + }); + + // POST /decks/:private-deck-id/pull-update — Smart-Merge-Pull. + r.post('/private/:deckId/pull-update', async (c) => { + const userId = c.get('userId'); + const deckId = c.req.param('deckId'); + const db = dbOf(); + + const [privateDeck] = await db + .select() + .from(decks) + .where(and(eq(decks.id, deckId), eq(decks.userId, userId))) + .limit(1); + if (!privateDeck) return c.json({ error: 'not_found' }, 404); + if (!privateDeck.forkedFromMarketplaceDeckId) { + return c.json({ error: 'not_a_fork' }, 422); + } + if (!privateDeck.forkedFromMarketplaceVersionId) { + return c.json({ error: 'fork_pointer_missing' }, 500); + } + + const [sourceDeck] = await db + .select() + .from(publicDecks) + .where(eq(publicDecks.id, privateDeck.forkedFromMarketplaceDeckId)) + .limit(1); + if (!sourceDeck) return c.json({ error: 'source_deck_gone' }, 404); + if (!sourceDeck.latestVersionId) return c.json({ error: 'no_published_version' }, 409); + + // Already up-to-date? + if (sourceDeck.latestVersionId === privateDeck.forkedFromMarketplaceVersionId) { + return c.json({ + up_to_date: true, + added: 0, + changed: 0, + removed: 0, + }); + } + + const [fromCards, toCards, latestVersion, fromVersion] = await Promise.all([ + db + .select({ contentHash: publicDeckCards.contentHash, ord: publicDeckCards.ord }) + .from(publicDeckCards) + .where(eq(publicDeckCards.versionId, privateDeck.forkedFromMarketplaceVersionId)), + loadVersionCards(db, sourceDeck.latestVersionId), + db + .select() + .from(publicDeckVersions) + .where(eq(publicDeckVersions.id, sourceDeck.latestVersionId)) + .limit(1) + .then((rows) => rows[0] ?? null), + db + .select() + .from(publicDeckVersions) + .where(eq(publicDeckVersions.id, privateDeck.forkedFromMarketplaceVersionId)) + .limit(1) + .then((rows) => rows[0] ?? null), + ]); + if (!latestVersion || !fromVersion) { + return c.json({ error: 'version_resolution_failed' }, 500); + } + + const diff = computeDiff({ + from: fromCards, + to: toCards.map((card) => ({ + contentHash: card.contentHash, + type: card.type, + fields: card.fields as Record, + ord: card.ord, + })), + fromInfo: { semver: fromVersion.semver, versionId: fromVersion.id }, + toInfo: { semver: latestVersion.semver, versionId: latestVersion.id }, + }); + + // Apply: added + changed.next werden als neue private Cards + // eingefügt (mit neuem content_hash, da identisch zur + // Marketplace-Karte). Unveränderte Karten existieren schon + // privat — wir können den Insert sicher überspringen, weil + // (deck_id, content_hash) im privaten cards.cards einzigartig + // ist (gleiche Hash-Funktion via @cards/domain). + // Removed-Cards bleiben lokal (server-authoritative User-Choice). + const now = new Date(); + + // Existing private content-hashes lookup, damit wir keine + // Duplikate einfügen falls die Heuristik mal danebenliegt. + const existingPrivate = await db + .select({ contentHash: cards.contentHash }) + .from(cards) + .where(and(eq(cards.deckId, deckId), eq(cards.userId, userId))); + const existingHashes = new Set(existingPrivate.map((row) => row.contentHash)); + + const toInsert = [...diff.added, ...diff.changed.map((entry) => entry.next)].filter( + (card) => !existingHashes.has(card.contentHash) + ); + + await db.transaction(async (tx) => { + for (const card of toInsert) { + const cardId = ulid(); + // Content-Hash kommt aus Marketplace; wir haben die Karte + // schon nicht im privaten Set, also sicher zu inserten. + const computedHash = await cardContentHash({ + type: card.type, + fields: card.fields, + }); + await tx.insert(cards).values({ + id: cardId, + deckId, + userId, + type: card.type, + fields: card.fields, + mediaRefs: [], + contentHash: computedHash, + createdAt: now, + updatedAt: now, + }); + + const subIndexes = subIndexCountFor(card.type, card.fields); + const initialReviews = buildInitialReviews(userId, cardId, subIndexes, now); + if (initialReviews.length > 0) { + await tx.insert(reviews).values(initialReviews); + } + } + + // Update Fork-Pointer auf die neue Version. + await tx + .update(decks) + .set({ + forkedFromMarketplaceVersionId: sourceDeck.latestVersionId, + updatedAt: now, + }) + .where(eq(decks.id, deckId)); + }); + + return c.json({ + up_to_date: false, + from: { semver: fromVersion.semver, versionId: fromVersion.id }, + to: { semver: latestVersion.semver, versionId: latestVersion.id }, + added: diff.added.length, + changed: diff.changed.length, + removed: diff.removed.length, + cards_inserted: toInsert.length, + }); + }); + + return r; +} diff --git a/apps/api/src/routes/marketplace/subscriptions.ts b/apps/api/src/routes/marketplace/subscriptions.ts new file mode 100644 index 0000000..489695c --- /dev/null +++ b/apps/api/src/routes/marketplace/subscriptions.ts @@ -0,0 +1,252 @@ +import { and, asc, eq } from 'drizzle-orm'; +import { Hono } from 'hono'; + +import { getDb, type CardsDb } from '../../db/connection.ts'; +import { + deckSubscriptions, + publicDeckCards, + publicDeckVersions, + publicDecks, +} from '../../db/schema/index.ts'; +import { authMiddleware, type AuthVars } from '../../middleware/auth.ts'; +import { optionalAuthMiddleware } from '../../middleware/marketplace/optional-auth.ts'; +import { computeDiff } from '../../lib/marketplace/diff.ts'; + +/** + * Subscriptions + Version-Read + Smart-Merge-Diff. + * + * - `POST /decks/:slug/subscribe` — Intent-Tracking. Wenn paid: + * Purchase-Check (R3 dormant). + * - `DELETE /decks/:slug/subscribe` — abbestellen. + * - `GET /me/subscriptions` — eigene Subs + update-Indikator. + * - `GET /decks/:slug/versions/:semver` — voller Version-Payload mit + * Cards in stable ord-Order. + * - `GET /decks/:slug/diff?from=:semver` — Smart-Merge-Payload. + * + * Geport aus + * `cards-decommission-base:services/cards-server/src/services/subscriptions.ts`. + */ + +export type MarketplaceSubscriptionsDeps = { db?: CardsDb }; + +async function findDeckBySlug(db: CardsDb, slug: string) { + const [row] = await db.select().from(publicDecks).where(eq(publicDecks.slug, slug)).limit(1); + return row ?? null; +} + +async function findVersion(db: CardsDb, deckId: string, semver: string) { + const [row] = await db + .select() + .from(publicDeckVersions) + .where(and(eq(publicDeckVersions.deckId, deckId), eq(publicDeckVersions.semver, semver))) + .limit(1); + return row ?? null; +} + +async function loadVersionCards(db: CardsDb, versionId: string) { + return db + .select() + .from(publicDeckCards) + .where(eq(publicDeckCards.versionId, versionId)) + .orderBy(asc(publicDeckCards.ord)); +} + +export function subscriptionsRouter( + deps: MarketplaceSubscriptionsDeps = {} +): Hono<{ Variables: Partial }> { + const r = new Hono<{ Variables: Partial }>(); + const dbOf = () => deps.db ?? getDb(); + + // ─── Subscribe / Unsubscribe (auth — per-route middleware + // statt route-mount, damit die Public-Read-Routes weiter unten + // nicht vom Wildcard-Middleware des Auth-Subrouters gefangen werden) ── + + r.post('/decks/:slug/subscribe', authMiddleware, async (c) => { + const userId = c.get('userId'); + const slug = c.req.param('slug'); + const db = dbOf(); + const deck = await findDeckBySlug(db, slug); + if (!deck) return c.json({ error: 'not_found' }, 404); + if (deck.isTakedown) return c.json({ error: 'takedown_active' }, 403); + if (!deck.latestVersionId) { + return c.json({ error: 'no_published_version' }, 409); + } + // Paid-Decks brauchen einen Purchase-Beleg — Pipeline kommt mit + // Phase ζ wieder; bis dahin: nur Owner darf seinen eigenen paid + // Deck testweise subscriben. + if (deck.priceCredits > 0 && deck.ownerUserId !== userId) { + return c.json({ error: 'paid_deck_purchase_required' }, 402); + } + + await db + .insert(deckSubscriptions) + .values({ + userId, + deckId: deck.id, + currentVersionId: deck.latestVersionId, + }) + .onConflictDoUpdate({ + target: [deckSubscriptions.userId, deckSubscriptions.deckId], + set: { currentVersionId: deck.latestVersionId }, + }); + + return c.json( + { + subscribed: true, + deck_slug: slug, + current_version_id: deck.latestVersionId, + }, + 201 + ); + }); + + r.delete('/decks/:slug/subscribe', authMiddleware, async (c) => { + const userId = c.get('userId'); + const slug = c.req.param('slug'); + const db = dbOf(); + const deck = await findDeckBySlug(db, slug); + if (!deck) return c.json({ error: 'not_found' }, 404); + await db + .delete(deckSubscriptions) + .where(and(eq(deckSubscriptions.userId, userId), eq(deckSubscriptions.deckId, deck.id))); + return c.json({ subscribed: false }); + }); + + r.get('/me/subscriptions', authMiddleware, async (c) => { + const userId = c.get('userId'); + const rows = await dbOf() + .select({ + deckSlug: publicDecks.slug, + deckTitle: publicDecks.title, + deckDescription: publicDecks.description, + deckLatestVersionId: publicDecks.latestVersionId, + subscribedAt: deckSubscriptions.subscribedAt, + notifyUpdates: deckSubscriptions.notifyUpdates, + currentVersionId: deckSubscriptions.currentVersionId, + }) + .from(deckSubscriptions) + .innerJoin(publicDecks, eq(publicDecks.id, deckSubscriptions.deckId)) + .where(eq(deckSubscriptions.userId, userId)) + .orderBy(asc(deckSubscriptions.subscribedAt)); + + return c.json({ + subscriptions: rows.map((row) => ({ + deck_slug: row.deckSlug, + deck_title: row.deckTitle, + deck_description: row.deckDescription, + subscribed_at: row.subscribedAt.toISOString(), + notify_updates: row.notifyUpdates, + current_version_id: row.currentVersionId, + latest_version_id: row.deckLatestVersionId, + update_available: + row.deckLatestVersionId !== null && row.currentVersionId !== row.deckLatestVersionId, + })), + }); + }); + + r.get('/decks/:slug/subscribe', authMiddleware, async (c) => { + const userId = c.get('userId'); + const slug = c.req.param('slug'); + const db = dbOf(); + const rows = await db + .select({ id: deckSubscriptions.deckId, currentVersionId: deckSubscriptions.currentVersionId }) + .from(deckSubscriptions) + .innerJoin(publicDecks, eq(publicDecks.id, deckSubscriptions.deckId)) + .where(and(eq(deckSubscriptions.userId, userId), eq(publicDecks.slug, slug))) + .limit(1); + if (rows.length === 0) return c.json({ subscribed: false }); + return c.json({ subscribed: true, current_version_id: rows[0].currentVersionId }); + }); + + // ─── Public-Read: Version + Diff ───────────────────────────────── + + r.get('/decks/:slug/versions/:semver', optionalAuthMiddleware, async (c) => { + const slug = c.req.param('slug'); + const semver = c.req.param('semver'); + const db = dbOf(); + const deck = await findDeckBySlug(db, slug); + if (!deck) return c.json({ error: 'not_found' }, 404); + const version = await findVersion(db, deck.id, semver); + if (!version) return c.json({ error: 'version_not_found' }, 404); + const cards = await loadVersionCards(db, version.id); + + return c.json({ + version: { + id: version.id, + deck_id: version.deckId, + semver: version.semver, + changelog: version.changelog, + content_hash: version.contentHash, + card_count: version.cardCount, + published_at: version.publishedAt.toISOString(), + }, + cards: cards.map((card) => ({ + content_hash: card.contentHash, + type: card.type, + fields: card.fields, + ord: card.ord, + })), + }); + }); + + r.get('/decks/:slug/diff', optionalAuthMiddleware, async (c) => { + const slug = c.req.param('slug'); + const fromSemver = c.req.query('from'); + if (!fromSemver) return c.json({ error: 'missing_query', detail: 'from=:semver' }, 422); + + const db = dbOf(); + const deck = await findDeckBySlug(db, slug); + if (!deck) return c.json({ error: 'not_found' }, 404); + if (!deck.latestVersionId) return c.json({ error: 'no_published_version' }, 409); + + const [latest, from] = await Promise.all([ + db + .select() + .from(publicDeckVersions) + .where(eq(publicDeckVersions.id, deck.latestVersionId)) + .limit(1) + .then((rows) => rows[0] ?? null), + findVersion(db, deck.id, fromSemver), + ]); + if (!latest) return c.json({ error: 'latest_version_missing' }, 500); + if (!from) return c.json({ error: 'from_version_not_found' }, 404); + + // Empty diff bei Identität. + if (from.id === latest.id) { + return c.json({ + from: { semver: fromSemver, versionId: from.id }, + to: { semver: latest.semver, versionId: latest.id }, + added: [], + changed: [], + unchanged: [], + removed: [], + }); + } + + const [fromCards, toCardsRaw] = await Promise.all([ + db + .select({ contentHash: publicDeckCards.contentHash, ord: publicDeckCards.ord }) + .from(publicDeckCards) + .where(eq(publicDeckCards.versionId, from.id)), + loadVersionCards(db, latest.id), + ]); + + const toCards = toCardsRaw.map((card) => ({ + contentHash: card.contentHash, + type: card.type, + fields: card.fields as Record, + ord: card.ord, + })); + + const diff = computeDiff({ + from: fromCards, + to: toCards, + fromInfo: { semver: fromSemver, versionId: from.id }, + toInfo: { semver: latest.semver, versionId: latest.id }, + }); + + return c.json(diff); + }); + + return r; +} diff --git a/apps/api/tests/marketplace-diff.test.ts b/apps/api/tests/marketplace-diff.test.ts new file mode 100644 index 0000000..17cdf42 --- /dev/null +++ b/apps/api/tests/marketplace-diff.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest'; + +import { computeDiff } from '../src/lib/marketplace/diff.ts'; + +const fromInfo = { semver: '1.0.0', versionId: 'v1' }; +const toInfo = { semver: '1.1.0', versionId: 'v2' }; + +function fullCard(ord: number, hash: string, front = `Q${ord}`, back = `A${ord}`) { + return { contentHash: hash, type: 'basic', fields: { front, back }, ord }; +} + +describe('computeDiff', () => { + it('classifies all-unchanged when nothing moved', () => { + const diff = computeDiff({ + from: [ + { contentHash: 'h1', ord: 0 }, + { contentHash: 'h2', ord: 1 }, + ], + to: [fullCard(0, 'h1'), fullCard(1, 'h2')], + fromInfo, + toInfo, + }); + expect(diff.unchanged).toHaveLength(2); + expect(diff.added).toHaveLength(0); + expect(diff.changed).toHaveLength(0); + expect(diff.removed).toHaveLength(0); + }); + + it('detects added when a brand-new card appears', () => { + const diff = computeDiff({ + from: [{ contentHash: 'h1', ord: 0 }], + to: [fullCard(0, 'h1'), fullCard(1, 'h2')], + fromInfo, + toInfo, + }); + expect(diff.added).toHaveLength(1); + expect(diff.added[0].contentHash).toBe('h2'); + expect(diff.unchanged).toHaveLength(1); + }); + + it('detects removed when a card vanishes (and ord is unique)', () => { + const diff = computeDiff({ + from: [ + { contentHash: 'h1', ord: 0 }, + { contentHash: 'h2', ord: 1 }, + ], + to: [fullCard(0, 'h1')], + fromInfo, + toInfo, + }); + expect(diff.removed).toHaveLength(1); + expect(diff.removed[0].contentHash).toBe('h2'); + }); + + it('detects changed when same ord has different hash', () => { + const diff = computeDiff({ + from: [{ contentHash: 'h1', ord: 0 }], + to: [fullCard(0, 'h1-tweaked', 'Q0', 'A0-edited')], + fromInfo, + toInfo, + }); + expect(diff.changed).toHaveLength(1); + expect(diff.changed[0].previous.contentHash).toBe('h1'); + expect(diff.changed[0].next.contentHash).toBe('h1-tweaked'); + expect(diff.added).toHaveLength(0); + expect(diff.removed).toHaveLength(0); + }); + + it('mixed: 1 unchanged, 1 changed, 1 added, 1 removed', () => { + const diff = computeDiff({ + from: [ + { contentHash: 'h-stay', ord: 0 }, + { contentHash: 'h-old', ord: 1 }, + { contentHash: 'h-bye', ord: 2 }, + ], + to: [ + fullCard(0, 'h-stay'), + fullCard(1, 'h-new', 'replaced', 'card'), + fullCard(3, 'h-fresh', 'new', 'card'), + ], + fromInfo, + toInfo, + }); + expect(diff.unchanged.map((c) => c.contentHash)).toEqual(['h-stay']); + expect(diff.changed).toHaveLength(1); + expect(diff.changed[0].previous.contentHash).toBe('h-old'); + expect(diff.changed[0].next.contentHash).toBe('h-new'); + expect(diff.added).toHaveLength(1); + expect(diff.added[0].contentHash).toBe('h-fresh'); + expect(diff.removed).toHaveLength(1); + expect(diff.removed[0].contentHash).toBe('h-bye'); + }); + + it('returns the version-info verbatim', () => { + const diff = computeDiff({ + from: [], + to: [], + fromInfo, + toInfo, + }); + expect(diff.from).toEqual(fromInfo); + expect(diff.to).toEqual(toInfo); + }); +});