import { type Kysely } from 'kysely'; import type { TaprootDb } from '../db/client.js'; import type { ContentItemRow, ContentStatus, ContentTypeKind, ContentTypeRow, Database, FieldRow, RevisionReason } from '../db/schema.js'; import type { ItemSort } from './itemSort.js'; import { type IndexedValueKind } from './derivedIndex.js'; import { type SubtreeNode } from './paths.js'; export declare class ContentItemError extends Error { readonly code: 'not_found' | 'validation_failed' | 'cycle' | 'singleton_exists' | 'invalid_parent' | 'stale_order'; readonly fieldErrors: Record; name: string; constructor(message: string, code?: 'not_found' | 'validation_failed' | 'cycle' | 'singleton_exists' | 'invalid_parent' | 'stale_order', fieldErrors?: Record); } export interface ContentItem extends Omit { data: Record; seo: SeoData; } /** * Per-item SEO overrides. * * Every key is optional and absence means "fall back", which is what `resolveSeo` implements. No * length limits live here on purpose — see SEO_GUIDANCE in seo.ts for why an over-length title is * a warning in the editor rather than a rejected save. */ export interface SeoData { metaTitle?: string; metaDescription?: string; ogImageId?: string; noIndex?: boolean; } export declare function hydrateItem(row: ContentItemRow): ContentItem; export interface ListItemsOptions extends ItemFilters { limit?: number; offset?: number; /** * Defaults to `path` — or to relevance when there is a `search` and no named order, which is what * makes the admin's cross-type search and the delivery search endpoint rank without either of them * asking. Naming an order always wins, so a search page offering "newest first" gets it. */ sort?: ItemSort; /** * Which of the item's own fields `field_asc` / `field_desc` order by, and how it compares. * * The kind decides the column, and getting it wrong is silent rather than loud: sorted as text, * `10` comes before `9` and a numeric ordering is simply wrong in a way that looks plausible. * Callers get it from the field definition — `indexedValueKind` — rather than guessing. */ sortField?: { apiId: string; kind: IndexedValueKind; }; } /** The narrowing part of a list query, without paging. */ export interface ItemFilters { contentTypeId?: string; status?: ContentStatus; parentId?: string | null; /** * Narrow to everything **below** a path — a whole branch of the site tree. * * `parentId` is one level and this is the branch, which is the question a section-scoped listing * and a search inside one part of a site both actually have. * * **Descendants only, and implemented as a range rather than a `like`.** See * `descendantPathRange`: the index is BINARY, D1 refuses the PRAGMA that would let `like` use it, * and the root is excluded so the predicate needs no `or` — which SQLite has already been shown * here to refuse to spend indexes across. */ pathPrefix?: string; search?: string; /** * Term ids to filter by — an item carrying any one of them matches. * * A *list* rather than a single id because a term filter means the whole branch: filing something * under "Sciences" should find it when someone filters by "Academics". The expansion is the * caller's, through `termIdsForBranch`, so this stays a synchronous query builder that the status * counts can share. * * `undefined` means no filter. An empty array means *nothing matches*, following `listMedia`'s * precedent — with `in ()` being a syntax error, the tempting fallthrough is the dangerous one, * because it silently turns "filter by a term with no members" into "show everything". */ termIds?: string[]; /** * Narrow to what a visitor may see, through the one shared predicate. * * Distinct from `status: 'published'`, which is the trap this exists to avoid: "visible" is two * conditions, because a `scheduled` item whose moment has passed is live whether or not a sweep * has run. The delivery API needs exactly this, and needed it in SQL rather than as a filter over * the results — otherwise `total` counts rows the caller then discards, and paging is wrong by * however many drafts happened to fall in the page. */ visibleOnly?: boolean; /** * Narrow to items whose content type is one of these kinds. * * The delivery listing is the caller: a singleton's `path` is the synthetic * `/__singleton/{api_id}`, which is not a URL anybody can link to, so offering one to a consumer * building an index hands them a broken link. Expressed as kinds rather than "exclude singletons" * because `page` and `collection` are exactly the kinds that *have* public URLs — which is the * property the caller actually wants, and the one `kindHasPublicPath` names. */ contentTypeKinds?: ContentTypeKind[]; /** * Narrow to types whose items are served at their own URLs. * * For every caller that is about to hand somebody a **link**: site search, and an index page that * did not name a type. A collection with item pages turned off — a staff directory — is real * content that appears on a page the site builds, and listing it among things a visitor can click * through to offers a URL that answers 404. * * Not applied when a caller names the type it wants. Asking for `type=person` is asking for * people, and answering nothing because they have no pages of their own would be refusing the * question rather than answering it — that listing is exactly how a directory is built. */ contentTypeHasItemPages?: boolean; /** * Narrow by an item's own field values, through the derived value index. * * The reason the index exists: "events whose `starts_at` is after now" has no other SQL path, * because `data` is TEXT. Several filters are ANDed — each is its own `EXISTS`, so an item has to * satisfy all of them, and a multi-value field satisfying one counts once rather than * multiplying the row. */ valueFilters?: ItemValueFilter[]; } /** * One condition on an indexed field value. * * Dates only, so far, and stated as a bound rather than as "upcoming": *when* now is belongs to the * caller, not to stored data. A `dateFilter: 'upcoming'` saved on a page would otherwise be a * timestamp frozen at whatever moment somebody last pressed save — the same booby trap a stale * `publish_at` is. */ export interface ItemValueFilter { /** The field's `api_id`. */ field: string; operator: 'after' | 'before'; /** ISO 8601, compared against `value_date`. */ value: string; } /** * Whether items of this kind have a URL a visitor can request. * * `page` and `collection` do. A `singleton` gets the synthetic `/__singleton/{api_id}`, which is an * addressing convenience rather than a route, and a `block` type has no items at all. * * Asked as a question about the kind rather than as `kind !== 'singleton'` written out at each call * site — the preview link and the split-view pane both gate on it, and two copies is how they end * up disagreeing about the same page. */ export declare function kindHasPublicPath(kind: ContentTypeKind): boolean; /** * Whether this type's items are served at their own URLs. * * `kindHasPublicPath` asks about the *kind* and cannot answer this: a collection is addressed by * path whether or not the site publishes one, and a staff directory is the case where it should not. * The people are content items in every other respect — created, versioned, classified, listed on a * page the site builds — and giving each a URL means a consumer's catch-all renders a bare field * dump at `/people/anybody`, the admin offers a link to a page nobody designed, and site search * returns it. * * Asked as a question about the type rather than read off the column at each call site, because the * column is only meaningful for a collection: a page is a node in the site tree and a singleton has * no item URL to begin with. A caller reading `item_pages` directly is one that will forget that. */ export declare function typeHasItemPages(contentType: Pick): boolean; /** * The address on the public site where this item is rendered, or null if there is none. * * This is the question the preview pane, the mint endpoints, and the editor's path link all * actually have — `kindHasPublicPath` was standing in for it, and answered "no" for every * singleton because a singleton's own `path` cannot say where it is shown. That was right about * `/__singleton/{api_id}` and wrong about singletons: a homepage assembled from blocks is rendered * at `/`, and `content_types.preview_path` is how a site says so. * * Null is returned for a singleton nobody has configured, and that stays the default deliberately. * A settings record holding an address and social links has no page, and a preview that framed the * site's front page while claiming to show that record is worse than no preview at all — the same * failure `resolveSeo` living in core exists to prevent, one level up. * * A `page` or `collection` answers with `item.path`, ignoring the column entirely: those items * already know where they live, and reading a second source for it is how the two drift. * * A collection with **no item pages** answers null, because there is nothing to open — the site * serves no URL for it and `resolveDelivery` answers `not_found` at its path. Deliberately *not* * redirected to whatever listing shows the item instead: that page is a site route Taproot does not * know, and framing one while claiming to preview this item is the failure the singleton branch * above already refuses. * * **This is not a delivery route.** The consumer still asks `resolve` for `item.path`, which is * what a preview token is a capability over. This only says which URL to open. */ export declare function previewPathFor(contentType: Pick, item: Pick): string | null; /** An item without its field values — what a list of links needs and no more. */ export type ContentItemSummary = Omit; export declare function listItems(db: Kysely, options?: ListItemsOptions): Promise<{ items: ContentItem[]; total: number; }>; /** * The same listing without `data` and `seo`. * * For every caller that renders titles and links: the delivery API's `/items`, which returns seven * scalar fields, and the menu editor's candidate list. Those were reading and JSON-parsing the full * content of up to 200 items to show their names — the whole body of every page on the site, over * the wire from D1 and through `hydrateItem`, to render a list of anchors. * * Not a flag on `listItems`, because the two differ in *return type* and a boolean that changes what * a function gives back is the kind of thing a caller gets wrong once and never notices — the fields * would simply be `undefined`. A caller that needs field values asks for them by calling the other * function. */ export declare function listItemSummaries(db: Kysely, options?: ListItemsOptions): Promise<{ items: ContentItemSummary[]; total: number; }>; /** * How many items sit in each status, under every filter *except* status. * * The omission is the point, and it is why `status` is excluded at the type level rather than by * convention. A status facet exists to answer "what would I get if I switched to Draft?", so * counting within the current status filter would answer with the number already on screen and * zero everywhere else. Statuses with no items are returned as 0 rather than omitted, so callers * can render a complete list without treating a missing key as a special case. */ export declare function countItemsByStatus(db: Kysely, filters?: Omit): Promise>; export declare function getItem(db: Kysely, id: string): Promise; /** * Resolve a request path to a content item in a single indexed lookup. * * This is the hot path — every public page view runs it — which is why `path` is a unique indexed * column rather than something reconstructed by walking parents at request time. */ /** * The SQL condition for "a visitor may see this". * * One expression, used by every reader — the rule that decides what the public sees is exactly the * kind that must not be implemented twice. It lives here rather than in `scheduler.ts` because * `scheduler.ts` already depends on this module, and the reverse would close the loop. * * A `scheduled` item whose time has passed is included whether or not a sweep has run: that is * what makes "goes live at 9am" true on a deployment where nobody wired up a cron. */ export declare function visibleToPublic(eb: any): any; export declare function getItemByPath(db: Kysely, path: string, options?: { publishedOnly?: boolean; routableOnly?: boolean; }): Promise; /** * Just enough of an item to build its cache validator: which item a path names, and its version. * * The delivery route used to answer a conditional request by resolving the whole page — every * query, every loader, the full payload — and then discarding the body when the ETag matched. That * saves bytes, and bytes are the part Cloudflare does not charge for; D1 bills rows *read*, so a * 304 cost exactly what a 200 did. This is the one indexed lookup that answers the question the * validator actually asks. * * It has to share `visibleToPublic` with `getItemByPath`, and that is the whole reason it lives * beside it rather than in the route: a validator computed under a different visibility rule than * the payload would let a conditional request 304 against a version a visitor may not see. * * `updated_at` is the version because every path that changes what a page renders stamps it — * an edit, a publish, a status change, a cascading move, a release applying a staged version. The * one thing it does not cover is a reusable block edited in the library, which is unchanged here and * bounded by the shared TTL; see `deliveryCache`. */ export declare function getItemVersionByPath(db: Kysely, path: string, options?: { publishedOnly?: boolean; routableOnly?: boolean; }): Promise<{ id: string; updatedAt: string; } | undefined>; /** Look up a redirect for a path that no longer resolves. */ export declare function getRedirect(db: Kysely, path: string): Promise<{ to: string; status: number; } | undefined>; export declare function getChildren(db: Kysely, parentId: string | null): Promise; /** * Read an item and every descendant, in one recursive query. * * `WITH RECURSIVE` works identically on both drivers, which is what makes cascading * moves implementable rather than something to special-case away. */ export declare function getSubtree(db: Kysely, rootId: string): Promise; export interface CreateItemInput { contentTypeId: string; title: string; slug?: string; parentId?: string | null; status?: ContentStatus; data?: Record; seo?: SeoData; /** When a `scheduled` item should go live. ISO 8601. */ publishAt?: string | null; userId?: string | null; } export declare function createItem(handle: TaprootDb, contentType: ContentTypeRow, fields: FieldRow[], input: CreateItemInput): Promise; export interface UpdateItemInput { title?: string; slug?: string; parentId?: string | null; status?: ContentStatus; data?: Record; seo?: SeoData; /** * When a `scheduled` item should go live. ISO 8601, or null to clear. * * Cleared automatically whenever the status leaves `scheduled` — see the write below. A stale * time left on a published page is a booby trap: reschedule it later and it goes live in the * past, which is to say immediately. */ publishAt?: string | null; userId?: string | null; /** * How the resulting revision should be labelled. Defaults to `save`. * * Only `restoreRevision` sets this, to mark that a save came from restoring earlier content * rather than from someone editing. It is on the input rather than a separate code path because * a restore *is* an ordinary update — same validation, same path cascade, same redirects. */ revisionReason?: RevisionReason; /** The revision number being restored. Meaningful only with `revisionReason: 'restore'`. */ restoredFrom?: number | null; } /** * Update an item, cascading path changes to its descendants. * * When the slug or parent changes, every descendant's path changes with it, and each moved path * gets a redirect written automatically. Doing that by hand is what people forget, which is why * it happens here rather than being left to whoever remembers. * * The whole rewrite is submitted as one atomic batch, so a partially-renamed tree is not a state * the database can end up in. */ export declare function updateItem(handle: TaprootDb, contentType: ContentTypeRow, fields: FieldRow[], id: string, input: UpdateItemInput): Promise; /** * Restore an item to an earlier revision. * * Deliberately routed through `updateItem` rather than writing the old row back directly. A * revision stores the slug, so restoring one can move the page — and that has to cascade to every * descendant's path and write the redirects, exactly as an ordinary rename does. Writing the * snapshot back verbatim would restore the content and quietly corrupt the tree. * * The restore appends a new revision rather than truncating the log back to the restored point. * History stays append-only, so restoring the wrong revision is itself undoable. */ export declare function restoreRevision(handle: TaprootDb, contentType: ContentTypeRow, fields: FieldRow[], itemId: string, revisionId: string, userId?: string | null): Promise; /** * Put one sibling group in a new order. * * **`position` had no write path at all until this.** `createItem` set it to `siblings.length` and * nothing ever changed it again, so the order `resolveDelivery` hands a consumer as an item's * children was permanently the order somebody happened to create them in. The only fix available to * an editor was to delete a page and make it again, which loses its revisions, its id and every link * pointing at it. * * ## Why this takes a whole level, and rejects a partial one * * `position` means nothing except relative to siblings, so a reorder is inherently per-level — the * same reasoning `reorderMenuItems` records. This goes one step further and requires the ids to be * **exactly** the parent's children: no missing ones, no extras. * * A subset would be silently wrong. Positions here are assigned `0..n-1`, so reordering three of a * parent's eight children hands those three the positions the first three already hold, and the * result is a level with duplicate positions ordered by the `title` tiebreak — which looks like the * drag simply not working, on some rows, sometimes. * * Exactness also settles the concurrency case for free, and it is a real one: two editors on one * section of a site, one adds a page while the other drags. The dragger's list predates the insert, * so it is refused and their screen reloads, rather than the new page being shuffled to the end of a * level it was never part of. * * ## What it deliberately does not do * * **No revision.** A revision snapshots `title`, `slug`, `status`, `data` and `seo` — position is * in none of them, so restoring one has never restored an order and writing a revision here would * append history entries that say nothing and restore nothing. * * **No path rewrite, and that asymmetry is the whole reason this is a separate function rather than * a key on `UpdateItemInput`.** Reordering siblings touches one integer per row: no slug changes, * no descendant paths move, no redirects are written. Re-parenting does all three, for the item and * every descendant. Keeping them apart is what lets an editor's screen offer dragging as the cheap, * fluid act and re-parenting as an explicit one — a drag that could silently rewrite forty URLs and * write forty redirects is not a drag anybody should be offered. * * ## The parent's own timestamp moves * * `updated_at` is bumped on the reordered children **and on the parent**, which is not tidiness. * The parent's delivery response carries its children in this order, and `getItemVersionByPath` * answers a conditional request from `updated_at` alone — so leaving the parent's stamp still would * mean a cached copy of the parent revalidating, being told 304, and having its freshness renewed * against an order that has changed. RFC 9111 §4.3.4 makes that unbounded rather than capped at the * TTL, which is the same trap `reusableBlockLibraryVersion` exists to close one feature along. */ export declare function reorderSiblings(handle: TaprootDb, parentId: string | null, orderedIds: string[]): Promise; /** * What has to be cleared before this item can be deleted, and what merely changes if it is. * * Same shape and same reasoning as `contentTypeDeleteBlockers`: one function that both the guard * and the screen read, so a screen cannot work out 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. * * The split between the two lists is the difference between a broken invariant and a consequence. * Descendants block, because `parent_id` is `ON DELETE SET NULL` and the delete would leave them * at root with a `path` and `depth` still describing where they used to be — the materialised path * and the tree would disagree, which nothing downstream expects. A menu entry or an incoming * relation is a consequence: both already degrade visibly and on purpose, so the editor should be * told rather than stopped. */ export interface ItemDeleteImpact { blockers: string[]; warnings: string[]; } export declare function itemDeleteImpact(db: Kysely, itemId: string): Promise; export declare class ItemError extends Error { readonly code: 'in_use'; constructor(message: string, code: 'in_use'); } export declare function deleteItem(handle: TaprootDb, id: string): Promise; /** One item pointing at another through a `relation` field. */ export interface IncomingReference { id: string; title: string; path: string; status: ContentStatus; /** The relation field on the referring item that points here. */ fieldApiId: string; fieldLabel: string; /** * What this side of the relationship is called, from the field's `reverseLabel`. * * The config has collected this since the field type was designed and nothing ever read it, * which is what made the reverse side of a relation a promise rather than a feature. */ reverseLabel: string | null; contentTypeName: string; } /** * Which items point at this one through a relation field. * * The reverse side of `relation`, which SCOPE names as the thing Wolly gets wrong. Without it a * relation is one-directional in practice: an editor looking at a page has no way to know what * depends on it, and finds out by deleting it. * * Two steps, and the first is what makes the second honest. Relation targets live in another * type's JSON `config`, so the set of fields that *could* point here is found by reading the * `fields` table; only then is `content_items.data` searched, narrowed to the types that own one * of those fields. A `LIKE` for the bare id across every item would also match the id sitting in * a text field or a block's media reference, and would report a relationship that does not exist. * * Same trade as `countBlockUsage`: relation values live inside a JSON blob and have no rows of * their own, so this cannot be an indexed join. It runs on one screen and when deleting an item, * and is bounded by `limit`. */ export declare function itemsReferencing(db: Kysely, itemId: string, limit?: number): Promise; /** Where an item's path comes from depends on its type's kind. */ export declare function resolveItemPath(contentType: ContentTypeRow, parentPath: string | null, slug: string): string;