UNPKG

studiocms

Version:

Astro Native CMS for AstroDB. Built from the ground up by the Astro community.

469 lines (441 loc) 17.5 kB
import { eq } from 'astro:db'; import { CMSMailerConfigId, CMSNotificationSettingsId, CMSSiteConfigId, Next_MailerConfigId, Next_NotificationSettingsId, Next_SiteConfigId, } from '../../../consts.js'; import { Deepmerge, Effect } from '../../../effect.js'; import { CURRENT_CONFIG_VERSION } from '../consts.js'; import { AstroDB, type LibSQLClientError } from '../effect/db.js'; import { tsDynamicConfigSettings, tsMailerConfig, tsNotificationSettings, tsSiteConfig, } from '../tables.js'; import type { ConfigFinal, DynamicConfigEntry, LegacyMailerConfig, LegacyNotificationSettings, LegacySiteConfig, LegacyTables, RawDynamicConfigEntry, RawDynamicConfigInsert, StudioCMSDynamicConfigBase, StudioCMSMailerConfig, StudioCMSNotificationSettings, StudioCMSSiteConfig, } from './config-types.js'; export type { ConfigFinal, DynamicConfigEntry, LegacyMailerConfig, LegacyNotificationSettings, LegacySiteConfig, LegacyTables, RawDynamicConfigEntry, RawDynamicConfigInsert, StudioCMSDynamicConfigBase, StudioCMSMailerConfig, StudioCMSNotificationSettings, StudioCMSSiteConfig, } from './config-types.js'; /** * Casts the `data` property of a `RawDynamicConfigEntry` to the specified generic type `T` * and returns a new `DynamicConfigEntry<T>` object. * * @typeParam T - The type to cast the `data` property to. * @param param0 - An object containing the `data` to cast and its associated `id`. * @param param0.data - The raw data to be cast to type `T`. * @param param0.id - The identifier for the config entry. * @returns A `DynamicConfigEntry<T>` object with the `data` property cast to type `T`. */ export const castType = <T>({ data, id }: RawDynamicConfigEntry): DynamicConfigEntry<T> => ({ id, data: data as T, }); /** * Migrates a legacy site configuration object to the latest `StudioCMSSiteConfig` format. * * @param params - The legacy site configuration object, destructured to extract `id`, `gridItems`, and the rest of the config. * @param params.id - The unique identifier of the site (legacy, not used in the new config). * @param params.gridItems - The grid items from the legacy config, which will be cast to a string array. * @param params.legacyConfig - The remaining properties of the legacy config. * @returns The migrated site configuration object with the latest config version and normalized `gridItems`. */ export const migrateLegacySiteConfig = ({ id, gridItems, ...legacyConfig }: typeof tsSiteConfig.$inferSelect): StudioCMSSiteConfig => ({ ...legacyConfig, gridItems: (gridItems ?? []) as string[], _config_version: CURRENT_CONFIG_VERSION, }); /** * Migrates a legacy mailer configuration object to the current configuration version. * * @param param0 - The legacy mailer configuration object, destructured to extract the `id` and the rest of the configuration. * @returns The updated mailer configuration object with the current configuration version applied. */ export const migrateLegacyMailerConfig = ({ id, ...legacyConfig }: typeof tsMailerConfig.$inferSelect): StudioCMSMailerConfig => ({ ...legacyConfig, _config_version: CURRENT_CONFIG_VERSION, }); /** * Migrates legacy notification settings to the current configuration version. * * @param param0 - An object containing the legacy notification settings, including an `id` and other configuration properties. * @returns The migrated notification settings object with the updated `_config_version`. */ export const migrateLegacyNotificationSettings = ({ id, ...legacyConfig }: typeof tsNotificationSettings.$inferSelect): StudioCMSNotificationSettings => ({ ...legacyConfig, _config_version: CURRENT_CONFIG_VERSION, }); /** * Provides configuration management services for StudioCMS using an effect-based service pattern. * * This service handles CRUD operations for dynamic configuration entries, supports migration from legacy tables, * and exposes specialized interfaces for site, mailer, and notification configuration management. * * @remarks * - Uses dependency injection for database and deep merge utilities. * - Supports versioned dynamic configs and automatic migration from legacy schemas. * - Exposes effect-based methods for creating, retrieving, updating, and migrating configuration entries. * * @example * ```typescript * const configService = Effect.service(SDKCore_CONFIG); * const siteConfig = yield* configService.siteConfig.get(); * yield* configService.siteConfig.update({ ...siteConfig, someField: 'newValue' }); * ``` * * @service * @module studiocms/sdk/modules/SDKCore_CONFIG */ export class SDKCore_CONFIG extends Effect.Service<SDKCore_CONFIG>()( 'studiocms/sdk/modules/SDKCore_CONFIG', { dependencies: [AstroDB.Default, Deepmerge.Default], effect: Effect.gen(function* () { const [dbService, { merge }] = yield* Effect.all([AstroDB, Deepmerge]); /** * Inserts a new dynamic configuration entry into the database. * * @param entry - The raw dynamic configuration entry to insert. * @returns A promise that resolves to the inserted entry. * * @remarks * This function uses the `dbService.makeQuery` utility to perform the insertion * and returns the newly inserted record from the `tsDynamicConfigSettings` table. */ const _insert = dbService.makeQuery((ex, entry: RawDynamicConfigInsert) => ex((db) => db.insert(tsDynamicConfigSettings).values(entry).returning().get()) ); /** * Creates a query function to select a dynamic configuration setting by its ID. * * @param ex - The database executor function. * @param id - The unique identifier of the dynamic configuration setting. * @returns A promise that resolves to the configuration setting matching the provided ID. */ const _select = dbService.makeQuery((ex, id: string) => ex((db) => db.select().from(tsDynamicConfigSettings).where(eq(tsDynamicConfigSettings.id, id)).get() ) ); /** * Updates a dynamic configuration entry in the database. * * @param entry - The `RawDynamicConfigEntry` object containing the updated configuration data. * @returns A promise that resolves to the updated entry from the database. * * @remarks * This function uses the `dbService.makeQuery` utility to perform an update operation * on the `tsDynamicConfigSettings` table, setting the fields based on the provided `entry` * where the `id` matches. The updated entry is returned after the operation. */ const _update = dbService.makeQuery((ex, entry: RawDynamicConfigEntry) => ex((db) => db .update(tsDynamicConfigSettings) .set(entry) .where(eq(tsDynamicConfigSettings.id, entry.id)) .returning() .get() ) ); /** * Creates a new entry with the specified `id` and associated `data`. * * @template DataType - The type of the data to associate with the entry. * @param id - The unique identifier for the entry. * @param data - The data to be stored in the entry. * @returns An Effect that, when executed, inserts the entry and yields the result. */ const create = Effect.fn(function* <DataType>(id: string, data: DataType) { const entry = castType<DataType>({ id, data }); return yield* _insert(entry) as Effect.Effect< DynamicConfigEntry<DataType>, LibSQLClientError >; }); /** * Retrieves an entry by its ID and casts it to the specified data type. * * @template DataType - The expected type of the returned entry. * @param id - The unique identifier of the entry to retrieve. * @returns The entry cast to the specified type, or `undefined` if not found. */ const get = Effect.fn(function* <DataType>(id: string) { const entry = yield* _select(id); if (!entry) return undefined; return castType<DataType>(entry); }); /** * Updates an entry with the specified `id` and `data`. * * @template DataType - The type of the data to update. * @param id - The unique identifier of the entry to update. * @param data - The new data to associate with the entry. * @returns An Effect yielding the result of the update operation. */ const update = Effect.fn(function* <DataType>(id: string, data: DataType) { const entry = castType<DataType>({ id, data }); return yield* _update(entry) as Effect.Effect< DynamicConfigEntry<DataType>, LibSQLClientError >; }); /** * Runs a migration for a legacy configuration table entry to a new configuration format. * * This function retrieves a legacy configuration entry from the specified table using the provided legacy ID, * applies a migration function to transform it into the new configuration format, and then creates a new entry * with the specified new ID. * * @typeParam Table - The legacy table type to migrate from. * @typeParam LegacyConfig - The inferred select type of the legacy table. * @typeParam Config - The new configuration type to migrate to. * @param opts - The migration options. * @param opts.table - The legacy table to migrate from. * @param opts.legacyId - The ID of the legacy configuration entry. * @param opts.migrate - The migration function that transforms the legacy configuration to the new format. * @param opts.newId - The ID for the new configuration entry. * @returns The result of the creation operation, or `undefined` if the legacy configuration was not found. */ const runMigration = Effect.fn(function* < Table extends LegacyTables, LegacyConfig extends Table extends LegacySiteConfig ? LegacySiteConfig['$inferSelect'] : Table extends LegacyMailerConfig ? LegacyMailerConfig['$inferSelect'] : Table extends LegacyNotificationSettings ? LegacyNotificationSettings['$inferSelect'] : never, Config extends Table extends LegacySiteConfig ? StudioCMSSiteConfig : Table extends LegacyMailerConfig ? StudioCMSMailerConfig : Table extends LegacyNotificationSettings ? StudioCMSNotificationSettings : never, >(opts: { table: Table; legacyId: string | number; migrate: (legacyConfig: LegacyConfig) => Config; newId: string; }) { const { table, legacyId, migrate, newId } = opts; const legacyConfig = (yield* dbService.execute((db) => db.select().from(table).where(eq(table.id, legacyId)).get() )) as LegacyConfig | undefined; if (!legacyConfig) return; const newConfig = migrate(legacyConfig); return yield* create(newId, newConfig); }); /** * Retrieves a dynamic configuration entry by its ID. If the entry does not exist * or its configuration version does not match the current version, runs a migration * using the provided migration options and returns the migrated entry. * * @template DataType - The type of the dynamic configuration data. * @param id - The unique identifier of the configuration entry to retrieve. * @param migrationOpts - Options for migrating legacy configuration entries. * @param migrationOpts.table - The legacy table to use for migration. * @param migrationOpts.legacyId - The identifier of the legacy configuration entry. * @param migrationOpts.migrate - A function that transforms the legacy configuration into the new format. * @param migrationOpts.newId - The new identifier to assign to the migrated configuration entry. * @returns The configuration entry of type `DataType`, either retrieved or migrated. */ const dynamicGet = Effect.fn(function* <DataType extends StudioCMSDynamicConfigBase>( id: string, migrationOpts: { table: LegacyTables; legacyId: string | number; // biome-ignore lint/suspicious/noExplicitAny: the actual type is too complex migrate: (legacyConfig: any) => DataType; newId: string; } ) { const entry = yield* get<DataType>(id); // If missing, migrate from legacy (first-time population) if (!entry) { return yield* runMigration(migrationOpts) as Effect.Effect< DynamicConfigEntry<DataType> | undefined, LibSQLClientError >; } // If present but outdated, bump in-place to avoid UNIQUE conflicts and preserve user data if (entry.data._config_version !== CURRENT_CONFIG_VERSION) { return yield* update<DataType>(id, { ...entry.data, _config_version: CURRENT_CONFIG_VERSION, } as DataType); } return entry; }); /** * Updates a dynamic configuration entry by merging new data into the existing entry. * * @template DataType - The type of the dynamic configuration data. * @param id - The unique identifier of the configuration entry to update. * @param data - The new data to merge into the existing configuration entry. * @returns The updated configuration entry if it exists, otherwise `undefined`. * * @remarks * This function retrieves the existing configuration entry by its ID, merges the provided data into it, * and then updates the entry in the data store. If the entry does not exist, it returns `undefined`. */ const dynamicUpdate = Effect.fn(function* <DataType extends StudioCMSDynamicConfigBase>( id: string, data: DataType ) { const entry = yield* get<DataType>(id); if (!entry) return undefined; const updatedEntry = (yield* merge((m) => m(entry.data, data))) as DataType; return yield* update<DataType>(id, updatedEntry) as Effect.Effect< DynamicConfigEntry<DataType>, LibSQLClientError, never >; }); /** * Provides methods to interact with the site configuration. */ const siteConfig = { /** * Retrieves the current site configuration. */ get: () => dynamicGet<StudioCMSSiteConfig>(Next_SiteConfigId, { table: tsSiteConfig, legacyId: CMSSiteConfigId, migrate: migrateLegacySiteConfig, newId: Next_SiteConfigId, }), /** * Updates the site configuration with the provided data. * @param data The new configuration data, excluding the internal version field. * @returns The updated site configuration. */ update: (data: ConfigFinal<StudioCMSSiteConfig>) => dynamicUpdate<StudioCMSSiteConfig>(Next_SiteConfigId, { ...data, _config_version: CURRENT_CONFIG_VERSION, }), /** * Initializes the site configuration with the provided data. * @param data The initial configuration data, excluding the internal version field. * @returns The created site configuration. */ init: (data: ConfigFinal<StudioCMSSiteConfig>) => create<StudioCMSSiteConfig>(Next_SiteConfigId, { ...data, _config_version: CURRENT_CONFIG_VERSION, }), }; /** * Provides methods to interact with the StudioCMS mailer configuration. */ const mailerConfig = { /** * Retrieves the current mailer configuration. */ get: () => dynamicGet<StudioCMSMailerConfig>(Next_MailerConfigId, { table: tsMailerConfig, legacyId: CMSMailerConfigId, migrate: migrateLegacyMailerConfig, newId: Next_MailerConfigId, }), /** * Updates the mailer configuration with the provided data. * @param data The new configuration data, excluding the internal version field. * @returns The updated mailer configuration. */ update: (data: ConfigFinal<StudioCMSMailerConfig>) => dynamicUpdate<StudioCMSMailerConfig>(Next_MailerConfigId, { ...data, _config_version: CURRENT_CONFIG_VERSION, }), /** * Initializes the mailer configuration with the provided data. * @param data The initial configuration data, excluding the internal version field. * @returns The created mailer configuration. */ init: (data: ConfigFinal<StudioCMSMailerConfig>) => create<StudioCMSMailerConfig>(Next_MailerConfigId, { ...data, _config_version: CURRENT_CONFIG_VERSION, }), }; /** * Provides methods to interact with the notification settings configuration. */ const notificationConfig = { /** * Retrieves the current notification settings. */ get: () => dynamicGet<StudioCMSNotificationSettings>(Next_NotificationSettingsId, { table: tsNotificationSettings, legacyId: CMSNotificationSettingsId, migrate: migrateLegacyNotificationSettings, newId: Next_NotificationSettingsId, }), /** * Updates the notification settings with the provided data. * @param data The new notification settings, excluding the `_config_version` property. * @returns A promise resolving to the updated `StudioCMSNotificationSettings`. */ update: (data: ConfigFinal<StudioCMSNotificationSettings>) => dynamicUpdate<StudioCMSNotificationSettings>(Next_NotificationSettingsId, { ...data, _config_version: CURRENT_CONFIG_VERSION, }), /** * Initializes the notification settings with the provided data. * @param data The initial configuration data, excluding the internal version field. * @returns The created notification settings. */ init: (data: ConfigFinal<StudioCMSNotificationSettings>) => create<StudioCMSNotificationSettings>(Next_NotificationSettingsId, { ...data, _config_version: CURRENT_CONFIG_VERSION, }), }; return { siteConfig, mailerConfig, notificationConfig } as const; }), } ) {}