Phase 12 R4: Marketplace ε — Pull-Requests + Card-Discussions

Pull-Requests (Diff-Modell add/modify/remove, GitHub-style 3-way
merge in der DB-Transaktion):

- POST /decks/:slug/pull-requests (auth) — neuer PR mit
  diff.{add,modify,remove}; previousContentHash für modify identifiziert
  die zu ersetzende Karte by content-hash, type bleibt aus dem alten
  Eintrag (modify ist field-only Replace)
- GET /decks/:slug/pull-requests (optional-auth) — Liste mit Status-
  Filter (open/merged/closed/rejected)
- GET /pull-requests/:id (optional-auth) — Detail
- POST /pull-requests/:id/close (auth) — Author oder Deck-Owner
- POST /pull-requests/:id/reject (auth) — nur Deck-Owner; getrennt
  von close, damit der PR-Author klares Feedback hat
- POST /pull-requests/:id/merge (auth) — nur Deck-Owner; baut neue
  card-list aus latest version + diff (removes weglassen, modifies
  fields-replace, adds anhängen mit re-counted ord), schreibt
  publicDeckVersions + publicDeckCards atomar in einer Drizzle-
  Transaction, bumpt latestVersionId und setzt PR auf merged.
  Default-Semver-Bump: minor (1.0.0 → 1.1.0). Authorenüberschreibbar
  via mergeNote/newSemver-Body-Felder.

Card-Discussions (Threads pro card_content_hash, überleben
Versions-Bumps solange Karten-Inhalt bleibt):

- POST /decks/:slug/cards/:hash/discussions (auth) — neuer Thread
  oder Reply (parent_id muss in derselben card_content_hash-Gruppe
  leben → 422 sonst)
- GET /cards/:hash/discussions (optional-auth) — Liste sichtbarer
  Comments, hidden gefiltert
- GET /decks/:slug/discussions/counts (optional-auth) — Bulk-Count
  pro card_content_hash für Deck-Übersicht (kein N+1)
- POST /discussions/:id/hide (auth) — Soft-Hide (Author oder Deck-
  Owner); kein Delete, Audit-Trail bleibt

Helpers:

- lib/marketplace/semver.ts — bumpMinor, isSemver, semverGreater
  (klein, ohne Range-Logik). Wird von PRs + später vom decks.ts
  publish-Flow konsumiert.

Bug-Fix:

- routes/marketplace/fork.ts hatte r.use('*', authMiddleware) am
  Anfang. An dem /api/v1/marketplace-Mount-Punkt fängt das Wildcard
  alle nachfolgenden Router-Mounts (PRs, Discussions) → anonymer
  GET /pull-requests wurde mit 401 abgelehnt. Refactor auf per-
  route authMiddleware (Pattern wie in subscriptions.ts und
  engagement.ts seit R3). Lessons learned dokumentiert in der
  STATUS.md-Zeile.

Verifikation:

- type-check 0 errors
- 11 neue Semver-Tests, 89 gesamt grün
- E2E-Smoke gegen lokale cards-api durch:
  · Cardecky publisht v1.0.0 (Apatheia, Eudaimonia, Logos)
  · Till's PR: modify Eudaimonia-Back, remove Logos, add Tugendlehre
  · Till's Merge-Versuch → 403 (deck_owner_only)
  · Cardecky merged → v1.1.0 atomar, card_count=3, ord-Reihenfolge:
    [Apatheia, Eudaimonia-mit-neuem-Back, Tugendlehre]
  · Re-Merge → 409 (pr_already_merged)
  · Till's Discussion-Thread + Cardecky-Reply mit parent_id
  · Cross-Card-parent abgelehnt → 422
  · Hide → Comment verschwindet aus Liste, total von 2 auf 1
  · Bulk-Counts liefert {hash → 2}
  · Smart-Merge-Pull v1.0.0→v1.1.0 wertet PR-Merge korrekt aus
    (changed=2 via Eudaimonia + Logos↔Tugendlehre ord-Heuristik)

Verbleibend: R5 Frontend-Routes (/explore, /d/[slug], /u/[slug],
/me/{published,subscribed,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:50:16 +02:00
parent d45f1c0079
commit 92a1d5804f
7 changed files with 638 additions and 5 deletions

View file

@ -19,6 +19,8 @@ import { exploreRouter as marketplaceExploreRouter } from './routes/marketplace/
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';
import { pullRequestsRouter as marketplacePullRequestsRouter } from './routes/marketplace/pull-requests.ts';
import { discussionsRouter as marketplaceDiscussionsRouter } from './routes/marketplace/discussions.ts';
const app = new Hono();
@ -62,6 +64,8 @@ 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', marketplacePullRequestsRouter());
app.route('/api/v1/marketplace', marketplaceDiscussionsRouter());
app.route('/api/v1/marketplace/authors', marketplaceAuthorsRouter());
app.route('/api/v1/marketplace/decks', marketplaceDecksRouter());

View file

@ -0,0 +1,31 @@
/**
* Mini-SemVer-Helpers keine Range-/Caret-/Tilde-Logik, nur die zwei
* Operationen die der Marketplace braucht.
*/
const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)$/;
export function isSemver(value: string): boolean {
return SEMVER_RE.test(value);
}
/** `1.2.3` → `1.3.0`. Default-Bump beim PR-Merge. */
export function bumpMinor(semver: string): string {
const m = semver.match(SEMVER_RE);
if (!m) return '1.0.0';
return `${m[1]}.${Number(m[2]) + 1}.0`;
}
/** Strict-greater Vergleich. Wirft NICHT bei ungültigem Input — gibt `false`. */
export function semverGreater(a: string, b: string): boolean {
const ma = a.match(SEMVER_RE);
const mb = b.match(SEMVER_RE);
if (!ma || !mb) return false;
for (let i = 1; i <= 3; i++) {
const da = Number.parseInt(ma[i], 10);
const db = Number.parseInt(mb[i], 10);
if (da > db) return true;
if (da < db) return false;
}
return false;
}

View file

@ -0,0 +1,165 @@
import { and, asc, eq, sql } from 'drizzle-orm';
import { Hono } from 'hono';
import { z } from 'zod';
import { getDb, type CardsDb } from '../../db/connection.ts';
import { cardDiscussions, publicDecks } from '../../db/schema/index.ts';
import { authMiddleware, type AuthVars } from '../../middleware/auth.ts';
import { optionalAuthMiddleware } from '../../middleware/marketplace/optional-auth.ts';
/**
* Card-Discussions leichtgewichtige Inline-Threads keyed auf
* `card_content_hash` (nicht card-id), damit ein Thread Versions-Bumps
* überlebt solange der Karten-Inhalt bleibt.
*
* Threads sind flach-mit-parent: jede Reply hat optionalen `parent_id`
* noch eine Discussion in derselben `card_content_hash`-Gruppe. UI
* rendert MVP-flach, eine Threading-Tiefe ist Schema-vorbereitet.
*
* Geport aus
* `cards-decommission-base:services/cards-server/src/services/discussions.ts`.
*/
export type MarketplaceDiscussionsDeps = { db?: CardsDb };
const PostSchema = z.object({
body: z.string().min(1).max(2000),
parentId: z.string().uuid().optional(),
});
function toDto(row: typeof cardDiscussions.$inferSelect) {
return {
id: row.id,
card_content_hash: row.cardContentHash,
deck_id: row.deckId,
author_user_id: row.authorUserId,
parent_id: row.parentId,
body: row.body,
hidden: row.hidden,
created_at: row.createdAt.toISOString(),
};
}
async function findDeckBySlug(db: CardsDb, slug: string) {
const [row] = await db.select().from(publicDecks).where(eq(publicDecks.slug, slug)).limit(1);
return row ?? null;
}
export function discussionsRouter(
deps: MarketplaceDiscussionsDeps = {}
): Hono<{ Variables: Partial<AuthVars> }> {
const r = new Hono<{ Variables: Partial<AuthVars> }>();
const dbOf = () => deps.db ?? getDb();
// POST /decks/:slug/cards/:hash/discussions — neuer Thread/Reply
r.post('/decks/:slug/cards/:hash/discussions', authMiddleware, async (c) => {
const userId = c.get('userId');
const slug = c.req.param('slug');
const cardHash = c.req.param('hash');
const body = await c.req.json().catch(() => null);
const parsed = PostSchema.safeParse(body);
if (!parsed.success) {
return c.json(
{ error: 'invalid_input', issues: parsed.error.issues.map((i) => i.message) },
422
);
}
const db = dbOf();
const deck = await findDeckBySlug(db, slug);
if (!deck) return c.json({ error: 'not_found' }, 404);
// Parent muss in derselben card_content_hash-Gruppe leben.
if (parsed.data.parentId) {
const [parent] = await db
.select()
.from(cardDiscussions)
.where(eq(cardDiscussions.id, parsed.data.parentId))
.limit(1);
if (!parent) return c.json({ error: 'parent_not_found' }, 404);
if (parent.cardContentHash !== cardHash) {
return c.json({ error: 'parent_belongs_to_different_card' }, 422);
}
}
const [row] = await db
.insert(cardDiscussions)
.values({
cardContentHash: cardHash,
deckId: deck.id,
authorUserId: userId,
parentId: parsed.data.parentId ?? null,
body: parsed.data.body,
})
.returning();
return c.json(toDto(row), 201);
});
// GET /cards/:hash/discussions — alle sichtbaren Comments für eine Karte
r.get('/cards/:hash/discussions', optionalAuthMiddleware, async (c) => {
const cardHash = c.req.param('hash');
const rows = await dbOf()
.select()
.from(cardDiscussions)
.where(
and(
eq(cardDiscussions.cardContentHash, cardHash),
eq(cardDiscussions.hidden, false)
)
)
.orderBy(asc(cardDiscussions.createdAt));
return c.json({ discussions: rows.map(toDto), total: rows.length });
});
// GET /decks/:slug/discussions/counts — bulk-counts pro card_content_hash
r.get('/decks/:slug/discussions/counts', optionalAuthMiddleware, async (c) => {
const slug = c.req.param('slug');
const db = dbOf();
const deck = await findDeckBySlug(db, slug);
if (!deck) return c.json({ error: 'not_found' }, 404);
const rows = await db
.select({
contentHash: cardDiscussions.cardContentHash,
count: sql<number>`count(*)::int`.as('count'),
})
.from(cardDiscussions)
.where(and(eq(cardDiscussions.deckId, deck.id), eq(cardDiscussions.hidden, false)))
.groupBy(cardDiscussions.cardContentHash);
const counts: Record<string, number> = {};
for (const row of rows) counts[row.contentHash] = row.count;
return c.json({ counts });
});
// POST /discussions/:id/hide — Soft-Hide (Author oder Deck-Owner)
r.post('/discussions/:id/hide', authMiddleware, async (c) => {
const userId = c.get('userId');
const id = c.req.param('id');
const db = dbOf();
const [row] = await db
.select()
.from(cardDiscussions)
.where(eq(cardDiscussions.id, id))
.limit(1);
if (!row) return c.json({ error: 'not_found' }, 404);
const [deck] = await db
.select()
.from(publicDecks)
.where(eq(publicDecks.id, row.deckId))
.limit(1);
if (!deck) return c.json({ error: 'deck_not_found' }, 404);
if (row.authorUserId !== userId && deck.ownerUserId !== userId) {
return c.json({ error: 'forbidden', detail: 'comment_author_or_deck_owner_only' }, 403);
}
await db
.update(cardDiscussions)
.set({ hidden: true })
.where(eq(cardDiscussions.id, id));
return c.json({ hidden: true });
});
return r;
}

View file

@ -109,10 +109,12 @@ export function forkRouter(deps: MarketplaceForkDeps = {}): Hono<{ Variables: Au
const r = new Hono<{ Variables: AuthVars }>();
const dbOf = () => deps.db ?? getDb();
r.use('*', authMiddleware);
// Per-route Middleware: ein Wildcard `r.use('*', …)` würde an dem
// /api/v1/marketplace-Mount-Punkt sonst nachfolgende Router-Mounts
// (PRs, Discussions) versehentlich fangen. Lessons learned aus R3.
// POST /decks/:slug/fork — Marketplace → privater Deck.
r.post('/decks/:slug/fork', async (c) => {
r.post('/decks/:slug/fork', authMiddleware, async (c) => {
const userId = c.get('userId');
const slug = c.req.param('slug');
const body = await c.req.json().catch(() => ({}));
@ -197,8 +199,8 @@ export function forkRouter(deps: MarketplaceForkDeps = {}): Hono<{ Variables: Au
);
});
// POST /decks/:private-deck-id/pull-update — Smart-Merge-Pull.
r.post('/private/:deckId/pull-update', async (c) => {
// POST /private/:deckId/pull-update — Smart-Merge-Pull.
r.post('/private/:deckId/pull-update', authMiddleware, async (c) => {
const userId = c.get('userId');
const deckId = c.req.param('deckId');
const db = dbOf();

View file

@ -0,0 +1,383 @@
import { and, asc, desc, eq } from 'drizzle-orm';
import { Hono } from 'hono';
import { z } from 'zod';
import { cardContentHash } from '@cards/domain';
import { getDb, type CardsDb } from '../../db/connection.ts';
import {
deckPullRequests,
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 { bumpMinor, isSemver } from '../../lib/marketplace/semver.ts';
import { hashVersionCards } from '../../lib/marketplace/version-hash.ts';
/**
* Pull-Requests auf Decks. Differentiator zu Anki/Quizlet: Subscriber
* können einen card-level Patch einreichen, der Deck-Author reviewed +
* merged, und der Merge erzeugt automatisch eine neue Version, die
* über Smart-Merge zu allen anderen Subscribern fließt.
*
* Diff-Modell (3-way):
* - add: neue Karten (Server picked next ord)
* - modify: ersetzt existierende Karten by previousContentHash
* - remove: drop Karten by contentHash
*
* Status-Lifecycle:
* open merge merged (erzeugt eine neue deck_version)
* open close closed (Author oder PR-Author kann)
* open reject rejected (nur Deck-Author getrennt von closed",
* damit der PR-Author klares Feedback hat)
*
* Default-Semver-Bump beim Merge: minor (1.2.0 1.3.0). Author kann
* im Merge-Body überschreiben.
*
* Geport aus
* `cards-decommission-base:services/cards-server/src/services/pull-requests.ts`
* (Notify-Calls über mana-notify ausgelassen eigene Welle).
*/
export type MarketplacePullRequestsDeps = { db?: CardsDb };
const CardTypeEnum = z.enum([
'basic',
'basic-reverse',
'cloze',
'type-in',
'image-occlusion',
'audio',
'multiple-choice',
]);
const CardPayloadSchema = z.object({
type: CardTypeEnum,
fields: z.record(z.string()),
});
const CreatePrSchema = z.object({
title: z.string().min(1).max(140),
body: z.string().max(4000).optional(),
diff: z.object({
add: z.array(CardPayloadSchema).default([]),
modify: z
.array(
CardPayloadSchema.extend({
previousContentHash: z.string().min(1),
})
)
.default([]),
remove: z.array(z.object({ contentHash: z.string().min(1) })).default([]),
}),
});
const MergeSchema = z.object({
newSemver: z
.string()
.regex(/^\d+\.\d+\.\d+$/)
.optional(),
mergeNote: z.string().max(2000).optional(),
});
interface StoredDiff {
add: { type: string; fields: Record<string, string> }[];
modify: { contentHash: string; fields: Record<string, string> }[];
remove: { contentHash: string }[];
}
function toPrDto(pr: typeof deckPullRequests.$inferSelect) {
return {
id: pr.id,
deck_id: pr.deckId,
author_user_id: pr.authorUserId,
status: pr.status,
title: pr.title,
body: pr.body,
diff: pr.diff,
merged_into_version_id: pr.mergedIntoVersionId,
created_at: pr.createdAt.toISOString(),
resolved_at: pr.resolvedAt?.toISOString() ?? null,
};
}
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 findPrById(db: CardsDb, id: string) {
const [row] = await db.select().from(deckPullRequests).where(eq(deckPullRequests.id, id)).limit(1);
return row ?? null;
}
export function pullRequestsRouter(
deps: MarketplacePullRequestsDeps = {}
): Hono<{ Variables: Partial<AuthVars> }> {
const r = new Hono<{ Variables: Partial<AuthVars> }>();
const dbOf = () => deps.db ?? getDb();
// ─── Create + List + Get ─────────────────────────────────────────
r.post('/decks/:slug/pull-requests', authMiddleware, async (c) => {
const userId = c.get('userId');
const slug = c.req.param('slug');
const body = await c.req.json().catch(() => null);
const parsed = CreatePrSchema.safeParse(body);
if (!parsed.success) {
return c.json(
{ error: 'invalid_input', issues: parsed.error.issues.map((i) => i.message) },
422
);
}
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);
const total =
parsed.data.diff.add.length +
parsed.data.diff.modify.length +
parsed.data.diff.remove.length;
if (total === 0) return c.json({ error: 'empty_diff' }, 422);
const storedDiff: StoredDiff = {
add: parsed.data.diff.add.map((card) => ({ type: card.type, fields: card.fields })),
modify: parsed.data.diff.modify.map((m) => ({
contentHash: m.previousContentHash,
fields: m.fields,
})),
remove: parsed.data.diff.remove.map((r) => ({ contentHash: r.contentHash })),
};
const [pr] = await db
.insert(deckPullRequests)
.values({
deckId: deck.id,
authorUserId: userId,
title: parsed.data.title,
body: parsed.data.body,
status: 'open',
diff: storedDiff,
})
.returning();
return c.json(toPrDto(pr), 201);
});
r.get('/decks/:slug/pull-requests', optionalAuthMiddleware, async (c) => {
const slug = c.req.param('slug');
const status = c.req.query('status');
const validStatuses = ['open', 'merged', 'closed', 'rejected'] as const;
const isValidStatus = (s: string | undefined): s is (typeof validStatuses)[number] =>
!!s && (validStatuses as readonly string[]).includes(s);
const db = dbOf();
const deck = await findDeckBySlug(db, slug);
if (!deck) return c.json({ error: 'not_found' }, 404);
const where = isValidStatus(status)
? and(eq(deckPullRequests.deckId, deck.id), eq(deckPullRequests.status, status))
: eq(deckPullRequests.deckId, deck.id);
const rows = await db
.select()
.from(deckPullRequests)
.where(where)
.orderBy(desc(deckPullRequests.createdAt));
return c.json({ pull_requests: rows.map(toPrDto), total: rows.length });
});
r.get('/pull-requests/:id', optionalAuthMiddleware, async (c) => {
const id = c.req.param('id');
const db = dbOf();
const pr = await findPrById(db, id);
if (!pr) return c.json({ error: 'not_found' }, 404);
return c.json(toPrDto(pr));
});
// ─── Lifecycle: Close, Reject, Merge ─────────────────────────────
r.post('/pull-requests/:id/close', authMiddleware, async (c) => {
const userId = c.get('userId');
const id = c.req.param('id');
const db = dbOf();
const pr = await findPrById(db, id);
if (!pr) return c.json({ error: 'not_found' }, 404);
if (pr.status !== 'open') return c.json({ error: `pr_already_${pr.status}` }, 409);
const [deck] = await db.select().from(publicDecks).where(eq(publicDecks.id, pr.deckId)).limit(1);
if (!deck) return c.json({ error: 'deck_not_found' }, 404);
if (pr.authorUserId !== userId && deck.ownerUserId !== userId) {
return c.json({ error: 'forbidden', detail: 'pr_author_or_deck_owner_only' }, 403);
}
await db
.update(deckPullRequests)
.set({ status: 'closed', resolvedAt: new Date() })
.where(eq(deckPullRequests.id, id));
return c.json({ status: 'closed' });
});
r.post('/pull-requests/:id/reject', authMiddleware, async (c) => {
const userId = c.get('userId');
const id = c.req.param('id');
const db = dbOf();
const pr = await findPrById(db, id);
if (!pr) return c.json({ error: 'not_found' }, 404);
if (pr.status !== 'open') return c.json({ error: `pr_already_${pr.status}` }, 409);
const [deck] = await db.select().from(publicDecks).where(eq(publicDecks.id, pr.deckId)).limit(1);
if (!deck) return c.json({ error: 'deck_not_found' }, 404);
if (deck.ownerUserId !== userId) {
return c.json({ error: 'forbidden', detail: 'deck_owner_only' }, 403);
}
await db
.update(deckPullRequests)
.set({ status: 'rejected', resolvedAt: new Date() })
.where(eq(deckPullRequests.id, id));
return c.json({ status: 'rejected' });
});
r.post('/pull-requests/:id/merge', authMiddleware, async (c) => {
const userId = c.get('userId');
const id = c.req.param('id');
const body = await c.req.json().catch(() => ({}));
const parsed = MergeSchema.safeParse(body);
if (!parsed.success) {
return c.json(
{ error: 'invalid_input', issues: parsed.error.issues.map((i) => i.message) },
422
);
}
const db = dbOf();
const pr = await findPrById(db, id);
if (!pr) return c.json({ error: 'not_found' }, 404);
if (pr.status !== 'open') return c.json({ error: `pr_already_${pr.status}` }, 409);
const [deck] = await db.select().from(publicDecks).where(eq(publicDecks.id, pr.deckId)).limit(1);
if (!deck) return c.json({ error: 'deck_not_found' }, 404);
if (deck.ownerUserId !== userId) {
return c.json({ error: 'forbidden', detail: 'deck_owner_only' }, 403);
}
if (!deck.latestVersionId) {
return c.json({ error: 'no_published_version' }, 409);
}
const [latest] = await db
.select()
.from(publicDeckVersions)
.where(eq(publicDeckVersions.id, deck.latestVersionId))
.limit(1);
if (!latest) return c.json({ error: 'latest_version_missing' }, 500);
const newSemver = parsed.data.newSemver ?? bumpMinor(latest.semver);
if (!isSemver(newSemver)) return c.json({ error: 'invalid_semver' }, 422);
const currentCards = await db
.select()
.from(publicDeckCards)
.where(eq(publicDeckCards.versionId, latest.id))
.orderBy(asc(publicDeckCards.ord));
const diff = pr.diff as StoredDiff;
const removedHashes = new Set(diff.remove.map((entry) => entry.contentHash));
const modifyByHash = new Map(diff.modify.map((entry) => [entry.contentHash, entry.fields]));
// Apply: removed werden weggelassen, modified ersetzen die fields
// (type bleibt aus dem alten Card-Eintrag, weil der PR keinen
// type-Switch erlaubt — modify ist ein field-only Replace), added
// werden hinten angehängt mit re-counted ord.
const merged: { type: string; fields: Record<string, string>; ord: number }[] = [];
let nextOrd = 0;
for (const card of currentCards) {
if (removedHashes.has(card.contentHash)) continue;
const replaced = modifyByHash.get(card.contentHash);
merged.push({
type: card.type,
fields: replaced ?? (card.fields as Record<string, string>),
ord: nextOrd++,
});
}
for (const added of diff.add) {
merged.push({ type: added.type, fields: added.fields, ord: nextOrd++ });
}
if (merged.length === 0) {
return c.json({ error: 'merge_would_empty_deck' }, 422);
}
const versionContentHash = await hashVersionCards(merged);
const cardHashes = await Promise.all(
merged.map((card) => cardContentHash({ type: card.type, fields: card.fields }))
);
const result = await db.transaction(async (tx) => {
const [version] = await tx
.insert(publicDeckVersions)
.values({
deckId: deck.id,
semver: newSemver,
changelog:
parsed.data.mergeNote ??
`Merged PR: ${pr.title} (+${diff.add.length} added, ~${diff.modify.length} modified, ${diff.remove.length} removed)`,
contentHash: versionContentHash,
cardCount: merged.length,
})
.returning();
await tx.insert(publicDeckCards).values(
merged.map((card, i) => ({
versionId: version.id,
type: card.type as
| 'basic'
| 'basic-reverse'
| 'cloze'
| 'type-in'
| 'image-occlusion'
| 'audio'
| 'multiple-choice',
fields: card.fields,
ord: card.ord,
contentHash: cardHashes[i],
}))
);
await tx
.update(publicDecks)
.set({ latestVersionId: version.id })
.where(eq(publicDecks.id, deck.id));
const [updatedPr] = await tx
.update(deckPullRequests)
.set({
status: 'merged',
mergedIntoVersionId: version.id,
resolvedAt: new Date(),
})
.where(eq(deckPullRequests.id, id))
.returning();
return { version, pr: updatedPr };
});
return c.json(
{
pull_request: toPrDto(result.pr),
version: {
id: result.version.id,
deck_id: result.version.deckId,
semver: result.version.semver,
content_hash: result.version.contentHash,
card_count: result.version.cardCount,
changelog: result.version.changelog,
published_at: result.version.publishedAt.toISOString(),
},
},
201
);
});
return r;
}

View file

@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest';
import { bumpMinor, isSemver, semverGreater } from '../src/lib/marketplace/semver.ts';
describe('isSemver', () => {
it('accepts strict X.Y.Z', () => {
expect(isSemver('1.0.0')).toBe(true);
expect(isSemver('0.0.1')).toBe(true);
expect(isSemver('123.456.789')).toBe(true);
});
it('rejects pre-releases', () => {
expect(isSemver('1.0.0-rc1')).toBe(false);
});
it('rejects 2-segment', () => {
expect(isSemver('1.0')).toBe(false);
});
});
describe('bumpMinor', () => {
it('1.2.3 → 1.3.0', () => {
expect(bumpMinor('1.2.3')).toBe('1.3.0');
});
it('0.9.99 → 0.10.0', () => {
expect(bumpMinor('0.9.99')).toBe('0.10.0');
});
it('invalid → 1.0.0', () => {
expect(bumpMinor('garbage')).toBe('1.0.0');
});
});
describe('semverGreater', () => {
it('major', () => {
expect(semverGreater('2.0.0', '1.99.99')).toBe(true);
});
it('minor', () => {
expect(semverGreater('1.2.0', '1.1.99')).toBe(true);
});
it('patch', () => {
expect(semverGreater('1.0.1', '1.0.0')).toBe(true);
});
it('equal is not greater', () => {
expect(semverGreater('1.0.0', '1.0.0')).toBe(false);
});
it('invalid input → false', () => {
expect(semverGreater('garbage', '1.0.0')).toBe(false);
expect(semverGreater('1.0.0', 'garbage')).toBe(false);
});
});