/** * Wire shape of a comparison between two content-version snapshots. * * Kept UI-independent on purpose: the admin diff renderer and (later) a paid * approval-workflow "what changed since submission" view both consume this same * typed tree, so it lives in core and is exported from the package surface. The * engine that produces it (`compute-diff.ts`) is a pure function; redaction and * access live one layer up in the dispatcher. * * @module domains/versions/diff/types */ /** Whether a field (or list item) was added, removed, changed, or left alone. */ type DiffStatus = "added" | "removed" | "changed" | "unchanged"; /** * One run of a word-level text diff. `op` follows diff-match-patch: * -1 = present only on the left (deleted), 0 = unchanged, 1 = present only on * the right (inserted). */ type TextSegment = { op: -1 | 0 | 1; text: string; }; interface FieldDiffBase { /** Field name (the snapshot key). */ name: string; /** Human label, resolved from `field.label ?? field.name`. */ label: string; /** The field's declared type, so the renderer can pick a display. */ type: string; status: DiffStatus; } /** Text-like fields carry pre-computed segments so the client never diffs. */ interface TextFieldDiff extends FieldDiffBase { kind: "text"; segments: TextSegment[]; } /** * Display-relevant field configuration a value node carries so the client * renders it faithfully without re-deriving it from the live schema. The engine * already holds the correct `FieldConfig` for every node it emits (per-schema * even across a dynamic-zone type swap, and after flattening nameless groups), * so recording this here is both correct and simpler than a client re-walk. * * Only plain, serialisable data is included, never access rules or other * functions: the cardinality, relation targets, option labels, and date picker * that decide how a stored value reads. */ interface FieldDisplay { hasMany?: boolean; relationTo?: string | string[]; options?: { label?: string; value?: unknown; }[]; admin?: { date?: { pickerAppearance?: string; }; }; /** * The relationship's configured display column (`targetLabelField`), carried * so reference hydration can honor it instead of the default label candidates. */ labelField?: string; } /** Non-text scalars hand back both sides raw; the client renders each side. */ interface ValueFieldDiff extends FieldDiffBase { kind: "value"; before: unknown; after: unknown; /** * Display config for the field, so the client renders cardinality, option * labels, and date formatting faithfully. Absent when the field carries no * display-relevant configuration. * * A relationship or upload node's `before`/`after` is resolved in place to the * value kit's display shape (`{ id, label }` or `{ id, filename, ... }`) one * layer up, so the client renders it through the same kit as a live value with * no reference-specific field on this node. */ display?: FieldDisplay; } /** A group or single component: a nested list of field diffs. */ interface GroupFieldDiff extends FieldDiffBase { kind: "group"; fields: FieldDiff[]; /** * For a dynamic-zone component whose stored type changed between versions, the * before and after component slugs (either side is absent when the component * appeared or disappeared). Carried so a type swap still shows what changed * even when both schemas have no field values to diff. Absent for a plain * group or a fixed-schema component. */ componentTypeBefore?: string; componentTypeAfter?: string; } /** * One target of a relationship. `relationTo` is present only for a polymorphic * relationship, where the same `id` can refer to rows in different collections, * so it is part of the target's identity. Resolving `id` to a human title is a * separate rendering concern, kept out of this engine so it stays pure and * dialect-free. */ interface RelationTarget { id: string; relationTo?: string; /** * The target resolved to a display label, attached additively when hydrated. * Null when the target is unreadable or unlabelled, leaving the id as the * fallback the renderer shows. */ label?: string | null; } /** A many relationship field: a set difference of targets by identity. */ interface SetFieldDiff extends FieldDiffBase { kind: "set"; added: RelationTarget[]; removed: RelationTarget[]; /** * Display config for the field, chiefly its `relationTo` target(s), so the * client and the reference hydrator know which collection a non-polymorphic * target belongs to without re-deriving it from the live schema (a target * carries its own `relationTo` only when the relation is polymorphic). Absent * when the field carries no display-relevant configuration. */ display?: FieldDisplay; } /** * One item inside a repeatable/dynamic-zone list, matched by stable id. * * `status` describes the item's CONTENT (added/removed/changed/unchanged); * `hasMoved` is orthogonal POSITION, because an item can be both edited and * reordered. This split follows the identity model Sanity uses and beats the * index-based matching that marks every row after an insert as changed. */ interface ListItemDiff { /** Stable component-row UUID; identity for add/remove/move detection. */ id: string; /** The component slug (`_componentType`) when the snapshot carries one. */ componentType?: string; /** * For a row that kept its id but changed component type, the before and after * slugs, so the swap is visible even when neither component has field values * to diff. Absent when the type did not change. */ componentTypeBefore?: string; componentTypeAfter?: string; status: DiffStatus; /** True when the item kept its identity but changed position. */ hasMoved?: boolean; /** Prior index (present when the item existed before). */ fromIndex?: number; /** New index (present when the item exists after). */ toIndex?: number; /** Per-field diffs; empty for a pure move or an unchanged item. */ fields: FieldDiff[]; } /** A repeatable field or dynamic zone: items matched by id. */ interface ListFieldDiff extends FieldDiffBase { kind: "list"; items: ListItemDiff[]; } /** * A snapshot key with no matching field in the CURRENT schema (a field deleted * since capture). Surfaced rather than dropped so a diff never silently hides * that something changed. * * The value is deliberately NOT carried. A field absent from the current schema * has no findable `access.read` rule, so redaction cannot prove the caller may * read it; a since-removed protected field (a salary, a token) would otherwise * leak its history. Only the field's name and whether it changed are exposed. */ interface UnknownFieldDiff { kind: "unknown"; name: string; status: DiffStatus; } type FieldDiff = TextFieldDiff | ValueFieldDiff | GroupFieldDiff | SetFieldDiff | ListFieldDiff | UnknownFieldDiff; /** The full comparison of version `from` against version `to`. */ interface VersionDiff { from: number; to: number; /** The (single) locale both snapshots belong to; null for unlocalized docs. */ locale: string | null; /** True when any field node is not "unchanged". */ hasChanges: boolean; fields: FieldDiff[]; } /** * Version Diff API Route Handler for Next.js * * Compares two versions of one document and returns a typed diff. * * Services are auto-initialized on first request using environment variables: * - DB_DIALECT: Database dialect ("postgresql" | "mysql" | "sqlite") * - DATABASE_URL: Database connection string * * @example * ```typescript * // In your Next.js app: app/api/versions/[kind]/[slug]/[id]/diff/route.ts * export { GET } from 'nextly/api/versions-diff'; * // GET .../diff?from=3&to=7[&modifiedOnly=1] * ``` * * @module api/versions-diff */ /** * Context object for dynamic route handlers. * Next.js 15+ requires params to be a Promise. */ interface RouteContext { params: Promise<{ kind: string; slug: string; id: string; }>; } /** * GET handler returning a typed diff of versions `from` and `to`. * * The version pair is validated before the access gate so malformed input fails * fast. The gate confirms the caller may read the live document, and each * snapshot is redacted for the caller inside the shared core before the diff is * computed, so a diff never reveals a field a normal read would hide. */ declare const GET: (request: Request, context: RouteContext) => Promise; export { GET }; export type { DiffStatus, FieldDiff, FieldDisplay, GroupFieldDiff, ListFieldDiff, ListItemDiff, RelationTarget, SetFieldDiff, TextFieldDiff, TextSegment, UnknownFieldDiff, ValueFieldDiff, VersionDiff };