Phase 12 R3: Marketplace γ + δ — Discovery + Engagement + Subscribe + Smart-Merge

Routes (additiv unter /api/v1/marketplace/*):

Discovery (optional-auth, anonymer Read erlaubt):
- GET /explore — featured + trending side-by-side
- GET /decks — browse mit q/tag/language/author/sort/limit/offset
  (sort: recent | popular | trending; trending = star-velocity 7d)
- GET /tags — flacher Tag-Tree

Engagement (auth pro Schreib-Route, optional-auth für GET state):
- POST/DELETE/GET /decks/:slug/star
- POST/DELETE/GET /authors/:slug/follow (cannot-follow-self → 409)

Subscribe + Version-Read + Smart-Merge-Diff:
- POST/DELETE/GET /decks/:slug/subscribe
- GET /me/subscriptions (mit update_available-Indicator)
- GET /decks/:slug/versions/:semver — voller Cards-Payload in ord-
  Reihenfolge
- GET /decks/:slug/diff?from=:semver — computeDiff (added/changed/
  removed/unchanged) basierend auf content_hash + ord-Heuristik für
  "changed an gleicher Position"

Fork + Smart-Merge-Pull (auth):
- POST /decks/:slug/fork — kopiert latest version in privaten
  cards.decks (forked_from_marketplace_* gesetzt) + cards.cards mit
  übernommenem content_hash + frische FSRS-Reviews
- POST /private/:deckId/pull-update — Smart-Merge: existing private
  hashes deduplizieren, nur added/changed cards einfügen (mit fresh
  reviews), unveränderte Karten BEHALTEN inkl. FSRS-State, removed
  cards bleiben lokal (server-authoritative User-Choice). Update der
  forked_from_marketplace_version_id auf latest.

Schema (R3a):
- cards.decks: 2 neue Columns forked_from_marketplace_deck_id +
  forked_from_marketplace_version_id (text, nullable). Drizzle-push
  grün.

Architektur-Highlights:
- @cards/domain.cardContentHash ist die single source of truth für
  Karten-Hashing; marketplace.deck_cards und cards.cards berechnen
  identisch → Smart-Merge ist hash-equality + INSERT-IGNORE statt
  Diff-Replay
- pgSchema-Trennung (marketplace.* vs. cards.*) zahlt sich aus:
  Marketplace-Read-Path (Public + Engagement) und privater Lern-Pfad
  haben separate FK-Welten und können unabhängig versioniert werden
- Hono-Middleware-Pattern: per-route authMiddleware/optionalAuth statt
  Sub-Router-Mount, weil ein Wildcard '*' auf einem Sub-Router via
  r.route('/', sub) sonst die Public-GET-Routes des Parents fängt
  (Hono-Routing-Subtilität, kostete eine Smoke-Iteration)

Verifikation:
- type-check 0 errors
- 6 neue Diff-Heuristik-Tests, 78 gesamt grün
- End-to-End-Smoke gegen lokale cards-api:
  · Cardecky-Author + Deck `r3-stoische-grundbegriffe` v1.0.0 (3 Karten)
  · Till browst (anon → 200), starred, folgt Cardecky, subscribed
  · Till forkt → privates Deck mit 3 Karten + 3 fresh FSRS-Reviews
  · SQL-Manipulation: Apatheia-Review auf state='review',
    stability=10, reps=3 (simuliert "schon gelernt")
  · Cardecky publisht v1.1.0: Apatheia + Eudaimonia unverändert,
    Logos präzisiert (changed), Tugendlehre neu (added)
  · Diff-Endpoint zeigt: unchanged=2, changed=1, added=1, removed=0
  · Till pull-update → cards_inserted=2 (changed.next + added)
  · Verifikation: card_count=5 (war 3), Apatheia-Review **identisch
    erhalten** (state=review, stability=10, reps=3, last_review IS
    NOT NULL), neue Karten state=new — FSRS-State der unveränderten
    Karte überlebt Smart-Merge unverletzt

Verbleibend: R4 ε (PRs + Card-Discussions), R5 Frontend-Routes
(/explore, /d/[slug], /u/[slug], /me/subscribed, /me/forks), R6
voller UI-E2E.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-09 15:27:39 +02:00
parent 7dbbf63523
commit d45f1c0079
10 changed files with 1170 additions and 11 deletions

View file

@ -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(),

View file

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

View file

@ -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<string, string>;
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<number, string>();
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,
};
}

View file

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

View file

@ -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<AuthVars> }> {
const r = new Hono<{ Variables: Partial<AuthVars> }>();
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;
}

View file

@ -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<typeof BrowseQuerySchema>
): 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<string>`(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<number>`(SELECT count(*)::int FROM marketplace.deck_stars s WHERE s.deck_id = ${publicDecks.id})`;
const subscriberCount = sql<number>`(SELECT count(*)::int FROM marketplace.deck_subscriptions s WHERE s.deck_id = ${publicDecks.id})`;
const cardCountExpr = sql<number>`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<number>`(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<AuthVars> }> {
const r = new Hono<{ Variables: Partial<AuthVars> }>();
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;
}

View file

@ -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<string, string>): 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<string, string>
);
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<string, string>,
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;
}

View file

@ -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<AuthVars> }> {
const r = new Hono<{ Variables: Partial<AuthVars> }>();
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<string, string>,
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;
}

View file

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