← Files WixARCHIVED FILE
skills/wix-headless-templates/cms/app/wix/cms/items-core.ts
12.4 KB · Oct 8, 2026 · 12:02 UTC
// Wix Data rules and DTO mapping — transport-agnostic, imported by BOTH transports: ./items.ts
// (the SDK, managed Astro and React) and the REST twin in templates/cms/rest/items.ts (fetch, a
// static site or a port to another language). Every rule about normalizing an item, validating a
// filter, and spelling a query for the wire lives HERE, once. A raw item may come from the SDK
// (dates as Date objects) or from REST (dates as `{ "$date": iso }`); the mappers accept both.
// Imports are type-only so a strip to JS emits no imports.
import type { CmsFilter, CmsItem, CmsPage, CmsQuery, CmsSort } from "./types";
/** A raw data item's payload as either transport returns it (fields flat, `_id` included). */
export type Raw = Record<string, any>;
/** Media value + size → https URL. Injected: the SDK transport scales through @wix/sdk, REST through the URL form. */
export type ImgSrc = (value: any, width: number, height: number) => string;
/** Media value → height/width read from its `#originWidth=…&originHeight=…` fragment; null when it carries none. */
export type ImgRatio = (value: any) => number | null;
/** The media seams a transport injects — `imgSrc` and `imgRatio` from its media.ts. */
export interface Media {
imgSrc: ImgSrc;
imgRatio: ImgRatio;
}
/** Page size when a query names none. */
export const DEFAULT_LIMIT = 20;
/** Distinct values returned when a call names no limit. */
export const DISTINCT_LIMIT = 100;
/** The width every IMAGE field is resolved at — one URL per item, large enough for a detail page. */
export const IMAGE_WIDTH = 1200;
/** The height when the identifier carries no size (an image imported without its dimensions): 4:3. */
export const IMAGE_HEIGHT = 900;
// ---- DTO normalization ---------------------------------------------------------------------------
/** REST spells DATE/DATETIME fields as `{ "$date": "<iso>" }`; the SDK decodes them into Date objects. */
function isDateWrapper(v: unknown): v is { $date: string } {
if (!v || typeof v !== "object" || Array.isArray(v)) return false;
const keys = Object.keys(v as Raw);
return keys.length === 1 && keys[0] === "$date" && typeof (v as Raw).$date === "string";
}
/**
* The size an IMAGE field resolves at: IMAGE_WIDTH wide, at the image's own aspect when the
* `wix:image://` id carries `#originWidth/#originHeight` (a fill at another aspect would crop it),
* IMAGE_WIDTH × IMAGE_HEIGHT otherwise. The resolved URL carries the size (`/w_1200,h_800/`), so
* media.ts `imgRatio(url)` reads the aspect back for srcsets.
*/
export function imageSize(ratio: number | null): { width: number; height: number } {
return { width: IMAGE_WIDTH, height: ratio ? Math.round(IMAGE_WIDTH * ratio) : IMAGE_HEIGHT };
}
/**
* Raw → DTO value: dates become ISO strings (Date objects aren't serializable as island props;
* `{ $date }` wrappers aren't dates at all), IMAGE fields holding a `wix:image://` identifier
* become https URLs a browser can load. Recursive — included reference items too.
*/
export function toValue(v: unknown, media: Media): unknown {
if (v instanceof Date) return v.toISOString();
if (isDateWrapper(v)) {
// Same spelling as the SDK path (REST omits the milliseconds: "…T00:00:00Z").
const t = new Date(v.$date);
return Number.isNaN(t.getTime()) ? v.$date : t.toISOString();
}
if (typeof v === "string" && v.startsWith("wix:image://")) {
const { width, height } = imageSize(media.imgRatio(v));
return media.imgSrc(v, width, height);
}
if (Array.isArray(v)) return v.map((x) => toValue(x, media));
if (v && typeof v === "object") {
const out: Raw = {};
for (const [k, val] of Object.entries(v as Raw)) out[k] = toValue(val, media);
return out;
}
return v;
}
export const toItem = (raw: Raw, media: Media): CmsItem => toValue(raw, media) as CmsItem;
export function toPage(items: Raw[], hasNext: boolean, total: number | null | undefined, media: Media): CmsPage {
return { items: items.map((r) => toItem(r, media)), hasNext, total: total ?? null };
}
/** Distinct values of a field, normalized like item values (a DATE field's values → ISO strings). */
export const toValues = (values: unknown[], media: Media): unknown[] => values.map((v) => toValue(v, media));
/**
* A DATE/DATETIME field for display — the DTO carries an ISO string; this is the one place it
* becomes copy. "" for an absent or unparseable value (never "Invalid Date", never the raw ISO).
* formatDate(item.publishDate) → "August 20, 2026"
* formatDate(item.publishDate, "en-GB", { dateStyle: "medium" })
*/
export function formatDate(iso: unknown, locale = "en-US", options: Intl.DateTimeFormatOptions = { dateStyle: "long" }): string {
if (typeof iso !== "string" || !iso) return "";
const t = new Date(iso);
return Number.isNaN(t.getTime()) ? "" : new Intl.DateTimeFormat(locale, options).format(t);
}
// ---- filter rules --------------------------------------------------------------------------------
/**
* An undefined comparand silently changes what a query matches (every row, or none) with no
* server error — the classic "my items shows everyone's items" bug. Throw at the call site;
* omit the filter entirely when you don't hold a value yet. A `between` needs its two bounds.
*/
export function assertFilterValue(f: CmsFilter): void {
if (f.op === "isEmpty" || f.op === "isNotEmpty") return;
if (f.value === undefined) {
throw new Error(
`cms: filter on "${f.field}" (${f.op}) has an undefined value — pass a real value or omit the filter.`,
);
}
if (f.op === "between" && !(Array.isArray(f.value) && f.value.length === 2)) {
throw new Error(`cms: filter on "${f.field}" (between) takes [start, end].`);
}
}
/** The validated `[start, end]` of a between filter — start inclusive, end exclusive on both transports. */
export function betweenRange(f: CmsFilter): [unknown, unknown] {
assertFilterValue(f);
const [start, end] = f.value as unknown[];
return [start, end];
}
// ---- the REST spelling of a query ---------------------------------------------------------------
// The SDK's query builder serializes these itself; the REST twin sends them literally. Proven live:
// a DATE comparand must travel as `{ "$date": iso }` (a plain ISO string compares as text and
// matches nothing); isEmpty is `{ field: null }` and isNotEmpty `{ field: { $ne: null } }` — the
// `$isEmpty` keyword is rejected (WDE0076).
/**
* A comparand or field value on the wire: Date → `{ $date }`, arrays element-wise, everything else
* as-is. DATE and DATETIME fields BOTH travel as `{ "$date": iso }` (the SDK sends a Date the same
* way) — proven live: the seed writes DATE fields so, reads them back as dates, and date filters
* match them. Wix's headless-components write a DATE field as a "YYYY-MM-DD" string instead; that
* form is not copied here (their formatter is also a month off: getUTCMonth() without +1). A TIME
* field is a "hh:mm:ss.SSS" string and passes through as text.
*/
export function restValue(v: unknown): unknown {
if (v instanceof Date) return { $date: v.toISOString() };
if (Array.isArray(v)) return v.map(restValue);
return v;
}
const REST_OPS: Record<Exclude<CmsFilter["op"], "isEmpty" | "isNotEmpty" | "between">, string> = {
eq: "$eq",
ne: "$ne",
gt: "$gt",
ge: "$gte",
lt: "$lt",
le: "$lte",
contains: "$contains",
startsWith: "$startsWith",
endsWith: "$endsWith",
// `in` is spelled $hasSome on the wire, the same predicate the SDK transport sends (on a scalar
// field: equal to any of the values) — one behaviour on both transports.
in: "$hasSome",
hasSome: "$hasSome",
hasAll: "$hasAll",
};
/** One predicate → one filter object `{ field: condition }`. `between` is the builder's ge + lt pair. */
export function restCondition(f: CmsFilter): Raw {
assertFilterValue(f);
if (f.op === "isEmpty") return { [f.field]: null };
if (f.op === "isNotEmpty") return { [f.field]: { $ne: null } };
if (f.op === "between") {
const [start, end] = betweenRange(f);
return { [f.field]: { $gte: restValue(start), $lt: restValue(end) } };
}
return { [f.field]: { [REST_OPS[f.op]]: restValue(f.value) } };
}
/** All predicates AND-ed: `{}` for none, the one object for one, `{ $and: [...] }` otherwise. */
export function restFilter(filters: CmsFilter[] = []): Raw {
const conditions = filters.map(restCondition);
if (conditions.length === 0) return {};
if (conditions.length === 1) return conditions[0];
return { $and: conditions };
}
export function restSort(sort: CmsSort[] = []): { fieldName: string; order: "ASC" | "DESC" }[] {
return sort.map((s) => ({ fieldName: s.field, order: s.direction === "desc" ? "DESC" : "ASC" }));
}
/**
* The full Query Data Items body. Offset paging (`skip` = page N × limit) so every page can carry
* its own filter/sort and `returnTotalCount` works; `includeReferences[].field` inlines reference
* items. POST /wix-data/v2/items/query
*/
export function queryBody(collectionId: string, query: CmsQuery = {}): Raw {
const { filters = [], sort = [], limit = DEFAULT_LIMIT, skip = 0, include = [], withTotal = false } = query;
return {
dataCollectionId: collectionId,
query: {
filter: restFilter(filters),
...(sort.length ? { sort: restSort(sort) } : {}),
paging: { limit, offset: skip },
},
...(include.length ? { includeReferences: include.map((field) => ({ field })) } : {}),
...(withTotal ? { returnTotalCount: true } : {}),
};
}
/** Whether another page follows — the response says so; the arithmetic is the fallback. */
export function restHasNext(pagingMetadata: Raw | undefined, offset: number, limit: number): boolean {
if (typeof pagingMetadata?.hasNext === "boolean") return pagingMetadata.hasNext;
if (typeof pagingMetadata?.total === "number") return offset + (pagingMetadata.count ?? 0) < pagingMetadata.total;
return (pagingMetadata?.count ?? 0) >= limit;
}
/**
* The Count Data Items body — the filter in the same spelling a query carries, top-level.
* POST /wix-data/v2/items/count → { totalCount }
*/
export function countBody(collectionId: string, filters: CmsFilter[] = []): Raw {
return { dataCollectionId: collectionId, filter: restFilter(filters) };
}
/**
* The Query Distinct Values body — `fieldName` and `filter` sit top-level (not under `query`).
* POST /wix-data/v2/items/query-distinct-values → { distinctValues: [...], pagingMetadata }
*/
export function distinctBody(collectionId: string, field: string, filters: CmsFilter[] = [], limit = DISTINCT_LIMIT): Raw {
return { dataCollectionId: collectionId, fieldName: field, filter: restFilter(filters), paging: { limit, offset: 0 } };
}
// ---- the REST spelling of a write ---------------------------------------------------------------
/**
* Field values for an insert/update body — restValue per field (dates → `{ $date }`; JSON.stringify
* would turn a Date into a plain ISO string, which Wix stores as TEXT and never matches a date
* query). The read-only system fields are Wix's to stamp, never sent.
*/
export function restWriteData(data: Record<string, unknown>): Raw {
const out: Raw = {};
for (const [k, v] of Object.entries(data)) {
if (k === "_createdDate" || k === "_updatedDate" || k === "_owner") continue;
out[k] = restValue(v);
}
return out;
}
/** The Patch Data Item modifications for "set these fields" — one SET_FIELD per key. PATCH /wix-data/v2/items/{id} */
export function patchModifications(fields: Record<string, unknown>): Raw[] {
const entries = Object.entries(fields);
if (!entries.length) throw new Error("cms: patchItemFields called with no fields.");
return entries.map(([fieldPath, value]) => ({ fieldPath, action: "SET_FIELD", setFieldOptions: { value: restValue(value) } }));
}
/**
* The Bulk Insert / Bulk Remove Data Item References body: one entry per referenced id. The key is
* `dataItemReferences` with `referringItemFieldName` / `referringItemId` / `referencedItemId` — the
* natural-looking `references` shape is rejected (400 WDE0080).
* POST /wix-data/v2/bulk/items/insert-references · POST /wix-data/v2/bulk/items/remove-references
*/
export function referencesBody(collectionId: string, field: string, itemId: string, refIds: string[]): Raw {
if (!refIds.length) throw new Error("cms: linkItems/unlinkItems called with no referenced ids.");
return {
dataCollectionId: collectionId,
dataItemReferences: refIds.map((referencedItemId) => ({ referringItemFieldName: field, referringItemId: itemId, referencedItemId })),
};
}
SHA-256: 965e38f1e02c589d39e62604220d2b62c92886f6567b9080dda3881bd9b9ed1e