Files
sharper.ly/apps/cms/src/seed/upsert.ts
T

252 lines
9.3 KiB
TypeScript

/* ============================================================================
SEED - idempotent document helpers
----------------------------------------------------------------------------
Every write in the seed goes through here, so the whole seed can be run
again over a database it has already written to and change nothing but the
fields it owns. Nothing is ever deleted except demo entries, and only on an
explicit `--demo --clean`.
THE SHAPE OF A WRITE
A seed entry is always a pair - the Arabic document and its English
translation - because every localised content type on this site ships in
both languages (CLAUDE.md rule 7) and Arabic is the CMS's default locale.
`upsert` writes the Arabic document first, because that is the one
carrying the identity (the slug), then writes the English locale onto the
SAME document, which is how Strapi 5 models a translation: one
`documentId`, one row per locale.
DRAFT AND PUBLISH
Content is written as a draft and then published explicitly. Publishing
is a separate call rather than `status: 'published'` on the write, so a
half-built entry is never live for the moment between the two locales.
Two content types - `topic` and `region` - are pure taxonomy and have
draft & publish switched off, and a document service for such a type has
no `publish` method and no `status` to filter on at all. `usesDraft`
below reads that off the schema rather than keeping a list, so a type that
gains or loses the setting needs no change here.
TYPES
`strapi.documents(uid)` is generated per content type, so a helper that
takes the uid as a parameter cannot be typed against one schema. The
single cast below is the seam: past it, the seed's own data is checked by
the content files' own types, and Strapi validates the payload at runtime
against the real schema - a wrong field name fails the seed loudly.
========================================================================= */
import type { Core } from '@strapi/strapi';
/** The two locales this site ships. */
export const LOCALES = ['en', 'ar'] as const;
export type SeedLocale = (typeof LOCALES)[number];
/** One piece of content, written once per locale. */
export type Localised<T> = Readonly<Record<SeedLocale, T>>;
/** A field payload - checked by Strapi against the real schema at runtime. */
export type SeedData = Record<string, unknown>;
/** Any content type uid `strapi.documents()` accepts. */
export type SeedUid = Parameters<Core.Strapi['documents']>[0];
interface SeedDocument {
documentId: string;
[field: string]: unknown;
}
/**
* The document service, minus the per-uid generics.
*
* See the TYPES note above: this is the one place the seed steps outside
* Strapi's generated types, and it is deliberate.
*/
interface LooseDocumentService {
findFirst(params: SeedData): Promise<SeedDocument | null>;
findMany(params: SeedData): Promise<SeedDocument[]>;
create(params: SeedData): Promise<SeedDocument>;
update(params: SeedData): Promise<SeedDocument | null>;
delete(params: SeedData): Promise<unknown>;
publish(params: SeedData): Promise<unknown>;
}
const documents = (strapi: Core.Strapi, uid: SeedUid): LooseDocumentService =>
strapi.documents(uid) as unknown as LooseDocumentService;
/** Whether this content type has draft & publish, and so a `publish` call. */
const usesDraft = (strapi: Core.Strapi, uid: SeedUid): boolean =>
Boolean(strapi.contentType(uid).options?.draftAndPublish);
/** `status: 'draft'`, but only where a status exists to ask about. */
const draftFilter = (strapi: Core.Strapi, uid: SeedUid): SeedData =>
usesDraft(strapi, uid) ? { status: 'draft' } : {};
/** What a seed step did, for the run summary. */
export interface SeedResult {
documentId: string;
created: boolean;
}
/**
* Write both locales of a document and publish it.
*
* `find` identifies the entry across runs - a slug for a collection type,
* nothing at all for a single type, which has only ever one document. When it
* matches nothing the document is created; when it matches, the same fields
* are written over the existing one, so an editor's OTHER changes survive a
* re-run and the fields the seed owns go back to the seeded value.
*/
export async function upsert(
strapi: Core.Strapi,
uid: SeedUid,
data: Localised<SeedData>,
find?: SeedData
): Promise<SeedResult> {
const service = documents(strapi, uid);
/* Looked up as a draft: a document that exists but has never been published
is still the same document, and creating a second one beside it is how a
seed ends up with duplicates. */
const existing = await service.findFirst({
...(find ? { filters: find } : {}),
locale: 'ar',
...draftFilter(strapi, uid),
});
let documentId: string;
let created = false;
if (existing) {
documentId = existing.documentId;
await service.update({ documentId, locale: 'ar', data: data.ar });
} else {
const document = await service.create({ locale: 'ar', data: data.ar });
documentId = document.documentId;
created = true;
}
/* An update against a locale the document does not have yet creates that
translation - there is no separate "add a locale" call. */
await service.update({ documentId, locale: 'en', data: data.en });
if (usesDraft(strapi, uid)) {
await service.publish({ documentId, locale: '*' });
}
return { documentId, created };
}
/**
* Delete the documents a filter matches, in every locale.
*
* TWO THINGS THE DOCUMENT SERVICE DOES NOT DO BY ITSELF, both of which leave
* Arabic rows behind that the next run then collides with on the unique slug:
*
* 1. `delete({ documentId })` with no locale removes the DEFAULT locale
* only, not the document. It takes `locale: '*'` to remove the lot.
* 2. `findMany({ locale })` answers for one locale, so a document whose
* base half has already gone is invisible to a search in that locale.
* Both locales are searched and the documentIds pooled, which also
* sweeps up orphans an earlier, less careful run left.
*/
async function deleteMatching(
strapi: Core.Strapi,
uid: SeedUid,
filters: SeedData
): Promise<number> {
const service = documents(strapi, uid);
const documentIds = new Set<string>();
for (const locale of LOCALES) {
const matches = await service.findMany({
filters,
locale,
...draftFilter(strapi, uid),
pagination: { limit: -1 },
});
for (const match of matches) documentIds.add(match.documentId);
}
for (const documentId of documentIds) {
await service.delete({ documentId, locale: '*' });
}
return documentIds.size;
}
/**
* Delete every document of a type whose slug starts with the given prefix.
*
* This is how `--demo --clean` finds what it planted: the prefix is on the
* slug itself, so nothing has to be remembered between runs and an entry an
* editor has since renamed out of the prefix is deliberately left alone.
*/
export async function deleteBySlugPrefix(
strapi: Core.Strapi,
uid: SeedUid,
prefix: string
): Promise<number> {
return deleteMatching(strapi, uid, { slug: { $startsWith: prefix } });
}
/**
* Delete documents matched by an arbitrary filter.
*
* The FAQ content type has no slug to prefix, so its demo entries are found by
* their exact questions instead - in both languages, because the filter is
* matched per locale. Anything the seed did not write is left untouched,
* because the filter is the seeded list itself.
*/
export async function deleteWhere(
strapi: Core.Strapi,
uid: SeedUid,
filters: SeedData
): Promise<number> {
return deleteMatching(strapi, uid, filters);
}
/**
* Read one field off a single type before overwriting it.
*
* Used for the media an editor has already uploaded: the seed owns the words
* on a page, not the pictures, so it reads the current hero image back and
* writes it out again with the new copy rather than dropping it. A component
* is replaced wholesale on write, so anything inside it that is not in the
* payload is gone.
*/
export async function readExisting(
strapi: Core.Strapi,
uid: SeedUid,
populate: string[],
find?: SeedData,
locale: SeedLocale = 'ar'
): Promise<SeedDocument | null> {
return documents(strapi, uid).findFirst({
...(find ? { filters: find } : {}),
locale,
...draftFilter(strapi, uid),
populate,
});
}
/** The id of a media entry nested in a populated component, if there is one. */
export function mediaId(component: unknown, field: string): number | undefined {
if (!component || typeof component !== 'object') return undefined;
const media = (component as Record<string, unknown>)[field];
if (!media || typeof media !== 'object') return undefined;
const id = (media as Record<string, unknown>).id;
return typeof id === 'number' ? id : undefined;
}
/** The ids of populated media entries nested in a component. */
export function mediaIds(component: unknown, field: string): number[] {
if (!component || typeof component !== 'object') return [];
const media = (component as Record<string, unknown>)[field];
if (!Array.isArray(media)) return [];
return media.flatMap((item: unknown) => {
if (!item || typeof item !== 'object') return [];
const id = (item as Record<string, unknown>).id;
return typeof id === 'number' ? [id] : [];
});
}