import type { Kysely } from 'kysely'; import type { TaprootDb } from '../db/client.js'; import type { ContentStatus, Database, ReleaseItemRow, ReleaseRow, ReleaseStatus, User } from '../db/schema.js'; import { type SeoData } from './items.js'; /** * Content Releases: a named batch of content that goes live together. * * The feature exists for "tuition changes across a dozen live pages, all at 9am on the same day", * and until now Taproot had nowhere to put that work. `content_items` holds exactly one row per * item, so editing a published page changed what visitors saw at the moment of the save — there was * no pending version, and so nothing to coordinate. A release is the first place a page's next * version can wait. * * Three decisions shape everything below, and each has an alternative that looks simpler: * * - **A staged version carries its own content, rather than pointing at a revision.** Revisions are * an append-only record of what the live item *has been*. Staging by reference would mean every * edit to a not-yet-live version wrote a line into the history of a page that never showed it. * - **Pre-flight validation instead of atomicity.** The scope doc asks what happens when item 4 of * 12 fails at publish time. It cannot be answered with a transaction: D1 has no interactive * transactions, and each item's publish is already its own batch of path rewrites, redirects, and * a revision. So the check moves earlier — every staged version is validated *before* anything is * written, which turns the overwhelmingly common failure into "nothing happened, here is what to * fix". `release_items.published_at` then makes the residue of a genuinely unexpected failure * resumable rather than a puzzle. * - **Staging is not publishing.** Putting an item in a release is queuing work, which a * contributor may do; publishing the release is what reaches the public, and needs an editor. * That answers the permission question SCOPE.md left open, and it falls out of the workflow graph * rather than being a new rule: every transition into `published` already needs the editor role. */ export declare class ReleaseError extends Error { readonly code: 'not_found' | 'not_open' | 'already_published' | 'item_not_found' | 'validation_failed' | 'in_use'; readonly fieldErrors: Record; name: string; constructor(message: string, code?: 'not_found' | 'not_open' | 'already_published' | 'item_not_found' | 'validation_failed' | 'in_use', fieldErrors?: Record); } /** A staged version with its JSON columns parsed. */ export interface StagedVersion extends Omit { data: Record; seo: SeoData; } export declare function hydrateStagedVersion(row: ReleaseItemRow): StagedVersion; /** A release plus the numbers every list of them wants. */ export interface ReleaseSummary extends ReleaseRow { itemCount: number; /** How many staged versions have already been applied. Non-zero only after a partial publish. */ publishedCount: number; authorName: string | null; authorEmail: string | null; } /** A staged version alongside the live item it will overwrite. */ export interface StagedItemDetail extends StagedVersion { /** The live row. Never null in practice — `release_items` cascades — but read defensively. */ live: { id: string; title: string; path: string; status: string; contentTypeId: string; contentTypeName: string; } | null; stagedByName: string | null; /** * Other unpublished releases holding this same item. * * The scope doc calls for an item to be stageable in more than one release at once, which is a * real hazard rather than an oversight: publishing one makes the other's copy stale, and the * staler one wins on whichever release nobody reopened. Surfaced on the screen rather than * forbidden by the schema, because staging the same page in "Spring launch" and "Tuition update" * is a thing editors legitimately do. */ otherReleases: { id: string; name: string; status: ReleaseStatus; }[]; } export interface ListReleasesOptions { status?: ReleaseStatus; limit?: number; offset?: number; } export declare function listReleases(db: Kysely, options?: ListReleasesOptions): Promise<{ releases: ReleaseSummary[]; total: number; }>; export declare function getRelease(db: Kysely, id: string): Promise; /** * Everything a release's own screen needs, in one call. * * The cross-release conflict lookup is one query for the whole release rather than one per item — * a launch with thirty pages in it should not cost thirty round trips to answer a question that is * the same shape for all of them. */ export declare function getReleaseWithItems(db: Kysely, id: string): Promise<{ release: ReleaseRow; items: StagedItemDetail[]; } | undefined>; export declare function getStagedItem(db: Kysely, releaseId: string, contentItemId: string): Promise; /** * The unpublished releases holding each of these items, excluding one. * * `published` releases are left out because they are history: their staged versions have already * been applied and cannot go live a second time, so naming them would report a conflict that * cannot happen. */ export declare function releaseConflicts(db: Kysely, excludeReleaseId: string, contentItemIds: string[]): Promise>; /** * Unpublished releases holding one item. * * Read by the item editor's banner and by `itemDeleteImpact`. Kept separate from * `releaseConflicts` because it has no release to exclude — the caller is an item, not a release. */ export declare function openReleasesForItem(db: Kysely, contentItemId: string): Promise<{ id: string; name: string; status: ReleaseStatus; }[]>; /** Open releases an item is *not* already in — what an "Add to release" control offers. */ export declare function releasesAvailableFor(db: Kysely, contentItemId: string): Promise<{ id: string; name: string; }[]>; export interface ReleaseProblem { /** Null when the problem is with the release itself rather than one of its items. */ contentItemId: string | null; /** What to call the thing in a message — the staged title, or the release's name. */ label: string; reason: string; } /** * Everything that would stop this release publishing, found before anything is written. * * This is the answer to "what happens if item 4 of 12 fails validation at publish time": it does * not, because the check runs first and refuses the whole publish. That is not merely a nicer * error — it is the only form of atomicity available here. A release publish is N item updates, * each already a batch of its own, and D1 offers no transaction spanning them. * * Re-run rather than stored. A release blocked at 3am because a required field was empty is * unblocked the moment somebody fills it in, and a cached list of reasons would still be accusing * them an hour later. */ export declare function releasePreflight(db: Kysely, releaseId: string): Promise<{ ok: boolean; problems: ReleaseProblem[]; }>; export declare function createRelease(db: Kysely, input: { name: string; description?: string | null; userId?: string | null; }): Promise; export declare function updateRelease(db: Kysely, id: string, input: { name?: string; description?: string | null; }): Promise; /** * Move a release between the states a person can put it in. * * `publish_at` is cleared the moment the status leaves `scheduled`, in every path — the same rule * `updateItem` and `publishDueItems` keep for content items, and for the same reason. A stale time * left behind is a booby trap: reschedule the release later without picking a new moment and it * inherits one in the past, which is to say it goes live the instant the next sweep runs. */ export declare function setReleaseStatus(db: Kysely, id: string, status: Extract, options?: { publishAt?: string | null; actor?: Pick | null; }): Promise; /** * What has to be cleared before a release can be deleted. * * Same shape and same reasoning as `contentTypeDeleteBlockers` and `itemDeleteImpact`: one function * that both the guard and the screen read, so a screen cannot decide for itself that a delete would * succeed and then be refused. Blockers are phrased as standalone clauses so they read correctly * both bulleted and after the error's `Cannot delete X:` prefix. */ export declare function releaseDeleteBlockers(db: Kysely, id: string): Promise; export declare function deleteRelease(db: Kysely, id: string): Promise; /** * Put an item's current authored content into a release. * * The snapshot is taken at stage time and then diverges: editing the staged version afterwards * changes the release's copy and leaves the live page alone, which is the whole point. Re-syncing * from the live item is a deliberate act (`restageItem`) rather than something that happens * silently, because a release is a decision about what will go live and an invisible refresh would * quietly change it. */ export declare function stageItem(db: Kysely, releaseId: string, contentItemId: string, options?: { actor?: Pick | null; }): Promise; export declare function unstageItem(db: Kysely, releaseId: string, contentItemId: string, options?: { actor?: Pick | null; }): Promise; export interface UpdateStagedItemInput { title?: string; slug?: string; data?: Record; seo?: SeoData; } /** * Edit the version waiting inside a release, leaving the live page untouched. * * Validation runs here and not only at pre-flight, and that is a security property rather than a * convenience: `validateItemData` is where richtext is sanitised, and a staged version is stored * HTML that the admin renders in the editor long before anything publishes it. Deferring the * sanitising to publish time would leave unsanitised markup in the database and put it in front of * every editor who opened the release. The boundary is the write, here as everywhere else. */ export declare function updateStagedItem(db: Kysely, releaseId: string, contentItemId: string, input: UpdateStagedItemInput): Promise; /** * Refresh a staged version from the live item, discarding edits made inside the release. * * The inverse of publishing, and needed for the case where a page changed on the site after it was * staged: without this the release would quietly revert those changes when it published, because a * staged version is a whole snapshot rather than a diff. */ export declare function restageItem(db: Kysely, releaseId: string, contentItemId: string): Promise; export interface ReleasePublishResult { ok: boolean; /** Why nothing was written. Empty when pre-flight passed. */ problems: ReleaseProblem[]; /** * `from` is the status each item held before the release applied its staged version. * * Carried because it is knowable only inside the loop — the rows all read `published` by the time * this returns — and because a release does not publish everything it touches: a copy edit staged * against an already-live page goes `published → published`, which is a change to tell an * integration about but not a publication. Without it a caller has to guess, and the plausible * guess is the wrong one. */ published: { id: string; title: string; path: string; from: ContentStatus; }[]; /** Items that failed *after* pre-flight passed — a genuinely unexpected write failure. */ failed: { id: string; title: string; reason: string; }[]; } /** * Publish every staged version in a release. * * Ordered: pre-flight refuses the whole thing before a single write, then each staged version is * applied through `updateItem` — the ordinary path, so a staged slug change cascades to * descendants, writes its redirects, and appends a revision exactly as an editor's rename would. * Routing around it would mean a second implementation of the path rewrite, which is the part * people get wrong. * * A failure *after* pre-flight passed does not stop the loop. The remaining items are independent * pages, and holding eleven of them back because the twelfth hit an unexpected error would turn a * small problem into a missed launch. Each success is recorded on its own row, so re-running picks * up exactly what is left. */ export declare function publishRelease(handle: TaprootDb, releaseId: string, options?: { actor?: Pick | null; }): Promise;