feat(cards-server): Phase δ.1 — subscriptions + version reads + smart-merge diff

Server-side plumbing for Phase δ. Frontend hookup follows in δ.2.

  - services/subscriptions.ts: subscribe/unsubscribe (idempotent
    upsert on (user, deck), stamps the latest_version_id at
    subscribe-time so the client knows what it pulled). listForUser
    returns each sub with `updateAvailable: currentVersion !== latest`
    so the client can render an update indicator without a second
    round-trip. Refuses paid decks with 403 — that path comes back
    in Phase ζ once the credits Marketplace lands.
  - versionWithCards: deterministic ord-ordered card payload for a
    specific version. Read-public so anonymous browsers can preview
    a deck's content.
  - diffSince: smart-merge payload between any two versions. Splits
    the latest cards into added/changed/unchanged + lists removed
    by content_hash. The 'changed' bucket is heuristic (ord-position
    pair where one was removed and one was added) — solid enough
    until Phase ε's pull-request pipeline gives us real card
    lineage.
  - routes/subscriptions.ts mounts: GET /v1/me/subscriptions,
    POST/DELETE /v1/decks/:slug/subscribe (auth required),
    GET /v1/decks/:slug/versions/:semver (public),
    GET /v1/decks/:slug/diff?from=<semver> (public).

cards-web layout fix:
  - Marketplace surface (/explore, /u/, /d/) was previously gated
    behind the AuthGate — anonymous browsers got pushed to /login
    via client-side navigate. PUBLIC_PATHS extended so those routes
    SSR + render unauthed.

Validated: tsc clean on cards-server, svelte-check 0/0 on cards-web.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-07 19:33:58 +02:00
parent e77134bd8b
commit 86a01426e8
4 changed files with 317 additions and 1 deletions

View file

@ -14,7 +14,10 @@
// Auth/marketing pages render outside the gate so first-time visitors
// can actually reach them. Everything else is gated.
const PUBLIC_PATHS = ['/login', '/register', '/forgot-password'];
// Public marketplace surface — anyone can browse decks/profiles/explore
// without signing in. AuthGate kicks in once the user opens their own
// decks/learn pages.
const PUBLIC_PATHS = ['/login', '/register', '/forgot-password', '/explore', '/u/', '/d/'];
const isPublic = $derived(PUBLIC_PATHS.some((p) => page.url.pathname.startsWith(p)));
function handleAuthReady() {

View file

@ -20,10 +20,12 @@ import { AuthorService } from './services/authors';
import { DeckService } from './services/decks';
import { ExploreService } from './services/explore';
import { EngagementService } from './services/engagement';
import { SubscriptionService } from './services/subscriptions';
import { createAuthorRoutes } from './routes/authors';
import { createDeckRoutes } from './routes/decks';
import { createExploreRoutes } from './routes/explore';
import { createEngagementRoutes } from './routes/engagement';
import { createSubscriptionRoutes } from './routes/subscriptions';
// ─── Bootstrap ──────────────────────────────────────────────
@ -34,6 +36,7 @@ const authorService = new AuthorService(db);
const deckService = new DeckService(db, config.manaLlmUrl);
const exploreService = new ExploreService(db);
const engagementService = new EngagementService(db);
const subscriptionService = new SubscriptionService(db);
// ─── App ────────────────────────────────────────────────────
@ -73,6 +76,7 @@ v1.use('/*', optionalAuth(config.manaAuthUrl));
// via requireUser() helpers when needed.
v1.route('/', createExploreRoutes(exploreService));
v1.route('/', createEngagementRoutes(engagementService));
v1.route('/', createSubscriptionRoutes(subscriptionService));
v1.route('/authors', createAuthorRoutes(authorService));
v1.route('/decks', createDeckRoutes(authorService, deckService));

View file

@ -0,0 +1,56 @@
import { Hono } from 'hono';
import type { AuthUser } from '../middleware/jwt-auth';
import type { SubscriptionService } from '../services/subscriptions';
import { BadRequestError, UnauthorizedError } from '../lib/errors';
function requireUser(user: AuthUser | undefined): AuthUser {
if (!user || !user.userId) throw new UnauthorizedError();
return user;
}
export function createSubscriptionRoutes(service: SubscriptionService) {
const router = new Hono<{ Variables: { user?: AuthUser } }>();
// User-scoped routes -----------------------------------------------------
router.get('/me/subscriptions', async (c) => {
const user = requireUser(c.get('user'));
const list = await service.listForUser(user.userId);
return c.json(list);
});
router.post('/decks/:slug/subscribe', async (c) => {
const user = requireUser(c.get('user'));
const result = await service.subscribe(user.userId, c.req.param('slug'));
return c.json(result, 201);
});
router.delete('/decks/:slug/subscribe', async (c) => {
const user = requireUser(c.get('user'));
await service.unsubscribe(user.userId, c.req.param('slug'));
return c.json({ ok: true });
});
// Public read routes -----------------------------------------------------
router.get('/decks/:slug/versions/:semver', async (c) => {
const semver = c.req.param('semver');
if (!/^\d+\.\d+\.\d+$/.test(semver)) {
throw new BadRequestError('semver must look like 1.0.0');
}
const payload = await service.versionWithCards(c.req.param('slug'), semver);
return c.json(payload);
});
router.get('/decks/:slug/diff', async (c) => {
const url = new URL(c.req.url);
const from = url.searchParams.get('from');
if (!from || !/^\d+\.\d+\.\d+$/.test(from)) {
throw new BadRequestError('?from=<semver> required, e.g. ?from=1.0.0');
}
const diff = await service.diffSince(c.req.param('slug'), from);
return c.json(diff);
});
return router;
}

View file

@ -0,0 +1,253 @@
/**
* Subscriptions + version reads for Phase δ.
*
* `subscribe` records the user's intent and stamps the version they
* pulled at so the client can compute a per-card diff against
* whatever the deck's `latest_version_id` is now. We don't push the
* cards back: that's the client's job (it owns the local Dexie).
*
* `versionWithCards` returns a version's cards in stable `ord` order
* so the client can replay them deterministically into its own DB.
*
* `diffSince` computes the smart-merge payload server-side: based on
* per-card `content_hash`, classify each card in the latest version
* as `unchanged | changed | added`, and list the hashes the latest
* version no longer has (`removed`). Saves the client from holding
* both versions at once.
*/
import { and, asc, eq } from 'drizzle-orm';
import type { Database } from '../db/connection';
import { deckSubscriptions, publicDeckCards, publicDeckVersions, publicDecks } from '../db/schema';
import { ConflictError, ForbiddenError, NotFoundError } from '../lib/errors';
export interface VersionPayload {
id: string;
semver: string;
contentHash: string;
publishedAt: Date;
changelog: string | null;
cards: VersionCardPayload[];
}
export interface VersionCardPayload {
contentHash: string;
type: string;
fields: Record<string, string>;
ord: number;
}
export interface DiffPayload {
from: string;
to: string;
added: VersionCardPayload[];
changed: { previous: { contentHash: string }; next: VersionCardPayload }[];
unchanged: { contentHash: string; ord: number }[];
removed: { contentHash: string }[];
}
export class SubscriptionService {
constructor(private readonly db: Database) {}
async subscribe(userId: string, deckSlug: string) {
const deck = await this.db.query.publicDecks.findFirst({
where: eq(publicDecks.slug, deckSlug),
});
if (!deck) throw new NotFoundError('Deck not found');
if (deck.isTakedown) throw new ForbiddenError('Deck under takedown');
if (!deck.latestVersionId) throw new ConflictError('Deck has no published version yet');
// Paid decks need a purchase first — Phase ζ. For now: refuse.
if (deck.priceCredits > 0) {
throw new ForbiddenError('Paid decks require a purchase before subscribing (Phase ζ)');
}
await this.db
.insert(deckSubscriptions)
.values({
userId,
deckId: deck.id,
currentVersionId: deck.latestVersionId,
})
.onConflictDoUpdate({
target: [deckSubscriptions.userId, deckSubscriptions.deckId],
set: { currentVersionId: deck.latestVersionId },
});
return { deckSlug, latestVersionId: deck.latestVersionId };
}
async unsubscribe(userId: string, deckSlug: string) {
const deck = await this.db.query.publicDecks.findFirst({
where: eq(publicDecks.slug, deckSlug),
});
if (!deck) throw new NotFoundError('Deck not found');
await this.db
.delete(deckSubscriptions)
.where(and(eq(deckSubscriptions.userId, userId), eq(deckSubscriptions.deckId, deck.id)));
}
async listForUser(userId: string) {
const rows = await this.db
.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(deckSubscriptions.subscribedAt);
return rows.map((r) => ({
deckSlug: r.deckSlug,
deckTitle: r.deckTitle,
deckDescription: r.deckDescription,
subscribedAt: r.subscribedAt,
notifyUpdates: r.notifyUpdates,
currentVersionId: r.currentVersionId,
latestVersionId: r.deckLatestVersionId,
updateAvailable:
r.deckLatestVersionId !== null && r.currentVersionId !== r.deckLatestVersionId,
}));
}
async versionWithCards(deckSlug: string, semver: string): Promise<VersionPayload> {
const deck = await this.db.query.publicDecks.findFirst({
where: eq(publicDecks.slug, deckSlug),
});
if (!deck) throw new NotFoundError('Deck not found');
const version = await this.db.query.publicDeckVersions.findFirst({
where: and(eq(publicDeckVersions.deckId, deck.id), eq(publicDeckVersions.semver, semver)),
});
if (!version) throw new NotFoundError(`Version ${semver} not found`);
const cards = await this.db
.select()
.from(publicDeckCards)
.where(eq(publicDeckCards.versionId, version.id))
.orderBy(asc(publicDeckCards.ord));
return {
id: version.id,
semver: version.semver,
contentHash: version.contentHash,
publishedAt: version.publishedAt,
changelog: version.changelog,
cards: cards.map((c) => ({
contentHash: c.contentHash,
type: c.type,
fields: c.fields as Record<string, string>,
ord: c.ord,
})),
};
}
/** Smart-merge diff: tell the client what changed since `fromSemver`. */
async diffSince(deckSlug: string, fromSemver: string): Promise<DiffPayload> {
const deck = await this.db.query.publicDecks.findFirst({
where: eq(publicDecks.slug, deckSlug),
});
if (!deck) throw new NotFoundError('Deck not found');
if (!deck.latestVersionId) throw new NotFoundError('Deck has no published version');
const latestVersion = await this.db.query.publicDeckVersions.findFirst({
where: eq(publicDeckVersions.id, deck.latestVersionId),
});
if (!latestVersion) throw new NotFoundError('Latest version row missing');
const fromVersion = await this.db.query.publicDeckVersions.findFirst({
where: and(eq(publicDeckVersions.deckId, deck.id), eq(publicDeckVersions.semver, fromSemver)),
});
if (!fromVersion) throw new NotFoundError(`Version ${fromSemver} not found`);
// Empty diff if already on latest.
if (fromVersion.id === latestVersion.id) {
return {
from: fromSemver,
to: latestVersion.semver,
added: [],
changed: [],
unchanged: [],
removed: [],
};
}
const [fromCards, toCards] = await Promise.all([
this.db
.select({ contentHash: publicDeckCards.contentHash, ord: publicDeckCards.ord })
.from(publicDeckCards)
.where(eq(publicDeckCards.versionId, fromVersion.id)),
this.db
.select()
.from(publicDeckCards)
.where(eq(publicDeckCards.versionId, latestVersion.id))
.orderBy(asc(publicDeckCards.ord)),
]);
const fromHashes = new Set(fromCards.map((c) => c.contentHash));
const toHashes = new Set(toCards.map((c) => c.contentHash));
// Cards that are still here verbatim.
const unchanged: { contentHash: string; ord: number }[] = [];
// Brand-new cards (hash not in `from`).
const added: VersionCardPayload[] = [];
// Cards in `from` that vanished completely.
const removed: { contentHash: string }[] = fromCards
.filter((c) => !toHashes.has(c.contentHash))
.map((c) => ({ contentHash: c.contentHash }));
// `changed` is hard to detect without a stable card-id across
// versions. We approximate by treating ord-position pairs that
// neither match nor are in the unchanged set: an "added" at the
// same ord as a "removed" → changed. Phase ε's pull-request
// pipeline gives us a real card-lineage; until then this
// heuristic is good enough.
const changed: { previous: { contentHash: string }; next: VersionCardPayload }[] = [];
const removedByOrd = new Map<number, string>();
for (const c of fromCards) {
if (!toHashes.has(c.contentHash)) removedByOrd.set(c.ord, c.contentHash);
}
for (const c of toCards) {
if (fromHashes.has(c.contentHash)) {
unchanged.push({ contentHash: c.contentHash, ord: c.ord });
} else if (removedByOrd.has(c.ord)) {
const prevHash = removedByOrd.get(c.ord)!;
removedByOrd.delete(c.ord);
changed.push({
previous: { contentHash: prevHash },
next: {
contentHash: c.contentHash,
type: c.type,
fields: c.fields as Record<string, string>,
ord: c.ord,
},
});
} else {
added.push({
contentHash: c.contentHash,
type: c.type,
fields: c.fields as Record<string, string>,
ord: c.ord,
});
}
}
// Anything left in removedByOrd is a real removal (not paired up
// with a `changed`).
const trueRemoved = removed.filter((r) => [...removedByOrd.values()].includes(r.contentHash));
return {
from: fromSemver,
to: latestVersion.semver,
added,
changed,
unchanged,
removed: trueRemoved,
};
}
}